Подключён MediatR в food-market.api с авторегистрацией из сборки food-market.application. Цель — показать паттерн, не полный рефакторинг контроллеров (это отдельный спринт). Образцы handler'ов в food-market.application: - Purchases/Commands/CreateSupplyCommand + CreateSupplyHandler — создание Draft-приёмки с делегированием персистентности через ISupplyWriter абстракцию (testable без EF). - Sales/Commands/PostRetailSaleCommand + PostRetailSaleHandler — проведение чека с валидацией платежа (переиспользует RetailPaymentValidator из Sprint 1) и делегированием stock-операции через IRetailSalePoster. - Sales/Queries/GetSalesReportQuery + GetSalesReportHandler — агрегация плоских sale-строк по period:day/period:month/product. Pure-функция, безопасно тестируется в памяти. Контроллеры пока используют прежние flow (контроллер → EF напрямую) — поэтапная миграция, не big-bang. Эти handler'ы — образец, на который оглядываемся при следующих feature'ах. Тесты: 6 unit (2 GetSalesReportHandler, 1 CreateSupplyHandler, 3 PostRetailSaleHandler). 57 unit total зелёных. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
139 lines
4.9 KiB
C#
139 lines
4.9 KiB
C#
using FluentAssertions;
|
|
using foodmarket.Application.Purchases.Commands;
|
|
using foodmarket.Application.Sales.Commands;
|
|
using foodmarket.Application.Sales.Queries;
|
|
using Xunit;
|
|
|
|
namespace foodmarket.UnitTests;
|
|
|
|
public class GetSalesReportHandlerTests
|
|
{
|
|
private readonly GetSalesReportHandler _h = new();
|
|
|
|
[Fact]
|
|
public async Task Groups_by_day_sums_revenue()
|
|
{
|
|
var d1 = new DateTime(2026, 5, 28, 10, 0, 0, DateTimeKind.Utc);
|
|
var d2 = new DateTime(2026, 5, 29, 11, 0, 0, DateTimeKind.Utc);
|
|
var lines = new[]
|
|
{
|
|
new FlatSale(Guid.NewGuid(), d1, Guid.NewGuid(), "A", 100m, 1),
|
|
new FlatSale(Guid.NewGuid(), d1, Guid.NewGuid(), "B", 200m, 1),
|
|
new FlatSale(Guid.NewGuid(), d2, Guid.NewGuid(), "A", 50m, 1),
|
|
};
|
|
var rows = await _h.Handle(new GetSalesReportQuery(lines, "period:day"), CancellationToken.None);
|
|
rows.Should().HaveCount(2);
|
|
rows.First(r => r.Key == "2026-05-28").Revenue.Should().Be(300m);
|
|
rows.First(r => r.Key == "2026-05-29").Revenue.Should().Be(50m);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Groups_by_product()
|
|
{
|
|
var pa = Guid.NewGuid();
|
|
var pb = Guid.NewGuid();
|
|
var lines = new[]
|
|
{
|
|
new FlatSale(Guid.NewGuid(), DateTime.UtcNow, pa, "A", 100m, 1),
|
|
new FlatSale(Guid.NewGuid(), DateTime.UtcNow, pa, "A", 50m, 1),
|
|
new FlatSale(Guid.NewGuid(), DateTime.UtcNow, pb, "B", 300m, 2),
|
|
};
|
|
var rows = await _h.Handle(new GetSalesReportQuery(lines, "product"), CancellationToken.None);
|
|
rows.Should().HaveCount(2);
|
|
rows.First(r => r.Key == pb.ToString()).Revenue.Should().Be(300m);
|
|
rows.First(r => r.Key == pa.ToString()).Revenue.Should().Be(150m);
|
|
}
|
|
}
|
|
|
|
public class CreateSupplyHandlerTests
|
|
{
|
|
[Fact]
|
|
public async Task Computes_total_calls_writer()
|
|
{
|
|
var writer = new FakeWriter();
|
|
var h = new CreateSupplyHandler(writer);
|
|
var cmd = new CreateSupplyCommand(
|
|
Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(),
|
|
DateTime.UtcNow, "test",
|
|
new[]
|
|
{
|
|
new CreateSupplyLine(Guid.NewGuid(), 2m, 100m),
|
|
new CreateSupplyLine(Guid.NewGuid(), 3m, 50m),
|
|
});
|
|
var result = await h.Handle(cmd, CancellationToken.None);
|
|
result.Total.Should().Be(350m); // 2*100 + 3*50
|
|
result.Number.Should().Be("П-2026-000001");
|
|
writer.LastCreated.Should().NotBeNull();
|
|
writer.LastCreated!.Value.lines.Should().HaveCount(2);
|
|
}
|
|
|
|
private sealed class FakeWriter : ISupplyWriter
|
|
{
|
|
public (Guid id, decimal total, IReadOnlyList<CreateSupplyLine> lines)? LastCreated;
|
|
|
|
public Task<string> NextNumberAsync(int year, CancellationToken ct)
|
|
=> Task.FromResult($"П-{year}-000001");
|
|
|
|
public Task<Guid> CreateAsync(Guid supplierId, Guid storeId, Guid currencyId,
|
|
DateTime date, string? notes, string number,
|
|
IReadOnlyList<CreateSupplyLine> lines, decimal total, CancellationToken ct)
|
|
{
|
|
var id = Guid.NewGuid();
|
|
LastCreated = (id, total, lines);
|
|
return Task.FromResult(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
public class PostRetailSaleHandlerTests
|
|
{
|
|
[Fact]
|
|
public async Task Insufficient_payment_returns_error()
|
|
{
|
|
var poster = new FakePoster();
|
|
var h = new PostRetailSaleHandler(poster);
|
|
var result = await h.Handle(new PostRetailSaleCommand(
|
|
Guid.NewGuid(), PaidCash: 100m, PaidCard: 0m, Total: 500m,
|
|
Lines: new[] { new PostSaleLine(Guid.NewGuid(), 1m, 500m) }),
|
|
CancellationToken.None);
|
|
result.Posted.Should().BeFalse();
|
|
result.ErrorField.Should().Be("PaidCash");
|
|
poster.Called.Should().BeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Empty_lines_returns_error()
|
|
{
|
|
var poster = new FakePoster();
|
|
var h = new PostRetailSaleHandler(poster);
|
|
var result = await h.Handle(new PostRetailSaleCommand(
|
|
Guid.NewGuid(), 100m, 0m, 100m, Array.Empty<PostSaleLine>()),
|
|
CancellationToken.None);
|
|
result.Posted.Should().BeFalse();
|
|
result.ErrorField.Should().Be("lines");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Sufficient_payment_calls_poster()
|
|
{
|
|
var poster = new FakePoster();
|
|
var h = new PostRetailSaleHandler(poster);
|
|
var result = await h.Handle(new PostRetailSaleCommand(
|
|
Guid.NewGuid(), 500m, 0m, 500m,
|
|
new[] { new PostSaleLine(Guid.NewGuid(), 1m, 500m) }),
|
|
CancellationToken.None);
|
|
result.Posted.Should().BeTrue();
|
|
poster.Called.Should().BeTrue();
|
|
}
|
|
|
|
private sealed class FakePoster : IRetailSalePoster
|
|
{
|
|
public bool Called { get; private set; }
|
|
public Task PostAsync(Guid saleId, IReadOnlyList<PostSaleLine> lines, CancellationToken ct)
|
|
{
|
|
Called = true;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
}
|