- ValidateCardInfo accepts saved-card ref + new_card_token coexistence (matches resolveChargeSource); new_card_token added to terminal/till request structs so SCA tokens are never dropped - maxOnlineTipPence (£250) enforced on the overflow-tip carve AND buildSplitRecords (both carve paths) — closes the £10k bypass - completion-path campaign increments made atomic reserve-first (conditional UPDATE ... RETURNING) + schema backstops (chk_times_redeemed, partial unique index on milestone redemptions) - webhook orphan detection gated on B1 evidence (b1_attempts / sweep-duplicate refund row) so a delayed legit completion is never marked failed - gift-card: per-user £500/day cap lock held across read-modify-write, expired-card top-up gate, NaN/Inf float bounds, refund_failed ack filter, on_the_house excluded from balance, postChargeRecheck notification - admin apply-redemption route + admin-or-owner, in-handler isAdminRequest on 4 gift-card handlers, tip lock key aligned - 2FA fallback machinery removed (insertTwoFAFallbackAudit/reissue/consent), dead fields stripped from charge structs - tests: prod-tag suite, mock SCA parity, tip-cap overflow, completion races, cards pagination, ValidateCardInfo tables
141 lines
5.7 KiB
Go
141 lines
5.7 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)
|
|
}
|
|
|
|
// No split records / completion side effects may have been applied by the
|
|
// sweep (the rescue is gated on the row still pending).
|
|
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 != 1 {
|
|
t.Errorf("expected exactly the one webhook-completed payment row (no sweep splits), 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 != "pending" {
|
|
t.Errorf("expected the sweep not to complete the booking after the webhook settled the row, got %q", bookingStatus)
|
|
}
|
|
}
|