food-market/tests/food-market.UnitTests/StockServiceTests.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

100 lines
4.1 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;
namespace foodmarket.UnitTests;
/// <summary>StockService.ApplyMovement: материализация Stock + запись StockMovement.
/// FK отключены — тестируем арифметику остатка и факт записи движения, не целостность.</summary>
public class StockServiceTests
{
private static StockMovementDraft Draft(Guid product, Guid store, decimal qty) =>
new(product, store, qty, MovementType.Supply, "supply",
DocumentId: Guid.NewGuid(), DocumentNumber: "DOC-1", UnitCost: 100m, OccurredAt: DateTime.UtcNow);
[Fact]
public async Task Creates_stock_and_movement_on_first_application()
{
using var sqlite = new SqliteDb(foreignKeys: false);
var org = Guid.NewGuid();
var tenant = new FakeTenantContext { OrganizationId = org };
var product = Guid.NewGuid();
var store = Guid.NewGuid();
decimal returned;
using (var db = sqlite.Create(tenant))
{
returned = await new StockService(db, tenant, NoopNegativeStockAlertService.Instance).ApplyMovementAsync(Draft(product, store, 10m));
await db.SaveChangesAsync();
}
returned.Should().Be(10m);
using (var db = sqlite.Create(tenant))
{
var stock = await db.Stocks.SingleAsync(s => s.ProductId == product && s.StoreId == store);
stock.Quantity.Should().Be(10m);
stock.OrganizationId.Should().Be(org);
(await db.StockMovements.CountAsync()).Should().Be(1);
}
}
// Каждое проведение документа = свой SaveChanges, поэтому повторное движение
// по тому же товару находит уже материализованный Stock и инкрементит его.
[Fact]
public async Task Second_application_increments_existing_stock()
{
using var sqlite = new SqliteDb(foreignKeys: false);
var tenant = new FakeTenantContext { OrganizationId = Guid.NewGuid() };
var product = Guid.NewGuid();
var store = Guid.NewGuid();
using var db = sqlite.Create(tenant);
var svc = new StockService(db, tenant, NoopNegativeStockAlertService.Instance);
await svc.ApplyMovementAsync(Draft(product, store, 10m));
await db.SaveChangesAsync();
var afterSecond = await svc.ApplyMovementAsync(Draft(product, store, 5m));
await db.SaveChangesAsync();
afterSecond.Should().Be(15m);
(await db.Stocks.SingleAsync(s => s.ProductId == product && s.StoreId == store)).Quantity.Should().Be(15m);
(await db.StockMovements.CountAsync()).Should().Be(2);
}
[Fact]
public async Task Negative_movement_decrements_stock()
{
using var sqlite = new SqliteDb(foreignKeys: false);
var tenant = new FakeTenantContext { OrganizationId = Guid.NewGuid() };
var product = Guid.NewGuid();
var store = Guid.NewGuid();
using var db = sqlite.Create(tenant);
var svc = new StockService(db, tenant, NoopNegativeStockAlertService.Instance);
await svc.ApplyMovementAsync(Draft(product, store, 10m));
await db.SaveChangesAsync();
var afterSale = await svc.ApplyMovementAsync(Draft(product, store, -3m));
await db.SaveChangesAsync();
afterSale.Should().Be(7m);
}
[Fact]
public async Task Throws_when_tenant_not_set()
{
using var sqlite = new SqliteDb(foreignKeys: false);
var tenant = new FakeTenantContext { OrganizationId = null };
using var db = sqlite.Create(tenant);
var svc = new StockService(db, tenant, NoopNegativeStockAlertService.Instance);
await FluentActions
.Awaiting(() => svc.ApplyMovementAsync(Draft(Guid.NewGuid(), Guid.NewGuid(), 1m)))
.Should().ThrowAsync<InvalidOperationException>();
}
}