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:
parent
b613adf558
commit
f3d517f257
|
|
@ -59,6 +59,7 @@
|
||||||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||||
<PackageVersion Include="coverlet.collector" Version="6.0.3" />
|
<PackageVersion Include="coverlet.collector" Version="6.0.3" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.11" />
|
<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" />
|
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.1.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.pos.core", "src
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.pos", "src\food-market.pos\food-market.pos.csproj", "{B178B74E-A739-4722-BFA8-D9AB694024BB}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.pos", "src\food-market.pos\food-market.pos.csproj", "{B178B74E-A739-4722-BFA8-D9AB694024BB}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{B178B74E-A739-4722-BFA8-D9AB694024BB}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{BB7142C2-94F3-423F-938C-A44FF79133C0} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
|
{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}
|
{9E075C56-081E-4ABB-8DB3-ED649FD696FA} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
|
||||||
{BF3FBFD2-F40D-4510-8067-37305FFE1D14} = {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}
|
{B178B74E-A739-4722-BFA8-D9AB694024BB} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
|
||||||
|
{D8556BE1-70E3-49AD-84FD-209C80B17B57} = {F29E3026-31A5-4277-A265-081E87C76A28}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|
|
||||||
|
|
@ -289,12 +289,9 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
.Where(s => s.ProductId == line.ProductId)
|
.Where(s => s.ProductId == line.ProductId)
|
||||||
.SumAsync(s => (decimal?)s.Quantity, ct) ?? 0m;
|
.SumAsync(s => (decimal?)s.Quantity, ct) ?? 0m;
|
||||||
|
|
||||||
// 1. Cost — скользящее среднее.
|
// 1. Cost — скользящее среднее (формула в MovingAverageCost, юнит-тест).
|
||||||
var totalQty = currentQty + line.Quantity;
|
product.Cost = foodmarket.Application.Inventory.MovingAverageCost.Compute(
|
||||||
var newCost = totalQty == 0m || product.Cost == 0m && currentQty == 0m
|
currentQty, product.Cost, line.Quantity, line.UnitPrice);
|
||||||
? line.UnitPrice
|
|
||||||
: (currentQty * product.Cost + line.Quantity * line.UnitPrice) / totalQty;
|
|
||||||
product.Cost = Math.Round(newCost, 4, MidpointRounding.AwayFromZero);
|
|
||||||
|
|
||||||
// 2. ReferencePrice — автозаполнение при первой приёмке.
|
// 2. ReferencePrice — автозаполнение при первой приёмке.
|
||||||
if (product.ReferencePrice is null)
|
if (product.ReferencePrice is null)
|
||||||
|
|
|
||||||
|
|
@ -308,10 +308,13 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
// Сдача — нормально (PaidCash > Total), недоплата — нет. Иначе касса
|
// Сдача — нормально (PaidCash > Total), недоплата — нет. Иначе касса
|
||||||
// может «провести» чек на 5000 ₸, не получив с покупателя ни тенге.
|
// может «провести» чек на 5000 ₸, не получив с покупателя ни тенге.
|
||||||
// Округление до 2 знаков защищает от floating-point дрейфа.
|
// Округление до 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 paid = decimal.Round(sale.PaidCash + sale.PaidCard, 2);
|
||||||
var due = decimal.Round(sale.Total, 2);
|
var due = decimal.Round(sale.Total, 2);
|
||||||
if (paid < due)
|
|
||||||
{
|
|
||||||
return BadRequest(new
|
return BadRequest(new
|
||||||
{
|
{
|
||||||
error = $"Сумма оплаты {paid} меньше итога {due}. Доплатите или измените позиции чека.",
|
error = $"Сумма оплаты {paid} меньше итога {due}. Доплатите или измените позиции чека.",
|
||||||
|
|
|
||||||
25
src/food-market.application/Inventory/MovingAverageCost.cs
Normal file
25
src/food-market.application/Inventory/MovingAverageCost.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/food-market.application/Sales/RetailPaymentValidator.cs
Normal file
15
src/food-market.application/Sales/RetailPaymentValidator.cs
Normal 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);
|
||||||
|
}
|
||||||
58
tests/food-market.UnitTests/MovingAverageCostTests.cs
Normal file
58
tests/food-market.UnitTests/MovingAverageCostTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
tests/food-market.UnitTests/RetailPaymentValidatorTests.cs
Normal file
34
tests/food-market.UnitTests/RetailPaymentValidatorTests.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
99
tests/food-market.UnitTests/StockServiceTests.cs
Normal file
99
tests/food-market.UnitTests/StockServiceTests.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tests/food-market.UnitTests/Support/FakeTenantContext.cs
Normal file
13
tests/food-market.UnitTests/Support/FakeTenantContext.cs
Normal 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; }
|
||||||
|
}
|
||||||
38
tests/food-market.UnitTests/Support/SqliteDb.cs
Normal file
38
tests/food-market.UnitTests/Support/SqliteDb.cs
Normal 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();
|
||||||
|
}
|
||||||
81
tests/food-market.UnitTests/TenantQueryFilterTests.cs
Normal file
81
tests/food-market.UnitTests/TenantQueryFilterTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
tests/food-market.UnitTests/food-market.UnitTests.csproj
Normal file
29
tests/food-market.UnitTests/food-market.UnitTests.csproj
Normal 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>
|
||||||
Loading…
Reference in a new issue