feat(backend): update loyalty tests for manual redemption model

Refactor all loyalty tests from auto-redemption to manual-redemption model: - applyLoyaltyRedemption helper with HTTP endpoint - New tests: DepositBeforeRedemption (rejected), RedemptionBeforeDeposit (locked-in), MultipleEarnCycles, NormalEarnNoRedemption, RedemptionAppliedBeforeStampIncrement - Move TestDiscount_Stacking_TimeBasedPlusMilestone to alphabetical position - Add getTotalPaymentCount, getAmountPaid helpers - Remove TestDiscount_RedemptionAppliedBeforeStampIncrement (replaced)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-19 11:34:31 +01:00
co-authored by Sisyphus
parent 1e100e4db7
commit e74f190cdd
+323 -85
View File
@@ -14,6 +14,7 @@ import (
"time"
"crussell/db"
"crussell/handlers/payments"
"crussell/mw"
"crussell/testutils/fixtures"
@@ -243,23 +244,51 @@ func getPaymentDiscountRowCount(t *testing.T, bookingID string) int {
return count
}
func getTotalPaymentCount(t *testing.T, bookingID string) int {
t.Helper()
var count int
err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&count)
require.NoError(t, err)
return count
}
func getAmountPaid(t *testing.T, bookingID string) float64 {
t.Helper()
var amount float64
err := db.DB.QueryRow(context.Background(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&amount)
require.NoError(t, err)
return amount
}
func applyLoyaltyRedemption(t *testing.T, bookingID, userID string) {
t.Helper()
handler := http.HandlerFunc(payments.ApplyLoyaltyRedemption)
req := httptest.NewRequest("POST", "/api/bookings/"+bookingID+"/apply-redemption", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
ctx = context.WithValue(ctx, mw.UserRoleKey, "customer")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "apply-redemption failed: %s", w.Body.String())
}
// =============================================================================
// Loyalty Auto-Redemption Tests
// Loyalty Manual-Redemption Tests
// =============================================================================
func TestDiscount_Loyalty_FullCycle(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID := createTestUser(t, 0)
userID := createTestUser(t, 10)
serviceID := createTestService(t, 50.00)
// Simulate completing 10 bookings on different days by directly setting stamps
_, err := db.DB.Exec(context.Background(), `UPDATE users SET loyalty_stamps = 10 WHERE id = $1`, userID)
require.NoError(t, err)
// Verify pending redemption was created when stamps hit 10
_, err = db.DB.Exec(context.Background(), `
_, err := db.DB.Exec(context.Background(), `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
@@ -268,16 +297,20 @@ func TestDiscount_Loyalty_FullCycle(t *testing.T) {
assert.Equal(t, 10, getStamps(t, userID))
assert.Equal(t, 1, getPendingRedemptions(t, userID))
// 11th booking → applies discount, resets stamps to 0, then +1 for completion
bookingID11 := createPendingBooking(t, userID, serviceID, time.Now().AddDate(0, 0, 12))
completeBooking(t, bookingID11)
// Apply loyalty redemption manually on a new booking
bookingID := createPendingBooking(t, userID, serviceID, time.Now().AddDate(0, 0, 12))
applyLoyaltyRedemption(t, bookingID, userID)
source, amount, exists := getDiscountForBooking(t, bookingID11)
require.True(t, exists, "Expected discount on 11th booking")
source, amount, exists := getDiscountForBooking(t, bookingID)
require.True(t, exists, "Expected discount after manual redemption")
assert.Equal(t, "loyalty", source)
assert.Equal(t, 5.00, amount, "10% of £50 = £5")
assert.Equal(t, 1, getStamps(t, userID), "Stamps: 0 after redemption + 1 for this booking")
assert.Equal(t, 0, getPendingRedemptions(t, userID), "No pending redemptions after applying")
assert.Equal(t, 0, getStamps(t, userID), "Stamps deducted by 10")
assert.Equal(t, 0, getPendingRedemptions(t, userID), "Pending redemption consumed")
// Complete the booking — no stamp awarded (take or receive, never both)
completeBooking(t, bookingID)
assert.Equal(t, 0, getStamps(t, userID), "No stamp for a booking that claimed a reward")
}
func TestDiscount_Loyalty_ExistingRedemptionApplies(t *testing.T) {
@@ -299,10 +332,15 @@ func TestDiscount_Loyalty_ExistingRedemptionApplies(t *testing.T) {
require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
// Apply loyalty redemption manually before completion
applyLoyaltyRedemption(t, bookingID, userID)
// Complete booking — milestone campaign applies at completion
insertInPersonCardPayment(t, bookingID)
completeBooking(t, bookingID)
assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows")
assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows (loyalty + campaign)")
assert.Equal(t, 2, getPaymentDiscountRowCount(t, bookingID), "Expected 2 discount payment rows")
totalDiscount := getTotalDiscountAmount(t, bookingID)
@@ -331,13 +369,15 @@ func TestDiscount_Stacking_LoyaltyPlusMilestone(t *testing.T) {
serviceID := createTestService(t, 100.00)
// Create 4 prior completed bookings (backdated)
for i := 0; i < 4; i++ {
startTime := time.Now().AddDate(0, 0, -(i + 10))
_ = createCompletedBooking(t, userID, serviceID, startTime, 100.00)
}
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
// Apply loyalty manually, then complete (milestone applies at completion)
applyLoyaltyRedemption(t, bookingID, userID)
completeBooking(t, bookingID)
assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows")
@@ -365,11 +405,12 @@ func TestDiscount_Stacking_LoyaltyPlusAnniversary(t *testing.T) {
serviceID := createTestService(t, 100.00)
// First completed booking backdated 400 days (first visit > 1 year ago)
firstStartTime := time.Now().AddDate(0, 0, -400)
_ = createCompletedBooking(t, userID, serviceID, firstStartTime, 100.00)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
applyLoyaltyRedemption(t, bookingID, userID)
completeBooking(t, bookingID)
assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows")
@@ -399,11 +440,12 @@ func TestDiscount_Stacking_AllThreeTypes(t *testing.T) {
serviceID := createTestService(t, 100.00)
// First booking backdated 400 days for anniversary
firstStartTime := time.Now().AddDate(0, 0, -400)
_ = createCompletedBooking(t, userID, serviceID, firstStartTime, 100.00)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
applyLoyaltyRedemption(t, bookingID, userID)
completeBooking(t, bookingID)
assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 discount rows")
@@ -471,6 +513,8 @@ func TestDiscount_Stacking_LoyaltyPlusGlobalMilestone(t *testing.T) {
serviceID := createTestService(t, 100.00)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
applyLoyaltyRedemption(t, bookingID, userID)
insertInPersonCardPayment(t, bookingID)
completeBooking(t, bookingID)
@@ -480,36 +524,6 @@ func TestDiscount_Stacking_LoyaltyPlusGlobalMilestone(t *testing.T) {
assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total should be £15 (10% + 5%)")
}
func TestDiscount_Stacking_TimeBasedPlusMilestone(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil)
mt := "per_user_booking_count"
mu := "bookings"
mv := 3
_ = createTestCampaign(t, "3rd Booking", "milestone", 10.0, &mt, &mu, &mv, nil)
userID := createTestUser(t, 0)
serviceID := createTestService(t, 100.00)
// 2 prior completed bookings
startTime1 := time.Now().AddDate(0, 0, -14)
_ = createCompletedBooking(t, userID, serviceID, startTime1, 100.00)
startTime2 := time.Now().AddDate(0, 0, -7)
_ = createCompletedBooking(t, userID, serviceID, startTime2, 100.00)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
completeBooking(t, bookingID)
assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows")
totalDiscount := getTotalDiscountAmount(t, bookingID)
assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total should be £15 (5% + 10%)")
}
func TestDiscount_Stacking_DiscountAmountsSumCorrectly(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
@@ -531,6 +545,8 @@ func TestDiscount_Stacking_DiscountAmountsSumCorrectly(t *testing.T) {
serviceID := createTestService(t, 200.00)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
applyLoyaltyRedemption(t, bookingID, userID)
completeBooking(t, bookingID)
assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 discount rows")
@@ -538,7 +554,6 @@ func TestDiscount_Stacking_DiscountAmountsSumCorrectly(t *testing.T) {
totalDiscount := getTotalDiscountAmount(t, bookingID)
assert.InDelta(t, 50.00, totalDiscount, 0.01, "Total should be £50")
// Verify each individual discount_amount is against the original total
discounts := getAllDiscountsForBooking(t, bookingID)
require.Len(t, discounts, 3)
for _, d := range discounts {
@@ -576,6 +591,8 @@ func TestDiscount_Stacking_MultiplePaymentRecords(t *testing.T) {
serviceID := createTestService(t, 200.00)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
applyLoyaltyRedemption(t, bookingID, userID)
completeBooking(t, bookingID)
assert.Equal(t, 3, getPaymentDiscountRowCount(t, bookingID), "Expected 3 discount payment rows")
@@ -609,11 +626,12 @@ func TestDiscount_Stacking_MultipleBookingDiscountRows(t *testing.T) {
serviceID := createTestService(t, 200.00)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
applyLoyaltyRedemption(t, bookingID, userID)
completeBooking(t, bookingID)
assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 booking_discounts rows")
// Verify discount_percent and original_total for each row
type discountDetail struct {
Source string
CampType string
@@ -658,6 +676,36 @@ func TestDiscount_Stacking_MultipleBookingDiscountRows(t *testing.T) {
}
}
func TestDiscount_Stacking_TimeBasedPlusMilestone(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil)
mt := "per_user_booking_count"
mu := "bookings"
mv := 3
_ = createTestCampaign(t, "3rd Booking", "milestone", 10.0, &mt, &mu, &mv, nil)
userID := createTestUser(t, 0)
serviceID := createTestService(t, 100.00)
// 2 prior completed bookings
startTime1 := time.Now().AddDate(0, 0, -14)
_ = createCompletedBooking(t, userID, serviceID, startTime1, 100.00)
startTime2 := time.Now().AddDate(0, 0, -7)
_ = createCompletedBooking(t, userID, serviceID, startTime2, 100.00)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
completeBooking(t, bookingID)
assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows")
totalDiscount := getTotalDiscountAmount(t, bookingID)
assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total should be £15 (5% + 10%)")
}
// =============================================================================
// Zero Total Edge Case Tests
// =============================================================================
@@ -791,7 +839,6 @@ func TestDiscount_MultiplePendingRedemptions_UsesOldest(t *testing.T) {
ctx := context.Background()
// Insert two pending redemptions with different timestamps
_, err := db.DB.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW() - INTERVAL '2 days')
@@ -805,12 +852,13 @@ func TestDiscount_MultiplePendingRedemptions_UsesOldest(t *testing.T) {
require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
completeBooking(t, bookingID)
// Manual apply-redemption should pick the oldest pending redemption
applyLoyaltyRedemption(t, bookingID, userID)
count := getDiscountRowCount(t, bookingID)
assert.Equal(t, 1, count, "Exactly 1 loyalty discount row should be applied")
// Verify the oldest redemption was applied and the newer one remains pending
type redemptionRow struct {
ID string
Status string
@@ -840,7 +888,7 @@ func TestDiscount_StampCountAboveTen(t *testing.T) {
userID := createTestUser(t, 9)
serviceID := createTestService(t, 50.00)
// First booking: stamps 9 → 10, pending redemption auto-created
// First booking: stamps 9 → 10, pending redemption auto-created at completion
bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
completeBooking(t, bookingID1)
backdateBooking(t, bookingID1, 2)
@@ -848,16 +896,232 @@ func TestDiscount_StampCountAboveTen(t *testing.T) {
assert.Equal(t, 10, getStamps(t, userID), "Stamps should be 10 after first completion")
assert.Equal(t, 1, getPendingRedemptions(t, userID), "Pending redemption should be auto-created")
// Second booking (different day): redemption applies, stamps reset to 0 then +1
// Second booking: stamps accumulate to 11 (no auto-deduct)
bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour))
// Second booking: stamps accumulate to 11 (no auto-deduct)
// Apply redemption while booking is still confirmed (not yet completed)
applyLoyaltyRedemption(t, bookingID2, userID)
assert.Equal(t, 0, getStamps(t, userID), "Stamps: 10 - 10 = 0")
assert.Equal(t, 0, getPendingRedemptions(t, userID), "Redemption consumed")
// Complete the booking — no stamp awarded (take or receive, never both)
completeBooking(t, bookingID2)
source, amount, exists := getDiscountForBooking(t, bookingID2)
require.True(t, exists, "Expected loyalty discount on second booking")
require.True(t, exists, "Expected loyalty discount after manual redemption")
assert.Equal(t, "loyalty", source)
assert.Equal(t, 5.00, amount, "10% of £50 = £5")
assert.Equal(t, 1, getStamps(t, userID), "Stamps: 0 after redemption + 1 for this booking = 1")
assert.Equal(t, 0, getPendingRedemptions(t, userID), "No pending redemptions after applying")
assert.Equal(t, 0, getStamps(t, userID), "No stamp for a booking that used a loyalty redemption")
}
func TestDiscount_RedemptionAppliedBeforeStampIncrement(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID := createTestUser(t, 10)
serviceID := createTestService(t, 50.00)
_, err := db.DB.Exec(context.Background(), `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
// Apply redemption manually before completing
applyLoyaltyRedemption(t, bookingID, userID)
// Complete the booking — should NOT give a stamp (take or receive, never both)
completeBooking(t, bookingID)
assert.Equal(t, 0, getStamps(t, userID), "No stamp awarded when loyalty redemption was used")
assert.Equal(t, 0, getPendingRedemptions(t, userID), "No pending redemptions")
}
// =============================================================================
// Additional loyalty flow tests
// =============================================================================
func TestDiscount_NormalEarn_NoRedemption(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
// User starts at 5 stamps, completes 3 bookings without ever redeeming.
// Stamps should accumulate normally with no discount interference.
userID := createTestUser(t, 5)
serviceID := createTestService(t, 50.00)
booking1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
completeBooking(t, booking1)
assert.Equal(t, 6, getStamps(t, userID), "5 + 1 = 6")
assert.Equal(t, 0, getDiscountRowCount(t, booking1), "No discounts applied")
assert.Equal(t, 0, getPendingRedemptions(t, userID), "No pending redemption (< 10)")
backdateBooking(t, booking1, 2)
booking2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour))
completeBooking(t, booking2)
assert.Equal(t, 7, getStamps(t, userID), "6 + 1 = 7")
assert.Equal(t, 0, getDiscountRowCount(t, booking2), "No discounts applied")
backdateBooking(t, booking2, 2)
booking3 := createPendingBooking(t, userID, serviceID, time.Now().Add(72*time.Hour))
completeBooking(t, booking3)
assert.Equal(t, 8, getStamps(t, userID), "7 + 1 = 8")
assert.Equal(t, 0, getDiscountRowCount(t, booking3), "No discounts applied")
}
func TestDiscount_DepositBeforeRedemption_Rejected(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
// User pays a deposit upfront without loyalty — the first real payment has been
// made, so loyalty redemption should be rejected by the first-payment guard.
userID := createTestUser(t, 10)
serviceID := createTestService(t, 200.00)
_, err := db.DB.Exec(context.Background(), `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
assert.Equal(t, 1, getPendingRedemptions(t, userID))
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
// Pay a deposit first (the first real payment)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'deposit', 'online_square', 4000, 'completed', $2)
`, bookingID, userID)
require.NoError(t, err)
// Later at the till, admin tries to apply loyalty — should be rejected
handler := http.HandlerFunc(payments.ApplyLoyaltyRedemption)
req := httptest.NewRequest("POST", "/api/bookings/"+bookingID+"/apply-redemption", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
ctx = context.WithValue(ctx, mw.UserRoleKey, "customer")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code, "Expected 400 after deposit was paid")
// Verify nothing was changed — discount not applied, stamps intact, redemption pending
assert.Equal(t, 0, getDiscountRowCount(t, bookingID), "No loyalty discount applied")
assert.Equal(t, 10, getStamps(t, userID), "Stamps not deducted")
assert.Equal(t, 1, getPendingRedemptions(t, userID), "Redemption still pending")
assert.Equal(t, 1, getTotalPaymentCount(t, bookingID), "Only the deposit payment exists")
}
func TestDiscount_RedemptionBeforeDeposit_DiscountLockedIn(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
// User applies loyalty redemption online first, then pays the deposit.
// The discount locks in at 10% of total and persists through to completion.
userID := createTestUser(t, 10)
serviceID := createTestService(t, 200.00)
_, err := db.DB.Exec(context.Background(), `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
// Apply loyalty redemption (simulating online payment checkbox)
applyLoyaltyRedemption(t, bookingID, userID)
// Discount should be 10% of total
source, amount, exists := getDiscountForBooking(t, bookingID)
require.True(t, exists)
assert.Equal(t, "loyalty", source)
assert.InDelta(t, 20.00, amount, 0.01, "Discount is 10%% of £200 = £20")
// Pay a deposit after redemption (£40 deposit on the current due)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'deposit', 'online_square', 4000, 'completed', $2)
`, bookingID, userID)
require.NoError(t, err)
// Verify: 2 payments (discount + deposit), discount still intact
assert.Equal(t, 2, getTotalPaymentCount(t, bookingID), "Expected 2 payment records (discount + deposit)")
// Complete the booking — no stamp awarded (take or receive)
completeBooking(t, bookingID)
assert.Equal(t, 0, getStamps(t, userID), "No stamp for a booking that used loyalty")
assert.Equal(t, 1, getPaymentDiscountRowCount(t, bookingID), "Discount payment still present after completion")
// Re-read discount after completion to confirm it wasn't altered
_, amountAfter, existsAfter := getDiscountForBooking(t, bookingID)
require.True(t, existsAfter)
assert.InDelta(t, 20.00, amountAfter, 0.01, "Discount amount unchanged after completion")
}
func TestDiscount_MultipleEarnCycles(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
// Two complete earn-and-redeem cycles: earn 10 → redeem → earn 10 more → redeem
userID := createTestUser(t, 0)
serviceID := createTestService(t, 50.00)
// Cycle 1: reach 10 stamps
_, err := db.DB.Exec(context.Background(), `UPDATE users SET loyalty_stamps = 10 WHERE id = $1`, userID)
require.NoError(t, err)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
assert.Equal(t, 10, getStamps(t, userID))
assert.Equal(t, 1, getPendingRedemptions(t, userID))
// Redeem on first booking
booking1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
applyLoyaltyRedemption(t, booking1, userID)
assert.Equal(t, 0, getStamps(t, userID))
assert.Equal(t, 0, getPendingRedemptions(t, userID))
// Complete booking — no stamp (take or receive)
completeBooking(t, booking1)
assert.Equal(t, 0, getStamps(t, userID), "No stamp — this booking used loyalty")
// Cycle 2: set stamps back to 10 via direct DB update
_, err = db.DB.Exec(context.Background(), `UPDATE users SET loyalty_stamps = 10 WHERE id = $1`, userID)
require.NoError(t, err)
// Create a new pending redemption for the second cycle
_, err = db.DB.Exec(context.Background(), `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
assert.Equal(t, 10, getStamps(t, userID))
assert.Equal(t, 1, getPendingRedemptions(t, userID))
// Redeem again on a new booking
booking2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour))
applyLoyaltyRedemption(t, booking2, userID)
assert.Equal(t, 0, getStamps(t, userID), "Stamps deducted again")
assert.Equal(t, 1, getDiscountRowCount(t, booking2), "Second discount applied")
source, amount, exists := getDiscountForBooking(t, booking2)
require.True(t, exists)
assert.Equal(t, "loyalty", source)
assert.InDelta(t, 5.00, amount, 0.01, "10%% of £50 = £5")
}
func TestDiscount_MixedFreeAndPaidServices(t *testing.T) {
@@ -1209,32 +1473,6 @@ func TestDiscount_TenStampsCreatesRedemption(t *testing.T) {
assert.Equal(t, 1, getPendingRedemptions(t, userID), "1 pending redemption should be auto-created")
}
func TestDiscount_RedemptionAppliedBeforeStampIncrement(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID := createTestUser(t, 10)
serviceID := createTestService(t, 50.00)
_, err := db.DB.Exec(context.Background(), `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
completeBooking(t, bookingID)
count := getDiscountRowCount(t, bookingID)
assert.Equal(t, 1, count, "Loyalty discount should be applied")
source, amount, exists := getDiscountForBooking(t, bookingID)
require.True(t, exists)
assert.Equal(t, "loyalty", source)
assert.Equal(t, 5.00, amount, "10%% of £50 = £5")
assert.Equal(t, 1, getStamps(t, userID), "Stamps: 0 after redemption + 1 for this booking = 1")
}
// =============================================================================
// Campaign Status Lifecycle Tests
// =============================================================================