Files
Crussell/backend/handlers/payments/concurrency_test.go
T
popertots 738f6b6a51 Expand payment test coverage: lock contention, nonce-direct, provisional rows, GDPR scrub, validators
Close the coverage-gap round: terminal CreateCheckout-failure marks the provisional row failed, GetCheckoutStatus reference_id mismatch 400, deadline wire shape, concurrent loyalty redemption 409, delete_guest_user + stale-guest saved-card scrubbing, ValidateAmount and isTokenLike direct units, bounded try-lock timeout, buildSplitRecords tip-overflow, and concurrent same-key dedup for gift card / booking / tip / checkout.
2026-08-22 00:34:49 +01:00

522 lines
18 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 (
"context"
"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)
})
}