Платежі

Тестування платіжних інтеграцій

Стратегії тестування платіжної підсистеми — Sandbox режими, тестові картки, unit/integration тести з mock-провайдерами, webhook тестування та CI/CD.

Тестування платіжних інтеграцій

Чому платіжний код важко тестувати

Платіжний код має унікальний виклик: він взаємодіє із зовнішніми системами, що мають свій lifecycle, і кожен «живий» виклик потенційно впливає на фінансові записи. Не можна просто «запустити тести» і підключитися до реального LiqPay.

Хороша платіжна тест-стратегія базується на чотирьох рівнях:

  1. Unit-тести — перевірка ізольованої логіки (підписи, маппінг статусів, state machine)
  2. Integration-тести — перевірка взаємодії компонентів з mock-провайдером
  3. Sandbox-тести — реальні виклики до Sandbox PSP
  4. Contract-тести — перевірка, що ваш код відповідає контракту PSP API

Рівень 1: Unit-тести

Тестування LiqPaySignatureHelper

Tests/Unit/LiqPaySignatureTests.cs
public class LiqPaySignatureTests
{
    [Fact]
    public void GenerateSignature_ShouldProduceCorrectHash()
    {
        // Arrange
        const string data = "dGVzdA==";       // Base64("test")
        const string privateKey = "test_private_key";

        // Act
        var signature = LiqPaySignatureHelper.GenerateSignature(data, privateKey);

        // Assert: очікуваний підпис розраховано заздалегідь вручну
        // SHA1(privateKey + data + privateKey) → Base64
        signature.Should().NotBeNullOrEmpty();
        // Детермінований алгоритм: той самий input → той самий output
        var signature2 = LiqPaySignatureHelper.GenerateSignature(data, privateKey);
        signature.Should().Be(signature2);
    }

    [Fact]
    public void VerifySignature_InvalidSignature_ShouldReturnFalse()
    {
        const string data = "dGVzdA==";
        const string privateKey = "test_private_key";
        const string fakeSignature = "invalidsignaturebase64==";

        var result = LiqPaySignatureHelper.VerifySignature(data, fakeSignature, privateKey);

        result.Should().BeFalse();
    }

    [Fact]
    public void VerifySignature_ValidSignature_ShouldReturnTrue()
    {
        const string data = "dGVzdA==";
        const string privateKey = "test_private_key";

        var signature = LiqPaySignatureHelper.GenerateSignature(data, privateKey);
        var result = LiqPaySignatureHelper.VerifySignature(data, signature, privateKey);

        result.Should().BeTrue();
    }
}

Тестування PaymentStateMachine

Tests/Unit/PaymentStateMachineTests.cs
public class PaymentStateMachineTests
{
    [Theory]
    [InlineData(PaymentStatus.Created, PaymentStatus.Pending, true)]
    [InlineData(PaymentStatus.Pending, PaymentStatus.Authorized, true)]
    [InlineData(PaymentStatus.Settled, PaymentStatus.Refunded, true)]
    [InlineData(PaymentStatus.Failed, PaymentStatus.Settled, false)]
    [InlineData(PaymentStatus.Settled, PaymentStatus.Pending, false)]
    [InlineData(PaymentStatus.Cancelled, PaymentStatus.Active, false)]
    public void CanTransition_ShouldReturnCorrectResult(
        PaymentStatus from, PaymentStatus to, bool expected)
    {
        var result = PaymentStateMachine.CanTransition(from, to);
        result.Should().Be(expected);
    }

    [Fact]
    public void EnsureTransition_InvalidTransition_ShouldThrow()
    {
        var act = () => PaymentStateMachine.EnsureTransition(
            PaymentStatus.Failed, PaymentStatus.Settled);

        act.Should().Throw<InvalidOperationException>()
            .WithMessage("*Invalid payment state transition*");
    }
}

Рівень 2: Integration-тести з Mock-провайдером

Mock IPaymentProvider

Tests/Mocks/MockPaymentProvider.cs
public class MockPaymentProvider : IPaymentProvider
{
    public string ProviderName => "mock";

    // Контролюємо поведінку через ці властивості
    public bool ShouldSucceed { get; set; } = true;
    public PaymentStatus ResultStatus { get; set; } = PaymentStatus.Settled;
    public string? ErrorMessage { get; set; }

    // Перехоплюємо всі виклики для assertions
    public List<PaymentRequest> CreateCalls { get; } = [];
    public List<string> RefundCalls { get; } = [];

    public Task<CreatePaymentResult> CreatePaymentAsync(
        PaymentRequest request, CancellationToken ct = default)
    {
        CreateCalls.Add(request);

        if (!ShouldSucceed)
            return Task.FromResult(new CreatePaymentResult(
                false, null, null, null, ErrorMessage ?? "Mock error"));

        return Task.FromResult(new CreatePaymentResult(
            true,
            CheckoutUrl: "https://mock-psp.com/checkout",
            FormData: null,
            ProviderPaymentId: $"mock_txn_{Guid.NewGuid():N}",
            ErrorMessage: null));
    }

    public Task<PaymentStatusResult> GetPaymentStatusAsync(
        string providerTransactionId, CancellationToken ct = default)
        => Task.FromResult(new PaymentStatusResult(
            true, ResultStatus, providerTransactionId, null));

    public Task<RefundResult> RefundAsync(
        string providerTransactionId, decimal amount, CancellationToken ct = default)
    {
        RefundCalls.Add(providerTransactionId);
        return Task.FromResult(new RefundResult(ShouldSucceed, $"mock_refund_{Guid.NewGuid():N}", null));
    }

    public Task<WebhookResult> ProcessWebhookAsync(
        HttpRequest request, CancellationToken ct = default)
        => throw new NotImplementedException();
}

Integration-тест з WebApplicationFactory

Tests/Integration/PaymentEndpointTests.cs
public class PaymentEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public PaymentEndpointTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Замінюємо реальний провайдер на mock
                services.AddScoped<MockPaymentProvider>();
                services.AddScoped<IPaymentProviderFactory>(sp =>
                {
                    var mock = sp.GetRequiredService<MockPaymentProvider>();
                    return new MockPaymentProviderFactory(mock);
                });

                // In-memory база для ізоляції тестів
                services.AddDbContext<AppDbContext>(opts =>
                    opts.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}"));
            });
        });
    }

    [Fact]
    public async Task POST_CreatePayment_ShouldReturn_CheckoutUrl()
    {
        var client = _factory.CreateClient();
        var request = new
        {
            orderId = Guid.NewGuid(),
            amount = 100m,
            currency = "UAH",
            description = "Test payment"
        };

        var response = await client.PostAsJsonAsync("/api/payments", request);

        response.StatusCode.Should().Be(HttpStatusCode.OK);
        var body = await response.Content.ReadFromJsonAsync<JsonElement>();
        body.GetProperty("checkoutUrl").GetString()
            .Should().StartWith("https://mock-psp.com/checkout");
    }

    [Fact]
    public async Task POST_LiqPayWebhook_ValidSignature_ShouldUpdatePaymentStatus()
    {
        const string testPrivateKey = "test_private_key";
        var client = _factory.CreateClient();

        // Спочатку створимо платіж
        var paymentId = Guid.NewGuid().ToString();
        var payload = new { order_id = paymentId, status = "success", transaction_id = "txn_123" };
        var data = LiqPaySignatureHelper.EncodeData(payload);
        var sig = LiqPaySignatureHelper.GenerateSignature(data, testPrivateKey);

        var content = new FormUrlEncodedContent([
            new("data", data),
            new("signature", sig)
        ]);

        var response = await client.PostAsync("/webhooks/liqpay", content);

        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }
}

Рівень 3: Sandbox тестні картки

LiqPay тестові картки

КарткаПоведінка
4242424242424242Успішна оплата
4000000000000002Decline (insufficient funds)
40000000000032203DS challenged

Monobank тестові картки (у developer режимі)

КарткаПоведінка
4242424242424242Успішна оплата
5105105105105100Decline

Stripe тестові картки

КарткаПоведінка
4242 4242 4242 4242Успішна оплата
4000 0000 0000 0002Decline (card_declined)
4000 0025 0000 31553DS authentication required
4000 0000 0000 9995Decline (insufficient_funds)
4000 0000 0000 0069Decline (expired_card)

Тестування конфігурацій via appsettings

appsettings.Testing.json
{
  "LiqPay": {
    "PublicKey": "test_public_key",
    "PrivateKey": "test_private_key",
    "IsSandbox": true
  },
  "Stripe": {
    "SecretKey": "sk_test_placeholder",
    "WebhookSecret": "whsec_test_placeholder",
    "Currency": "uah"
  }
}

CI/CD налаштування

.github/workflows/payment-tests.yml
name: Payment Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'

      - name: Run Unit Tests
        run: dotnet test --filter "Category=Unit"

      - name: Run Integration Tests
        run: dotnet test --filter "Category=Integration"
        env:
          # Sandbox ключі зберігаються в GitHub Secrets
          LiqPay__PublicKey: ${{ secrets.LIQPAY_SANDBOX_PUBLIC_KEY }}
          LiqPay__PrivateKey: ${{ secrets.LIQPAY_SANDBOX_PRIVATE_KEY }}

      # Sandbox тести (тільки на main branch)
      - name: Run Sandbox Tests
        if: github.ref == 'refs/heads/main'
        run: dotnet test --filter "Category=Sandbox"

Підсумок

Надійна тест-стратегія для платіжного модуля будується з: unit-тестів (підписи, state machine), integration-тестів з Mock (бізнес-логіка без реальних PSP-викликів), sandbox-тестів (реальні виклики PSP у тестовому режимі) та contract-тестів (перевірка відповідності API контракту). Тестові картки дозволяють симулювати будь-який сценарій — success, decline, 3DS — без витрат реальних коштів.