Files
Crussell/backend/handlers/payments/loyalty_test.go
T
popertots fe88f2084d fix: review-loop B — adversarial findings (sweep auto-refund, admin clamp, 2FA real challenge, opaque refresh tokens, gated client IP, GBP pence)
Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests

All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
2026-08-22 00:34:50 +01:00

773 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build test && dev
package payments
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
)
// =============================================================================
// ApplyLoyaltyRedemption — POST /api/bookings/{id}/apply-redemption
// =============================================================================
func setupLoyaltyUser(t *testing.T, ctx context.Context, q db.Querier, stamps int) (string, string, string) {
t.Helper()
userID, err := fixtures.CreateTestUser(q)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = q.Exec(ctx,
"UPDATE users SET loyalty_stamps = $1 WHERE id = $2", stamps, userID)
if err != nil {
t.Fatalf("failed to set loyalty_stamps: %v", err)
}
// Create a pending loyalty_redemption if stamps >= 10
if stamps >= 10 {
_, err = q.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, status, redeemed_at, expires_at)
VALUES ($1, 'pending', NOW(), NOW() + INTERVAL '6 months')
`, userID)
if err != nil {
t.Fatalf("failed to create loyalty redemption: %v", err)
}
}
serviceID, err := fixtures.CreateTestService(q)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = q.Exec(ctx,
"UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set booking status: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
return userID, bookingID, userToken
}
func makeApplyRedemptionRequest(bookingID, token string, ctx context.Context) *httptest.ResponseRecorder {
handler := http.HandlerFunc(ApplyLoyaltyRedemption)
req := httptest.NewRequest("POST", "/api/bookings/"+bookingID+"/apply-redemption", nil)
req.Header.Set("Authorization", "Bearer "+token)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
userID := extractUserFromTestJWT(token)
if userID != nil {
ctx = context.WithValue(ctx, mw.UserIDKey, userID.userID)
ctx = context.WithValue(ctx, mw.UserRoleKey, userID.role)
}
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
func TestApplyLoyaltyRedemption_Success(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupLoyaltyUser(t, ctx, tx, 10)
w := makeApplyRedemptionRequest(bookingID, userToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["success"] != true {
t.Error("expected success=true")
}
discountAmount, ok := resp["discount_amount"].(float64)
if !ok || discountAmount <= 0 {
t.Errorf("expected positive discount_amount, got %v", resp["discount_amount"])
}
// Verify booking_discounts was created
var discountCount int
err := tx.QueryRow(ctx,
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'", bookingID).Scan(&discountCount)
if err != nil {
t.Fatalf("failed to query booking_discounts: %v", err)
}
if discountCount != 1 {
t.Errorf("expected 1 loyalty discount record, got %d", discountCount)
}
// Verify stamps were deducted (10 - 10 = 0)
var stamps int
err = tx.QueryRow(ctx, "SELECT loyalty_stamps FROM users WHERE id = (SELECT user_id FROM bookings WHERE id = $1)", bookingID).Scan(&stamps)
if err != nil {
t.Fatalf("failed to query stamps: %v", err)
}
if stamps != 0 {
t.Errorf("expected 0 stamps after redemption, got %d", stamps)
}
// Verify a discount payment record was created
var paymentCount int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'", bookingID).Scan(&paymentCount)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
if paymentCount != 1 {
t.Errorf("expected 1 discount payment record, got %d", paymentCount)
}
}
func TestApplyLoyaltyRedemption_InsufficientStamps(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupLoyaltyUser(t, ctx, tx, 5)
w := makeApplyRedemptionRequest(bookingID, userToken, ctx)
if w.Code != http.StatusBadRequest && w.Code != http.StatusConflict {
t.Fatalf("expected 4xx for insufficient stamps, got %d: %s", w.Code, w.Body.String())
}
}
func TestApplyLoyaltyRedemption_AlreadyApplied(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupLoyaltyUser(t, ctx, tx, 10)
// First call should succeed
w := makeApplyRedemptionRequest(bookingID, userToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("first call expected 200, got %d: %s", w.Code, w.Body.String())
}
// Second call should be rejected
w = makeApplyRedemptionRequest(bookingID, userToken, ctx)
if w.Code != http.StatusConflict && w.Code != http.StatusBadRequest {
t.Fatalf("second call expected 4xx, got %d: %s", w.Code, w.Body.String())
}
// Verify still only 1 discount record
var discountCount int
tx.QueryRow(ctx,
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'", bookingID).Scan(&discountCount)
if discountCount != 1 {
t.Errorf("expected 1 loyalty discount record, got %d", discountCount)
}
}
func TestApplyLoyaltyRedemption_TerminalBooking(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupLoyaltyUser(t, ctx, tx, 10)
// Set booking to a terminal status
_, err := tx.Exec(ctx,
"UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set booking status: %v", err)
}
w := makeApplyRedemptionRequest(bookingID, userToken, ctx)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for terminal booking, got %d: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// applyEligibleCampaignsAtPayment — campaign auto-apply at payment time
// =============================================================================
func setupCampaignTest(t *testing.T, ctx context.Context, q db.Querier) (string, string, string) {
t.Helper()
userID, err := fixtures.CreateTestUser(q)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(q)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = q.Exec(ctx,
"UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set booking status: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
return userID, bookingID, userToken
}
func TestCampaignAutoApply_TimeBased(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupCampaignTest(t, ctx, tx)
// Create an active time-based campaign
now := clock.Now()
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ($1, 'time_based', 10, 'active', $2, $3, 0)
RETURNING id
`, "Early Payment Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
// Insert a deposit payment to trigger campaign auto-apply (£25 on the £50
// booking — leaves headroom so the F1 over-credit cap still lets the 10%
// discount through).
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 25.00, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Call applyEligibleCampaignsAtPayment
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
// Verify booking_discounts was created
var discountCount int
tx.QueryRow(ctx,
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount)
if discountCount != 1 {
t.Errorf("expected 1 campaign discount, got %d", discountCount)
}
// Verify times_redeemed was incremented
var timesRedeemed int
tx.QueryRow(ctx,
"SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(&timesRedeemed)
if timesRedeemed != 1 {
t.Errorf("expected 1 redemption, got %d", timesRedeemed)
}
}
func TestCampaignAutoApply_UserMilestone(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupCampaignTest(t, ctx, tx)
// Give user 5 completed bookings to match milestone_value=5
for i := 0; i < 5; i++ {
var bid string
tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id
`, userID, time.Date(2024, time.Month(i+1), 15, 10, 0, 0, 0, time.UTC)).Scan(&bid)
}
// Create user milestone campaign for 5th booking
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed)
VALUES ($1, 'milestone', 15, 'active', NOW(), NOW() + INTERVAL '1 year', 'per_user_booking_count', 5, 1, 0)
RETURNING id
`, "5th Booking Bonus").Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
// Insert a payment (£25 deposit leaves F1 cap headroom for the 15% discount)
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 25.00, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
var discountCount int
tx.QueryRow(ctx,
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount)
if discountCount != 1 {
t.Errorf("expected 1 campaign discount, got %d", discountCount)
}
}
func TestCampaignAutoApply_GlobalMilestoneSkippedOnline(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupCampaignTest(t, ctx, tx)
// Set global completed count high enough
now := clock.Now()
for i := 0; i < 100; i++ {
tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id
`, userID, now.Add(-time.Duration(i)*24*time.Hour)).Scan(new(string))
}
// Create global milestone campaign at milestone_value=100
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed)
VALUES ($1, 'milestone', 20, 'active', NOW(), NOW() + INTERVAL '1 year', 'global_booking_count', 100, 5, 0)
RETURNING id
`, "100th Booking Celebration").Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
// Insert an ONLINE payment first (not in_person_card)
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
// Verify NO discount was applied (global milestone skipped for online payment)
var discountCount int
tx.QueryRow(ctx,
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount)
if discountCount != 0 {
t.Errorf("expected 0 campaign discounts (global milestone skipped for online), got %d", discountCount)
}
}
func TestCampaignAutoApply_GlobalMilestoneAppliedInPerson(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupCampaignTest(t, ctx, tx)
for i := 0; i < 100; i++ {
tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id
`, userID, time.Date(2024, time.Month(i%12+1), 15, 10, 0, 0, 0, time.UTC)).Scan(new(string))
}
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed)
VALUES ($1, 'milestone', 20, 'active', NOW(), NOW() + INTERVAL '1 year', 'global_booking_count', 100, 5, 0)
RETURNING id
`, "100th Booking Celebration").Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
// Insert an IN-PERSON payment (£40 on the £50 booking — leaves the £10
// headroom the 20% global-milestone discount needs to pass the F1 cap).
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'full', 'in_person_card', 40.00, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Simulate what CreateBookingPayment does: set status to confirmed after payment
tx.Exec(ctx,
"UPDATE bookings SET status = 'confirmed', updated_at = NOW() WHERE id = $1", bookingID)
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
var discountCount int
tx.QueryRow(ctx,
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount)
if discountCount != 1 {
t.Errorf("expected 1 campaign discount (in-person global milestone), got %d", discountCount)
}
}
func TestCampaignAutoApply_DoubleApplyGuard(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupCampaignTest(t, ctx, tx)
now := clock.Now()
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ($1, 'time_based', 10, 'active', $2, $3, 0)
RETURNING id
`, "Test Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
// Manually insert a booking_discount to simulate it was already applied at payment time
_, err = tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'time_based', 10, 5000, 500)
`, bookingID, userID, campaignID)
if err != nil {
t.Fatalf("failed to insert existing discount: %v", err)
}
// Pretend campaign was already redeemed
tx.Exec(ctx,
"UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1", campaignID)
// Insert payment to trigger applyEligibleCampaignsAtPayment
tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
`, bookingID)
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
// Verify still only 1 discount
var discountCount int
tx.QueryRow(ctx,
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount)
if discountCount != 1 {
t.Errorf("expected 1 campaign discount (no double-apply), got %d", discountCount)
}
}
// =============================================================================
// Referral discount via applyEligibleCampaignsAtPayment
// =============================================================================
func TestCampaignAutoApply_ReferralDiscount(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupCampaignTest(t, ctx, tx)
// Create the referral + discount
var refID string
err := tx.QueryRow(ctx, `
INSERT INTO user_referrals (referrer_id, referred_id)
VALUES ($1, $2)
RETURNING id
`, userID, userID).Scan(&refID)
if err != nil {
t.Fatalf("failed to create referral: %v", err)
}
var rdID string
err = tx.QueryRow(ctx, `
INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used)
VALUES ($1, $2, 10.00, false)
RETURNING id
`, userID, refID).Scan(&rdID)
if err != nil {
t.Fatalf("failed to insert referral discount: %v", err)
}
// Insert payment to trigger auto-apply (£25 deposit leaves F1 cap headroom)
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 25.00, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
// Verify referral discount was applied
var discountCount int
err = tx.QueryRow(ctx,
`SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral'`, bookingID).Scan(&discountCount)
if err != nil {
t.Fatalf("failed to count referral discounts: %v", err)
}
if discountCount != 1 {
t.Errorf("expected 1 referral discount, got %d", discountCount)
}
// Verify referral discount was marked as used
var used bool
err = tx.QueryRow(ctx,
"SELECT used FROM referral_discounts WHERE id = $1", rdID).Scan(&used)
if err != nil {
t.Fatalf("failed to query referral discount: %v", err)
}
if !used {
t.Error("expected referral discount to be marked as used")
}
// Verify discount payment was created
var paymentCount int
tx.QueryRow(ctx,
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&paymentCount)
if paymentCount == 0 {
t.Error("expected at least 1 discount payment to be created")
}
}
func TestCampaignAutoApply_ReferralDiscount_DoubleApplyGuard(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupCampaignTest(t, ctx, tx)
var refID string
err := tx.QueryRow(ctx, `
INSERT INTO user_referrals (referrer_id, referred_id)
VALUES ($1, $2)
RETURNING id
`, userID, userID).Scan(&refID)
if err != nil {
t.Fatalf("failed to create referral: %v", err)
}
var rdID string
err = tx.QueryRow(ctx, `
INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used)
VALUES ($1, $2, 10.00, false)
RETURNING id
`, userID, refID).Scan(&rdID)
if err != nil {
t.Fatalf("failed to insert referral discount: %v", err)
}
// Pre-apply the referral discount to simulate it was applied on a previous attempt
_, err = tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'referral', $3, 10.00, 5000, 500)
`, bookingID, userID, rdID)
if err != nil {
t.Fatalf("failed to insert existing booking discount: %v", err)
}
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
// Verify no second referral discount was applied
var discountCount int
tx.QueryRow(ctx,
`SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral'`, bookingID).Scan(&discountCount)
if discountCount != 1 {
t.Errorf("expected 1 referral discount (no double-apply), got %d", discountCount)
}
// Verify referral discount is still unused (since the function should skip it)
var used bool
tx.QueryRow(ctx,
"SELECT used FROM referral_discounts WHERE id = $1", rdID).Scan(&used)
if used {
t.Error("expected referral discount to remain unused (skipped by double-apply guard)")
}
}
func TestCampaignAutoApply_ReferralDiscount_AlreadyUsed(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupCampaignTest(t, ctx, tx)
var refID string
err := tx.QueryRow(ctx, `
INSERT INTO user_referrals (referrer_id, referred_id)
VALUES ($1, $2)
RETURNING id
`, userID, userID).Scan(&refID)
if err != nil {
t.Fatalf("failed to create referral: %v", err)
}
// Create an already-used referral discount
var rdID string
err = tx.QueryRow(ctx, `
INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used)
VALUES ($1, $2, 10.00, true)
RETURNING id
`, userID, refID).Scan(&rdID)
if err != nil {
t.Fatalf("failed to insert used referral discount: %v", err)
}
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, nil)
var discountCount int
tx.QueryRow(ctx,
`SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral'`, bookingID).Scan(&discountCount)
if discountCount != 0 {
t.Errorf("expected 0 referral discounts (already used), got %d", discountCount)
}
}
// =============================================================================
// ApplyLoyaltyRedemption — additional error-path coverage
// =============================================================================
func TestApplyLoyaltyRedemption_BookingNotFound(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
w := makeApplyRedemptionRequest("000000000001", userToken, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestApplyLoyaltyRedemption_Unauthorized(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to update booking status: %v", err)
}
otherUserID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create other user: %v", err)
}
otherToken := jwt.GenerateUserToken(otherUserID)
w := makeApplyRedemptionRequest(bookingID, otherToken, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d: %s", w.Code, w.Body.String())
}
}
func TestApplyLoyaltyRedemption_RealPaymentExists(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupLoyaltyUser(t, ctx, tx, 10)
_, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
w := makeApplyRedemptionRequest(bookingID, userToken, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestApplyLoyaltyRedemption_NoPendingRedemption(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, "UPDATE users SET loyalty_stamps = 10 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set stamps: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set booking status: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
w := makeApplyRedemptionRequest(bookingID, userToken, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// TestApplyLoyaltyRedemption_LockContended_Returns409 verifies the bounded
// try-lock defence (loyalty.go): while another connection holds the
// "crussell:payment:<booking>" advisory lock (e.g. an in-flight payment), a
// redemption attempt must NOT block the pool connection — it gives up after
// the ~3s bound and surfaces a 409 instead.
func TestApplyLoyaltyRedemption_LockContended_Returns409(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupLoyaltyUser(t, ctx, tx, 10)
// Commit the setup tx so the handler sees committed rows.
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
if err := innerTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
// Hold the booking-payment advisory lock on a dedicated pinned connection
// so every try-lock attempt from the handler's connection fails.
holder, err := db.Conn.Acquire(context.Background())
if err != nil {
t.Fatalf("failed to acquire holder connection: %v", err)
}
defer holder.Release()
if _, err := holder.Exec(context.Background(),
`SELECT pg_advisory_lock(hashtext('crussell:payment:' || $1))`, bookingID); err != nil {
t.Fatalf("failed to acquire holder lock: %v", err)
}
defer func() {
_, _ = holder.Exec(context.Background(),
`SELECT pg_advisory_unlock(hashtext('crussell:payment:' || $1))`, bookingID)
}()
start := time.Now()
w := makeApplyRedemptionRequest(bookingID, userToken, context.Background())
elapsed := time.Since(start)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409 on contended lock, got %d: %s", w.Code, w.Body.String())
}
// The bound is 30×100ms ≈ 3s. It must give up within a sane window (did
// not block forever on the pool) — allow generous CI headroom.
if elapsed > 15*time.Second {
t.Errorf("lock contention should give up after ~3s, took %v", elapsed)
}
// Redemption must NOT have been applied.
var discountCount int
err = db.Conn.QueryRow(context.Background(),
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'", bookingID).Scan(&discountCount)
if err != nil {
t.Fatalf("failed to query booking_discounts: %v", err)
}
if discountCount != 0 {
t.Errorf("expected 0 discounts (redemption rejected), got %d", discountCount)
}
}