113 lines
5.9 KiB
C#
113 lines
5.9 KiB
C#
using Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger;
|
|
using Baya.Application.Features.Payments.Commands.HandlePaymentWebhook;
|
|
using Baya.Application.Features.Payments.Commands.InitiatePayment;
|
|
using Baya.Application.Models.Common;
|
|
using Baya.Domain.Entities.Booking;
|
|
using Baya.Domain.Entities.Payments;
|
|
using Mediator;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using NSubstitute;
|
|
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
|
|
|
|
namespace Baya.Test.Foundation.Payments;
|
|
|
|
public class PaymentWebhookTests
|
|
{
|
|
private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero);
|
|
private const long Price = 23_300_000;
|
|
|
|
private static ISender SenderRoutingConfirmTo(ConfirmPaymentAndPostLedgerCommandHandler confirm)
|
|
{
|
|
var sender = Substitute.For<ISender>();
|
|
sender.Send(Arg.Any<ConfirmPaymentAndPostLedgerCommand>(), Arg.Any<CancellationToken>())
|
|
.Returns(ci => confirm.Handle(ci.Arg<ConfirmPaymentAndPostLedgerCommand>(), ci.Arg<CancellationToken>()));
|
|
return sender;
|
|
}
|
|
|
|
private static async Task<(long RequestId, string Reference)> SeedPendingAsync(PaymentsTestHost host)
|
|
{
|
|
var variantId = host.AddVariant(sessionCount: 1, price: Price);
|
|
var requestId = host.AddAcceptedRequest(variantId);
|
|
var initiate = new InitiatePaymentCommandHandler(host.AsCustomer(), host.UnitOfWork, host.PaymentProvider, host.Clock(Now));
|
|
var init = await initiate.Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None);
|
|
return (requestId, init.Result.GatewayReferenceCode);
|
|
}
|
|
|
|
private static string SuccessBody(string reference, string eventId)
|
|
=> $"{{\"external_event_id\":\"{eventId}\",\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\"}}";
|
|
|
|
[Fact]
|
|
public async Task Webhook_success_confirms_the_booking_and_posts_one_balanced_group()
|
|
{
|
|
using var host = new PaymentsTestHost();
|
|
var (_, reference) = await SeedPendingAsync(host);
|
|
|
|
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
|
|
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications());
|
|
var handler = new HandlePaymentWebhookCommandHandler(
|
|
SenderRoutingConfirmTo(confirm), host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));
|
|
|
|
var result = await handler.Handle(
|
|
new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), SuccessBody(reference, "evt-1")),
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(WebhookProcessingStatus.Processed, result.Result.ProcessingStatus);
|
|
Assert.False(result.Result.Duplicate);
|
|
|
|
Assert.Equal(BookingStatus.Confirmed, host.Db.Set<BookingEntity>().AsNoTracking().Single().Status);
|
|
Assert.Equal(3, host.Db.Set<LedgerEntry>().AsNoTracking().Count());
|
|
Assert.Single(host.Db.Set<PaymentWebhookEvent>().AsNoTracking());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Replayed_webhook_event_is_a_no_op()
|
|
{
|
|
using var host = new PaymentsTestHost();
|
|
var (_, reference) = await SeedPendingAsync(host);
|
|
|
|
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
|
|
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications());
|
|
var sender = SenderRoutingConfirmTo(confirm);
|
|
var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));
|
|
|
|
var body = SuccessBody(reference, "evt-1");
|
|
await handler.Handle(new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), body), CancellationToken.None);
|
|
var replay = await handler.Handle(new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), body), CancellationToken.None);
|
|
|
|
Assert.True(replay.IsSuccess);
|
|
Assert.True(replay.Result.Duplicate);
|
|
|
|
// No second confirm, no second ledger group, one webhook event row.
|
|
await sender.Received(1).Send(Arg.Any<ConfirmPaymentAndPostLedgerCommand>(), Arg.Any<CancellationToken>());
|
|
Assert.Equal(3, host.Db.Set<LedgerEntry>().AsNoTracking().Count());
|
|
Assert.Single(host.Db.Set<PaymentWebhookEvent>().AsNoTracking());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unverified_signature_callback_mutates_nothing()
|
|
{
|
|
using var host = new PaymentsTestHost();
|
|
var (_, reference) = await SeedPendingAsync(host);
|
|
|
|
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
|
|
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications());
|
|
var sender = SenderRoutingConfirmTo(confirm);
|
|
var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));
|
|
|
|
// The default MockWebhookVerifier marks a body carrying the invalid-signature marker as invalid.
|
|
var body = $"{{\"external_event_id\":\"evt-x\",\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\",\"note\":\"INVALID_SIGNATURE\"}}";
|
|
var result = await handler.Handle(
|
|
new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), body), CancellationToken.None);
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(WebhookProcessingStatus.Ignored, result.Result.ProcessingStatus);
|
|
|
|
await sender.DidNotReceive().Send(Arg.Any<ConfirmPaymentAndPostLedgerCommand>(), Arg.Any<CancellationToken>());
|
|
Assert.Empty(host.Db.Set<LedgerEntry>().AsNoTracking());
|
|
Assert.Empty(host.Db.Set<BookingEntity>().AsNoTracking());
|
|
var txn = host.Db.Set<PaymentTransaction>().AsNoTracking().Single();
|
|
Assert.Equal(PaymentTransactionStatus.Pending, txn.Status);
|
|
}
|
|
}
|