TransferGiftCard locked source-then-destination in caller-chosen order, so concurrent cross-transfers (A→B and B→A) acquired row locks in opposite orders and deadlocked (SQLSTATE 40P01), aborting one transfer with a generic 500. Both rows are now locked in lexicographically sorted ID order (case-insensitive, matching the 12-hex mixed-case card codes), with the scanned values mapped back to source/destination roles afterward. Adds a deterministic concurrency regression test that holds both row locks on dedicated connections to force the AB-BA cycle, proving the old code deadlocks and the new code serializes cleanly.
678 lines
23 KiB
Go
678 lines
23 KiB
Go
//go:build test && dev
|
||
|
||
package payments
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"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)
|
||
}
|
||
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)
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
})
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|