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>
This commit is contained in:
nns 2026-05-27 03:01:56 +05:00
parent b613adf558
commit f3d517f257
13 changed files with 411 additions and 9 deletions

View file

@ -59,6 +59,7 @@
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.3" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.1.0" />
</ItemGroup>
</Project>

View file

@ -19,6 +19,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.pos.core", "src
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.pos", "src\food-market.pos\food-market.pos.csproj", "{B178B74E-A739-4722-BFA8-D9AB694024BB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F29E3026-31A5-4277-A265-081E87C76A28}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.UnitTests", "tests\food-market.UnitTests\food-market.UnitTests.csproj", "{D8556BE1-70E3-49AD-84FD-209C80B17B57}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -56,6 +60,10 @@ Global
{B178B74E-A739-4722-BFA8-D9AB694024BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B178B74E-A739-4722-BFA8-D9AB694024BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B178B74E-A739-4722-BFA8-D9AB694024BB}.Release|Any CPU.Build.0 = Release|Any CPU
{D8556BE1-70E3-49AD-84FD-209C80B17B57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D8556BE1-70E3-49AD-84FD-209C80B17B57}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D8556BE1-70E3-49AD-84FD-209C80B17B57}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D8556BE1-70E3-49AD-84FD-209C80B17B57}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{BB7142C2-94F3-423F-938C-A44FF79133C0} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
@ -65,5 +73,6 @@ Global
{9E075C56-081E-4ABB-8DB3-ED649FD696FA} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
{BF3FBFD2-F40D-4510-8067-37305FFE1D14} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
{B178B74E-A739-4722-BFA8-D9AB694024BB} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
{D8556BE1-70E3-49AD-84FD-209C80B17B57} = {F29E3026-31A5-4277-A265-081E87C76A28}
EndGlobalSection
EndGlobal

View file

@ -289,12 +289,9 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
.Where(s => s.ProductId == line.ProductId)
.SumAsync(s => (decimal?)s.Quantity, ct) ?? 0m;
// 1. Cost — скользящее среднее.
var totalQty = currentQty + line.Quantity;
var newCost = totalQty == 0m || product.Cost == 0m && currentQty == 0m
? line.UnitPrice
: (currentQty * product.Cost + line.Quantity * line.UnitPrice) / totalQty;
product.Cost = Math.Round(newCost, 4, MidpointRounding.AwayFromZero);
// 1. Cost — скользящее среднее (формула в MovingAverageCost, юнит-тест).
product.Cost = foodmarket.Application.Inventory.MovingAverageCost.Compute(
currentQty, product.Cost, line.Quantity, line.UnitPrice);
// 2. ReferencePrice — автозаполнение при первой приёмке.
if (product.ReferencePrice is null)

View file

@ -308,10 +308,13 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
// Сдача — нормально (PaidCash > Total), недоплата — нет. Иначе касса
// может «провести» чек на 5000 ₸, не получив с покупателя ни тенге.
// Округление до 2 знаков защищает от floating-point дрейфа.
// Правило вынесено в RetailPaymentValidator (юнит-тест). paid/due также
// считаем локально для текста ошибки — округление совпадает с валидатором.
if (!foodmarket.Application.Sales.RetailPaymentValidator.IsSufficient(
sale.PaidCash, sale.PaidCard, sale.Total))
{
var paid = decimal.Round(sale.PaidCash + sale.PaidCard, 2);
var due = decimal.Round(sale.Total, 2);
if (paid < due)
{
return BadRequest(new
{
error = $"Сумма оплаты {paid} меньше итога {due}. Доплатите или измените позиции чека.",

View file

@ -0,0 +1,25 @@
namespace foodmarket.Application.Inventory;
/// <summary>Скользящее средневзвешенное себестоимости товара при приёмке.
///
/// Себестоимость пересчитывается так, чтобы отражать средневзвешенную цену
/// всех закупленных единиц: <c>(остаток·текущаяСебестоимость + приход·ценаЗакупки)
/// / (остаток + приход)</c>. На первой приёмке (нет остатка и себестоимости)
/// берётся цена закупки. Результат округляется до 4 знаков.
///
/// Вынесено из <c>SuppliesController.Post</c> для юнит-тестируемости —
/// контроллер вызывает этот метод.</summary>
public static class MovingAverageCost
{
public static decimal Compute(
decimal currentQty, decimal currentCost, decimal incomingQty, decimal incomingUnitPrice)
{
var totalQty = currentQty + incomingQty;
// Порядок важен: && связывает крепче ||, т.е.
// (totalQty==0) || ((currentCost==0) && (currentQty==0)).
var newCost = totalQty == 0m || currentCost == 0m && currentQty == 0m
? incomingUnitPrice
: (currentQty * currentCost + incomingQty * incomingUnitPrice) / totalQty;
return Math.Round(newCost, 4, MidpointRounding.AwayFromZero);
}
}

View file

@ -0,0 +1,15 @@
namespace foodmarket.Application.Sales;
/// <summary>Правило достаточности оплаты розничного чека при проведении.
///
/// Сумма наличных + по карте должна покрывать итог чека. Переплата (сдача)
/// допустима, недоплата — нет (иначе касса может «провести» чек, не получив
/// денег). Округление до 2 знаков защищает от floating-point дрейфа.
///
/// Вынесено из <c>RetailSalesController.Post</c> для юнит-тестируемости —
/// контроллер вызывает этот метод для решения о проведении.</summary>
public static class RetailPaymentValidator
{
public static bool IsSufficient(decimal paidCash, decimal paidCard, decimal total)
=> decimal.Round(paidCash + paidCard, 2) >= decimal.Round(total, 2);
}

View file

@ -0,0 +1,58 @@
using FluentAssertions;
using foodmarket.Application.Inventory;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Скользящее средневзвешенное себестоимости (логика SuppliesController.Post).</summary>
public class MovingAverageCostTests
{
[Fact]
public void First_supply_with_no_stock_uses_purchase_price()
{
// нет остатка, нет себестоимости → берём цену закупки
MovingAverageCost.Compute(currentQty: 0m, currentCost: 0m, incomingQty: 10m, incomingUnitPrice: 100m)
.Should().Be(100m);
}
[Fact]
public void Weighted_average_of_old_and_new()
{
// 10 шт по 100 + 10 шт по 200 → среднее 150
MovingAverageCost.Compute(currentQty: 10m, currentCost: 100m, incomingQty: 10m, incomingUnitPrice: 200m)
.Should().Be(150m);
}
[Fact]
public void Weighted_average_respects_quantities()
{
// 30 шт по 100 + 10 шт по 200 → (3000+2000)/40 = 125
MovingAverageCost.Compute(currentQty: 30m, currentCost: 100m, incomingQty: 10m, incomingUnitPrice: 200m)
.Should().Be(125m);
}
[Fact]
public void Result_is_rounded_to_four_decimals()
{
// (1*1 + 2*2)/3 = 1.6666... → 1.6667
MovingAverageCost.Compute(currentQty: 1m, currentCost: 1m, incomingQty: 2m, incomingUnitPrice: 2m)
.Should().Be(1.6667m);
}
[Fact]
public void Existing_stock_with_zero_cost_still_averages_when_qty_positive()
{
// currentCost==0 но currentQty>0 → НЕ берём цену закупки целиком,
// считаем среднее: (5*0 + 5*200)/10 = 100
MovingAverageCost.Compute(currentQty: 5m, currentCost: 0m, incomingQty: 5m, incomingUnitPrice: 200m)
.Should().Be(100m);
}
[Fact]
public void Zero_total_quantity_falls_back_to_purchase_price()
{
// возврат/сторно может дать totalQty==0 → не делим на ноль
MovingAverageCost.Compute(currentQty: -10m, currentCost: 50m, incomingQty: 10m, incomingUnitPrice: 70m)
.Should().Be(70m);
}
}

View file

@ -0,0 +1,34 @@
using FluentAssertions;
using foodmarket.Application.Sales;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Достаточность оплаты розничного чека (логика RetailSalesController.Post).</summary>
public class RetailPaymentValidatorTests
{
[Theory]
[InlineData(1000, 0, 1000)] // ровно наличными
[InlineData(0, 1000, 1000)] // ровно картой
[InlineData(600, 400, 1000)] // смешанная оплата, ровно
[InlineData(1500, 0, 1000)] // переплата наличными (сдача) — ок
[InlineData(700, 700, 1000)] // переплата смешанная — ок
public void Sufficient_payment_passes(decimal cash, decimal card, decimal total)
=> RetailPaymentValidator.IsSufficient(cash, card, total).Should().BeTrue();
[Theory]
[InlineData(999, 0, 1000)] // недоплата наличными
[InlineData(0, 0, 1000)] // не оплачено вовсе
[InlineData(400, 400, 1000)] // смешанная недоплата
public void Insufficient_payment_fails(decimal cash, decimal card, decimal total)
=> RetailPaymentValidator.IsSufficient(cash, card, total).Should().BeFalse();
[Fact]
public void Rounds_to_two_decimals_before_comparing()
{
// 999.999 округляется до 1000.00 ≥ 1000 → ок (защита от float-дрейфа)
RetailPaymentValidator.IsSufficient(999.999m, 0m, 1000m).Should().BeTrue();
// 999.994 → 999.99 < 1000 → недоплата
RetailPaymentValidator.IsSufficient(999.994m, 0m, 1000m).Should().BeFalse();
}
}

View file

@ -0,0 +1,99 @@
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>();
}
}

View file

@ -0,0 +1,13 @@
using foodmarket.Application.Common.Tenancy;
namespace foodmarket.UnitTests.Support;
/// <summary>Подменяемый ITenantContext для тестов: значения задаются напрямую,
/// без HttpContext/JWT.</summary>
public sealed class FakeTenantContext : ITenantContext
{
public Guid? OrganizationId { get; set; }
public bool IsAuthenticated { get; set; } = true;
public bool IsSuperAdmin { get; set; }
public bool IsTenantOverride { get; set; }
}

View file

@ -0,0 +1,38 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Persistence;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.UnitTests.Support;
/// <summary>SQLite in-memory БД для тестов, использующих реальный AppDbContext
/// (query-фильтр мультитенантности, StockService). Соединение держим открытым —
/// in-memory БД живёт, пока открыт коннект; разные DbContext'ы на одном коннекте
/// видят одни данные. EnsureCreated строит схему по реальной модели (включая
/// tenant query-filter).</summary>
public sealed class SqliteDb : IDisposable
{
private readonly SqliteConnection _connection;
/// <param name="foreignKeys">false — отключить проверку FK (для фокусных
/// тестов логики, где не хотим засевать все родительские строки).</param>
public SqliteDb(bool foreignKeys = true)
{
_connection = new SqliteConnection($"DataSource=:memory:;Foreign Keys={foreignKeys}");
_connection.Open();
// Схему создаём контекстом-сидером с правами SuperAdmin (фильтр на запись
// не влияет, но единообразно). EnsureCreated идемпотентен в рамках коннекта.
using var db = Create(new FakeTenantContext { IsSuperAdmin = true });
db.Database.EnsureCreated();
}
public AppDbContext Create(ITenantContext tenant)
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
return new AppDbContext(options, tenant);
}
public void Dispose() => _connection.Dispose();
}

View file

@ -0,0 +1,81 @@
using FluentAssertions;
using foodmarket.Domain.Organizations;
using foodmarket.UnitTests.Support;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Мультитенантный query-filter AppDbContext: tenant видит только свои
/// строки; SuperAdmin без override — все; SuperAdmin в режиме «открыть как…»
/// (override) снова ограничен выбранной оргой. EmployeeRole выбран как
/// TenantEntity без внешних FK — минимальный засев.</summary>
public class TenantQueryFilterTests
{
private static readonly Guid OrgA = Guid.NewGuid();
private static readonly Guid OrgB = Guid.NewGuid();
private static EmployeeRole Role(Guid org, string name) =>
new() { OrganizationId = org, Name = name, Permissions = new RolePermissions() };
private static async Task SeedTwoOrgsAsync(SqliteDb sqlite)
{
using var seed = sqlite.Create(new FakeTenantContext { IsSuperAdmin = true });
seed.EmployeeRoles.AddRange(Role(OrgA, "A-role"), Role(OrgB, "B-role"));
await seed.SaveChangesAsync();
}
[Fact]
public async Task Tenant_sees_only_its_own_rows()
{
using var sqlite = new SqliteDb();
await SeedTwoOrgsAsync(sqlite);
using var db = sqlite.Create(new FakeTenantContext { OrganizationId = OrgA });
var roles = await db.EmployeeRoles.ToListAsync();
roles.Should().ContainSingle()
.Which.OrganizationId.Should().Be(OrgA);
}
[Fact]
public async Task Other_tenant_cannot_see_foreign_rows()
{
using var sqlite = new SqliteDb();
await SeedTwoOrgsAsync(sqlite);
using var db = sqlite.Create(new FakeTenantContext { OrganizationId = OrgB });
var roles = await db.EmployeeRoles.ToListAsync();
roles.Should().OnlyContain(r => r.OrganizationId == OrgB);
roles.Should().HaveCount(1);
}
[Fact]
public async Task SuperAdmin_without_override_sees_all_tenants()
{
using var sqlite = new SqliteDb();
await SeedTwoOrgsAsync(sqlite);
using var db = sqlite.Create(new FakeTenantContext { IsSuperAdmin = true });
(await db.EmployeeRoles.CountAsync()).Should().Be(2);
}
[Fact]
public async Task SuperAdmin_with_override_is_scoped_to_selected_org()
{
using var sqlite = new SqliteDb();
await SeedTwoOrgsAsync(sqlite);
// «Открыть как…» орг A — изоляция обязана работать даже для SuperAdmin.
using var db = sqlite.Create(new FakeTenantContext
{
IsSuperAdmin = true,
IsTenantOverride = true,
OrganizationId = OrgA,
});
var roles = await db.EmployeeRoles.ToListAsync();
roles.Should().ContainSingle().Which.OrganizationId.Should().Be(OrgA);
}
}

View file

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>foodmarket.UnitTests</RootNamespace>
<AssemblyName>foodmarket.UnitTests</AssemblyName>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\food-market.domain\food-market.domain.csproj" />
<ProjectReference Include="..\..\src\food-market.application\food-market.application.csproj" />
<ProjectReference Include="..\..\src\food-market.infrastructure\food-market.infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
</Project>