Files
Crussell/backend/handlers/webhooks/webhooks_completion_asymmetry_test.go
popertotsandSisyphus 985b114c8b test: payments round-2 — webhook gate/M2 refund, gift-card cancel re-issue, till lock contention, sweep VAT rescue coverage
- webhooks: booking-status gate rejects cancelled bookings, M2 stranded-charge refund row + alert, gift-card rows left pending, payable-booking side-effects, unknown-event 503, refund-before-row 503, webhook-after-sync no-double-complete
- giftcards: saved_card_id SCA wire, card_id+token rejected, resume re-issue never over-refunds entitlement, pending-Square-refund blocks, diff re-issue only what is owed
- sweep: VAT on split-rescued primary, all-tip rows VAT-free, till status/key-changed-while-locked skip, recordUntrackedTillSalePayment VAT
- till: suffixed-key slot scan lock held across Square round-trip

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

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00

144 lines
5.9 KiB
Go

//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)
}
}