Чистая логика вынесена в 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>
100 lines
3.9 KiB
C#
100 lines
3.9 KiB
C#
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>();
|
||
}
|
||
}
|