food-market/tests/food-market.UnitTests/FiscalMockProviderTests.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

77 lines
2.8 KiB
C#
Raw 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 FluentAssertions;
using foodmarket.Application.Common.Fiscal;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Fiscal;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Контракт MockFiscalProvider'а: возвращает префикс MOCK-, тот же
/// чек двумя вызовами даёт тот же FiscalNumber (идемпотентность), QR содержит
/// FiscalNumber, ProviderTxId не пуст. SimulatedLatency обнулена — тест
/// должен быть мгновенным.</summary>
public class FiscalMockProviderTests
{
private static MockFiscalProvider New() => new(NullLogger<MockFiscalProvider>.Instance)
{
SimulatedLatency = TimeSpan.Zero,
};
private static RetailSale SaleStub(Guid? id = null) => new()
{
Id = id ?? Guid.NewGuid(),
Number = "TST-000001",
OrganizationId = Guid.NewGuid(),
StoreId = Guid.NewGuid(),
CurrencyId = Guid.NewGuid(),
Total = 1000m,
};
[Fact]
public void Kind_is_Mock()
=> New().Kind.Should().Be(FiscalProviderKind.Mock);
[Fact]
public async Task Register_returns_mock_prefixed_fiscal_number()
{
var sale = SaleStub();
var r = await New().RegisterAsync(sale, CancellationToken.None);
r.FiscalNumber.Should().StartWith("MOCK-").And.HaveLength("MOCK-".Length + 8);
r.FiscalQrCode.Should().Contain(r.FiscalNumber);
r.FiscalUrl.Should().StartWith("https://mock.ofd.local/check/");
r.ProviderTxId.Should().NotBeNullOrEmpty().And.StartWith("mock-tx-");
}
[Fact]
public async Task Register_is_idempotent_per_sale_id()
{
var id = Guid.NewGuid();
var r1 = await New().RegisterAsync(SaleStub(id), CancellationToken.None);
var r2 = await New().RegisterAsync(SaleStub(id), CancellationToken.None);
r1.FiscalNumber.Should().Be(r2.FiscalNumber);
r1.ProviderTxId.Should().Be(r2.ProviderTxId);
}
[Fact]
public async Task Different_sales_get_different_fiscal_numbers()
{
var a = await New().RegisterAsync(SaleStub(), CancellationToken.None);
var b = await New().RegisterAsync(SaleStub(), CancellationToken.None);
a.FiscalNumber.Should().NotBe(b.FiscalNumber);
}
[Fact]
public async Task Simulated_latency_actually_delays()
{
var prov = new MockFiscalProvider(NullLogger<MockFiscalProvider>.Instance)
{
SimulatedLatency = TimeSpan.FromMilliseconds(150),
};
var sw = System.Diagnostics.Stopwatch.StartNew();
await prov.RegisterAsync(SaleStub(), CancellationToken.None);
sw.Stop();
sw.ElapsedMilliseconds.Should().BeGreaterThanOrEqualTo(140);
}
}