Files
Crussell/backend/handlers/payments/giftcards_test.go
T
popertotsandSisyphus 7b24f8e484 refactor(payments): integrate VAT into gift card buy flow and wrap in transactions
Refactor BuyGiftCard to insert pending payment before Square call with VAT applied. Add transaction wrapping to gift card handlers. Remove redundant Content-Type header sets. Migrate all time.Now() to clock.Now().

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:40 +01:00

1424 lines
45 KiB
Go

//go:build test && dev
// +build test,dev
package payments
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"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 cents
"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 cents
"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 cents
"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 cents
"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
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 != 60.00 {
t.Errorf("expected gift card balance to be 60.00, 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 pay £25 using user account balance
reqBody3, _ := json.Marshal(map[string]interface{}{
"amount": 2500, // £25.00 in cents
"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.StatusOK {
t.Errorf("expected status 200, got %d. Body: %s", w3.Code, w3.Body.String())
}
// Verify user account balance was 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 != 35.00 {
t.Errorf("expected user balance to be 35.00, 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)
}
}
// TestAdminCreateGiftCard_ExpiryDateIsNull verifies that gift cards created via
// CreateGiftCard no longer have expiry_date set (rolling 24-month expiry via last_used_at).
func TestAdminCreateGiftCard_ExpiryDateIsNull(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 NULL in the database
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.Error("expected expiry_date to be NULL for rolling-expiry gift cards")
}
}
// 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)
}
}
// 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)
}
}