using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// End-to-end лояльности: создание программы Percentage 10% →
/// выпуск карты → создание чека с loyaltyCardNumber → проверка Total
/// уменьшился ровно на 10% от Subtotal.
///
/// Multi-tenant: B не видит программу/карту A.
[Collection(ApiCollection.Name)]
public class LoyaltyFlowTests
{
private readonly ApiFactory _factory;
public LoyaltyFlowTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Percentage_program_discounts_total_in_retail_sale()
{
var actor = new ApiActor(_factory.CreateClient());
var email = $"loy-{Guid.NewGuid():N}@example.kz";
(await actor.SignupAsync(email, "Passw0rd!", "Loy Org")).EnsureSuccessStatusCode();
actor.UseToken(await actor.TokenAsync(email, "Passw0rd!"));
// Программа Percentage 10%
var progResp = await actor.Http.PostAsJsonAsync("/api/loyalty/programs", new
{
name = "Постоянник 10%", type = 1 /* Percentage */, rate = 10m,
minSubtotal = 0m, isActive = true, description = (string?)null,
});
progResp.EnsureSuccessStatusCode();
var program = await progResp.Content.ReadFromJsonAsync();
var programId = program.GetProperty("id").GetString()!;
// Контрагент-покупатель
var cpResp = await actor.Http.PostAsJsonAsync("/api/catalog/counterparties",
new { name = "VIP покупатель", type = 1 /* Individual */ });
cpResp.EnsureSuccessStatusCode();
var cp = await cpResp.Content.ReadFromJsonAsync();
var counterpartyId = cp.GetProperty("id").GetString()!;
// Карта
var cardResp = await actor.Http.PostAsJsonAsync("/api/loyalty/cards/issue", new
{
programId, counterpartyId, cardNumber = "VIP-001",
});
cardResp.EnsureSuccessStatusCode();
// Lookup карты должен вернуть данные
var lookupResp = await actor.Http.GetAsync("/api/loyalty/cards/lookup?number=VIP-001");
lookupResp.EnsureSuccessStatusCode();
var lookup = await lookupResp.Content.ReadFromJsonAsync();
lookup.GetProperty("programType").GetInt32().Should().Be(1); // Percentage
// Сидим товар + остаток через приёмку
var (productId, storeId, retailPointId, currencyId) = await SeedProductAsync(actor);
// Создаём чек с loyaltyCardNumber=VIP-001, Subtotal=1000
var saleResp = await actor.Http.PostAsJsonAsync("/api/sales/retail", new
{
date = DateTime.UtcNow,
storeId, retailPointId, currencyId,
payment = 0, isReturn = false,
lines = new[] { new { productId, quantity = 10m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
subtotal = 1000m, discountTotal = 0m, total = 1000m,
paidCash = 900m, paidCard = 0m,
loyaltyCardNumber = "VIP-001",
});
saleResp.EnsureSuccessStatusCode();
var sale = await saleResp.Content.ReadFromJsonAsync();
// 10% от 1000 = 100 → loyaltyBonusApplied=100, total=900
sale.GetProperty("loyaltyBonusApplied").GetDecimal().Should().Be(100m);
sale.GetProperty("total").GetDecimal().Should().Be(900m);
sale.GetProperty("loyaltyCardId").GetString().Should().NotBeNullOrEmpty();
}
[Fact]
public async Task Points_accrual_credits_card_balance_on_post()
{
var actor = new ApiActor(_factory.CreateClient());
var email = $"loy2-{Guid.NewGuid():N}@example.kz";
(await actor.SignupAsync(email, "Passw0rd!", "Loy2 Org")).EnsureSuccessStatusCode();
actor.UseToken(await actor.TokenAsync(email, "Passw0rd!"));
// Программа PointsAccrual 5% → 5% от чека идёт в Balance
var progResp = await actor.Http.PostAsJsonAsync("/api/loyalty/programs", new
{
name = "Баллы 5%", type = 3 /* PointsAccrual */, rate = 5m,
minSubtotal = 0m, isActive = true, description = (string?)null,
});
progResp.EnsureSuccessStatusCode();
var programId = (await progResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString()!;
var cpResp = await actor.Http.PostAsJsonAsync("/api/catalog/counterparties",
new { name = "Bonus Buyer", type = 1 });
cpResp.EnsureSuccessStatusCode();
var counterpartyId = (await cpResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString()!;
var cardResp = await actor.Http.PostAsJsonAsync("/api/loyalty/cards/issue", new
{
programId, counterpartyId, cardNumber = "BNS-001",
});
cardResp.EnsureSuccessStatusCode();
var cardId = (await cardResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString()!;
var (productId, storeId, retailPointId, currencyId) = await SeedProductAsync(actor);
// Создаём чек: Subtotal=2000, total остаётся 2000 (баллы не уменьшают)
var saleResp = await actor.Http.PostAsJsonAsync("/api/sales/retail", new
{
date = DateTime.UtcNow,
storeId, retailPointId, currencyId,
payment = 0, isReturn = false,
lines = new[] { new { productId, quantity = 20m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
subtotal = 2000m, discountTotal = 0m, total = 2000m,
paidCash = 2000m, paidCard = 0m,
loyaltyCardNumber = "BNS-001",
});
saleResp.EnsureSuccessStatusCode();
var sale = await saleResp.Content.ReadFromJsonAsync();
sale.GetProperty("loyaltyPointsAccrued").GetDecimal().Should().Be(100m); // 5% от 2000
sale.GetProperty("total").GetDecimal().Should().Be(2000m); // не уменьшилось
var saleId = sale.GetProperty("id").GetString()!;
// Post чек → баланс карты должен стать 100
(await actor.Http.PostAsync($"/api/sales/retail/{saleId}/post", null)).EnsureSuccessStatusCode();
var cardListResp = await actor.Http.GetAsync("/api/loyalty/cards?pageSize=10");
cardListResp.EnsureSuccessStatusCode();
var cardList = await cardListResp.Content.ReadFromJsonAsync();
var card = cardList.GetProperty("items").EnumerateArray().First(x => x.GetProperty("id").GetString() == cardId);
card.GetProperty("balance").GetDecimal().Should().Be(100m);
}
[Fact]
public async Task Multi_tenant_isolation_lookup_returns_404_for_other_org_card()
{
var actorA = new ApiActor(_factory.CreateClient());
var emailA = $"loyA-{Guid.NewGuid():N}@example.kz";
(await actorA.SignupAsync(emailA, "Passw0rd!", "LoyA Org")).EnsureSuccessStatusCode();
actorA.UseToken(await actorA.TokenAsync(emailA, "Passw0rd!"));
var actorB = new ApiActor(_factory.CreateClient());
var emailB = $"loyB-{Guid.NewGuid():N}@example.kz";
(await actorB.SignupAsync(emailB, "Passw0rd!", "LoyB Org")).EnsureSuccessStatusCode();
actorB.UseToken(await actorB.TokenAsync(emailB, "Passw0rd!"));
// A создаёт программу + карту ISOL-A-001
var progA = await (await actorA.Http.PostAsJsonAsync("/api/loyalty/programs", new
{
name = "p", type = 1, rate = 10m, minSubtotal = 0m, isActive = true, description = (string?)null,
})).Content.ReadFromJsonAsync();
var cpA = await (await actorA.Http.PostAsJsonAsync("/api/catalog/counterparties",
new { name = "cp", type = 1 })).Content.ReadFromJsonAsync();
var cardResp = await actorA.Http.PostAsJsonAsync("/api/loyalty/cards/issue", new
{
programId = progA.GetProperty("id").GetString(),
counterpartyId = cpA.GetProperty("id").GetString(),
cardNumber = "ISOL-A-001",
});
cardResp.EnsureSuccessStatusCode();
// B пытается lookup → 404
var lookupB = await actorB.Http.GetAsync("/api/loyalty/cards/lookup?number=ISOL-A-001");
lookupB.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
}
private async Task<(string ProductId, string StoreId, string RetailPointId, string CurrencyId)>
SeedProductAsync(ApiActor actor)
{
var units = (await actor.GetJsonAsync("/api/catalog/units-of-measure?pageSize=200"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("code").GetString() == "796");
var groups = (await actor.GetJsonAsync("/api/catalog/product-groups"))
.GetProperty("items").EnumerateArray().First();
var pts = (await actor.GetJsonAsync("/api/catalog/price-types"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("isRetail").GetBoolean());
var curs = (await actor.GetJsonAsync("/api/catalog/currencies"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("code").GetString() == "KZT");
var stores = (await actor.GetJsonAsync("/api/catalog/stores"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("isMain").GetBoolean());
var retailPoints = (await actor.GetJsonAsync("/api/catalog/retail-points"))
.GetProperty("items").EnumerateArray().First();
var prodResp = await actor.Http.PostAsJsonAsync("/api/catalog/products", new
{
name = "Loy test", article = $"LOY-{Guid.NewGuid():N}",
unitOfMeasureId = units.GetProperty("id").GetString(),
vat = 12, vatEnabled = true,
productGroupId = groups.GetProperty("id").GetString(),
packaging = 1,
prices = new[] { new { priceTypeId = pts.GetProperty("id").GetString(), amount = 100m, currencyId = curs.GetProperty("id").GetString() } },
barcodes = new[] { new { code = $"600000{Guid.NewGuid().GetHashCode():X}".Replace("-", "").Substring(0, 12) + "0", type = 1, isPrimary = true } },
});
prodResp.EnsureSuccessStatusCode();
var prod = await prodResp.Content.ReadFromJsonAsync();
var productId = prod.GetProperty("id").GetString()!;
var supplier = await (await actor.Http.PostAsJsonAsync("/api/catalog/counterparties",
new { name = "Sup", type = 2 })).Content.ReadFromJsonAsync();
var supply = await (await actor.Http.PostAsJsonAsync("/api/purchases/supplies", new
{
date = DateTime.UtcNow,
supplierId = supplier.GetProperty("id").GetString(),
storeId = stores.GetProperty("id").GetString(),
currencyId = curs.GetProperty("id").GetString(),
lines = new[] { new { productId, quantity = 100m, unitPrice = 50m } },
})).Content.ReadFromJsonAsync();
(await actor.Http.PostAsync($"/api/purchases/supplies/{supply.GetProperty("id").GetString()}/post", null))
.EnsureSuccessStatusCode();
return (productId,
stores.GetProperty("id").GetString()!,
retailPoints.GetProperty("id").GetString()!,
curs.GetProperty("id").GetString()!);
}
}