//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 _, 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) } // Call applyEligibleCampaignsAtPayment applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID) // 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 _, 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) 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) // 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 _, 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', 5000, '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) 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) // 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 _, 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) // 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) // 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) 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:" 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) } }