refinement phase 6
This commit is contained in:
@@ -84,6 +84,45 @@ public class PaymentWebhookTests
|
||||
Assert.Single(host.Db.Set<PaymentWebhookEvent>().AsNoTracking());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Racing_same_key_insert_is_caught_as_an_idempotent_no_op()
|
||||
{
|
||||
using var host = new PaymentsTestHost();
|
||||
var (_, reference) = await SeedPendingAsync(host);
|
||||
|
||||
// A provider that omits external_event_id skips the read-dedup, so the (provider_code, external_event_id)
|
||||
// UNIQUE is the SOLE backstop — exactly the state a true concurrent insert reaches when both requests read
|
||||
// "no existing event" before either commits. Pre-seed the colliding empty-key row so the handler's own
|
||||
// insert loses the unique race and must be treated as an idempotent no-op (DbUpdateException → duplicate).
|
||||
var existing = new PaymentWebhookEvent
|
||||
{
|
||||
ProviderCode = "zarinpal", ExternalEventId = string.Empty, EventType = "payment.succeeded",
|
||||
SignatureValid = true, PayloadJson = "{}", ReceivedAt = Now.UtcDateTime
|
||||
};
|
||||
existing.MarkProcessed(null, Now.UtcDateTime);
|
||||
host.Db.Set<PaymentWebhookEvent>().Add(existing);
|
||||
host.Db.SaveChanges();
|
||||
|
||||
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
|
||||
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks());
|
||||
var sender = SenderRoutingConfirmTo(confirm);
|
||||
var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));
|
||||
|
||||
// No external_event_id in the body → verification.ExternalEventId == "" (the read-dedup is skipped).
|
||||
var body = $"{{\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\"}}";
|
||||
var result = await handler.Handle(
|
||||
new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), body), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Result.Duplicate);
|
||||
Assert.Equal(WebhookProcessingStatus.Processed, result.Result.ProcessingStatus);
|
||||
|
||||
// The insert lost the race → no confirm, no ledger, and only the pre-existing event row survives.
|
||||
await sender.DidNotReceive().Send(Arg.Any<ConfirmPaymentAndPostLedgerCommand>(), Arg.Any<CancellationToken>());
|
||||
Assert.Empty(host.Db.Set<LedgerEntry>().AsNoTracking());
|
||||
Assert.Single(host.Db.Set<PaymentWebhookEvent>().AsNoTracking());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unverified_signature_callback_mutates_nothing()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user