using System.Net; using System.Text; using System.Text.Json; using FluentAssertions; using foodmarket.Application.Common.Fiscal; using foodmarket.Domain.Catalog; using foodmarket.Domain.Sales; using foodmarket.Infrastructure.Fiscal; using Xunit; namespace foodmarket.UnitTests; /// Webkassa-провайдер: чистые тесты на payload-builder (без HTTP) /// плюс end-to-end тест внутреннего метода через MockHttpHandler. /// /// Реальный HTTP-flow (Authorize → Check) не покрываем сквозь полный /// RegisterAsync — он зависит от per-organization кредов из БД (через /// IServiceScopeFactory), что требует тестового DbContext'а. Для этого /// есть integration-тест FiscalIntegrationTests с реальной БД. Здесь /// проверяем чистую логику маппинга, которая не требует никакой инфры. public class WebkassaProviderTests { private static RetailSale SaleWith(decimal subtotal, decimal vat, bool isReturn = false) { var unit = new UnitOfMeasure { Id = Guid.NewGuid(), Name = "шт", Code = "796" }; var p = new Product { Id = Guid.NewGuid(), Name = "Хлеб бородинский", UnitOfMeasureId = unit.Id }; var sale = new RetailSale { Id = Guid.NewGuid(), OrganizationId = Guid.NewGuid(), Number = "ПР-000001", Date = new DateTime(2026, 6, 7, 12, 0, 0, DateTimeKind.Utc), StoreId = Guid.NewGuid(), CurrencyId = Guid.NewGuid(), Total = subtotal, Subtotal = subtotal, PaidCash = subtotal, IsReturn = isReturn, }; sale.Lines.Add(new RetailSaleLine { ProductId = p.Id, Product = p, Quantity = 2m, UnitPrice = subtotal / 2m, LineTotal = subtotal, VatPercent = vat, }); return sale; } [Fact] public void Payload_maps_lines_payments_and_operation_type() { var sale = SaleWith(subtotal: 1000m, vat: 12m); var req = WebkassaProvider.BuildCheckPayload(sale, cashboxNumber: "CB-42", token: "tok"); req.Token.Should().Be("tok"); req.CashboxUniqueNumber.Should().Be("CB-42"); req.OperationType.Should().Be(1, "продажа = 1"); req.ExternalCheckNumber.Should().Be(sale.Number); req.Positions.Should().HaveCount(1); req.Positions[0].PositionName.Should().Be("Хлеб бородинский"); req.Positions[0].Count.Should().Be(2m); req.Positions[0].TaxPercent.Should().Be(12); // Tax «в-ставке»: 1000 * 12 / 112 ≈ 107.14 req.Positions[0].Tax.Should().BeApproximately(107.14m, 0.01m); req.Payments.Should().HaveCount(1); req.Payments[0].PaymentType.Should().Be(0); // cash req.Payments[0].Sum.Should().Be(1000m); } [Fact] public void Payload_marks_returns_with_operation_type_2() { var sale = SaleWith(subtotal: 500m, vat: 0m, isReturn: true); var req = WebkassaProvider.BuildCheckPayload(sale, "CB-1", "tok"); req.OperationType.Should().Be(2); } [Fact] public void Payload_uses_mixed_payments_when_both_cash_and_card() { var sale = SaleWith(subtotal: 1000m, vat: 0m); sale.PaidCash = 400m; sale.PaidCard = 600m; var req = WebkassaProvider.BuildCheckPayload(sale, "CB-1", "tok"); req.Payments.Should().HaveCount(2); req.Payments.Single(p => p.PaymentType == 0).Sum.Should().Be(400m); req.Payments.Single(p => p.PaymentType == 1).Sum.Should().Be(600m); } [Fact] public void Payload_fallbacks_to_total_when_no_explicit_payment() { // Сценарий «оплачено бонусами»: PaidCash/PaidCard оба 0, Total > 0. // Webkassa требует хотя бы один Payment — провайдер добавляет fallback // на Total как наличные, иначе оператор отвергнет чек. var sale = SaleWith(subtotal: 800m, vat: 0m); sale.PaidCash = 0m; sale.PaidCard = 0m; var req = WebkassaProvider.BuildCheckPayload(sale, "CB-1", "tok"); req.Payments.Should().HaveCount(1); req.Payments[0].Sum.Should().Be(800m); } [Fact] public void Payload_with_zero_vat_does_not_divide_by_zero() { // 0% НДС → формула tax-в-ставке должна не падать. Tax == 0. var sale = SaleWith(subtotal: 1000m, vat: 0m); var req = WebkassaProvider.BuildCheckPayload(sale, "CB-1", "tok"); req.Positions[0].Tax.Should().Be(0m); } /// Smoke-проверка JSON-сериализации: Webkassa требует camelCase /// поля (стандарт ASP.NET JsonSerializerDefaults.Web), проверяем что /// сериализованный payload содержит ожидаемые ключи. [Fact] public void Payload_serializes_to_camelCase_json() { var sale = SaleWith(1000m, 12m); var req = WebkassaProvider.BuildCheckPayload(sale, "CB-1", "tok-x"); // UnsafeRelaxedJsonEscaping: иначе кириллица будет \uXXXX'и, и хотя // Webkassa их корректно разберёт, тестовый assert и человеку // ревьюить json приятнее в plain UTF-8. var json = JsonSerializer.Serialize(req, new JsonSerializerOptions(JsonSerializerDefaults.Web) { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }); json.Should().Contain("\"cashboxUniqueNumber\":\"CB-1\""); json.Should().Contain("\"operationType\":1"); json.Should().Contain("\"externalCheckNumber\":\"ПР-000001\""); json.Should().Contain("\"positions\":"); json.Should().Contain("\"payments\":"); } } /// HttpMessageHandler-мок: возвращает заранее настроенные ответы /// по URL'у. Используется и юнит-тестами провайдеров, и потенциально /// integration-тестами (через .ConfigurePrimaryHttpMessageHandler). internal sealed class StubHttpHandler : HttpMessageHandler { public List Requests { get; } = new(); private readonly Dictionary _responses; public StubHttpHandler(Dictionary responses) { _responses = responses; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { Requests.Add(request); var key = request.RequestUri!.AbsolutePath; if (_responses.TryGetValue(key, out var pair)) { return Task.FromResult(new HttpResponseMessage(pair.Code) { Content = new StringContent(pair.Body, Encoding.UTF8, "application/json"), }); } return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent($"unexpected: {request.Method} {request.RequestUri}"), }); } }