- webhooks: booking-status gate rejects cancelled bookings, M2 stranded-charge refund row + alert, gift-card rows left pending, payable-booking side-effects, unknown-event 503, refund-before-row 503, webhook-after-sync no-double-complete - giftcards: saved_card_id SCA wire, card_id+token rejected, resume re-issue never over-refunds entitlement, pending-Square-refund blocks, diff re-issue only what is owed - sweep: VAT on split-rescued primary, all-tip rows VAT-free, till status/key-changed-while-locked skip, recordUntrackedTillSalePayment VAT - till: suffixed-key slot scan lock held across Square round-trip Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
3207 lines
112 KiB
Go
3207 lines
112 KiB
Go
//go:build test && dev
|
||
|
||
package payments
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"math"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"crussell/clock"
|
||
"crussell/db"
|
||
"crussell/internal/square"
|
||
"crussell/mw"
|
||
"crussell/testutils"
|
||
"crussell/testutils/fixtures"
|
||
"crussell/testutils/jwt"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
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 := clock.Now()
|
||
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())
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// FIX 1 — saved_card_id on the gift-card buy surface
|
||
// =============================================================================
|
||
|
||
// TestBuyGiftCard_SCASavedCard_BindsCustomer pins the FIX 1 wire contract on
|
||
// the gift-card buy surface: a buy carrying saved_card_id + new_card_token
|
||
// (the SCA tokenize-result shape the account page sends) must resolve the
|
||
// charge as a SAVED-card SCA charge — the tokenize-result token is the one-time
|
||
// source_id, the Square customer derives from the saved-card row, and the
|
||
// payment row records the saved-card reference. Before the fix the unknown
|
||
// saved_card_id field was silently dropped and the token was charged as a
|
||
// new-card one-off with no customer binding (Square requires the customer for a
|
||
// card-on-file charge).
|
||
func TestBuyGiftCard_SCASavedCard_BindsCustomer(t *testing.T) {
|
||
ctx, tx := testutils.SetupTestTx(t)
|
||
userID, err := fixtures.CreateTestUser(tx)
|
||
require.NoError(t, err)
|
||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||
t.Cleanup(func() { InvalidateSquareCustomerCache(userID) })
|
||
|
||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_buy_sca", "VISA", "4242")
|
||
require.NoError(t, err)
|
||
|
||
rec := installRecordingClient(t)
|
||
|
||
scaToken := "cnon:sca-tokenize-buy"
|
||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||
"amount": 2000,
|
||
"recipient_type": "friend",
|
||
"saved_card_id": cardID,
|
||
"new_card_token": scaToken,
|
||
"idempotency_key": "buy-gc-sca-savedcard",
|
||
})
|
||
r := httptest.NewRequest(http.MethodPost, "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
|
||
r.Header.Set("Authorization", "Bearer "+token)
|
||
r.Header.Set("Content-Type", "application/json")
|
||
r = r.WithContext(db.ContextWithTx(r.Context(), tx.(pgx.Tx)))
|
||
w := httptest.NewRecorder()
|
||
router := chi.NewRouter()
|
||
router.Use(mw.RequireAuth)
|
||
router.With(mw.RequireNonGuest).Post("/api/user/giftcards/buy", BuyGiftCard)
|
||
router.ServeHTTP(w, r)
|
||
require.Equal(t, http.StatusCreated, w.Code, "SCA saved-card buy must succeed, body: %s", w.Body.String())
|
||
|
||
rec.mu.Lock()
|
||
last := rec.lastReq
|
||
rec.mu.Unlock()
|
||
require.Equal(t, scaToken, last.SourceID, "the SCA tokenize-result token must be the charge source_id")
|
||
require.NotEqual(t, "ccof:mock_buy_sca", last.SourceID, "the stored ccof id must NOT be the source for the tokenize-result flow")
|
||
require.NotEmpty(t, last.CustomerID, "customer_id must derive from the saved-card row, not a new-card one-off")
|
||
require.NotNil(t, last.CustomerDetails, "a stored-credential charge must carry customer_details")
|
||
|
||
var payID string
|
||
require.NoError(t, tx.QueryRow(ctx, `
|
||
SELECT id FROM payments WHERE created_by = $1 AND payment_method = 'online_square' AND booking_id IS NULL
|
||
`, userID).Scan(&payID))
|
||
var squareSource, uscID sql.NullString
|
||
require.NoError(t, tx.QueryRow(ctx, `SELECT square_source_id, user_saved_card_id FROM payments WHERE id = $1`, payID).Scan(&squareSource, &uscID))
|
||
require.Equal(t, scaToken, squareSource.String, "the payment row must record the tokenize-result token as its square source")
|
||
require.Equal(t, cardID, uscID.String, "the payment row must reference the saved card")
|
||
}
|
||
|
||
// TestBuyGiftCard_CardIDPlusNewCardToken_Rejected pins the regression guard for
|
||
// the FIX 1 wire contract: the legacy card_id field must NEVER coexist with
|
||
// new_card_token on the gift-card buy surface — the SCA tokenize-result path
|
||
// requires saved_card_id (the frontend documents this and never sends the mixed
|
||
// shape). Before the guard the mixed shape was accepted and resolved without
|
||
// the saved-card customer binding.
|
||
func TestBuyGiftCard_CardIDPlusNewCardToken_Rejected(t *testing.T) {
|
||
ctx, tx := testutils.SetupTestTx(t)
|
||
userID, err := fixtures.CreateTestUser(tx)
|
||
require.NoError(t, err)
|
||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||
|
||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_buy_mixed", "VISA", "4242")
|
||
require.NoError(t, err)
|
||
|
||
rec := installRecordingClient(t)
|
||
|
||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||
"amount": 2000,
|
||
"recipient_type": "friend",
|
||
"card_id": cardID,
|
||
"new_card_token": "cnon:sca-tokenize-mixed",
|
||
"idempotency_key": "buy-gc-cardid-plus-token",
|
||
})
|
||
r := httptest.NewRequest(http.MethodPost, "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
|
||
r.Header.Set("Authorization", "Bearer "+token)
|
||
r.Header.Set("Content-Type", "application/json")
|
||
r = r.WithContext(db.ContextWithTx(r.Context(), tx.(pgx.Tx)))
|
||
w := httptest.NewRecorder()
|
||
router := chi.NewRouter()
|
||
router.Use(mw.RequireAuth)
|
||
router.With(mw.RequireNonGuest).Post("/api/user/giftcards/buy", BuyGiftCard)
|
||
router.ServeHTTP(w, r)
|
||
require.Equal(t, http.StatusBadRequest, w.Code, "card_id + new_card_token must be rejected, body: %s", w.Body.String())
|
||
|
||
rec.mu.Lock()
|
||
last := rec.lastReq
|
||
rec.mu.Unlock()
|
||
require.Empty(t, last.SourceID, "no Square charge may be attempted for the rejected shape")
|
||
var payCount int
|
||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE created_by = $1`, userID).Scan(&payCount))
|
||
assert.Equal(t, 0, payCount, "no payment row for the rejected shape")
|
||
}
|
||
|
||
// =============================================================================
|
||
// FIX 2 — CancelGiftCard resume must never over-refund the re-verified entitlement
|
||
// =============================================================================
|
||
|
||
// seedGiftCardCancelResume seeds an online gift-card purchase (as
|
||
// round9SeedGiftCardPurchase) plus a prior gift-card-cancel refund attempt in
|
||
// the given state (pending/failed) claiming the FULL purchase value under the
|
||
// deterministic cancel key. When spentPounds > 0 the card balance is reduced
|
||
// and a completed giftcard payment row records the till spend so
|
||
// giftCardSpendAtTill verifies the shortfall — the resume-over-refund scenario.
|
||
func seedGiftCardCancelResume(t *testing.T, ctx context.Context, tx pgx.Tx, userID string, amountPounds, spentPounds float64, refundStatus string) (cardID, paymentID, squarePaymentID string) {
|
||
t.Helper()
|
||
cardID, paymentID = round9SeedGiftCardPurchase(t, ctx, tx, userID, amountPounds, 0)
|
||
var sqID string
|
||
require.NoError(t, tx.QueryRow(ctx, `SELECT square_payment_id FROM payments WHERE id = $1`, paymentID).Scan(&sqID))
|
||
squarePaymentID = sqID
|
||
|
||
_, err := tx.Exec(ctx, `
|
||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin)
|
||
VALUES ($1, NULL, $2, $3, $4, $5, $6, NOW(), 'giftcard_cancel')
|
||
`, paymentID, amountPounds, refundStatus, giftCardCancelRefundReason,
|
||
paymentID+"-gccancel-"+strconv.FormatInt(int64(math.Round(amountPounds*100)), 10), userID)
|
||
require.NoError(t, err)
|
||
|
||
if spentPounds > 0 {
|
||
_, err := tx.Exec(ctx, `UPDATE gift_cards SET amount_remaining = $1 WHERE id = $2`, amountPounds-spentPounds, cardID)
|
||
require.NoError(t, err)
|
||
_, err = tx.Exec(ctx, `
|
||
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, gift_card_id, created_by, idempotency_key, created_at, updated_at)
|
||
VALUES (NULL, 'full', 'giftcard', 'completed', $1, $2, $3, $4, NOW(), NOW())
|
||
`, spentPounds, cardID, userID, "till-spend-"+cardID)
|
||
require.NoError(t, err)
|
||
}
|
||
return cardID, paymentID, squarePaymentID
|
||
}
|
||
|
||
// TestCancelGiftCard_ResumeFailed_RefundsCurrentEntitlementNotPrior pins the
|
||
// FIX 2 over-refund exploit end-to-end: a cancel attempt that ended 'failed'
|
||
// at the full purchase value is retried AFTER a till spend. The resume must
|
||
// never refund more than the re-verified entitlement — min(priorAmount,
|
||
// currentEntitlement) — and must re-issue under a FRESH deterministic key (the
|
||
// old key encodes the old amount). Exploit before the fix: buy £50 → cancel
|
||
// fails → spend £5 at the till → retry refunded £50 against a £45 entitlement.
|
||
func TestCancelGiftCard_ResumeFailed_RefundsCurrentEntitlementNotPrior(t *testing.T) {
|
||
ctx, tx := testutils.SetupTestTx(t)
|
||
userID, err := fixtures.CreateTestUser(tx)
|
||
require.NoError(t, err)
|
||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||
|
||
origClient := SquareClient
|
||
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
||
SquareClient = counting
|
||
defer func() { SquareClient = origClient }()
|
||
|
||
cardID, paymentID, _ := seedGiftCardCancelResume(t, ctx, tx.(pgx.Tx), userID, 50.00, 5.00, "failed")
|
||
|
||
w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, paymentID)
|
||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||
|
||
calls := counting.refundCalls()
|
||
require.Len(t, calls, 1, "exactly one Square refund for the resumed cancellation")
|
||
assert.Equal(t, int64(4500), calls[0].Amount, "the resumed refund must be the £45 current entitlement, never the stale £50")
|
||
assert.NotEqual(t, paymentID+"-gccancel-5000", calls[0].IdempotencyKey, "the stale amount-encoding key must not be reused")
|
||
|
||
var amount float64
|
||
var status, key string
|
||
var sqRefundID sql.NullString
|
||
require.NoError(t, tx.QueryRow(ctx, `
|
||
SELECT amount, status, idempotency_key, square_refund_id FROM refunds WHERE payment_id = $1
|
||
`, paymentID).Scan(&amount, &status, &key, &sqRefundID))
|
||
assert.Equal(t, 45.00, amount, "the refund row records the re-issued £45")
|
||
assert.Equal(t, "completed", status, "the refund row resolves to completed")
|
||
assert.Equal(t, paymentID+"-gccancel-4500", key, "the refund row carries the fresh deterministic key")
|
||
require.True(t, sqRefundID.Valid && sqRefundID.String != "", "the Square refund id must be recorded")
|
||
|
||
var rem float64
|
||
var expiry sql.NullTime
|
||
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry))
|
||
assert.Equal(t, 0.00, rem, "card balance must be zeroed after the resumed cancellation")
|
||
require.True(t, expiry.Valid, "the cancelled card must carry an expiry date")
|
||
assert.False(t, expiry.Time.After(clock.Now()), "the cancelled card must be expired")
|
||
|
||
var cancelTxCount int
|
||
require.NoError(t, tx.QueryRow(ctx, `
|
||
SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'cancelled'
|
||
`, cardID).Scan(&cancelTxCount))
|
||
assert.Equal(t, 1, cancelTxCount, "a 'cancelled' audit row must record the reversal")
|
||
}
|
||
|
||
// TestCancelGiftCard_ResumePending_SquareAlreadyRefunded_NoDoubleRefund pins
|
||
// the FIX 2 reconcile path: a 'pending' cancel row whose Square refund has in
|
||
// fact COMPLETED (a lost-response prior attempt that landed) must NOT issue a
|
||
// second refund — the handler reconciles via ListPaymentRefunds, resolves the
|
||
// row completed with the Square refund id, and neutralises the card.
|
||
func TestCancelGiftCard_ResumePending_SquareAlreadyRefunded_NoDoubleRefund(t *testing.T) {
|
||
ctx, tx := testutils.SetupTestTx(t)
|
||
userID, err := fixtures.CreateTestUser(tx)
|
||
require.NoError(t, err)
|
||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||
|
||
origClient := SquareClient
|
||
mock := square.NewDevClient().(*square.MockClient)
|
||
counting := &countingRefundClient{SquareClient: mock}
|
||
SquareClient = counting
|
||
defer func() { SquareClient = origClient }()
|
||
|
||
cardID, paymentID, squarePaymentID := seedGiftCardCancelResume(t, ctx, tx.(pgx.Tx), userID, 50.00, 0, "pending")
|
||
|
||
// The prior attempt actually landed at Square: pre-seed a COMPLETED refund
|
||
// in the mock's ledger (the local row is still 'pending' because the
|
||
// outcome was never observed).
|
||
prelanded, err := counting.SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
|
||
PaymentID: squarePaymentID,
|
||
Amount: 5000,
|
||
IdempotencyKey: "prelanded-" + paymentID,
|
||
Reason: giftCardCancelRefundReason,
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, paymentID)
|
||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||
assert.Contains(t, w.Body.String(), "already been refunded")
|
||
|
||
require.Empty(t, counting.refundCalls(), "no second Square refund when the prior refund already completed at Square")
|
||
|
||
var status string
|
||
var sqRefundID sql.NullString
|
||
require.NoError(t, tx.QueryRow(ctx, `SELECT status, square_refund_id FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &sqRefundID))
|
||
assert.Equal(t, "completed", status, "the pending row must resolve to completed")
|
||
assert.Equal(t, prelanded.ID, sqRefundID.String, "the row records the pre-landed Square refund id")
|
||
|
||
var rem float64
|
||
var expiry sql.NullTime
|
||
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry))
|
||
assert.Equal(t, 0.00, rem, "the card must be neutralised")
|
||
require.True(t, expiry.Valid)
|
||
assert.False(t, expiry.Time.After(clock.Now()), "the card must be expired")
|
||
}
|
||
|
||
// TestCancelGiftCard_ResumePending_ReissuesWithFreshKey pins the FIX 2
|
||
// pending-resume re-issue branch when Square has NO completed refund: the
|
||
// entitlement-bounded amount is re-issued under the SAME deterministic key it
|
||
// was originally derived from (no spend in between → the fresh key equals the
|
||
// stored key, so Square dedups a prior landed refund) and the row is completed.
|
||
func TestCancelGiftCard_ResumePending_ReissuesWithFreshKey(t *testing.T) {
|
||
ctx, tx := testutils.SetupTestTx(t)
|
||
userID, err := fixtures.CreateTestUser(tx)
|
||
require.NoError(t, err)
|
||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||
|
||
origClient := SquareClient
|
||
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
||
SquareClient = counting
|
||
defer func() { SquareClient = origClient }()
|
||
|
||
cardID, paymentID, _ := seedGiftCardCancelResume(t, ctx, tx.(pgx.Tx), userID, 50.00, 0, "pending")
|
||
|
||
w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, paymentID)
|
||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||
|
||
calls := counting.refundCalls()
|
||
require.Len(t, calls, 1, "exactly one Square refund for the resumed pending cancellation")
|
||
assert.Equal(t, int64(5000), calls[0].Amount, "the full £50 entitlement is still due when no spend has occurred")
|
||
assert.Equal(t, paymentID+"-gccancel-5000", calls[0].IdempotencyKey, "the fresh key equals the original key when the amount is unchanged")
|
||
|
||
var status string
|
||
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status))
|
||
assert.Equal(t, "completed", status)
|
||
}
|