food-market/tests/food-market.UnitTests/WebkassaProviderTests.cs
nns 0d3ef81f72 feat(s11): ОФД-scaffolding — IFiscalProvider + 4 провайдера + UI/тесты
Sprint 11 — каркас для интеграции с операторами фискальных данных РК.
Реальные ApiKey'и появятся у user'а позже; задача — построить такой
фрейм, чтобы подключение оператора сводилось к вписыванию кредов в UI
без правок кода/деплоя.

Что сделано:
- IFiscalProvider (Application/Common/Fiscal) + FiscalResult,
  FiscalProviderKind (None/Mock/Webkassa/Kassa24/OfdSolo),
  IFiscalProviderFactory, FiscalNotConfiguredException.
- 4 реализации в Infrastructure/Fiscal:
  • MockFiscalProvider — фейк MOCK-<8hex> через 300мс, идемпотентный
    по Sale.Id (используется dev/stage и интеграционными тестами);
  • WebkassaProvider — полный HTTP-pipeline Authorize→Check, парсинг
    JSON-ответа, NDS-в-ставке, retry-safe через ExternalCheckNumber;
  • Kassa24Provider / OfdSoloProvider — скелет с тем же контрактом,
    RegisterAsync бросает FiscalNotConfiguredException (нужны
    спецификации API от user'а, NDA-only).
- Миграция Phase11a: 5 колонок в retail_sales (FiscalNumber, QrCode,
  Url, ProviderTxId, ProviderKind) + 5 в organizations (FiscalProvider
  NOT NULL default 0, ApiKey/Secret encrypted, CashboxUniqueNumber,
  ApiBaseUrl). Default 0 = обратная совместимость, существующие чеки
  и продажи без фискализации работают как раньше.
- RetailSalesController.Post — TryFiscalizeAsync после commit'а
  stock-транзакции. Best-effort: сетевые/HTTP-ошибки логируются, чек
  остаётся проведённым. Идемпотентность по IsNullOrEmpty(FiscalNumber).
- OrgFiscalSettingsController: GET/PUT настройки + GET /providers
  (опции для select'а) + POST /test-send (фейк-чек к выбранному
  провайдеру, не сохраняет в БД).
- UI: FiscalSection в OrganizationSettingsPage с password-input'ами
  для ApiKey/Secret (шифруются DataProtection.purpose=foodmarket.fiscal,
  в GET — только has-* флаги), спец-значение "__clear__" для снятия,
  кнопка «Тестовая отправка».
- Тесты: 11 unit (Mock 5 + Webkassa payload 6) + 3 integration
  (Mock сохраняет FiscalNumber, test-send даёт MOCK-номер, None
  не фискализует).
- docs/ofd-integration.md — гид с архитектурой, шагами подключения
  Webkassa (полный pap), TODO для Касса24/ОФД-Соло, безопасностью
  кредов, retry-сценариями.

Все 68 unit + 8 integration в Fiscal/Loyalty/RetailOversell — зелёные.
Web vite build — зелёный.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 02:27:17 +05:00

166 lines
7.3 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>Webkassa-провайдер: чистые тесты на payload-builder (без HTTP)
/// плюс end-to-end тест внутреннего метода через MockHttpHandler.
///
/// Реальный HTTP-flow (Authorize → Check) не покрываем сквозь полный
/// RegisterAsync — он зависит от per-organization кредов из БД (через
/// IServiceScopeFactory), что требует тестового DbContext'а. Для этого
/// есть integration-тест FiscalIntegrationTests с реальной БД. Здесь
/// проверяем чистую логику маппинга, которая не требует никакой инфры.</summary>
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);
}
/// <summary>Smoke-проверка JSON-сериализации: Webkassa требует camelCase
/// поля (стандарт ASP.NET JsonSerializerDefaults.Web), проверяем что
/// сериализованный payload содержит ожидаемые ключи.</summary>
[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\":");
}
}
/// <summary>HttpMessageHandler-мок: возвращает заранее настроенные ответы
/// по URL'у. Используется и юнит-тестами провайдеров, и потенциально
/// integration-тестами (через .ConfigurePrimaryHttpMessageHandler).</summary>
internal sealed class StubHttpHandler : HttpMessageHandler
{
public List<HttpRequestMessage> Requests { get; } = new();
private readonly Dictionary<string, (HttpStatusCode Code, string Body)> _responses;
public StubHttpHandler(Dictionary<string, (HttpStatusCode Code, string Body)> responses)
{
_responses = responses;
}
protected override Task<HttpResponseMessage> 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}"),
});
}
}