food-market/tests/food-market.IntegrationTests/LoyaltyFlowTests.cs
nns 91128a7ed0
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Docker API / Build + push API (push) Has been cancelled
Docker API / Deploy API on stage (push) Has been cancelled
feat(loyalty+promotions): P2-12 + P2-13 — лояльность и промокоды (Sprint 9 п.1-2)
Domain:
- LoyaltyProgram { Type=Percentage|FixedAmount|PointsAccrual, Rate,
  MinSubtotal, IsActive } — org-scoped.
- LoyaltyCard { ProgramId, CounterpartyId, CardNumber unique per org,
  Balance, IsBlocked }.
- Promotion { Type=Percent|FixedDiscount, Value, Scope=All|ProductGroups|
  Products, Code unique per org, period, ProductGroupIds/ProductIds (jsonb) }.
- RetailSale: LoyaltyCardId, LoyaltyBonusApplied, LoyaltyPointsAccrued,
  PromotionId, PromotionCode (snapshot), PromotionDiscount.

EF:
- SalesConfigurations: indexes, FK Restrict, jsonb-converters для Guid-
  списков Promotion (ValueComparer для change-tracker).
- Phase9b миграция: 3 таблицы + 6 колонок на retail_sales.
- RolePermissions: LoyaltyManage, PromotionsManage добавлены (попадают
  в All() для Admin).

API:
- /api/loyalty/programs CRUD (Get/List/Create/Update/Delete; запрет delete
  при существующих картах → 409).
- /api/loyalty/cards CRUD + /issue + /{id}/block + /{id}/unblock + /lookup
  (POS использует при оплате — 404 если нет, 409 если blocked/inactive).
- /api/promotions CRUD; код уникален per org (БД-индекс + 23505 → 409).
- RetailSale.Create/Update: новые поля input.LoyaltyCardNumber +
  input.PromotionCode. Метод ApplyLoyaltyAndPromotionAsync:
  • Lookup карты, проверка active/blocked/MinSubtotal.
  • Расчёт скидки или баллов в зависимости от Type.
  • Lookup промокода, проверка периода/MinSaleAmount/scope.
  • MatchingSubtotal для Scope=ProductGroups/Products считаем по
    input.Lines (sale.Lines ещё пустой в этот момент).
  • Финальный Total = Subtotal - DiscountTotal - LoyaltyBonusApplied
    - PromotionDiscount, max(0).
- RetailSale.Post: начисление баллов на LoyaltyCard.Balance (внутри
  транзакции, чтобы rollback не оставил orphan баллы).

UI:
- /loyalty/programs — list + create/edit modal с Type/Rate/MinSubtotal.
- /loyalty/cards — list + issue modal (Program select + AsyncSelect
  counterparty + CardNumber).
- /promotions — list + create/edit modal (Type/Value/период/MinSaleAmount/Code).
- Sidebar: новый блок «Продажи» с пунктами Промокоды/Программы/Карты
  (Admin-only).
- i18n: ru.json + en.json пополнены nav-ключами.

Тесты:
- LoyaltyFlowTests (3/3 ✓): percentage уменьшает Total на 10%, points-accrual
  пополняет Balance после Post, multi-tenant lookup→404 чужой org.
- PromotionFlowTests (2/2 ✓): SALE20 уменьшает Total на 20%, невалидный
  код→400 с понятной message и field=promotionCode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:06:10 +05:00

218 lines
11 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>End-to-end лояльности: создание программы Percentage 10% →
/// выпуск карты → создание чека с loyaltyCardNumber → проверка Total
/// уменьшился ровно на 10% от Subtotal.
///
/// Multi-tenant: B не видит программу/карту A.</summary>
[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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
// 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<JsonElement>()).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<JsonElement>()).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<JsonElement>()).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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
var cpA = await (await actorA.Http.PostAsJsonAsync("/api/catalog/counterparties",
new { name = "cp", type = 1 })).Content.ReadFromJsonAsync<JsonElement>();
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<JsonElement>();
var productId = prod.GetProperty("id").GetString()!;
var supplier = await (await actor.Http.PostAsJsonAsync("/api/catalog/counterparties",
new { name = "Sup", type = 2 })).Content.ReadFromJsonAsync<JsonElement>();
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<JsonElement>();
(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()!);
}
}