test: round-9/10 adversarial suites — sync-path vs webhook completion, gift-card double-refund, sweep VAT, till lock, account lockout + erasure

- handlers_round9/round10: status-guarded flips, split-key hashing, cross-booking key 409, existingCount refund exclusion, SCA save-card exemption, routeNonCompletedPayment, no phantom split rows
- giftcards_round10: saved-card SCA buy, cancel resume reconcile (pending blocks, diff-only re-issue, no over-refund)
- sweep/till_round10: split-accurate VAT, all-tip VAT-free, status/key-changed skip, final-key lock held across charge
- webhooks_round8/9: booking gate + M2, payable side-effects, unknown-event 503, refund-before-row 503, no double-complete after sync
- account_round9: password lockout budgets, S3 erasure outbox, DAV in-tx deletion

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
This commit is contained in:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent 985b114c8b
commit 1d6d3e2f8d
9 changed files with 2958 additions and 0 deletions
@@ -0,0 +1,369 @@
//go:build test
package webhooks
// Round 8 regression tests — CRITICAL/MAJOR webhook money-safety fixes in
// handlePaymentUpdated / handleRefundUpdated (backend/handlers/webhooks/square.go):
//
// 1. payment.completed for a CANCELLED booking → the pending row is marked
// FAILED (never completed), an M2 auto-refund row for the full stranded
// charge (origin 'cancellation', deterministic paymentID+"-square-"+pence
// key) is inserted, and a critical admin notification is raised.
// 2. payment.completed for a payable booking → the pending row is promoted to
// 'completed', the charge is re-split (deposit + balance), and the
// fully-paid booking is completed with the loyalty/campaign side-effects.
// 3. a genuinely unknown payment.completed (no local payments/till_sales row,
// no orphan origin) → 503, no dedup row (Square retries).
// 4. refund.updated APPROVED/COMPLETED arriving before the refund row exists
// → 503, no dedup row (the row may be inserted in the same transaction as
// the charge in some paths).
// 5. C6: a booking-less payment row (gift-card purchase) is left pending by a
// COMPLETED payment.updated — the same-key retry delivers the card.
import (
"context"
"encoding/json"
"net/http"
"testing"
"crussell/db"
"crussell/testutils/fixtures"
)
// attachWebhookTestBooking creates a fresh pending booking with total_amount
// set to total and attaches the given payment to it. The webhook's booking
// gate (round-8) only completes booking-attached rows; the fixture-created
// booking has no total_amount, which would make the fully-paid completion
// check a no-op, so the explicit total lets the payable-booking path run its
// split/completion side-effects deterministically.
func attachWebhookTestBooking(t *testing.T, payID string, total float64) (bookingID string) {
t.Helper()
userID, err := fixtures.CreateTestUser(db.Conn)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.Conn)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err = fixtures.CreateTestBooking(db.Conn, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
if _, err := db.Conn.Exec(context.Background(),
"UPDATE bookings SET total_amount = $1 WHERE id = $2", total, bookingID); err != nil {
t.Fatalf("failed to set booking total: %v", err)
}
if _, err := db.Conn.Exec(context.Background(),
"UPDATE payments SET booking_id = $1 WHERE id = $2", bookingID, payID); err != nil {
t.Fatalf("failed to attach payment to booking: %v", err)
}
return bookingID
}
// TestWebhook_Round8_PaymentCompleted_CancelledBooking_FailsWithAutoRefund
// locks fix 1: a payment.completed whose booking is cancelled/lapsed/no-show
// must NEVER be completed — the cancellation refund path computes refunds from
// completed payments and would miss it, charging a customer with NO automatic
// refund (F3). The pending row is marked FAILED, an M2 auto-refund row for the
// full stranded charge (origin 'cancellation') is inserted so the
// pending-refund sweep issues it at Square, and a critical admin notification
// is raised — mirroring the stale-pending sweep's gate refused branch.
func TestWebhook_Round8_PaymentCompleted_CancelledBooking_FailsWithAutoRefund(t *testing.T) {
const squarePaymentID = "sqp_round8_cancelled"
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
bookingID := attachWebhookTestBooking(t, payID, 40.00)
// The payment amount drives the auto-refund row (the deterministic key is
// paymentID + "-square-" + amount pence).
if _, err := db.Conn.Exec(context.Background(),
"UPDATE payments SET amount = 40.00 WHERE id = $1", payID); err != nil {
t.Fatalf("failed to set payment amount: %v", err)
}
if _, err := db.Conn.Exec(context.Background(),
"UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID); err != nil {
t.Fatalf("failed to cancel booking: %v", err)
}
event := SquareWebhookEvent{
Type: "payment.completed",
EventID: "evt_round8_cancelled_1",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
"object": {
"payment": {
"id": "` + squarePaymentID + `",
"status": "COMPLETED",
"amount_money": {"amount": 4000, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 (the refused row is still resolved in-app), got %d: %s", w.Code, w.Body.String())
}
if got := getPaymentStatus(t, payID); got != "failed" {
t.Errorf("expected the cancelled-booking payment to be marked 'failed', got %q", got)
}
var (
refundBookingID string
amount float64
origin string
key string
)
err := db.Conn.QueryRow(context.Background(), `
SELECT booking_id, amount, origin, idempotency_key FROM refunds WHERE payment_id = $1
`, payID).Scan(&refundBookingID, &amount, &origin, &key)
if err != nil {
t.Fatalf("expected an M2 auto-refund row for the stranded charge, got: %v", err)
}
if refundBookingID != bookingID || amount != 40.00 || origin != "cancellation" {
t.Errorf("expected a cancellation-origin refund of 40.00 on the booking, got booking=%s amount=%v origin=%q", refundBookingID, amount, origin)
}
if want := payID + "-square-4000"; key != want {
t.Errorf("expected deterministic refund key %q, got %q", want, key)
}
if n := countUnackedCriticalNotificationsForBooking(t, bookingID); n != 1 {
t.Errorf("expected exactly 1 unacknowledged critical notification for the booking, got %d", n)
}
if n := countWebhookEvents(t, event.EventID); n != 1 {
t.Errorf("expected 1 dedup row, got %d", n)
}
}
// TestWebhook_Round8_PaymentCompleted_PayableBooking_CompletesWithSplits locks
// fix 2: a payment.completed on a payable booking promotes the pending row to
// 'completed' and runs the SAME split/VAT/completion side-effects the sweep
// rescue applies — the charge is re-split (deposit + balance), a deposit-paid
// pending_release booking is promoted to confirmed, and a fully-paid booking is
// completed with the loyalty/campaign bookkeeping (the payment-time campaign
// discount row is preserved, never re-applied).
func TestWebhook_Round8_PaymentCompleted_PayableBooking_CompletesWithSplits(t *testing.T) {
const (
squarePaymentID = "sqp_round8_payable"
campaignName = "Round8 Completion Campaign"
)
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
bookingID := attachWebhookTestBooking(t, payID, 50.00)
var userID string
if err := db.Conn.QueryRow(context.Background(),
"SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&userID); err != nil {
t.Fatalf("failed to read booking user: %v", err)
}
// Active time_based campaign — the completion side-effects would apply it
// were it not already recorded at payment time.
var campaignID string
if err := db.Conn.QueryRow(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
VALUES ($1, 'time_based', 10, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 day', 100, 0)
RETURNING id
`, campaignName).Scan(&campaignID); err != nil {
t.Fatalf("failed to seed campaign: %v", err)
}
// The online flow applies the campaign at PAYMENT time (before the charge):
// a discount ledger row + the booking_discounts record. The completion's
// already-recorded guard then skips re-application.
if _, err := db.Conn.Exec(context.Background(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', 5.00, 'completed', $2)
`, bookingID, userID); err != nil {
t.Fatalf("failed to seed discount payment row: %v", err)
}
if _, err := db.Conn.Exec(context.Background(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'time_based', 10, 50.00, 5.00)
`, bookingID, userID, campaignID); err != nil {
t.Fatalf("failed to seed booking discount: %v", err)
}
// The charge that lands at Square is the DISCOUNTED amount (£45 for a £50
// booking at 10% off); the pending row records it.
if _, err := db.Conn.Exec(context.Background(),
"UPDATE payments SET amount = 45.00, idempotency_key = 'round8-payable-key' WHERE id = $1", payID); err != nil {
t.Fatalf("failed to set payment amount/key: %v", err)
}
event := SquareWebhookEvent{
Type: "payment.completed",
EventID: "evt_round8_payable_1",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
"object": {
"payment": {
"id": "` + squarePaymentID + `",
"status": "COMPLETED",
"idempotency_key": "round8-payable-key",
"amount_money": {"amount": 4500, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getPaymentStatus(t, payID); got != "completed" {
t.Fatalf("expected payment 'completed', got %q", got)
}
var bookingStatus string
if err := db.Conn.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil {
t.Fatalf("failed to read booking status: %v", err)
}
if bookingStatus != "completed" {
t.Errorf("expected the fully-paid booking to be 'completed', got %q", bookingStatus)
}
// Split records: the £45 charge carves a £25 deposit primary + £20 balance
// (pre-start split) — plus the £5 discount row, so the booking ledger holds
// 3 completed rows and is fully paid (45 + 5 = 50).
var payCount int
if err := db.Conn.QueryRow(context.Background(),
"SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count payment records: %v", err)
}
if payCount != 3 {
t.Errorf("expected 3 payment records (deposit + balance + discount), got %d", payCount)
}
// The payment-time campaign discount row is preserved (never re-applied or
// dropped by the completion side-effects).
var discCount int
if err := db.Conn.QueryRow(context.Background(), `
SELECT COUNT(*) FROM booking_discounts
WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'time_based'
`, bookingID).Scan(&discCount); err != nil {
t.Fatalf("failed to count booking discounts: %v", err)
}
if discCount != 1 {
t.Errorf("expected the campaign discount row to be preserved, got %d", discCount)
}
}
// TestWebhook_Round8_UnknownPaymentCompleted_Returns503 locks fix 3: a
// payment.completed whose square_payment_id matches NO local row (payments or
// till_sales) and NO pending orphan origin is a genuinely unknown money event.
// It is NOT acked — the handler returns 503 so Square re-delivers (its retry
// budget bounds the retries) and writes NO dedup row, so the event can never be
// dropped permanently.
func TestWebhook_Round8_UnknownPaymentCompleted_Returns503(t *testing.T) {
const squarePaymentID = "sqp_round8_unknown"
event := SquareWebhookEvent{
Type: "payment.completed",
EventID: "evt_round8_unknown_1",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
"object": {
"payment": {
"id": "` + squarePaymentID + `",
"status": "COMPLETED",
"idempotency_key": "round8-never-used",
"amount_money": {"amount": 1000, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 for a genuinely unknown COMPLETED payment (Square must retry), got %d: %s", w.Code, w.Body.String())
}
if n := countWebhookEvents(t, event.EventID); n != 0 {
t.Errorf("expected NO dedup row for the unresolved unknown payment, got %d", n)
}
}
// TestWebhook_Round8_RefundUpdated_BeforeRowExists_Returns503 locks fix 4: an
// APPROVED or COMPLETED refund.updated arriving BEFORE the local refunds row
// exists must not be acked-and-dropped — the refund row can be created in the
// same transaction as the charge in some paths, and acking would lose the
// terminal settlement trail forever. The handler returns 503 so Square
// re-delivers once the row appears.
func TestWebhook_Round8_RefundUpdated_BeforeRowExists_Returns503(t *testing.T) {
approved := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_round8_refund_approved_1",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "refund",
"id": "sqr_round8_unknown",
"object": {
"refund": {
"id": "sqr_round8_unknown",
"status": "APPROVED",
"payment_id": "sqp_round8_refund_unknown"
}
}
}`),
}
w := deliverWebhook(t, approved)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 for APPROVED refund before its row exists, got %d: %s", w.Code, w.Body.String())
}
if n := countWebhookEvents(t, approved.EventID); n != 0 {
t.Errorf("expected no dedup row for the APPROVED-before-row refund, got %d", n)
}
completed := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_round8_refund_completed_1",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "refund",
"id": "sqr_round8_unknown2",
"object": {
"refund": {
"id": "sqr_round8_unknown2",
"status": "COMPLETED",
"payment_id": "sqp_round8_refund_unknown2"
}
}
}`),
}
w2 := deliverWebhook(t, completed)
if w2.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 for COMPLETED refund before its row exists, got %d: %s", w2.Code, w2.Body.String())
}
if n := countWebhookEvents(t, completed.EventID); n != 0 {
t.Errorf("expected no dedup row for the COMPLETED-before-row refund, got %d", n)
}
}
// TestWebhook_Round8_PaymentCompleted_BookinglessGiftCardRow_StaysPending
// locks the C6 semantics of fix 1: a COMPLETED payment.updated for a
// booking-less payments row (a gift-card purchase) must NOT auto-complete the
// row — the same-key retry delivers the card through the synchronous purchase
// path. The row stays pending and the dedup row still commits.
func TestWebhook_Round8_PaymentCompleted_BookinglessGiftCardRow_StaysPending(t *testing.T) {
const squarePaymentID = "sqp_round8_giftcard"
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
event := SquareWebhookEvent{
Type: "payment.completed",
EventID: "evt_round8_giftcard_1",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
"object": {
"payment": {
"id": "` + squarePaymentID + `",
"status": "COMPLETED"
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getPaymentStatus(t, payID); got != "pending" {
t.Errorf("expected the booking-less gift-card payment to stay 'pending' (C6), got %q", got)
}
if n := countWebhookEvents(t, event.EventID); n != 1 {
t.Errorf("expected 1 dedup row, got %d", n)
}
}
@@ -0,0 +1,309 @@
//go:build test
package webhooks
// Round 9 adversarial regression tests — the webhook-vs-synchronous-path
// double-completion race on the SAME booking/payment.
//
// The bug (R9): `reconcileCompletedPayments` runs the FULL completion
// side-effects (split records, VAT, booking completion, loyalty/campaigns)
// when a webhook promotes a pending payment row to completed. The synchronous
// saved-card path (handlers.go CreateBookingPayment) ALSO runs its own
// post-charge completion after Square returns. If the webhook arrives BETWEEN
// Square returning the charge and the sync path's recheck transaction
// committing, both paths contend on the same booking row, and the sync path —
// whose completion UPDATE had NO `status='pending'` guard — would re-run its
// side-effects on top of the webhook's: phantom duplicate split rows (whose
// deterministic idempotency keys are UNIQUE) and a re-aligned primary row that
// no longer reconciles to the Square charge.
//
// The coordinated fix has two halves:
// - the sync path (handlers.go, owned by another agent) guards its own
// completion flip on `status='pending'` and re-reads the payment status
// before re-running side-effects;
// - the webhook half (this suite): the webhook is ALREADY safe under that
// fix because its reconcile SELECT matches only `status='pending'` rows
// and its flip UPDATE is guarded on `status='pending'` — the payment
// row's status is the single mutual-exclusion point, so whichever path
// wins the flip, the side-effects run exactly once.
//
// These tests lock the webhook half:
// 1. the webhook arrives AFTER the sync path committed its flip → the
// pending-only SELECT matches nothing, the charge is acked as a known
// settled replay, and NO side-effect re-runs (no duplicate split rows, no
// re-completion of the booking);
// 2. the webhook's SELECT reads the row while it is STILL pending (the sync
// path's recheck tx holds the booking lock but has not committed), then
// the sync path commits before the webhook's guarded flip runs → the
// flip's `AND status='pending'` guard makes it a no-op and the webhook
// never re-runs the side-effects.
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/db"
)
// seedRound9SyncCompletedState simulates the synchronous saved-card path having
// WON the race and fully completed the payment + booking: the primary row is
// flipped to 'completed' and re-aligned to the deposit split, the balance split
// is inserted with the deterministic idempotency key the split logic mints
// (<key>-split-1), and the fully-paid booking is completed. This is exactly the
// ledger the webhook would produce if IT ran the side-effects, so any regression
// that makes the webhook re-run them would collide on the UNIQUE idempotency
// key or mint phantom rows.
func seedRound9SyncCompletedState(t *testing.T, payID, bookingID, squarePaymentID, idemKey, userID string, total float64) {
t.Helper()
deposit := total * 0.5 // protected deposit max: 50% of total, pre-start split
balance := total - deposit
if _, err := db.Conn.Exec(context.Background(),
`UPDATE payments SET status = 'completed', amount = $1, payment_type = 'deposit', updated_at = NOW() WHERE id = $2`,
deposit, payID); err != nil {
t.Fatalf("failed to flip payment to completed: %v", err)
}
if _, err := db.Conn.Exec(context.Background(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, square_payment_id, idempotency_key, created_by, created_at, updated_at)
VALUES ($1, 'balance', 'online_square', $2, 'completed', $3, $4, $5, NOW(), NOW())
`, bookingID, balance, squarePaymentID, idemKey+"-split-1", userID); err != nil {
t.Fatalf("failed to seed balance split: %v", err)
}
if _, err := db.Conn.Exec(context.Background(),
`UPDATE bookings SET status = 'completed', updated_at = NOW() WHERE id = $1`, bookingID); err != nil {
t.Fatalf("failed to complete booking: %v", err)
}
}
func countRound9BookingPayments(t *testing.T, bookingID string) int {
t.Helper()
var n int
if err := db.Conn.QueryRow(context.Background(),
"SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&n); err != nil {
t.Fatalf("failed to count booking payments: %v", err)
}
return n
}
func countRound9ByIdempotencyKey(t *testing.T, idemKey string) int {
t.Helper()
var n int
if err := db.Conn.QueryRow(context.Background(),
"SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", idemKey).Scan(&n); err != nil {
t.Fatalf("failed to count payments by idempotency key: %v", err)
}
return n
}
// round9CompletedEvent builds a payment.updated webhook carrying a COMPLETED
// Square charge.
func round9CompletedEvent(squarePaymentID, idemKey string) SquareWebhookEvent {
return SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_round9_" + squarePaymentID,
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
"object": {
"payment": {
"id": "` + squarePaymentID + `",
"status": "COMPLETED",
"idempotency_key": "` + idemKey + `",
"amount_money": {"amount": 5000, "currency": "GBP"}
}
}
}`),
}
}
// TestWebhook_Round9_WebhookAfterSyncCompletion_NoOp locks fix half 1 (the R9
// race outcome where the SYNC path wins): the synchronous saved-card path has
// already flipped the payment to 'completed', re-split it (deposit primary +
// balance), and completed the booking BEFORE the webhook is delivered. The
// webhook's reconcile SELECT matches only status='pending' rows, so it finds
// nothing and acks the charge as a known settled replay — the row is never
// re-flipped, no phantom split rows are minted, and the booking-completion
// side-effects are not double-run.
func TestWebhook_Round9_WebhookAfterSyncCompletion_NoOp(t *testing.T) {
const (
squarePaymentID = "sqp_round9_sync_first"
idemKey = "round9-sync-key-1"
total = 50.00
)
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
bookingID := attachWebhookTestBooking(t, payID, total)
var userID string
if err := db.Conn.QueryRow(context.Background(),
"SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&userID); err != nil {
t.Fatalf("failed to read booking user: %v", err)
}
if _, err := db.Conn.Exec(context.Background(),
"UPDATE payments SET amount = $1, idempotency_key = $2 WHERE id = $3", total, idemKey, payID); err != nil {
t.Fatalf("failed to set payment amount/key: %v", err)
}
// The sync path completed the charge and its booking first (the race
// outcome this test locks).
seedRound9SyncCompletedState(t, payID, bookingID, squarePaymentID, idemKey, userID, total)
paymentsBefore := countRound9BookingPayments(t, bookingID)
event := round9CompletedEvent(squarePaymentID, idemKey)
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 (known settled charge, plain replay), got %d: %s", w.Code, w.Body.String())
}
// No double-completion: the payment stays exactly as the sync path left it.
if got := getPaymentStatus(t, payID); got != "completed" {
t.Errorf("expected payment to stay 'completed', got %q", got)
}
// The primary row keeps the sync path's split shape — never re-aligned or
// re-split by the webhook.
var primaryAmount float64
var primaryType string
if err := db.Conn.QueryRow(context.Background(),
"SELECT amount, payment_type FROM payments WHERE id = $1", payID).Scan(&primaryAmount, &primaryType); err != nil {
t.Fatalf("failed to read primary payment: %v", err)
}
if primaryAmount != total/2 || primaryType != "deposit" {
t.Errorf("expected primary to stay the deposit split (%.2f/deposit), got amount=%v type=%q", total/2, primaryAmount, primaryType)
}
// No phantom split rows: the ledger is byte-identical to the sync path's.
if n := countRound9BookingPayments(t, bookingID); n != paymentsBefore {
t.Errorf("expected the payment ledger unchanged (%d rows), got %d", paymentsBefore, n)
}
// No duplicate balance split: the deterministic key exists exactly once.
if n := countRound9ByIdempotencyKey(t, idemKey+"-split-1"); n != 1 {
t.Errorf("expected the balance split key to exist exactly once, got %d", n)
}
// Booking side-effects are not double-run: the booking stays 'completed'
// (a second completion transition cannot fire).
var bookingStatus string
if err := db.Conn.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil {
t.Fatalf("failed to read booking status: %v", err)
}
if bookingStatus != "completed" {
t.Errorf("expected booking to stay 'completed', got %q", bookingStatus)
}
// The replay is acked and its dedup row committed.
if n := countWebhookEvents(t, event.EventID); n != 1 {
t.Errorf("expected 1 dedup row, got %d", n)
}
}
// TestWebhook_Round9_ConcurrentSyncFlip_Wins_FliGuardNoOps locks the flip-guard
// backstop — the IN-FLIGHT race (fix half 2). The webhook's pending-row SELECT
// reads the row while it is STILL 'pending' (the sync path's recheck tx holds
// the booking FOR UPDATE lock but has not committed its flip), then the sync
// path commits (flip + booking completion) BEFORE the webhook's guarded flip
// runs. The flip's `AND status='pending'` guard sees the committed 'completed'
// row and becomes a no-op: the webhook never re-runs the split/completion
// side-effects, so no duplicate rows and no double booking completion.
func TestWebhook_Round9_ConcurrentSyncFlip_Wins_FliGuardNoOps(t *testing.T) {
const (
squarePaymentID = "sqp_round9_sync_race"
idemKey = "round9-race-key-1"
total = 50.00
)
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
bookingID := attachWebhookTestBooking(t, payID, total)
if _, err := db.Conn.Exec(context.Background(),
"UPDATE payments SET amount = $1, idempotency_key = $2 WHERE id = $3", total, idemKey, payID); err != nil {
t.Fatalf("failed to set payment amount/key: %v", err)
}
// The sync path (postChargeRecheck) takes the booking FOR UPDATE lock and
// holds it while its recheck tx is in flight.
ctx := context.Background()
syncTx, err := db.Conn.Begin(ctx)
if err != nil {
t.Fatalf("failed to begin sync-path tx: %v", err)
}
if _, err := syncTx.Exec(ctx, `SELECT 1 FROM bookings WHERE id = $1 FOR UPDATE`, bookingID); err != nil {
syncTx.Rollback(ctx)
t.Fatalf("failed to lock booking row: %v", err)
}
// Deliver the webhook in a goroutine. Signature/body are prepared on the
// test goroutine (webhookTestEnv calls t.Setenv, which is test-goroutine
// only); makeWebhookRequest itself touches no *testing.T.
event := round9CompletedEvent(squarePaymentID, idemKey)
body, err := json.Marshal(event)
if err != nil {
t.Fatalf("failed to marshal webhook event: %v", err)
}
sig := webhookTestEnv(t, body)
done := make(chan *httptest.ResponseRecorder, 1)
go func() {
done <- makeWebhookRequest(body, sig, context.Background())
}()
// Wait (bounded) until the webhook tx is actually blocked on the booking
// FOR UPDATE lock — at that point its pending-row SELECT has ALREADY run and
// seen the row as 'pending', so the flip-guard interleaving is exercised
// deterministically. If it never blocks (a slow scheduler), the test still
// holds: the commit below turns it into the no-op replay interleaving.
waitDeadline := time.Now().Add(10 * time.Second)
blocked := false
for time.Now().Before(waitDeadline) {
var n int
if err := db.Conn.QueryRow(ctx, `
SELECT COUNT(*) FROM pg_stat_activity
WHERE query ILIKE '%SELECT status FROM bookings WHERE id%'
AND wait_event_type = 'Lock'
AND pid <> pg_backend_pid()
`).Scan(&n); err == nil && n > 0 {
blocked = true
break
}
time.Sleep(25 * time.Millisecond)
}
if !blocked {
t.Logf("webhook never blocked on the booking lock — fell through to the no-op replay interleaving (assertions still hold)")
}
// The sync path commits: guarded flip + booking completion.
if _, err := syncTx.Exec(ctx,
`UPDATE payments SET status = 'completed', amount = $1, payment_type = 'deposit', updated_at = NOW() WHERE id = $2`,
total/2, payID); err != nil {
syncTx.Rollback(ctx)
t.Fatalf("failed to flip payment in sync tx: %v", err)
}
if _, err := syncTx.Exec(ctx,
`UPDATE bookings SET status = 'completed', updated_at = NOW() WHERE id = $1`, bookingID); err != nil {
syncTx.Rollback(ctx)
t.Fatalf("failed to complete booking in sync tx: %v", err)
}
if err := syncTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit sync-path tx: %v", err)
}
w := <-done
if w.Code != http.StatusOK {
t.Fatalf("expected 200 (guarded flip no-op), got %d: %s", w.Code, w.Body.String())
}
// The payment was completed exactly once — by the sync path.
if got := getPaymentStatus(t, payID); got != "completed" {
t.Errorf("expected payment 'completed', got %q", got)
}
// No split rows beyond the primary: the webhook's side-effects never ran.
if n := countRound9BookingPayments(t, bookingID); n != 1 {
t.Errorf("expected exactly 1 payment row (the sync-flipped primary), got %d", n)
}
// The booking was completed by the sync path exactly once.
var bookingStatus string
if err := db.Conn.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil {
t.Fatalf("failed to read booking status: %v", err)
}
if bookingStatus != "completed" {
t.Errorf("expected booking 'completed' (the sync path completed it), got %q", bookingStatus)
}
if n := countWebhookEvents(t, event.EventID); n != 1 {
t.Errorf("expected 1 dedup row, got %d", n)
}
}