food-market/tests/food-market.UnitTests/StockServiceTests.cs
nns f3d517f257 test(unit): xUnit-проект food-market.UnitTests, 23 теста (P1-20)
Чистая логика вынесена в Application для тестируемости и используется контроллерами:
- MovingAverageCost.Compute (скользящее среднее себестоимости) ← SuppliesController.Post
- RetailPaymentValidator.IsSufficient (достаточность оплаты) ← RetailSalesController.Post

Тесты:
- MovingAverageCost: первая приёмка, средневзвешенное, округление до 4 знаков, totalQty=0.
- RetailPaymentValidator: ровно/переплата/недоплата, округление до 2 знаков.
- StockService.ApplyMovement (SQLite in-memory): материализация Stock+движение,
  инкремент, отрицательное списание, throw без tenant.
- Мультитенантный query-filter AppDbContext: tenant видит своё; чужой не видит;
  SuperAdmin без override — всё; с override — только выбранную оргу.

Все 23 зелёные. EF8 SQLite поддерживает ToJson (EmployeeRole.Permissions).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 03:01:56 +05:00

100 lines
3.9 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;
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).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);
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);
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);
await FluentActions
.Awaiting(() => svc.ApplyMovementAsync(Draft(Guid.NewGuid(), Guid.NewGuid(), 1m)))
.Should().ThrowAsync<InvalidOperationException>();
}
}