//go:build test package webhooks // M15 webhook-completion idempotency, exercised through the REAL // HandleSquareWebhook handler. The webhook's payment.updated completion path // (handlePaymentUpdated) and the payments package's stale-pending sweep rescue // can both try to complete the same payment; both flip status only while the // row is still 'pending', so the first writer wins and the second applies no // side effects. This test drives the real handler twice (distinct event ids, // so the dedup cache does not swallow the replay) and then runs the real sweep, // asserting the payment completes exactly once and the sweep adds nothing. import ( "context" "encoding/json" "net/http" "testing" "time" "crussell/db" "crussell/handlers/payments" "crussell/testutils/fixtures" ) // TestWebhook_AndSweep_DoNotDoubleComplete locks the webhook-first race // ordering end to end through the real HTTP handler: the first // payment.updated COMPLETED delivery completes the pending row; the second // delivery is a no-op (the row is no longer pending) and the sweep rescue // path, which runs after, finds nothing pending and applies no side effects. func TestWebhook_AndSweep_DoNotDoubleComplete(t *testing.T) { userID, err := fixtures.CreateTestUser(db.Conn) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(db.Conn) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBookingAtTime(db.Conn, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create booking: %v", err) } const squarePaymentID = "sqp_webhook_and_sweep" payID := createWebhookTestPayment(t, squarePaymentID, "pending") if _, err := db.Conn.Exec(context.Background(), "UPDATE payments SET booking_id = $1, created_at = NOW() - INTERVAL '25 hours' WHERE id = $2", bookingID, payID); err != nil { t.Fatalf("failed to attach booking and age the payment: %v", err) } // Give the booking a payable total so a (hypothetical) rescue-side // completion check would complete it — proving the sweep does NOT run it // after the webhook already settled the row. if _, err := db.Conn.Exec(context.Background(), "UPDATE bookings SET total_amount = 10.00 WHERE id = $1", bookingID); err != nil { t.Fatalf("failed to set booking total: %v", err) } // The sweep reads the payments package's exported SquareClient. Under the // `test,!dev` CI shape there is no dev mock to install, so guarantee the // sweep is a true no-op for OUR row instead: the row is already completed // (never fetched) and every OTHER stale pending row in this package's test // DB is deleted up front, so no reconcile ever touches SquareClient. if _, err := db.Conn.Exec(context.Background(), "DELETE FROM payments WHERE status = 'pending' AND created_at < NOW() - INTERVAL '24 hours' AND id <> $1", payID); err != nil { t.Fatalf("failed to clear leftover stale pending payments: %v", err) } if _, err := db.Conn.Exec(context.Background(), "DELETE FROM till_sales WHERE status = 'pending' AND created_at < NOW() - INTERVAL '24 hours'"); err != nil { t.Fatalf("failed to clear leftover stale pending till sales: %v", err) } // 1. First payment.updated COMPLETED delivery → completes the pending row. event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_webhook_and_sweep_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 on the first delivery, got %d: %s", w.Code, w.Body.String()) } if got := getPaymentStatus(t, payID); got != "completed" { t.Fatalf("expected the first webhook delivery to complete the payment, got %q", got) } // 2. Second delivery (distinct event id — the dedup cache is bypassed): // the row is already completed, so the pending-only UPDATE matches nothing. event2 := event event2.EventID = "evt_webhook_and_sweep_2" w2 := deliverWebhook(t, event2) if w2.Code != http.StatusOK { t.Fatalf("expected 200 on the replay delivery, got %d: %s", w2.Code, w2.Body.String()) } if got := getPaymentStatus(t, payID); got != "completed" { t.Errorf("expected the replay delivery to leave the payment completed, got %q", got) } // 3. The sweep's rescue path runs after the webhook already completed the // row: the row is no longer 'pending', so the sweep never fetches it and // never applies its completion side effects. if _, err := payments.SweepStalePendingPayments(context.Background()); err != nil { t.Fatalf("sweep failed: %v", err) } if got := getPaymentStatus(t, payID); got != "completed" { t.Errorf("expected the sweep to leave the webhook-completed payment alone, got %q", got) } // The webhook itself ran the payable-booking completion side-effects // (round-8 fix 2): the single £10 pending charge was re-split into a £5 // deposit primary + £5 balance record, and the fully-paid booking was // completed. The sweep adds NOTHING on top — exactly one deposit + one // balance row, no duplicates. var recordCount int if err := db.Conn.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&recordCount); err != nil { t.Fatalf("failed to count payment records: %v", err) } if recordCount != 2 { t.Errorf("expected the webhook-completed booking to have exactly 2 records (deposit + balance split), got %d", recordCount) } 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 query booking: %v", err) } if bookingStatus != "completed" { t.Errorf("expected the fully-paid webhook-completed booking to end 'completed', got %q", bookingStatus) } }