food-market/tests/food-market.UnitTests/StockServicePropertyTests.cs
nns 29a951a6ff
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
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 API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(notifications): центр уведомлений + агрегация/эскалация минусов + каналы
Персистентные уведомления (таблица notifications, Phase41) + REST
/api/notifications (список/счётчик/read/read-all) → центр уведомлений в вебе
(колокольчик, API-backed) и мобильном API.

Фоновый джоб NegativeStockNotificationJob (cron */5): агрегирует новые минусы
в ОДНО уведомление на склад (дебаунс по NotifiedAt + интервал орг., по умолч.
15 мин), уважает тихие часы (Asia/Almaty); эскалация просроченных (>N часов,
по умолч. 24) владельцу/админам. Получатели — админы, владелец, кладовщики
(право InventoryEdit). Каналы: SignalR (живой колокольчик), Telegram (владельцу),
push-заглушка.

Настройки организации: NegStockNotify{Enabled,IntervalMinutes,QuietFrom/ToHour,
EscalateHours} + GET/PUT /api/organization/settings + секция в настройках веба.
Блок «Просроченные минусы» на дашборде.

Тесты: unit тихих часов 3/3; integration — джоб создаёт уведомление админу,
дебаунс, read-all 3/3 (вся группа NegativeStockTests зелёная).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 02:56:59 +05:00

166 lines
8.8 KiB
C#
Raw 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, NoopNegativeStockAlertService.Instance);
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, NoopNegativeStockAlertService.Instance);
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, NoopNegativeStockAlertService.Instance);
// Каждый 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);
}
}