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.
773 lines
27 KiB
Go
773 lines
27 KiB
Go
//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(×Redeemed)
|
||
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)
|
||
}
|
||
}
|