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:
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user