Files
Crussell/backend/handlers/payments/giftcards_test.go
T
popertots 1429eddd34 fix: payments hardening — SCA wire contract (saved-card ref + tokenize-result), terminal/till token routing, tip-cap overflow carve, completion campaign atomicity, orphan B1-evidence gate, gift-card gates/locks, admin backstops
- ValidateCardInfo accepts saved-card ref + new_card_token coexistence (matches resolveChargeSource); new_card_token added to terminal/till request structs so SCA tokens are never dropped
- maxOnlineTipPence (£250) enforced on the overflow-tip carve AND buildSplitRecords (both carve paths) — closes the £10k bypass
- completion-path campaign increments made atomic reserve-first (conditional UPDATE ... RETURNING) + schema backstops (chk_times_redeemed, partial unique index on milestone redemptions)
- webhook orphan detection gated on B1 evidence (b1_attempts / sweep-duplicate refund row) so a delayed legit completion is never marked failed
- gift-card: per-user £500/day cap lock held across read-modify-write, expired-card top-up gate, NaN/Inf float bounds, refund_failed ack filter, on_the_house excluded from balance, postChargeRecheck notification
- admin apply-redemption route + admin-or-owner, in-handler isAdminRequest on 4 gift-card handlers, tip lock key aligned
- 2FA fallback machinery removed (insertTwoFAFallbackAudit/reissue/consent), dead fields stripped from charge structs
- tests: prod-tag suite, mock SCA parity, tip-cap overflow, completion races, cards pagination, ValidateCardInfo tables
2026-08-22 00:34:50 +01:00

2927 lines
98 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"
"database/sql"
"encoding/json"
"math"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
)
func TestAdminCreateGiftCard(t *testing.T) {
t.Parallel()
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")
reqBody, _ := json.Marshal(map[string]interface{}{"amount": 50.00})
req := httptest.NewRequest("POST", "/api/admin/gift-cards", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards", CreateGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d", w.Code)
}
var gc GiftCard
if err := json.NewDecoder(w.Body).Decode(&gc); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if gc.TotalFundsAdded != 50.00 || gc.AmountRemaining != 50.00 {
t.Errorf("expected funds and remaining to be 50.00, got added=%.2f remaining=%.2f", gc.TotalFundsAdded, gc.AmountRemaining)
}
}
func TestAdminTopUpGiftCard(t *testing.T) {
t.Parallel()
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")
// Create gift card
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (50.00, 50.00, $1)
RETURNING id
`, adminID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to insert gift card: %v", err)
}
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 25.00,
"payment_method": "on_the_house",
})
req := httptest.NewRequest("PUT", "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Put("/api/admin/gift-cards/{id}/topup", TopUpGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var gc GiftCard
if err := json.NewDecoder(w.Body).Decode(&gc); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if gc.TotalFundsAdded != 75.00 || gc.AmountRemaining != 75.00 {
t.Errorf("expected topped up card totals, got added=%.2f remaining=%.2f", gc.TotalFundsAdded, gc.AmountRemaining)
}
}
func TestAdminTransferGiftCard(t *testing.T) {
t.Parallel()
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")
// Create card 1 with £100
var card1ID string
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(&card1ID)
if err != nil {
t.Fatalf("failed to insert card 1: %v", err)
}
// Create card 2 with £20
var card2ID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (20.00, 20.00, $1)
RETURNING id
`, adminID).Scan(&card2ID)
if err != nil {
t.Fatalf("failed to insert card 2: %v", err)
}
// Transfer £30 from card 1 to card 2
reqBody, _ := json.Marshal(map[string]interface{}{
"to_card_id": card2ID,
"amount": 30.00,
})
req := httptest.NewRequest("POST", "/api/admin/gift-cards/"+card1ID+"/transfer", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards/{from}/transfer", TransferGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
// Verify card 1 has £70 remaining
var card1Remaining float64
err = tx.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", card1ID).Scan(&card1Remaining)
if err != nil {
t.Fatalf("failed to query card 1: %v", err)
}
if card1Remaining != 70.00 {
t.Errorf("expected card 1 to have 70.00, got %.2f", card1Remaining)
}
// Verify card 2 has £50 remaining and £50 total funds added
var card2Remaining, card2Added float64
err = tx.QueryRow(ctx, "SELECT amount_remaining, total_funds_added FROM gift_cards WHERE id = $1", card2ID).Scan(&card2Remaining, &card2Added)
if err != nil {
t.Fatalf("failed to query card 2: %v", err)
}
if card2Remaining != 50.00 || card2Added != 50.00 {
t.Errorf("expected card 2 to have remaining=50.00 added=50.00, got remaining=%.2f added=%.2f", card2Remaining, card2Added)
}
}
func TestUserRedeemGiftCard(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateTestToken(userID, "verified_email")
// Create gift card with £100
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining)
VALUES (100.00, 100.00)
RETURNING id
`).Scan(&cardID)
if err != nil {
t.Fatalf("failed to insert gift card: %v", err)
}
reqBody, _ := json.Marshal(map[string]interface{}{"code": cardID})
req := httptest.NewRequest("POST", "/api/user/giftcards/redeem", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/redeem", RedeemGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
// Verify card marked as spent (remaining = 0) and claimed
var amountRemaining float64
var redeemedBy string
err = tx.QueryRow(ctx, "SELECT amount_remaining, redeemed_by FROM gift_cards WHERE id = $1", cardID).Scan(&amountRemaining, &redeemedBy)
if err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if amountRemaining != 0.00 {
t.Errorf("expected card to be spent, got remaining=%.2f", amountRemaining)
}
if redeemedBy != userID {
t.Errorf("expected card redeemed_by to be user, got '%s'", redeemedBy)
}
// Verify balance added to user
var balance float64
err = tx.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
t.Fatalf("failed to query user balance: %v", err)
}
if balance != 100.00 {
t.Errorf("expected user balance to be 100.00, got %.2f", balance)
}
}
func TestBuyGiftCard_Self(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateTestToken(userID, "verified_email")
// Charge a mock payment token
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 2000, // £20.00 in pence
"recipient_type": "self",
"new_card_token": "cnon:card-nonce-ok",
"idempotency_key": "idempotency-key-buy-gc-self",
})
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/buy", BuyGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. Body: %s", w.Code, w.Body.String())
}
// Verify user balance is now £20.00
var balance float64
err = tx.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
t.Fatalf("failed to query user balance: %v", err)
}
if balance != 20.00 {
t.Errorf("expected user balance 20.00, got %.2f", balance)
}
// Verify purchase payment record was created
var payCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected 1 payment record, got %d", payCount)
}
}
func TestBuyGiftCard_TransactionFailure_SkipsSquare(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateTestToken(userID, "verified_email")
// Create a cancelled context so the nested transaction fails
cancelCtx, cancel := context.WithCancel(ctx)
cancel()
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 2000,
"recipient_type": "self",
"new_card_token": "cnon:card-nonce-ok",
"idempotency_key": "idempotency-key-buy-gc-txn-fail",
})
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(cancelCtx, tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/buy", BuyGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500 due to cancelled context, got %d. Body: %s", w.Code, w.Body.String())
}
// Verify no completed payment records exist — confirming Square was never called
var payCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1 AND status = 'completed'", userID).Scan(&payCount)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
if payCount != 0 {
t.Errorf("expected 0 completed payment records (Square should not have been called), got %d", payCount)
}
}
func TestBuyGiftCard_Friend(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateTestToken(userID, "verified_email")
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 5000, // £50.00 in pence
"recipient_type": "friend",
"new_card_token": "cnon:card-nonce-ok",
"idempotency_key": "idempotency-key-buy-gc-friend",
})
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/buy", BuyGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. Body: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
cardID := resp["code"].(string)
// Verify card was created with £50.00 remaining (stays active, unredeemed)
var remaining, added float64
var redeemedBy sql.NullString
err = tx.QueryRow(ctx, "SELECT amount_remaining, total_funds_added, redeemed_by FROM gift_cards WHERE id = $1", cardID).Scan(&remaining, &added, &redeemedBy)
if err != nil {
t.Fatalf("failed to query card: %v", err)
}
if remaining != 50.00 || added != 50.00 {
t.Errorf("expected card values to be 50.00, got remaining=%.2f added=%.2f", remaining, added)
}
if redeemedBy.Valid {
t.Errorf("expected card redeemed_by to be null, got '%s'", redeemedBy.String)
}
}
func TestAdminRecordPayment_CashAndGiftCard(t *testing.T) {
t.Parallel()
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")
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Update booking to in_progress so it is payable
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
// Create a physical gift card code with £100 balance
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining)
VALUES (100.00, 100.00)
RETURNING id
`).Scan(&cardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
// 1. Pay £30 with CASH
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 3000, // £30.00 in pence
"payment_type": "full",
"payment_method": "cash",
})
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
// Verify cash payment recorded
var cashPayCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'cash'", bookingID).Scan(&cashPayCount)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
if cashPayCount != 1 {
t.Errorf("expected 1 cash payment, got %d", cashPayCount)
}
// 2. Pay £40 with PHYSICAL GIFT CARD (guest checkout simulation)
reqBody2, _ := json.Marshal(map[string]interface{}{
"amount": 4000, // £40.00 in pence
"payment_type": "full",
"payment_method": "giftcard",
"gift_card_id": cardID,
})
req2 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody2))
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("Content-Type", "application/json")
req2 = req2.WithContext(db.ContextWithTx(req2.Context(), tx.(pgx.Tx)))
w2 := httptest.NewRecorder()
r2 := chi.NewRouter()
r2.Use(mw.RequireAuth)
r2.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r2.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. Body: %s", w2.Code, w2.Body.String())
}
// Verify gift card balance deducted from card directly. B3: after the £30
// cash payment the remaining obligation is £20, so the £40 gift-card
// payment is clamped to £20 (the PaymentModal amount that ignored prior
// payments must never be recorded verbatim) — the card keeps £80.
var remaining float64
err = tx.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", cardID).Scan(&remaining)
if err != nil {
t.Fatalf("failed to query card: %v", err)
}
if remaining != 80.00 {
t.Errorf("expected gift card balance to be 80.00 (only the £20 remaining obligation deducted), got %.2f", remaining)
}
// Verify gift card payment record created
var gcPayCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'", bookingID).Scan(&gcPayCount)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
if gcPayCount != 1 {
t.Errorf("expected 1 gift card payment, got %d", gcPayCount)
}
// 3. Redeem remaining £60 of gift card to user account
// Setup user account with some balance first
_, _ = tx.Exec(ctx, "INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 60.00)", adminID)
// Now try to pay £25 using user account balance — the booking is ALREADY
// fully paid (£30 cash + £20 clamped gift card = £50), so B3 rejects the
// overcharge instead of recording it (the user balance must be untouched).
reqBody3, _ := json.Marshal(map[string]interface{}{
"amount": 2500, // £25.00 in pence
"payment_type": "full",
"payment_method": "giftcard",
})
req3 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody3))
req3.Header.Set("Authorization", "Bearer "+token)
req3.Header.Set("Content-Type", "application/json")
req3 = req3.WithContext(db.ContextWithTx(req3.Context(), tx.(pgx.Tx)))
w3 := httptest.NewRecorder()
r3 := chi.NewRouter()
r3.Use(mw.RequireAuth)
r3.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r3.ServeHTTP(w3, req3)
if w3.Code != http.StatusBadRequest {
t.Errorf("expected status 400 (booking already fully paid), got %d. Body: %s", w3.Code, w3.Body.String())
}
// Verify user account balance was NOT deducted.
var userBalance float64
err = tx.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", adminID).Scan(&userBalance)
if err != nil {
t.Fatalf("failed to query user balance: %v", err)
}
if userBalance != 60.00 {
t.Errorf("expected user balance unchanged at 60.00 (overcharge rejected), got %.2f", userBalance)
}
}
// --- New Tests for branch features ---
func TestGetExpiredBalances(t *testing.T) {
t.Parallel()
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")
// Seed expired balances
for i := 0; i < 2; i++ {
_, err = tx.Exec(ctx, `
INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at)
VALUES ($1, $2, NOW() - interval '30 days')
`, adminID, float64(25.00*(i+1)))
if err != nil {
t.Fatalf("failed to seed expired balance %d: %v", i, err)
}
}
req := httptest.NewRequest("GET", "/api/admin/gift-cards/expired-balances", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Get("/api/admin/gift-cards/expired-balances", GetExpiredBalances)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
total, ok := resp["total"].(float64)
if !ok || total != 2 {
t.Errorf("expected total 2, got %v", resp["total"])
}
balances, ok := resp["expired_balances"].([]interface{})
if !ok || len(balances) != 2 {
t.Errorf("expected 2 expired_balances, got %d", len(balances))
}
}
func TestClaimExpiredBalance_HappyPath(t *testing.T) {
t.Parallel()
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")
// Seed an expired balance with known ID
var balanceID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at)
VALUES ($1, 50.00, NOW() - interval '30 days')
RETURNING id
`, adminID).Scan(&balanceID)
if err != nil {
t.Fatalf("failed to seed expired balance: %v", err)
}
notes := "claimed via test"
reqBody, _ := json.Marshal(map[string]interface{}{
"balance_id": balanceID,
"notes": notes,
})
req := httptest.NewRequest("POST", "/api/admin/gift-cards/expired-balances/claim", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards/expired-balances/claim", ClaimExpiredBalance)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["status"] != "claimed" {
t.Errorf("expected status 'claimed', got '%s'", resp["status"])
}
// Verify claimed_at is set in DB
var claimedAt sql.NullTime
err = tx.QueryRow(ctx, "SELECT claimed_at FROM gift_card_expired_balances WHERE id = $1", balanceID).Scan(&claimedAt)
if err != nil {
t.Fatalf("failed to query expired balance: %v", err)
}
if !claimedAt.Valid {
t.Error("expected claimed_at to be set, got null")
}
}
func TestClaimExpiredBalance_AlreadyClaimed(t *testing.T) {
t.Parallel()
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")
// Seed an expired balance that is already claimed
var balanceID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at, claimed_at, claimed_by_admin)
VALUES ($1, 50.00, NOW() - interval '30 days', NOW(), $2)
RETURNING id
`, adminID, adminID).Scan(&balanceID)
if err != nil {
t.Fatalf("failed to seed claimed expired balance: %v", err)
}
reqBody, _ := json.Marshal(map[string]interface{}{
"balance_id": balanceID,
})
req := httptest.NewRequest("POST", "/api/admin/gift-cards/expired-balances/claim", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards/expired-balances/claim", ClaimExpiredBalance)
r.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Errorf("expected 409, got %d. Body: %s", w.Code, w.Body.String())
}
}
func TestClaimExpiredBalance_NotFound(t *testing.T) {
t.Parallel()
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")
reqBody, _ := json.Marshal(map[string]interface{}{
"balance_id": "aaaaaaaaaaaa",
})
req := httptest.NewRequest("POST", "/api/admin/gift-cards/expired-balances/claim", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards/expired-balances/claim", ClaimExpiredBalance)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d. Body: %s", w.Code, w.Body.String())
}
}
func TestGetGiftCards_Pagination(t *testing.T) {
t.Parallel()
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")
// Create 15 gift cards
for i := 0; i < 15; i++ {
_, err = tx.Exec(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (10.00, 10.00, $1)
`, adminID)
if err != nil {
t.Fatalf("failed to create gift card %d: %v", i, err)
}
}
// Request page 1 with per_page=5
req := httptest.NewRequest("GET", "/api/admin/gift-cards?page=1&per_page=5", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Get("/api/admin/gift-cards", GetGiftCards)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String())
}
var resp GiftCardListResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(resp.GiftCards) != 5 {
t.Errorf("expected 5 gift cards on page 1, got %d", len(resp.GiftCards))
}
if resp.Total != 15 {
t.Errorf("expected total 15, got %d", resp.Total)
}
if resp.Page != 1 {
t.Errorf("expected page 1, got %d", resp.Page)
}
if resp.PerPage != 5 {
t.Errorf("expected perPage 5, got %d", resp.PerPage)
}
if resp.TotalPages != 3 {
t.Errorf("expected totalPages 3, got %d", resp.TotalPages)
}
// Request page 3 to verify last page
req3 := httptest.NewRequest("GET", "/api/admin/gift-cards?page=3&per_page=5", nil)
req3.Header.Set("Authorization", "Bearer "+token)
req3 = req3.WithContext(db.ContextWithTx(req3.Context(), tx.(pgx.Tx)))
w3 := httptest.NewRecorder()
r3 := chi.NewRouter()
r3.Use(mw.RequireAuth)
r3.Get("/api/admin/gift-cards", GetGiftCards)
r3.ServeHTTP(w3, req3)
if w3.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w3.Code)
}
var resp3 GiftCardListResponse
if err := json.NewDecoder(w3.Body).Decode(&resp3); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(resp3.GiftCards) != 5 {
t.Errorf("expected 5 gift cards on page 3, got %d", len(resp3.GiftCards))
}
if resp3.Total != 15 {
t.Errorf("expected total 15 on page 3, got %d", resp3.Total)
}
}
func TestGetGiftCards_Search(t *testing.T) {
t.Parallel()
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")
// Create cards with specific hex IDs for search predictability
searchableID := "aaaaaabbbbcc"
nonSearchableID := "ddddeeeeffff"
_, err = tx.Exec(ctx, `
INSERT INTO gift_cards (id, total_funds_added, amount_remaining, created_by)
VALUES ($1, 10.00, 10.00, $2)
`, searchableID, adminID)
if err != nil {
t.Fatalf("failed to create searchable card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO gift_cards (id, total_funds_added, amount_remaining, created_by)
VALUES ($1, 20.00, 20.00, $2)
`, nonSearchableID, adminID)
if err != nil {
t.Fatalf("failed to create non-searchable card: %v", err)
}
// Search by partial ID match
req := httptest.NewRequest("GET", "/api/admin/gift-cards?q=aaaa", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Get("/api/admin/gift-cards", GetGiftCards)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String())
}
var resp GiftCardListResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Total != 1 {
t.Errorf("expected total 1, got %d", resp.Total)
}
if len(resp.GiftCards) != 1 {
t.Errorf("expected 1 gift card in results, got %d", len(resp.GiftCards))
}
if len(resp.GiftCards) > 0 && resp.GiftCards[0].ID != searchableID {
t.Errorf("expected card ID %s, got %s", searchableID, resp.GiftCards[0].ID)
}
}
func TestCreateGiftCard_NegativeAmount(t *testing.T) {
t.Parallel()
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")
reqBody, _ := json.Marshal(map[string]interface{}{"amount": -10.00})
req := httptest.NewRequest("POST", "/api/admin/gift-cards", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards", CreateGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for negative amount, got %d. Body: %s", w.Code, w.Body.String())
}
}
func TestCreateGiftCard_ZeroAmountNoInventory(t *testing.T) {
t.Parallel()
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")
reqBody, _ := json.Marshal(map[string]interface{}{"amount": 0, "is_inventory": false})
req := httptest.NewRequest("POST", "/api/admin/gift-cards", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards", CreateGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for zero amount without inventory, got %d. Body: %s", w.Code, w.Body.String())
}
}
func TestCreateGiftCard_ZeroAmountInventory(t *testing.T) {
t.Parallel()
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")
reqBody, _ := json.Marshal(map[string]interface{}{"amount": 0, "is_inventory": true})
req := httptest.NewRequest("POST", "/api/admin/gift-cards", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards", CreateGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected 201 for inventory card with zero amount, got %d. Body: %s", w.Code, w.Body.String())
}
var gc GiftCard
if err := json.NewDecoder(w.Body).Decode(&gc); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !gc.IsInventory {
t.Error("expected card to be inventory card")
}
if gc.TotalFundsAdded != 0 || gc.AmountRemaining != 0 {
t.Errorf("expected zero balance card, got added=%.2f remaining=%.2f", gc.TotalFundsAdded, gc.AmountRemaining)
}
}
func TestTopUpGiftCard_InvalidPaymentMethod(t *testing.T) {
t.Parallel()
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")
// Create gift card
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (50.00, 50.00, $1)
RETURNING id
`, adminID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to insert gift card: %v", err)
}
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 25.00,
"payment_method": "invalid_method",
})
req := httptest.NewRequest("PUT", "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Put("/api/admin/gift-cards/{id}/topup", TopUpGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid payment method, got %d. Body: %s", w.Code, w.Body.String())
}
}
func TestTopUpGiftCard_RedeemedCard(t *testing.T) {
t.Parallel()
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")
// Create a card that's already redeemed
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by)
VALUES (50.00, 0, $1, NOW(), $1)
RETURNING id
`, adminID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to insert redeemed gift card: %v", err)
}
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 25.00,
"payment_method": "on_the_house",
})
req := httptest.NewRequest("PUT", "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Put("/api/admin/gift-cards/{id}/topup", TopUpGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for redeemed card topup, got %d. Body: %s", w.Code, w.Body.String())
}
}
func TestBuyGiftCard_Idempotency(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateTestToken(userID, "verified_email")
idempotencyKey := "idempotent-buy-gc-test"
reqBody := map[string]interface{}{
"amount": 2000,
"recipient_type": "self",
"new_card_token": "cnon:card-nonce-ok",
"idempotency_key": idempotencyKey,
}
// Send request
body1, _ := json.Marshal(reqBody)
req1 := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(body1))
req1.Header.Set("Authorization", "Bearer "+token)
req1.Header.Set("Content-Type", "application/json")
req1 = req1.WithContext(db.ContextWithTx(req1.Context(), tx.(pgx.Tx)))
w1 := httptest.NewRecorder()
r1 := chi.NewRouter()
r1.Use(mw.RequireAuth)
r1.Post("/api/user/giftcards/buy", BuyGiftCard)
r1.ServeHTTP(w1, req1)
if w1.Code != http.StatusCreated && w1.Code != http.StatusOK {
t.Errorf("buy request: expected 201 or 200, got %d. Body: %s", w1.Code, w1.Body.String())
}
// Verify exactly one payment record was created for this key
var payCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", idempotencyKey).Scan(&payCount)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected 1 payment record for idempotency key, got %d", payCount)
}
// Verify exactly one user balance record
var balCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balCount)
if err != nil {
t.Fatalf("failed to query user balances: %v", err)
}
if balCount != 1 {
t.Errorf("expected 1 user balance record, got %d", balCount)
}
// Verify exactly one gift card was created for self-purchase (amount_remaining=0, redeemed)
var cardCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE redeemed_by = $1", userID).Scan(&cardCount)
if err != nil {
t.Fatalf("failed to query gift cards: %v", err)
}
if cardCount != 1 {
t.Errorf("expected 1 gift card record, got %d", cardCount)
}
// Send second request with same idempotency key
body2, _ := json.Marshal(reqBody)
req2 := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(body2))
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("Content-Type", "application/json")
req2 = req2.WithContext(db.ContextWithTx(req2.Context(), tx.(pgx.Tx)))
w2 := httptest.NewRecorder()
r2 := chi.NewRouter()
r2.Use(mw.RequireAuth)
r2.Post("/api/user/giftcards/buy", BuyGiftCard)
r2.ServeHTTP(w2, req2)
// Verify counts remain unchanged (idempotent)
var payCount2 int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", idempotencyKey).Scan(&payCount2)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
if payCount2 != 1 {
t.Errorf("after second request: expected 1 payment record, got %d", payCount2)
}
var balCount2 int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balCount2)
if err != nil {
t.Fatalf("failed to query user balances: %v", err)
}
if balCount2 != 1 {
t.Errorf("after second request: expected 1 user balance record, got %d", balCount2)
}
var cardCount2 int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE redeemed_by = $1", userID).Scan(&cardCount2)
if err != nil {
t.Fatalf("failed to query gift cards: %v", err)
}
if cardCount2 != 1 {
t.Errorf("after second request: expected 1 gift card record, got %d", cardCount2)
}
}
// TestBuyGiftCard_RetryPending_ReattemptsCharge verifies that a same-key retry
// after a failed Square call (record left 'pending') re-attempts the charge and
// completes — it must NOT return the stale pending record as a false success,
// and must NOT issue the gift card twice.
func TestBuyGiftCard_RetryPending_ReattemptsCharge(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateTestToken(userID, "verified_email")
idempotencyKey := "buy-gc-pending-retry"
// Seed a PENDING payment record with the same key — simulates a prior
// attempt where the Square call failed.
_, err = tx.Exec(ctx, `
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at, created_by)
VALUES ('full', 'online_square', 'pending', 20.00, $1, NOW(), NOW(), $2)
`, idempotencyKey, userID)
if err != nil {
t.Fatalf("failed to seed pending payment: %v", err)
}
reqBody := map[string]interface{}{
"amount": 2000,
"recipient_type": "self",
"new_card_token": "cnon:card-nonce-ok",
"idempotency_key": idempotencyKey,
}
body1, _ := json.Marshal(reqBody)
req1 := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(body1))
req1.Header.Set("Authorization", "Bearer "+token)
req1.Header.Set("Content-Type", "application/json")
req1 = req1.WithContext(db.ContextWithTx(req1.Context(), tx.(pgx.Tx)))
w1 := httptest.NewRecorder()
r1 := chi.NewRouter()
r1.Use(mw.RequireAuth)
r1.Post("/api/user/giftcards/buy", BuyGiftCard)
r1.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK && w1.Code != http.StatusCreated {
t.Fatalf("buy request: expected 200/201, got %d. Body: %s", w1.Code, w1.Body.String())
}
// Exactly one payment record for the key, now completed.
var payCount int
var payStatus string
err = tx.QueryRow(ctx, "SELECT COUNT(*), MAX(status) FROM payments WHERE idempotency_key = $1", idempotencyKey).Scan(&payCount, &payStatus)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected 1 payment record (reuse, not duplicate), got %d", payCount)
}
if payStatus != "completed" {
t.Errorf("expected pending record to be completed after retry, got %s", payStatus)
}
// Exactly one gift card issued for the single charge.
var cardCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE redeemed_by = $1", userID).Scan(&cardCount)
if err != nil {
t.Fatalf("failed to query gift cards: %v", err)
}
if cardCount != 1 {
t.Errorf("expected 1 gift card issued, got %d", cardCount)
}
}
// TestAdminCreateGiftCard_SetsRollingExpiry verifies that gift cards created via
// CreateGiftCard get an expiry_date of last_used_at + gift_card_expiry_months
// (the configured rolling-expiry window, default 24 months). The test DB seeds
// business_settings.gift_card_expiry_months = 24, so the expiry must be ~24
// months in the future.
func TestAdminCreateGiftCard_SetsRollingExpiry(t *testing.T) {
t.Parallel()
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")
reqBody, _ := json.Marshal(map[string]interface{}{"amount": 50.00})
req := httptest.NewRequest("POST", "/api/admin/gift-cards", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards", CreateGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", w.Code)
}
var gc GiftCard
if err := json.NewDecoder(w.Body).Decode(&gc); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
// Verify expiry_date IS set to last_used_at + the configured window (24mo).
var expiryDate *time.Time
err = tx.QueryRow(ctx, `SELECT expiry_date FROM gift_cards WHERE id = $1`, gc.ID).Scan(&expiryDate)
if err != nil {
t.Fatalf("failed to query gift card expiry_date: %v", err)
}
if expiryDate == nil {
t.Fatal("expected expiry_date to be set for rolling-expiry gift cards")
}
// The test DB seeds gift_card_expiry_months = 24; the SQL computes
// NOW() + (24 * INTERVAL '1 month') = exactly +24 calendar months, so
// compare against AddDate(0, 24, 0) with slack for clock skew.
now := time.Now().UTC()
want := now.AddDate(0, 24, 0)
if expiryDate.Before(want.Add(-24 * time.Hour)) {
t.Errorf("expected expiry_date ~24 calendar months in the future, got %v (now %v)", expiryDate, now)
}
if expiryDate.After(want.Add(24 * time.Hour)) {
t.Errorf("expected expiry_date ~24 calendar months in the future, got %v (now %v)", expiryDate, now)
}
}
// TestGetGiftCards_InventoryFilter verifies the ?type=customer|inventory query parameter.
func TestGetGiftCards_InventoryFilter(t *testing.T) {
t.Parallel()
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")
// Insert one customer card and one inventory card
_, err = tx.Exec(ctx, `INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory) VALUES (10.00, 10.00, $1, FALSE)`, adminID)
if err != nil {
t.Fatalf("failed to create customer card: %v", err)
}
_, err = tx.Exec(ctx, `INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory) VALUES (20.00, 20.00, $1, TRUE)`, adminID)
if err != nil {
t.Fatalf("failed to create inventory card: %v", err)
}
// Test ?type=customer filter
reqCustomer := httptest.NewRequest("GET", "/api/admin/gift-cards?type=customer", nil)
reqCustomer.Header.Set("Authorization", "Bearer "+token)
reqCustomer = reqCustomer.WithContext(db.ContextWithTx(reqCustomer.Context(), tx.(pgx.Tx)))
wCustomer := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Get("/api/admin/gift-cards", GetGiftCards)
r.ServeHTTP(wCustomer, reqCustomer)
if wCustomer.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", wCustomer.Code)
}
var respCustomer GiftCardListResponse
if err := json.NewDecoder(wCustomer.Body).Decode(&respCustomer); err != nil {
t.Fatalf("failed to decode customer response: %v", err)
}
customerCount := 0
inventoryCount := 0
for _, gc := range respCustomer.GiftCards {
if gc.IsInventory {
inventoryCount++
} else {
customerCount++
}
}
if respCustomer.Total != 1 {
t.Errorf("expected total 1 (customer cards only), got %d", respCustomer.Total)
}
if customerCount != 1 {
t.Errorf("expected 1 customer card in filtered list, got %d", customerCount)
}
if inventoryCount != 0 {
t.Errorf("expected 0 inventory cards in customer filter, got %d", inventoryCount)
}
// Test ?type=inventory filter
reqInventory := httptest.NewRequest("GET", "/api/admin/gift-cards?type=inventory", nil)
reqInventory.Header.Set("Authorization", "Bearer "+token)
reqInventory = reqInventory.WithContext(db.ContextWithTx(reqInventory.Context(), tx.(pgx.Tx)))
wInventory := httptest.NewRecorder()
rInventory := chi.NewRouter()
rInventory.Use(mw.RequireAuth)
rInventory.Get("/api/admin/gift-cards", GetGiftCards)
rInventory.ServeHTTP(wInventory, reqInventory)
if wInventory.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", wInventory.Code)
}
var respInventory GiftCardListResponse
if err := json.NewDecoder(wInventory.Body).Decode(&respInventory); err != nil {
t.Fatalf("failed to decode inventory response: %v", err)
}
if respInventory.Total != 1 {
t.Errorf("expected total 1 (inventory cards only), got %d", respInventory.Total)
}
for _, gc := range respInventory.GiftCards {
if !gc.IsInventory {
t.Errorf("expected only inventory cards, got customer card %s", gc.ID)
}
}
// Test no filter (should return both)
reqAll := httptest.NewRequest("GET", "/api/admin/gift-cards", nil)
reqAll.Header.Set("Authorization", "Bearer "+token)
reqAll = reqAll.WithContext(db.ContextWithTx(reqAll.Context(), tx.(pgx.Tx)))
wAll := httptest.NewRecorder()
rAll := chi.NewRouter()
rAll.Use(mw.RequireAuth)
rAll.Get("/api/admin/gift-cards", GetGiftCards)
rAll.ServeHTTP(wAll, reqAll)
if wAll.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", wAll.Code)
}
var respAll GiftCardListResponse
if err := json.NewDecoder(wAll.Body).Decode(&respAll); err != nil {
t.Fatalf("failed to decode all response: %v", err)
}
if respAll.Total != 2 {
t.Errorf("expected total 2 (all cards), got %d", respAll.Total)
}
}
// =============================================================================
// GetGiftCardBalance — GET /api/user/giftcards/balance
// =============================================================================
func TestGetGiftCardBalance_HappyPath(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, `INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 75.50)`, userID)
if err != nil {
t.Fatalf("failed to insert balance: %v", err)
}
req := httptest.NewRequest("GET", "/api/user/giftcards/balance", nil)
reqCtx := context.WithValue(req.Context(), mw.UserIDKey, userID)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
reqCtx = db.ContextWithTx(reqCtx, tx.(pgx.Tx))
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
GetGiftCardBalance(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp map[string]float64
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["balance"] != 75.50 {
t.Errorf("expected balance 75.50, got %.2f", resp["balance"])
}
}
func TestGetGiftCardBalance_NoBalance(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
req := httptest.NewRequest("GET", "/api/user/giftcards/balance", nil)
reqCtx := context.WithValue(req.Context(), mw.UserIDKey, userID)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
GetGiftCardBalance(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp map[string]float64
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["balance"] != 0.00 {
t.Errorf("expected balance 0.00, got %.2f", resp["balance"])
}
}
func TestGetGiftCardBalance_Unauthenticated(t *testing.T) {
_, _ = testutils.SetupTestTx(t)
req := httptest.NewRequest("GET", "/api/user/giftcards/balance", nil)
w := httptest.NewRecorder()
GetGiftCardBalance(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// TransferGiftCard — Additional edge cases
// =============================================================================
func TestTransferGiftCard_SameCardRejected(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")
// Create a gift card.
var cardID string
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(&cardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
reqBody, _ := json.Marshal(map[string]interface{}{
"to_card_id": cardID,
"amount": 30.00,
})
req := httptest.NewRequest("POST", "/api/admin/gift-cards/"+cardID+"/transfer", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards/{from}/transfer", TransferGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for same-card transfer, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestTransferGiftCard_SourceNotFound(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")
// Create a destination card.
var card2ID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (20.00, 20.00, $1)
RETURNING id
`, adminID).Scan(&card2ID)
if err != nil {
t.Fatalf("failed to create destination card: %v", err)
}
reqBody, _ := json.Marshal(map[string]interface{}{
"to_card_id": card2ID,
"amount": 10.00,
})
req := httptest.NewRequest("POST", "/api/admin/gift-cards/aaaaaaaaaaaa/transfer", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards/{from}/transfer", TransferGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestTransferGiftCard_DestinationNotFound(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")
// Create a source card.
var card1ID string
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(&card1ID)
if err != nil {
t.Fatalf("failed to create source card: %v", err)
}
reqBody, _ := json.Marshal(map[string]interface{}{
"to_card_id": "bbbbbbbbbbbb",
"amount": 10.00,
})
req := httptest.NewRequest("POST", "/api/admin/gift-cards/"+card1ID+"/transfer", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards/{from}/transfer", TransferGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestTransferGiftCard_InsufficientBalance(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")
var card1ID, card2ID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (10.00, 10.00, $1)
RETURNING id
`, adminID).Scan(&card1ID)
if err != nil {
t.Fatalf("failed to create source card: %v", err)
}
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (20.00, 20.00, $1)
RETURNING id
`, adminID).Scan(&card2ID)
if err != nil {
t.Fatalf("failed to create destination card: %v", err)
}
// Try to transfer more than available.
reqBody, _ := json.Marshal(map[string]interface{}{
"to_card_id": card2ID,
"amount": 50.00,
})
req := httptest.NewRequest("POST", "/api/admin/gift-cards/"+card1ID+"/transfer", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards/{from}/transfer", TransferGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// RedeemGiftCard — Additional edge cases
// =============================================================================
func TestRedeemGiftCard_AlreadyRedeemed(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.GenerateTestToken(userID, "verified_email")
// Create a gift card that is already redeemed.
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, redeemed_by, redeemed_at)
VALUES (100.00, 0, $1, NOW())
RETURNING id
`, userID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to create redeemed gift card: %v", err)
}
reqBody, _ := json.Marshal(map[string]interface{}{"code": cardID})
req := httptest.NewRequest("POST", "/api/user/giftcards/redeem", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/redeem", RedeemGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestRedeemGiftCard_NotFound(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateTestToken(userID, "verified_email")
reqBody, _ := json.Marshal(map[string]interface{}{"code": "cccccccccccc"})
req := httptest.NewRequest("POST", "/api/user/giftcards/redeem", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/redeem", RedeemGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestRedeemGiftCard_InvalidCode(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateTestToken(userID, "verified_email")
reqBody, _ := json.Marshal(map[string]interface{}{"code": "$$$"})
req := httptest.NewRequest("POST", "/api/user/giftcards/redeem", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/redeem", RedeemGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestRedeemGiftCard_ZeroBalance(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.GenerateTestToken(userID, "verified_email")
// Create a gift card with zero remaining balance (but not redeemed).
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining)
VALUES (0, 0)
RETURNING id
`).Scan(&cardID)
if err != nil {
t.Fatalf("failed to create zero-balance gift card: %v", err)
}
reqBody, _ := json.Marshal(map[string]interface{}{"code": cardID})
req := httptest.NewRequest("POST", "/api/user/giftcards/redeem", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/redeem", RedeemGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// RedeemGiftCard — B16 per-card failure rate limiting
// =============================================================================
// redeemCodeRequest dispatches a redeem request for the given code against
// RedeemGiftCard, reusing the caller's test transaction.
func redeemCodeRequest(t *testing.T, token string, tx pgx.Tx, code string) *httptest.ResponseRecorder {
t.Helper()
reqBody, _ := json.Marshal(map[string]interface{}{"code": code})
req := httptest.NewRequest("POST", "/api/user/giftcards/redeem", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/redeem", RedeemGiftCard)
r.ServeHTTP(w, req)
return w
}
// TestGiftCardRedeemFailCounter_Semantics unit-tests the B16 in-memory per-card
// failure counter: 5 consecutive failures lock a code, a reset clears it, and a
// fresh streak starts after the window expires.
func TestGiftCardRedeemFailCounter_Semantics(t *testing.T) {
const code = "b16bad000004"
giftCardRedeemReset(code)
if giftCardRedeemLocked(code) {
t.Fatal("expected a fresh code to start unlocked")
}
giftCardRedeemFail(code)
if giftCardRedeemLocked(code) {
t.Fatal("expected a single failure not to lock the code")
}
for i := 0; i < giftCardRedeemFailMax-1; i++ {
giftCardRedeemFail(code)
}
if !giftCardRedeemLocked(code) {
t.Fatal("expected the code to be locked after giftCardRedeemFailMax consecutive failures")
}
giftCardRedeemReset(code)
if giftCardRedeemLocked(code) {
t.Fatal("expected reset to clear the lock")
}
// Window expiry: re-lock, then push the entry's window into the past.
for i := 0; i < giftCardRedeemFailMax; i++ {
giftCardRedeemFail(code)
}
if !giftCardRedeemLocked(code) {
t.Fatal("expected the code to be locked again for the expiry check")
}
giftCardRedeemFailMu.Lock()
if st, ok := giftCardRedeemFails[code]; ok {
st.windowEnd = clock.Now().Add(-time.Minute)
giftCardRedeemFails[code] = st
}
giftCardRedeemFailMu.Unlock()
if giftCardRedeemLocked(code) {
t.Fatal("expected the code to be unlocked after the window expired")
}
giftCardRedeemFail(code) // fresh streak starts at 1
if giftCardRedeemLocked(code) {
t.Fatal("expected a fresh streak to need giftCardRedeemFailMax failures again")
}
giftCardRedeemReset(code)
}
// TestRedeemGiftCard_InvalidCode_RateLimit429 locks the B16 HTTP behaviour: 5
// consecutive invalid-code failures for the same code return 404, and the 6th
// attempt is rejected 429 before any DB work.
func TestRedeemGiftCard_InvalidCode_RateLimit429(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateTestToken(userID, "verified_email")
const badCode = "b16bad000001"
giftCardRedeemReset(badCode)
for i := 0; i < giftCardRedeemFailMax; i++ {
if w := redeemCodeRequest(t, token, tx.(pgx.Tx), badCode); w.Code != http.StatusNotFound {
t.Fatalf("attempt %d: expected 404 for an unknown code, got %d. body: %s", i+1, w.Code, w.Body.String())
}
}
if w := redeemCodeRequest(t, token, tx.(pgx.Tx), badCode); w.Code != http.StatusTooManyRequests {
t.Errorf("expected 429 after %d consecutive invalid-code failures, got %d. body: %s", giftCardRedeemFailMax, w.Code, w.Body.String())
}
giftCardRedeemReset(badCode)
}
// TestRedeemGiftCard_RateLimit_LockedBlocksEvenExistingCard proves the 429
// check runs BEFORE the DB lookup: once a code is locked, even a real card
// created under that code is rejected until the window expires.
func TestRedeemGiftCard_RateLimit_LockedBlocksEvenExistingCard(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.GenerateTestToken(userID, "verified_email")
const code = "b16bad000002"
giftCardRedeemReset(code)
for i := 0; i < giftCardRedeemFailMax; i++ {
if w := redeemCodeRequest(t, token, tx.(pgx.Tx), code); w.Code != http.StatusNotFound {
t.Fatalf("attempt %d: expected 404, got %d", i+1, w.Code)
}
}
// The code is now locked; a real card under it must still be blocked.
if _, err := tx.Exec(ctx, `
INSERT INTO gift_cards (id, total_funds_added, amount_remaining)
VALUES ($1, 50.00, 50.00)
`, code); err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
if w := redeemCodeRequest(t, token, tx.(pgx.Tx), code); w.Code != http.StatusTooManyRequests {
t.Errorf("expected 429 for a locked code even when a real card exists, got %d. body: %s", w.Code, w.Body.String())
}
giftCardRedeemReset(code)
}
// TestRedeemGiftCard_RateLimit_ResetOnFoundRow verifies a redeem attempt that
// resolves the code to a REAL card clears the code's invalid-code streak (the
// consecutive-failure definition breaks when the code is found).
func TestRedeemGiftCard_RateLimit_ResetOnFoundRow(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.GenerateTestToken(userID, "verified_email")
const code = "b16bad000003"
giftCardRedeemReset(code)
for i := 0; i < giftCardRedeemFailMax-1; i++ {
if w := redeemCodeRequest(t, token, tx.(pgx.Tx), code); w.Code != http.StatusNotFound {
t.Fatalf("attempt %d: expected 404, got %d", i+1, w.Code)
}
}
if giftCardRedeemLocked(code) {
t.Fatal("expected the code not to be locked after 4 failures")
}
// Now the card exists under the code and is redeemed — the found row must
// clear the streak.
if _, err := tx.Exec(ctx, `
INSERT INTO gift_cards (id, total_funds_added, amount_remaining)
VALUES ($1, 50.00, 50.00)
`, code); err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
if w := redeemCodeRequest(t, token, tx.(pgx.Tx), code); w.Code != http.StatusOK {
t.Fatalf("expected a successful redeem of the real card, got %d. body: %s", w.Code, w.Body.String())
}
if giftCardRedeemLocked(code) {
t.Error("expected the found-row redeem to reset the invalid-code streak")
}
giftCardRedeemReset(code)
}
// =============================================================================
// TopUpGiftCard — Additional edge cases
// =============================================================================
func TestTopUpGiftCard_NotFound(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")
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 25.00,
"payment_method": "on_the_house",
})
req := httptest.NewRequest("PUT", "/api/admin/gift-cards/dddddddddddd/topup", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Put("/api/admin/gift-cards/{id}/topup", TopUpGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestTopUpGiftCard_NegativeAmount(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")
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (50.00, 50.00, $1)
RETURNING id
`, adminID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": -10.00,
"payment_method": "on_the_house",
})
req := httptest.NewRequest("PUT", "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Put("/api/admin/gift-cards/{id}/topup", TopUpGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestTopUpGiftCard_ZeroAmount(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")
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (50.00, 50.00, $1)
RETURNING id
`, adminID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 0,
"payment_method": "on_the_house",
})
req := httptest.NewRequest("PUT", "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Put("/api/admin/gift-cards/{id}/topup", TopUpGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestTopUpGiftCard_InventoryCardFirstTopUp(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")
// Create an inventory card with zero balance.
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory)
VALUES (0, 0, $1, TRUE)
RETURNING id
`, adminID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to create inventory card: %v", err)
}
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 30.00,
"payment_method": "on_the_house",
})
req := httptest.NewRequest("PUT", "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Put("/api/admin/gift-cards/{id}/topup", TopUpGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var gc GiftCard
if err := json.NewDecoder(w.Body).Decode(&gc); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if gc.TotalFundsAdded != 30.00 || gc.AmountRemaining != 30.00 {
t.Errorf("expected added and remaining 30.00, got added=%.2f remaining=%.2f", gc.TotalFundsAdded, gc.AmountRemaining)
}
// Verify transaction type is 'purchase' for first top-up on inventory card.
var txType string
err = tx.QueryRow(ctx, "SELECT transaction_type FROM gift_card_transactions WHERE gift_card_id = $1", cardID).Scan(&txType)
if err != nil {
t.Fatalf("failed to query transaction: %v", err)
}
if txType != "purchase" {
t.Errorf("expected transaction type 'purchase' for first inventory top-up, got %q", txType)
}
}
// =============================================================================
// BuyGiftCard — Additional edge cases
// =============================================================================
func TestBuyGiftCard_InvalidAmount(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateTestToken(userID, "verified_email")
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 7500,
"recipient_type": "self",
"new_card_token": "cnon:card-nonce-ok",
"idempotency_key": "idempotency-invalid-amount",
})
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/buy", BuyGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestBuyGiftCard_InvalidRecipientType(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateTestToken(userID, "verified_email")
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 2000,
"recipient_type": "invalid",
"new_card_token": "cnon:card-nonce-ok",
"idempotency_key": "idempotency-invalid-recipient",
})
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/buy", BuyGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestBuyGiftCard_CardNotFound(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateTestToken(userID, "verified_email")
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 2000,
"recipient_type": "self",
"card_id": "nonexistent-card-id",
"idempotency_key": "idempotency-card-not-found",
})
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/buy", BuyGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestBuyGiftCard_NoCardInfo(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateTestToken(userID, "verified_email")
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 2000,
"recipient_type": "self",
"idempotency_key": "idempotency-no-card",
})
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/buy", BuyGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestBuyGiftCard_Unauthenticated(t *testing.T) {
_, _ = testutils.SetupTestTx(t)
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 2000,
"recipient_type": "self",
"new_card_token": "cnon:card-nonce-ok",
})
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Post("/api/user/giftcards/buy", BuyGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// TestGetUserGiftCardBalanceAdmin_AuditLog verifies admin balance checks
// are recorded in the admin_audit_log table.
func TestGetUserGiftCardBalanceAdmin_AuditLog(t *testing.T) {
t.Parallel()
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)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
// Give the user a balance
_, err = tx.Exec(ctx, `INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 42.50)`, userID)
if err != nil {
t.Fatalf("failed to insert user balance: %v", err)
}
token := jwt.GenerateTestToken(adminID, "admin")
// Set up request with chi route context for URL param extraction
req := httptest.NewRequest("GET", "/"+userID+"/giftcard-balance", nil)
req.Header.Set("Authorization", "Bearer "+token)
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", userID)
ctxWithRoute := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(ctxWithRoute)
// Add user context (from token)
info := extractUserFromTestJWT(token)
if info != nil {
reqCtx := context.WithValue(req.Context(), mw.UserIDKey, info.userID)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, info.role)
req = req.WithContext(reqCtx)
}
w := httptest.NewRecorder()
GetUserGiftCardBalanceAdmin(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. Body: %s", w.Code, w.Body.String())
}
var resp map[string]float64
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["balance"] != 42.50 {
t.Errorf("expected balance 42.50, got %.2f", resp["balance"])
}
// Verify audit log entry was created
var logCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_audit_log WHERE admin_id = $1 AND target_user_id = $2 AND action_type = 'balance_check'`, adminID, userID).Scan(&logCount)
if err != nil {
t.Fatalf("failed to query admin_audit_log: %v", err)
}
if logCount != 1 {
t.Errorf("expected 1 audit log entry, got %d", logCount)
}
}
// =============================================================================
// TransferGiftCard — Validation gap tests
// =============================================================================
func TestTransferGiftCard_InvalidFromCardID(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")
reqBody, _ := json.Marshal(map[string]interface{}{
"to_card_id": "aaaaaaaaaaaa",
"amount": 10.00,
})
req := httptest.NewRequest("POST", "/api/admin/gift-cards/$$$/transfer", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards/{from}/transfer", TransferGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestTransferGiftCard_InvalidToCardID(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")
reqBody, _ := json.Marshal(map[string]interface{}{
"to_card_id": "$$$",
"amount": 10.00,
})
req := httptest.NewRequest("POST", "/api/admin/gift-cards/aaaaaaaaaaaa/transfer", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards/{from}/transfer", TransferGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestTransferGiftCard_ZeroAmount(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")
reqBody, _ := json.Marshal(map[string]interface{}{
"to_card_id": "aaaaaaaaaaaa",
"amount": 0,
})
req := httptest.NewRequest("POST", "/api/admin/gift-cards/aaaaaaaaaaaa/transfer", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards/{from}/transfer", TransferGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestTransferGiftCard_JSONDecodeError(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")
req := httptest.NewRequest("POST", "/api/admin/gift-cards/aaaaaaaaaaaa/transfer", bytes.NewBuffer([]byte(`{invalid}`)))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards/{from}/transfer", TransferGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestBuyGiftCard_NoClientKey_TwoPurchases_DoNotCollapse verifies the fallback
// idempotency-key fix: two identical purchases WITHOUT a client-supplied key
// must be treated as DISTINCT purchases — each issues its own gift card. The
// old deterministic fallback key (user+amount+recipient+card) collapsed the
// second purchase into the first, silently returning the first card's code.
func TestBuyGiftCard_NoClientKey_TwoPurchases_DoNotCollapse(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateTestToken(userID, "verified_email")
buy := func() (int, string) {
t.Helper()
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 2000,
"recipient_type": "self",
"new_card_token": "cnon:card-nonce-ok",
})
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/buy", BuyGiftCard)
r.ServeHTTP(w, req)
return w.Code, w.Body.String()
}
if code, body := buy(); code != http.StatusCreated {
t.Fatalf("first purchase: expected 201, got %d: %s", code, body)
}
if code, body := buy(); code != http.StatusCreated {
t.Fatalf("second purchase: expected 201, got %d: %s", code, body)
}
// Two DISTINCT fallback keys — never one shared dedup key.
var distinctKeys int
err = tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&distinctKeys)
if err != nil {
t.Fatalf("failed to query distinct keys: %v", err)
}
if distinctKeys != 2 {
t.Errorf("expected 2 DISTINCT fallback idempotency keys for two no-key purchases, got %d", distinctKeys)
}
// Two payment records and two issued gift cards — the second purchase must
// NOT have been deduped into the first.
var payCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
if payCount != 2 {
t.Errorf("expected 2 payment records for 2 distinct no-key purchases, got %d", payCount)
}
var cardCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount)
if err != nil {
t.Fatalf("failed to query gift cards: %v", err)
}
if cardCount != 2 {
t.Errorf("expected 2 gift cards issued for 2 distinct purchases, got %d", cardCount)
}
}
// TestBuyGiftCard_PendingRow_StoresSquareSourceID verifies that BuyGiftCard's
// pending payment row stores the EXACT source_id sent to Square's CreatePayment
// (payments.square_source_id), so the sweep can replay the charge with an
// identical request body under the same idempotency key.
func TestBuyGiftCard_PendingRow_StoresSquareSourceID(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateTestToken(userID, "verified_email")
idempotencyKey := "buy-gc-source-id-test"
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 2000,
"recipient_type": "self",
"new_card_token": "cnon:card-nonce-ok",
"idempotency_key": idempotencyKey,
})
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/buy", BuyGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
// The row must carry the exact one-off nonce sent as CreatePayment's
// SourceID (new-card path with save_card=false).
var sourceID string
err = tx.QueryRow(ctx, "SELECT COALESCE(square_source_id, '') FROM payments WHERE idempotency_key = $1", idempotencyKey).Scan(&sourceID)
if err != nil {
t.Fatalf("failed to query square_source_id: %v", err)
}
if sourceID != "cnon:card-nonce-ok" {
t.Errorf("expected square_source_id %q (the exact CreatePayment SourceID), got %q", "cnon:card-nonce-ok", sourceID)
}
}
// =============================================================================
// M7 — £5,000/day admin gift-card cap is race-free
// =============================================================================
// TestTopUpGiftCard_DailyCap_Concurrent pins the M7 daily-cap TOCTOU fix: N
// concurrent top-ups by the same admin must never let the cumulative issued
// value exceed the £5,000/day cap. The cap check (adminGiftCardValueToday) and
// the transaction recording the top-up value run under one per-admin advisory
// lock, so every check sees the previous top-up's committed row — the excess
// requests are rejected. Without the lock two top-ups in the same batch read
// the same pre-write cumulative value and both pass, over-issuing value.
//
// The per-transaction £250 cap bounds each top-up, so exceeding the £5,000
// daily cap needs 21 top-ups of £250; a semaphore bounds how many run
// simultaneously (each handler holds one pool conn for its advisory lock and
// one for its transaction). The admin, the card, and every handler invocation
// run directly against the REAL pool (no per-test transaction), so the batches
// exercise genuine cross-connection concurrency exactly like production.
func TestTopUpGiftCard_DailyCap_Concurrent(t *testing.T) {
ctx := context.Background()
adminID, err := fixtures.CreateTestUser(db.Conn)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
if _, err := db.Conn.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID); err != nil {
t.Fatalf("failed to promote admin: %v", err)
}
var cardID string
if err := db.Conn.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (0, 0, $1) RETURNING id`, adminID).Scan(&cardID); err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
t.Cleanup(func() {
cctx := context.Background()
_, _ = db.Conn.Exec(cctx, `DELETE FROM admin_audit_log WHERE admin_id = $1 OR target_user_id = $1 OR target_gift_card_id = $2`, adminID, cardID)
_, _ = db.Conn.Exec(cctx, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, cardID)
_, _ = db.Conn.Exec(cctx, `DELETE FROM gift_cards WHERE id = $1`, cardID)
_, _ = db.Conn.Exec(cctx, `DELETE FROM users WHERE id = $1`, adminID)
})
token := jwt.GenerateTestToken(adminID, "admin")
const perTopUp = 250.00 // £250 per top-up (at the £250 per-transaction cap)
const totalOps = 21 // 21 × £250 = £5,250 > the £5,000 daily cap
const concurrencyLimit = 6 // at most 6 handlers in flight (bounded pool conns)
sem := make(chan struct{}, concurrencyLimit)
start := make(chan struct{})
var wg sync.WaitGroup
var mu sync.Mutex
successes := 0
failCodes := map[int]int{}
for i := 0; i < totalOps; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
sem <- struct{}{}
defer func() { <-sem }()
body, _ := json.Marshal(TopUpGiftCardRequest{Amount: perTopUp, PaymentMethod: "cash"})
r := httptest.NewRequest(http.MethodPut, "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewReader(body))
r.Header.Set("Authorization", "Bearer "+token)
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router := chi.NewRouter()
router.Use(mw.RequireAuth)
router.With(mw.RequireAdmin).Put("/api/admin/gift-cards/{id}/topup", TopUpGiftCard)
router.ServeHTTP(w, r)
mu.Lock()
if w.Code == http.StatusOK {
successes++
} else {
failCodes[w.Code]++
}
mu.Unlock()
}()
}
close(start)
wg.Wait()
// Money-safety invariant under serialization: at most 20 of the 21 top-ups
// may succeed (20 × £250 = £5,000 = the inclusive cap; the 21st would land
// the day on £5,250 and must be rejected). A rejected attempt surfaces as
// either the 400 cap rejection or a 409 from the bounded try-lock giving up
// under heavy contention — both are the designed backpressure and neither
// records value. The OLD cap check (no per-admin lock) lets each batch read
// the pre-write cumulative value so all 21 succeed, overshooting the cap —
// `successes > 20` (or a cumulative over the cap below) is the regression
// signal this test must catch.
if successes < 1 || successes > 20 {
t.Errorf("expected between 1 and 20 of 21 concurrent top-ups to succeed under the £5,000 cap, got %d (cumulative value £%.2f); failure codes: %v", successes, perTopUp*float64(successes), failCodes)
}
// The day's issued value (the cap signal) must never exceed the cap.
issuedToday, err := adminGiftCardValueToday(ctx, db.Conn, adminID)
if err != nil {
t.Fatalf("failed to query today's issued value: %v", err)
}
if int64(math.Round(issuedToday*100)) > maxAdminGiftCardDailyPence {
t.Errorf("cumulative daily issued value £%.2f exceeds the £5,000 cap", issuedToday)
}
// The card must hold exactly the value of the successful top-ups.
var remaining float64
if err := db.Conn.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining); err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if remaining != perTopUp*float64(successes) {
t.Errorf("expected card balance £%.2f, got £%.2f", perTopUp*float64(successes), remaining)
}
}
// =============================================================================
// Expiry enforcement — DB clock boundary
// =============================================================================
// TestRedeemGiftCard_ExpiryEdge_DBClock pins the unified expiry-clock source:
// redemption expiry enforcement compares expiry_date against the DATABASE clock
// (SELECT NOW()), the same clock that writes expiry_date. A card whose expiry
// passed one second before the DB clock is rejected as expired; a card whose
// expiry is a few seconds ahead of the DB clock still redeems. An app-clock
// drift can therefore neither extend nor shorten a card's life.
func TestRedeemGiftCard_ExpiryEdge_DBClock(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.GenerateTestToken(userID, "verified_email")
// A card whose expiry passed one second before the DB clock's now must be
// rejected as expired.
const expiredCardID = "3a3a3a3a3a01"
if _, err := tx.Exec(ctx, `
INSERT INTO gift_cards (id, total_funds_added, amount_remaining, expiry_date)
VALUES ($1, 50.00, 50.00, NOW() - INTERVAL '1 second')
`, expiredCardID); err != nil {
t.Fatalf("failed to create expired-edge card: %v", err)
}
if w := redeemCodeRequest(t, token, tx.(pgx.Tx), expiredCardID); w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for a card expired just before the DB clock, got %d. body: %s", w.Code, w.Body.String())
}
// A card whose expiry is a few seconds ahead of the DB clock must still
// redeem (the redemption check runs long before the +5s expiry passes).
const liveCardID = "3a3a3a3a3a02"
if _, err := tx.Exec(ctx, `
INSERT INTO gift_cards (id, total_funds_added, amount_remaining, expiry_date)
VALUES ($1, 50.00, 50.00, NOW() + INTERVAL '5 seconds')
`, liveCardID); err != nil {
t.Fatalf("failed to create live-edge card: %v", err)
}
if w := redeemCodeRequest(t, token, tx.(pgx.Tx), liveCardID); w.Code != http.StatusOK {
t.Errorf("expected 200 for a card expiring just after the DB clock, got %d. body: %s", w.Code, w.Body.String())
}
}
// Defense-in-depth (S-1): the money-moving gift-card admin handlers carry an
// in-handler isAdminRequest backstop, so a non-admin request must be refused
// with 403 even when the route is mounted on a router WITHOUT mw.RequireAdmin.
// Each router below applies ONLY mw.RequireAuth — the weaker-router scenario
// the backstop guards against.
func TestCreateGiftCard_NonAdminRefused(t *testing.T) {
t.Parallel()
_, tx := testutils.SetupTestTx(t)
token := jwt.GenerateVerifiedUserToken("nonadminuser001")
reqBody, _ := json.Marshal(map[string]interface{}{"amount": 50.00})
req := httptest.NewRequest("POST", "/api/admin/gift-cards", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards", CreateGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for non-admin CreateGiftCard, got %d. body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "Admin access required") {
t.Errorf("expected 'Admin access required' body, got: %s", w.Body.String())
}
}
func TestTopUpGiftCard_NonAdminRefused(t *testing.T) {
t.Parallel()
_, tx := testutils.SetupTestTx(t)
token := jwt.GenerateVerifiedUserToken("nonadminuser001")
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 25.00,
"payment_method": "on_the_house",
})
req := httptest.NewRequest("PUT", "/api/admin/gift-cards/aabbccddeeff/topup", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Put("/api/admin/gift-cards/{id}/topup", TopUpGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for non-admin TopUpGiftCard, got %d. body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "Admin access required") {
t.Errorf("expected 'Admin access required' body, got: %s", w.Body.String())
}
}
func TestTransferGiftCard_NonAdminRefused(t *testing.T) {
t.Parallel()
_, tx := testutils.SetupTestTx(t)
token := jwt.GenerateVerifiedUserToken("nonadminuser001")
reqBody, _ := json.Marshal(map[string]interface{}{
"to_card_id": "112233445566",
"amount": 30.00,
})
req := httptest.NewRequest("POST", "/api/admin/gift-cards/aabbccddeeff/transfer", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards/{from}/transfer", TransferGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for non-admin TransferGiftCard, got %d. body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "Admin access required") {
t.Errorf("expected 'Admin access required' body, got: %s", w.Body.String())
}
}
func TestClaimExpiredBalance_NonAdminRefused(t *testing.T) {
t.Parallel()
_, tx := testutils.SetupTestTx(t)
token := jwt.GenerateVerifiedUserToken("nonadminuser001")
reqBody, _ := json.Marshal(map[string]interface{}{"balance_id": "aabbccddeeff"})
req := httptest.NewRequest("POST", "/api/admin/gift-cards/expired-balances/claim", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/gift-cards/expired-balances/claim", ClaimExpiredBalance)
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for non-admin ClaimExpiredBalance, got %d. body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "Admin access required") {
t.Errorf("expected 'Admin access required' body, got: %s", w.Body.String())
}
}