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}"),
});
}
}