- Added generic formatAndPreserveCursor() helper on frontend to track and restore selection caret position during dynamic input sanitization - Applied to all card inputs, gift card code inputs, and expiry inputs - Added Luhn validation (isValidLuhn) for saved cards and gift cards - Rebuilt payments test DB and got 100% green tests
501 lines
15 KiB
Go
501 lines
15 KiB
Go
//go:build test && dev
|
|
// +build test,dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"crussell/db"
|
|
"crussell/mw"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
"crussell/testutils/testdb"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
func resetGiftCardsTestData(t *testing.T) {
|
|
t.Helper()
|
|
testdb.TruncateTables(t, db.DB)
|
|
}
|
|
|
|
func TestAdminCreateGiftCard(t *testing.T) {
|
|
resetGiftCardsTestData(t)
|
|
|
|
ctx := context.Background()
|
|
adminID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
_, _ = db.DB.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")
|
|
|
|
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) {
|
|
resetGiftCardsTestData(t)
|
|
|
|
ctx := context.Background()
|
|
adminID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
_, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
|
|
|
|
token := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
// Create gift card
|
|
var cardID string
|
|
err = db.DB.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})
|
|
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")
|
|
|
|
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) {
|
|
resetGiftCardsTestData(t)
|
|
|
|
ctx := context.Background()
|
|
adminID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
_, _ = db.DB.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 = db.DB.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 = db.DB.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")
|
|
|
|
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 = db.DB.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 = db.DB.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) {
|
|
resetGiftCardsTestData(t)
|
|
|
|
ctx := context.Background()
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
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 = db.DB.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")
|
|
|
|
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 = db.DB.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 = db.DB.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) {
|
|
resetGiftCardsTestData(t)
|
|
|
|
ctx := context.Background()
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateTestToken(userID, "verified_email")
|
|
|
|
// Charge a mock payment token
|
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
|
"amount": 2000, // £20.00 in cents
|
|
"recipient_type": "self",
|
|
"new_card_token": "cnon:card-nonce-ok",
|
|
"idempotency_key": "idempotency-key-buy-gc-self",
|
|
})
|
|
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
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 = db.DB.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 = db.DB.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_Friend(t *testing.T) {
|
|
resetGiftCardsTestData(t)
|
|
|
|
ctx := context.Background()
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateTestToken(userID, "verified_email")
|
|
|
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
|
"amount": 5000, // £50.00 in cents
|
|
"recipient_type": "friend",
|
|
"new_card_token": "cnon:card-nonce-ok",
|
|
"idempotency_key": "idempotency-key-buy-gc-friend",
|
|
})
|
|
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
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 = db.DB.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) {
|
|
resetGiftCardsTestData(t)
|
|
|
|
ctx := context.Background()
|
|
adminID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
_, _ = db.DB.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
|
|
|
|
token := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, adminID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
// Update booking to in_progress so it is payable
|
|
_, _ = db.DB.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 = db.DB.QueryRow(ctx, `
|
|
INSERT INTO gift_cards (total_funds_added, amount_remaining)
|
|
VALUES (100.00, 100.00)
|
|
RETURNING id
|
|
`).Scan(&cardID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create gift card: %v", err)
|
|
}
|
|
|
|
// 1. Pay £30 with CASH
|
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
|
"amount": 3000, // £30.00 in cents
|
|
"payment_type": "full",
|
|
"payment_method": "cash",
|
|
})
|
|
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
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 = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'cash'", bookingID).Scan(&cashPayCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query payments: %v", err)
|
|
}
|
|
if cashPayCount != 1 {
|
|
t.Errorf("expected 1 cash payment, got %d", cashPayCount)
|
|
}
|
|
|
|
// 2. Pay £40 with PHYSICAL GIFT CARD (guest checkout simulation)
|
|
reqBody2, _ := json.Marshal(map[string]interface{}{
|
|
"amount": 4000, // £40.00 in cents
|
|
"payment_type": "full",
|
|
"payment_method": "giftcard",
|
|
"gift_card_id": cardID,
|
|
})
|
|
req2 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody2))
|
|
req2.Header.Set("Authorization", "Bearer "+token)
|
|
req2.Header.Set("Content-Type", "application/json")
|
|
|
|
w2 := httptest.NewRecorder()
|
|
r2 := chi.NewRouter()
|
|
r2.Use(mw.RequireAuth)
|
|
r2.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
|
|
r2.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. Body: %s", w2.Code, w2.Body.String())
|
|
}
|
|
|
|
// Verify gift card balance deducted from card directly
|
|
var remaining float64
|
|
err = db.DB.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", cardID).Scan(&remaining)
|
|
if err != nil {
|
|
t.Fatalf("failed to query card: %v", err)
|
|
}
|
|
if remaining != 60.00 {
|
|
t.Errorf("expected gift card balance to be 60.00, got %.2f", remaining)
|
|
}
|
|
|
|
// Verify gift card payment record created
|
|
var gcPayCount int
|
|
err = db.DB.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
|
|
_, _ = db.DB.Exec(ctx, "INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 60.00)", adminID)
|
|
|
|
// Now pay £25 using user account balance
|
|
reqBody3, _ := json.Marshal(map[string]interface{}{
|
|
"amount": 2500, // £25.00 in cents
|
|
"payment_type": "full",
|
|
"payment_method": "giftcard",
|
|
})
|
|
req3 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody3))
|
|
req3.Header.Set("Authorization", "Bearer "+token)
|
|
req3.Header.Set("Content-Type", "application/json")
|
|
|
|
w3 := httptest.NewRecorder()
|
|
r3 := chi.NewRouter()
|
|
r3.Use(mw.RequireAuth)
|
|
r3.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
|
|
r3.ServeHTTP(w3, req3)
|
|
|
|
if w3.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. Body: %s", w3.Code, w3.Body.String())
|
|
}
|
|
|
|
// Verify user account balance was deducted
|
|
var userBalance float64
|
|
err = db.DB.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", adminID).Scan(&userBalance)
|
|
if err != nil {
|
|
t.Fatalf("failed to query user balance: %v", err)
|
|
}
|
|
if userBalance != 35.00 {
|
|
t.Errorf("expected user balance to be 35.00, got %.2f", userBalance)
|
|
}
|
|
}
|