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()!); } }