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