Files
Crussell/backend/handlers/payments/concurrency_test.go
T
popertots faceb9809c fix: review-loop A — discount credit on admin payments, campaign over-credit cap, sweep replay window, dedup refund revalidation, duplication/modularisation, GBP pence naming
Round-A fresh review (6 agents) + fix + secondary cross-cutting + verification rounds:
- F1: campaign discounts reduce the charged amount (deposit credit + admin PaymentModal discounted total); capDiscountToRemainingObligation prevents over-credit at completion in all four campaign blocks
- F2: sweep replay rescue distinguishes legitimate same-key retries (21h window) from expired-key new charges; ccof blind-fails leave pending + CRITICAL instead of clawing back
- F3: post-start online overflow carved as a tip record (mirrors terminal split builder)
- A1: single-source Square decline-code classification (till delegates to square.IsDefinitivePaymentError)
- A2/A5: refund attempt-cap literals consolidated; refund-failure counter capped + reset on terminal resolutions + admin notifications
- A3/A9: idempotency helpers adopted across derivations; IsExplicitDevOrMockEnv relocated + all gates unified (incl. health-check)
- A7: 2FA user+IP limiter + TRUST_PROXY_HEADERS startup warning; SNAPSHOT_ENC_KEY startup validation; TWO_FACTOR_PEPPER docs corrected
- A8: snapshot encryption on all 6 write sites + marker-aware reuse paths; MPV->SPV effective voucher type (single VAT point)
- A10/A11/A12/A16: gift-card slot scan advances past failed; amount-aware refund reconciliation; completed-booking refund re-check; PaymentWasRefunded on SquareClient interface
- Dedup refund revalidation on tip/terminal/gift-card paths; sweep acknowledged_at IS NULL parity; refund-notification single source (exported payments.InsertRefundFailedNotifications)
- Duplication/modularisation round: shared frontend helpers (sanitizeDecimalInput, campaignDiscountCents, twoFactorBlocksSavedCards getter, generateUUID), single-source MaxIdempotencyKeyLength, notification-helper consolidation, snapshot-guard comments
- Cross-cutting GBP rename: Cents->Pence across backend + frontend + tests (26 identifiers, 16 files)
- Tests: 11 behavior-change tests updated to new invariants; coverage for fixed functions; frontend vitest 55 tests; docs corrected (test counts, 2FA delivery, pre-launch checklist, resolution status)
- gitleaks: allowlist backend/internal/square test fixtures (mock idempotency keys)

All 25 backend packages pass; frontend 55/55 + build clean; env-docs 41/41.
2026-08-22 00:34:50 +01:00

905 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build test && dev
package payments
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
)
// slowCreatePaymentClient delays the Square charge so each handler holds its
// advisory lock long enough that a concurrent same-key request would race it
// without the lock (two goroutines both reading "no record", both charging).
type slowCreatePaymentClient struct {
square.SquareClient
delay time.Duration
}
func (c *slowCreatePaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
time.Sleep(c.delay)
return c.SquareClient.CreatePayment(ctx, req)
}
// cleanupConcurrentTestRows deletes the rows a concurrency test committed at
// pool level. These tests must COMMIT their setup so the advisory locks work
// across independent connections, which leaves committed rows in the shared
// test DB — without cleanup they leak into parallel tests (e.g. GetGiftCards
// counts gift_cards/user_giftcard_balances globally). FK-safe deletion order.
func cleanupConcurrentTestRows(t *testing.T, pool context.Context, userID, bookingID string) {
t.Helper()
t.Cleanup(func() {
var gcIDs []string
rows, err := db.Conn.Query(pool, `SELECT id FROM gift_cards WHERE created_by = $1`, userID)
if err == nil {
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
gcIDs = append(gcIDs, id)
}
}
rows.Close()
}
for _, gcID := range gcIDs {
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, gcID)
}
_, _ = db.Conn.Exec(pool, `DELETE FROM user_giftcard_balances WHERE user_id = $1`, userID)
if bookingID != "" {
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_services WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
}
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE created_by = $1 OR idempotency_key LIKE 'concurrent-%'`, userID)
for _, gcID := range gcIDs {
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, gcID)
}
_, _ = db.Conn.Exec(pool, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
})
}
// TestBuyGiftCard_ConcurrentSameKey_SingleRecord proves the BuyGiftCard
// advisory lock (giftcards.go): two goroutines POSTing the same idempotency key
// must produce exactly ONE payment record and ONE funded gift card — never
// 2× value for 1 charge. Without the lock, both goroutines pass the
// idempotency check, both reuse/insert pending records, and both fund the card.
func TestBuyGiftCard_ConcurrentSameKey_SingleRecord(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateUserToken(userID)
cleanupConcurrentTestRows(t, context.Background(), userID, "")
// Commit the setup so both goroutines operate at pool level — the advisory
// locks only serialize across independent connections, and a per-test tx
// would route both sides through a single shared connection.
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
if err := innerTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
origClient := SquareClient
slow := &slowCreatePaymentClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond}
SquareClient = slow
defer func() { SquareClient = origClient }()
pool := context.Background()
key := "buy-gc-concurrent-same-key"
reqBody := map[string]interface{}{
"amount": 2000,
"recipient_type": "self",
"new_card_token": "cnon:concurrent-card",
"idempotency_key": key,
}
var wg sync.WaitGroup
startBoth := make(chan struct{})
recs := make([]*httptest.ResponseRecorder, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBoth
recs[idx] = makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", reqBody, token, pool)
}(i)
}
close(startBoth)
wg.Wait()
// Both requests must succeed — the lock serializes them and the second
// finds the completed record (idempotent dedup, HTTP 200), so neither
// double-charges nor errors.
for i, rec := range recs {
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
t.Errorf("request %d expected 201 (create) or 200 (dedup), got %d. body: %s", i, rec.Code, rec.Body.String())
}
}
// Exactly one payment record for this key.
var payCount int
err = db.Conn.QueryRow(pool,
`SELECT COUNT(*) FROM payments WHERE idempotency_key = $1`, key).Scan(&payCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected exactly 1 payment record, got %d (double-charge!)", payCount)
}
// Exactly one funded gift card for this user's self-purchase.
var gcCount int
err = db.Conn.QueryRow(pool,
`SELECT COUNT(*) FROM gift_cards WHERE total_funds_added = 20.00 AND created_by = $1`, userID).Scan(&gcCount)
if err != nil {
t.Fatalf("failed to count gift cards: %v", err)
}
if gcCount != 1 {
t.Errorf("expected exactly 1 funded gift card, got %d (2× value!)", gcCount)
}
// User balance credited exactly once.
var balance float64
err = db.Conn.QueryRow(pool,
`SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balance)
if err != nil {
t.Fatalf("failed to query balance: %v", err)
}
if balance != 20.00 {
t.Errorf("expected balance 20.00, got %.2f (double-credit!)", balance)
}
}
// TestTipPayment_ConcurrentSameKey_SingleRecord proves the tip advisory lock
// (handlers.go): two goroutines POSTing the same booking with the same key must
// produce exactly ONE tip payment record — never two charges for one booking.
func TestTipPayment_ConcurrentSameKey_SingleRecord(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Past start so the tip is accepted (tips require the booking to have started).
start := clock.Now().Add(-1 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, start)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
if _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed"); err != nil {
t.Fatalf("failed to create prior payment: %v", err)
}
token := jwt.GenerateUserToken(userID)
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
if err := innerTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
origClient := SquareClient
slow := &slowCreatePaymentClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond}
SquareClient = slow
defer func() { SquareClient = origClient }()
pool := context.Background()
key := "tip-concurrent-same-key"
cardToken := "cnon:concurrent-tip-card"
reqBody := CreateTipPaymentRequest{
Amount: 500,
NewCardToken: &cardToken,
IdempotencyKey: key,
}
var wg sync.WaitGroup
startBoth := make(chan struct{})
recs := make([]*httptest.ResponseRecorder, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBoth
recs[idx] = makePaymentRequest(CreateTipPayment, "POST", "/api/bookings/"+bookingID+"/tip", reqBody, token, pool)
}(i)
}
close(startBoth)
wg.Wait()
for i, rec := range recs {
if rec.Code != http.StatusOK {
t.Errorf("request %d expected 200, got %d. body: %s", i, rec.Code, rec.Body.String())
}
}
var tipCount int
err = db.Conn.QueryRow(pool,
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip' AND idempotency_key = $2`, bookingID, key).Scan(&tipCount)
if err != nil {
t.Fatalf("failed to count tip payments: %v", err)
}
if tipCount != 1 {
t.Errorf("expected exactly 1 tip payment record, got %d (double-charge!)", tipCount)
}
}
// TestBookingPayment_ConcurrentSameKey_SingleRecord proves the booking-payment
// advisory lock (handlers.go): two goroutines paying the same booking with the
// same key must produce exactly ONE payment record.
func TestBookingPayment_ConcurrentSameKey_SingleRecord(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
token := jwt.GenerateUserToken(userID)
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
if err := innerTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
origClient := SquareClient
slow := &slowCreatePaymentClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond}
SquareClient = slow
defer func() { SquareClient = origClient }()
pool := context.Background()
key := "booking-pay-concurrent-same-key"
cardToken := "cnon:concurrent-booking-card"
reqBody := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: key,
}
var wg sync.WaitGroup
startBoth := make(chan struct{})
recs := make([]*httptest.ResponseRecorder, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBoth
recs[idx] = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", reqBody, token, pool)
}(i)
}
close(startBoth)
wg.Wait()
for i, rec := range recs {
if rec.Code != http.StatusOK {
t.Errorf("request %d expected 200, got %d. body: %s", i, rec.Code, rec.Body.String())
}
}
var payCount int
err := db.Conn.QueryRow(pool,
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, key).Scan(&payCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected exactly 1 payment record, got %d (double-charge!)", payCount)
}
}
// countingCreatePaymentClient delays the Square charge (widening the advisory
// lock race window) and counts every successful CreatePayment call so the test
// can assert exactly one charge reaches Square.
type countingCreatePaymentClient struct {
square.SquareClient
mu sync.Mutex
charges int
}
func (c *countingCreatePaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
c.mu.Lock()
c.charges++
c.mu.Unlock()
time.Sleep(300 * time.Millisecond)
return c.SquareClient.CreatePayment(ctx, req)
}
func (c *countingCreatePaymentClient) chargeCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.charges
}
// TestBookingPayment_ConcurrentPartials_SingleCharge proves the in-lock
// remaining-balance re-check (handlers.go): two concurrent 'partial' payments
// whose combined amount exceeds the booking's remaining balance must yield ONE
// successful charge — the loser is rejected with 4xx inside the advisory lock
// BEFORE inserting a pending record or hitting Square. Without the re-check
// both pass the pre-lock ValidatePartialAmount against the same balance, both
// charge, and the overflow is silently recorded as a tip.
func TestBookingPayment_ConcurrentPartials_SingleCharge(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
token := jwt.GenerateUserToken(userID)
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
if err := innerTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
// The fixture service costs £50, so the booking's remaining balance is 5000
// pence. Two £30 partials sum to £60 > £50 — only one may succeed.
var remainingPence int64
if err := db.Conn.QueryRow(context.Background(),
`SELECT ROUND(total_amount * 100)::bigint FROM bookings WHERE id = $1`, bookingID).Scan(&remainingPence); err != nil {
t.Fatalf("failed to read booking total: %v", err)
}
if remainingPence != 5000 {
t.Fatalf("expected fixture booking total of 5000 pence, got %d", remainingPence)
}
origClient := SquareClient
slow := &countingCreatePaymentClient{SquareClient: square.NewDevClient()}
SquareClient = slow
defer func() { SquareClient = origClient }()
pool := context.Background()
cardToken := "cnon:concurrent-partial-card"
reqBody := CreateBookingPaymentRequest{
Amount: 3000,
PaymentType: "partial",
NewCardToken: &cardToken,
IdempotencyKey: "partial-concurrent-" + bookingID,
}
var wg sync.WaitGroup
startBoth := make(chan struct{})
recs := make([]*httptest.ResponseRecorder, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBoth
recs[idx] = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", reqBody, token, pool)
}(i)
}
close(startBoth)
wg.Wait()
// Exactly one request wins; the loser is rejected by the in-lock balance
// re-check (409) — or by the pre-lock filter if it read the reduced balance
// after the winner committed (400). Either way, never a second charge.
okCount, rejectedCount := 0, 0
for i, rec := range recs {
switch {
case rec.Code == http.StatusOK:
okCount++
case rec.Code == http.StatusBadRequest || rec.Code == http.StatusConflict:
rejectedCount++
default:
t.Errorf("request %d unexpected status %d: %s", i, rec.Code, rec.Body.String())
}
}
if okCount != 1 {
t.Errorf("expected exactly 1 successful partial payment, got %d", okCount)
}
if rejectedCount != 1 {
t.Errorf("expected exactly 1 rejected partial payment, got %d", rejectedCount)
}
// Exactly one charge reached Square.
if n := slow.chargeCount(); n != 1 {
t.Errorf("expected exactly 1 Square charge, got %d (double-charge!)", n)
}
// Exactly one completed payment for the booking, and no overpayment: the
// recorded total must not exceed the booking's remaining balance.
var payCount int
if err := db.Conn.QueryRow(pool,
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected exactly 1 completed payment, got %d (double-charge!)", payCount)
}
var paidPence int64
if err := db.Conn.QueryRow(pool,
`SELECT ROUND(COALESCE(SUM(amount), 0) * 100)::bigint FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type <> 'tip'`,
bookingID).Scan(&paidPence); err != nil {
t.Fatalf("failed to sum paid amount: %v", err)
}
if paidPence > remainingPence {
t.Errorf("overpayment recorded: paid %d pence exceeds remaining balance %d pence", paidPence, remainingPence)
}
}
// completedCheckoutClient forces GetCheckout to return a fixed COMPLETED
// payment for any checkout id, deterministically exercising the terminal
// completion dedup+insert path (the mock's real async goroutine would be
// non-deterministic in a race test).
type completedCheckoutClient struct {
square.SquareClient
result *square.PaymentResult
}
func (c *completedCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
return c.result, nil
}
// TestGetCheckoutStatus_ConcurrentPolls_SingleRecord proves the P3 fix: the
// terminal-completion path serializes on a per-payment advisory lock, so two
// concurrent polls of the same completed checkout produce exactly ONE payment
// record. Without the lock, both goroutines pass the dedup SELECT, both INSERT,
// and the second dies on the idempotency_key UNIQUE constraint after the
// customer already paid.
func TestGetCheckoutStatus_ConcurrentPolls_SingleRecord(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, start)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
if err := innerTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
origClient := SquareClient
SquareClient = &completedCheckoutClient{
SquareClient: square.NewDevClient(),
result: &square.PaymentResult{
ID: "pay_terminal_race",
Status: "COMPLETED",
Amount: 5000,
Fees: 88,
SquarePayID: "pay_terminal_race",
CardBrand: "VISA",
CardLast4: "4242",
ReceiptURL: "https://receipt.example/pay_terminal_race",
EntryMethod: "EMV",
LocationID: "loc",
ReferenceID: bookingID,
CreatedAt: "2026-07-31T00:00:00Z",
UpdatedAt: "2026-07-31T00:00:00Z",
},
}
defer func() { SquareClient = origClient }()
pool := context.Background()
checkoutID := "abcd1234ef56" // 12 hex chars, passes the checkout-id validation
var wg sync.WaitGroup
startBoth := make(chan struct{})
recs := make([]*httptest.ResponseRecorder, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBoth
req := httptest.NewRequest("GET", "/api/checkout/"+checkoutID+"/status?booking_id="+bookingID, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("checkout_id", checkoutID)
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
// GetCheckoutStatus is admin-only (defense-in-depth S-1 check).
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
GetCheckoutStatus(w, req)
recs[idx] = w
}(i)
}
close(startBoth)
wg.Wait()
for i, rec := range recs {
if rec.Code != http.StatusOK {
t.Errorf("request %d expected 200, got %d. body: %s", i, rec.Code, rec.Body.String())
}
}
// Exactly one completed terminal payment record for this booking.
var payCount int
err = db.Conn.QueryRow(pool,
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'in_person_card' AND square_payment_id = $2`,
bookingID, "pay_terminal_race").Scan(&payCount)
if err != nil {
t.Fatalf("failed to count terminal payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected exactly 1 terminal payment record, got %d (double-record race!)", payCount)
}
}
// TestLoyaltyRedemption_ConcurrentSameBooking_SingleApply proves the loyalty
// redemption advisory lock (loyalty.go): two goroutines redeeming the same
// booking must apply the 10% discount exactly once. One request succeeds (200)
// and the other is rejected by the in-lock re-check ("A loyalty discount has
// already been applied", 400) — never two discount payments for one booking.
func TestLoyaltyRedemption_ConcurrentSameBooking_SingleApply(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupLoyaltyUser(t, ctx, tx, 10)
token := jwt.GenerateUserToken(userID)
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
// Commit the setup so both goroutines operate at pool level — the advisory
// lock only serializes across independent connections.
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
if err := innerTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
pool := context.Background()
var wg sync.WaitGroup
startBoth := make(chan struct{})
recs := make([]*httptest.ResponseRecorder, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBoth
recs[idx] = makeApplyRedemptionRequest(bookingID, token, pool)
}(i)
}
close(startBoth)
wg.Wait()
// Exactly one request wins the redemption; the other finds the already
// applied discount inside the lock and is rejected with 400 (the first
// handler completes in milliseconds, well under the ~3s lock bound, so the
// 409 lock-timeout path is not exercised).
okCount, rejectedCount := 0, 0
for i, rec := range recs {
switch {
case rec.Code == http.StatusOK:
okCount++
case rec.Code == http.StatusBadRequest || rec.Code == http.StatusConflict:
rejectedCount++
default:
t.Errorf("request %d unexpected status %d: %s", i, rec.Code, rec.Body.String())
}
}
if okCount != 1 {
t.Errorf("expected exactly 1 successful redemption, got %d", okCount)
}
if rejectedCount != 1 {
t.Errorf("expected exactly 1 rejected redemption, got %d", rejectedCount)
}
// Exactly one loyalty discount and one discount payment row.
var discountCount int
if err := db.Conn.QueryRow(pool,
`SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'`, bookingID).Scan(&discountCount); err != nil {
t.Fatalf("failed to count loyalty discounts: %v", err)
}
if discountCount != 1 {
t.Errorf("expected exactly 1 loyalty discount, got %d (double-apply!)", discountCount)
}
var payCount int
if err := db.Conn.QueryRow(pool,
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count discount payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected exactly 1 discount payment, got %d", payCount)
}
// The redemption is applied exactly once.
var appliedCount int
if err := db.Conn.QueryRow(pool,
`SELECT COUNT(*) FROM loyalty_redemptions WHERE user_id = $1 AND status = 'applied'`, userID).Scan(&appliedCount); err != nil {
t.Fatalf("failed to count applied redemptions: %v", err)
}
if appliedCount != 1 {
t.Errorf("expected exactly 1 applied redemption, got %d", appliedCount)
}
// Clean up the committed loyalty_redemptions row (cleanupConcurrentTestRows
// does not cover it; user_id is SET NULL by the FK so it would otherwise leak).
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM loyalty_redemptions WHERE user_id = $1 OR applied_to_booking_id = $2`, userID, bookingID)
})
}
// TestCreateTillSale_ConcurrentSameKey_SingleRecord proves the till-sale
// advisory lock (till.go): two goroutines POSTing the same idempotency key must
// produce exactly ONE till_sales row and ONE funded gift card — never 2× value
// for one charge. Without the lock, both goroutines pass the idempotency check,
// both fund a gift card, and one dies on the till_sales idempotency_key UNIQUE
// constraint after the funding already committed.
func TestCreateTillSale_ConcurrentSameKey_SingleRecord(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
pool := context.Background()
cleanupConcurrentTestRows(t, pool, adminID, "")
key := "till-concurrent-same-key"
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE idempotency_key = $1`, key)
})
// Commit the setup so both goroutines operate at pool level — the advisory
// locks only serialize across independent connections, and a per-test tx
// would route both sides through a single shared connection.
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
if err := innerTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
origClient := SquareClient
slow := &slowCreatePaymentClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond}
SquareClient = slow
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:concurrent-till-card",
IdempotencyKey: key,
}
var wg sync.WaitGroup
startBoth := make(chan struct{})
recs := make([]*httptest.ResponseRecorder, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBoth
recs[idx] = makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", reqBody, adminToken, pool)
}(i)
}
close(startBoth)
wg.Wait()
// Both requests must succeed — the lock serializes them and the second
// finds the completed record (idempotent dedup), so neither double-charges
// nor errors.
for i, rec := range recs {
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
t.Errorf("request %d expected 201 (create) or 200 (dedup), got %d. body: %s", i, rec.Code, rec.Body.String())
}
}
// Exactly one till_sales row for this key.
var saleCount int
err = db.Conn.QueryRow(pool,
`SELECT COUNT(*) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount)
if err != nil {
t.Fatalf("failed to count till_sales: %v", err)
}
if saleCount != 1 {
t.Errorf("expected exactly 1 till_sales row, got %d (double-charge!)", saleCount)
}
// Exactly one funded gift card for this admin's till sale.
var gcCount int
err = db.Conn.QueryRow(pool,
`SELECT COUNT(*) FROM gift_cards WHERE total_funds_added = 50.00 AND created_by = $1`, adminID).Scan(&gcCount)
if err != nil {
t.Fatalf("failed to count gift cards: %v", err)
}
if gcCount != 1 {
t.Errorf("expected exactly 1 funded gift card, got %d (2× value!)", gcCount)
}
}
// TestTransferGiftCard_ConcurrentCrossTransfer_NoDeadlock proves the gift-card
// transfer lock ordering (giftcards.go): two concurrent cross-transfers A→B and
// B→A must both succeed. Locking "source first" (the caller's chosen order)
// would deadlock — A→B locks A then B while B→A locks B then A — and Postgres
// aborts one with SQLSTATE 40P01. Locking the lesser ID first makes both
// transactions acquire the same lock sequence, so neither deadlocks.
func TestTransferGiftCard_ConcurrentCrossTransfer_NoDeadlock(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
cleanupConcurrentTestRows(t, context.Background(), adminID, "")
// Both cards hold ample balance so either transfer direction succeeds no
// matter which transaction wins the race (net effect is a wash).
var cardAID, cardBID string
if err := tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (100.00, 100.00, $1) RETURNING id
`, adminID).Scan(&cardAID); err != nil {
t.Fatalf("failed to insert card A: %v", err)
}
if err := tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (100.00, 100.00, $1) RETURNING id
`, adminID).Scan(&cardBID); err != nil {
t.Fatalf("failed to insert card B: %v", err)
}
// Commit the setup so both goroutines run at pool level on independent
// connections — a shared per-test tx would serialize them on one connection
// and mask the deadlock entirely.
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
if err := innerTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
pool := context.Background()
// Route each request through a chi router so chi.URLParam("from") resolves.
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/admin/gift-cards/{from}/transfer", TransferGiftCard)
transfer := func(from, to string, amount float64) int {
reqBody, _ := json.Marshal(map[string]interface{}{
"to_card_id": to,
"amount": amount,
})
req := httptest.NewRequest("POST", "/admin/gift-cards/"+from+"/transfer", bytes.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w.Code
}
var wg sync.WaitGroup
startBoth := make(chan struct{})
codes := make([]int, 2)
// Deterministically force the deadlock window instead of hoping two
// sub-millisecond transactions interleave: hold each card's row lock on a
// dedicated pool connection, launch both cross-transfers (each blocks on
// its first lock), then release the held locks one at a time.
//
// Old "source first" order: G1 (A→B) holds A and queues on B; releasing
// holderB grants B to G2 (B→A, queued first) which then queues on A held
// by G1 → Postgres aborts one with 40P01 → 500.
//
// Fixed sorted order: both transactions want the SAME first lock, so only
// one ever holds it; the loser waits, the winner proceeds, and both
// succeed with no cycle.
holderA, err := db.Conn.Acquire(pool)
if err != nil {
t.Fatalf("failed to acquire holder conn A: %v", err)
}
defer holderA.Release()
if _, err := holderA.Exec(pool, "SELECT amount_remaining FROM gift_cards WHERE id = $1 FOR UPDATE", cardAID); err != nil {
t.Fatalf("failed to hold lock on card A: %v", err)
}
holderB, err := db.Conn.Acquire(pool)
if err != nil {
t.Fatalf("failed to acquire holder conn B: %v", err)
}
defer holderB.Release()
if _, err := holderB.Exec(pool, "SELECT amount_remaining FROM gift_cards WHERE id = $1 FOR UPDATE", cardBID); err != nil {
t.Fatalf("failed to hold lock on card B: %v", err)
}
wg.Add(2)
go func() {
defer wg.Done()
<-startBoth
codes[0] = transfer(cardAID, cardBID, 10.00)
}()
go func() {
defer wg.Done()
<-startBoth
codes[1] = transfer(cardBID, cardAID, 10.00)
}()
close(startBoth)
// Give both goroutines time to reach their first blocked SELECT.
time.Sleep(300 * time.Millisecond)
// Release A first: the A→B transfer acquires A and queues on B (still
// held). Then release B: the B→A transfer was queued on B first, so it
// acquires B and queues on A — completing the cycle under old code.
if _, err := holderA.Exec(pool, "COMMIT"); err != nil {
t.Fatalf("failed to release holder A: %v", err)
}
time.Sleep(300 * time.Millisecond)
if _, err := holderB.Exec(pool, "COMMIT"); err != nil {
t.Fatalf("failed to release holder B: %v", err)
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("concurrent cross-transfers deadlocked (no response within 30s)")
}
for i, code := range codes {
if code != http.StatusOK {
t.Errorf("cross-transfer %d expected 200, got %d (deadlock aborted one tx)", i, code)
}
}
// Both transfers succeeded, so each card's balance is back to £100.
for _, id := range []string{cardAID, cardBID} {
var remaining float64
if err := db.Conn.QueryRow(pool, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, id).Scan(&remaining); err != nil {
t.Fatalf("failed to query balance for card %s: %v", id, err)
}
if remaining != 100.00 {
t.Errorf("card %s expected net balance 100.00, got %.2f", id, remaining)
}
}
}