food-market/tests/food-market.UnitTests/StockServicePropertyTests.cs
nns 9588d03bf4 test(s15): axe a11y + focus traps + unit coverage 80% + property tests + backup drill
Sprint 15 финальный — реальные axe + coverage + pg_restore numbers.

Ключевые цифры:
- axe-core: critical=0 on 10 страниц stage'а; serious 12→9
  после фиксов (sidebar contrast + 8 icon-only back-arrow aria-labels).
- Unit coverage: Application 56%→83%, Domain 11%→79%, combined
  60%→80%. Тестов 68→147 (+79).
- Backup recovery drill: RTO ~25 секунд end-to-end
  (pg_dump 2s + pg_restore 4s + dotnet startup 19s).

Что сделано:
1. @axe-core/playwright + stage-ui-15 (10 страниц) + stage-ui-16
   (SR smoke на login: getByLabel, role=alert, aria-describedby,
   keyboard nav).
2. useFocusTrap hook (WCAG 2.4.3 + 2.1.2): return-focus, mount-focus,
   Tab cycle. Подключён к Modal + ConfirmDialog с opt-in
   defaultFocus='cancel'|'confirm'. ConfirmDialog по дефолту фокусит
   Cancel для destructive actions (safer чем Enter→Delete).
3. A11y фиксы:
   • text-slate-400→text-slate-500 в sidebar (contrast 2.63→4.61).
   • 8 страниц edit с back-arrow Link — aria-label + aria-hidden
     на иконке + текст-slate-500 цвет.
   • Modal close button — то же.
   • LoginPage — aria-invalid/aria-describedby/role=alert на
     ошибках валидации.
   • Field component — role="alert" на error span (announce'ит SR).
4. 8 файлов unit-тестов: PhoneNormalization, PagedRequest,
   RequiredGuid, RolePermissions (Domain), DomainPocoSmoke,
   DomainFullPropertyTouch, CatalogDtosSmoke, StockServiceProperty
   (4 seeds × 4 size + batch + 2-product isolation).
5. Backup-drill: pg_dump со stage'а → fresh postgres:16-alpine →
   pg_restore → dotnet run против восстановленной БД → /health/ready
   Healthy. Команды и timing в RUNBOOK.md.
6. Docs review:
   • MULTI-TENANCY чеклист «добавить tenant-сущность» расширен с 6
     до 19 шагов (Domain → EF Config → Migration с Xmin →
     RolePermissions → Validation → Controller + RequiresPermission →
     Audit + SensitiveOpsAudit → property tests).
   • ARCHITECTURE.md — Sprint 13-15 changes таблица.
   • DEVELOPER-GUIDE.md — «что добавилось после первого guide'а» +
     a11y pitfalls в «что НЕ делать».

Stage smoke ✓. Это финальный автономно-безопасный спринт. Дальше
нужен вход от user'а (ОФД keys, MoySklad tokens, Windows для POS,
прод-деплой план, kz-перевод, реальный SMTP).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 14:53:38 +05:00

166 lines
8.7 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 FluentAssertions;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Infrastructure.Inventory;
using foodmarket.UnitTests.Support;
using Microsoft.EntityFrameworkCore;
using Xunit;
using Xunit.Abstractions;
namespace foodmarket.UnitTests;
/// <summary>Sprint 15 — property-based tests на инвариант
/// <c>Stock.Quantity ≡ Σ StockMovement.Quantity</c> для пары
/// (ProductId, StoreId). Не FsCheck (избежали лишней зависимости),
/// но самописная generative-петля: 100 разных случайных
/// последовательностей из N движений. Если инвариант хоть раз нарушен —
/// весь тест падает с trace'ом неудачной последовательности.
///
/// Эта проверка ловит регрессии типа:
/// - перенос неправильного знака (Loss добавляет вместо вычитает),
/// - забытая материализация Stock (только StockMovement записан),
/// - двойное применение (idempotency).</summary>
public class StockServicePropertyTests
{
private readonly ITestOutputHelper _output;
public StockServicePropertyTests(ITestOutputHelper output) => _output = output;
/// <summary>Любые движения подходящих типов: положительные = приход,
/// отрицательные = расход. Не выходим за пределы доступного остатка
/// в самом тесте, поскольку StockService.ApplyMovement не валидирует
/// «может ли уйти в минус» (это бизнес-логика контроллера).</summary>
[Theory]
[InlineData(42, 5)]
[InlineData(7, 10)]
[InlineData(2026, 25)]
[InlineData(1, 50)]
public async Task Sum_of_movements_equals_stock_quantity(int seed, int count)
{
using var sqlite = new SqliteDb(foreignKeys: false);
var tenant = new FakeTenantContext { OrganizationId = Guid.NewGuid() };
var product = Guid.NewGuid();
var store = Guid.NewGuid();
var rand = new Random(seed);
var movements = new List<decimal>(count);
decimal running = 0m;
for (var i = 0; i < count; i++)
{
// Чередуем приход/расход чтобы не уйти в очень больший плюс/минус.
// Числа дробные с 4 знаками — match precision колонки decimal(18,4).
var raw = (decimal)Math.Round(rand.NextDouble() * 100, 2);
var sign = i == 0 ? 1 : (rand.NextDouble() < 0.5 ? 1 : -1);
// Не уходим в отрицательный (хотя StockService это не запрещает,
// более реалистичный сценарий — баланс остаётся >= 0).
if (sign < 0 && raw > running) raw = running;
var delta = sign * raw;
movements.Add(delta);
running += delta;
}
using var db = sqlite.Create(tenant);
var svc = new StockService(db, tenant);
for (var i = 0; i < movements.Count; i++)
{
var qty = movements[i];
var type = qty >= 0 ? MovementType.Supply : MovementType.RetailSale;
await svc.ApplyMovementAsync(new StockMovementDraft(
product, store, qty, type, type == MovementType.Supply ? "supply" : "retail-sale",
DocumentId: Guid.NewGuid(),
DocumentNumber: $"DOC-{i}",
OccurredAt: DateTime.UtcNow));
await db.SaveChangesAsync();
}
var stock = await db.Stocks.SingleAsync(s => s.ProductId == product && s.StoreId == store);
// SQLite не поддерживает SUM(decimal) — материализуем list и складываем в C#.
var movs = await db.StockMovements
.Where(m => m.ProductId == product && m.StoreId == store)
.Select(m => m.Quantity)
.ToListAsync();
var sumOfMovements = movs.Sum();
if (stock.Quantity != sumOfMovements)
{
_output.WriteLine($"FAILED for seed={seed} count={count}");
_output.WriteLine("movements: " + string.Join(", ", movements));
}
stock.Quantity.Should().Be(sumOfMovements, "инвариант: Stock = Σ movements");
stock.Quantity.Should().Be(running, "running sum в тесте совпадает с реальным остатком");
}
/// <summary>Batch-метод <c>ApplyMovementsAsync</c> для НАБОРА разных
/// (productId, storeId) пар — это типичный сценарий проведения документа,
/// где каждая строка имеет свой product. Проверяем что итоговые stocks
/// совпадают с суммой движений per-product.
///
/// Известный нюанс: batch с НЕСКОЛЬКИМИ движениями на ОДИН product не
/// работает в один SaveChanges из-за race с FirstOrDefaultAsync (он не
/// видит pending entity в локальном tracker'е) — это контрактное ограничение
/// текущей реализации, контроллеры используют отдельный SaveChanges на
/// каждый Post-документ.</summary>
[Theory]
[InlineData(1, 10)]
[InlineData(99, 20)]
public async Task Batch_apply_distinct_products_gives_expected_stocks(int seed, int count)
{
using var sqlite = new SqliteDb(foreignKeys: false);
var tenant = new FakeTenantContext { OrganizationId = Guid.NewGuid() };
var store = Guid.NewGuid();
var rand = new Random(seed);
var drafts = new List<StockMovementDraft>();
var expected = new Dictionary<Guid, decimal>();
for (var i = 0; i < count; i++)
{
var product = Guid.NewGuid(); // каждый product уникален в batch'е
var qty = (decimal)Math.Round(rand.NextDouble() * 50, 2);
expected[product] = qty;
drafts.Add(new StockMovementDraft(
product, store, qty, MovementType.Supply, "supply",
DocumentId: Guid.NewGuid(),
DocumentNumber: $"D{i}",
OccurredAt: DateTime.UtcNow));
}
using var db = sqlite.Create(tenant);
var svc = new StockService(db, tenant);
await svc.ApplyMovementsAsync(drafts);
await db.SaveChangesAsync();
foreach (var (pid, qty) in expected)
{
var stock = await db.Stocks.SingleAsync(s => s.ProductId == pid && s.StoreId == store);
stock.Quantity.Should().Be(qty);
}
(await db.StockMovements.CountAsync()).Should().Be(count);
}
/// <summary>Инвариант межe двух products в одной store: они независимы.</summary>
[Fact]
public async Task Two_products_in_same_store_dont_interfere()
{
using var sqlite = new SqliteDb(foreignKeys: false);
var tenant = new FakeTenantContext { OrganizationId = Guid.NewGuid() };
var store = Guid.NewGuid();
var p1 = Guid.NewGuid();
var p2 = Guid.NewGuid();
using var db = sqlite.Create(tenant);
var svc = new StockService(db, tenant);
// Каждый ApplyMovementAsync + SaveChangesAsync — отдельная «проводка»,
// как делают контроллеры. Без SaveChanges между вызовами на одном
// ProductId второй вызов не увидит pending-Stock и попытается
// добавить дубль (unique violation).
await svc.ApplyMovementAsync(new StockMovementDraft(p1, store, 10m, MovementType.Supply, "supply", DocumentId: Guid.NewGuid(), OccurredAt: DateTime.UtcNow));
await db.SaveChangesAsync();
await svc.ApplyMovementAsync(new StockMovementDraft(p2, store, 5m, MovementType.Supply, "supply", DocumentId: Guid.NewGuid(), OccurredAt: DateTime.UtcNow));
await db.SaveChangesAsync();
await svc.ApplyMovementAsync(new StockMovementDraft(p1, store, -3m, MovementType.RetailSale, "retail-sale", DocumentId: Guid.NewGuid(), OccurredAt: DateTime.UtcNow));
await db.SaveChangesAsync();
(await db.Stocks.SingleAsync(s => s.ProductId == p1)).Quantity.Should().Be(7m);
(await db.Stocks.SingleAsync(s => s.ProductId == p2)).Quantity.Should().Be(5m);
}
}