//go:build test // +build test package bookings import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "crussell/db" "crussell/mw" "crussell/testutils/fixtures" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func makeProgressRequest(handler http.HandlerFunc, method, path string, body interface{}, token string) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rctx := chi.NewRouteContext() parts := strings.Split(strings.Trim(path, "/"), "/") for i, p := range parts { if p == "bookings" && i+1 < len(parts) { rctx.URLParams.Add("id", parts[i+1]) break } } ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) if token != "" { ctx = context.WithValue(ctx, mw.UserIDKey, "admin-test-001") ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") } req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } func createTestUser(t *testing.T, stamps int) string { t.Helper() userID, err := fixtures.CreateTestUser(db.DB) require.NoError(t, err) if stamps > 0 { _, err := db.DB.Exec(context.Background(), "UPDATE users SET loyalty_stamps = $1 WHERE id = $2", stamps, userID) require.NoError(t, err) } return userID } func createTestService(t *testing.T, price float64) string { t.Helper() ctx := context.Background() var serviceID string err := db.DB.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id `, "Test Service", "A test service", price, 60, true, 16).Scan(&serviceID) require.NoError(t, err) return serviceID } func createTestCampaign(t *testing.T, name, campaignType string, percent float64, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int) string { t.Helper() ctx := context.Background() var id string now := time.Now() startDate := now.Add(-24 * time.Hour) endDate := now.Add(24 * time.Hour) err := db.DB.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit, max_redemptions, times_redeemed) VALUES ($1, $2, $3, 'active', $4, $5, $6, $7, $8, $9, 0) RETURNING id `, name, campaignType, percent, startDate, endDate, milestoneType, milestoneValue, milestoneUnit, maxRedemptions).Scan(&id) require.NoError(t, err) return id } func createCompletedBooking(t *testing.T, userID, serviceID string, startTime time.Time, price float64) string { t.Helper() ctx := context.Background() var bookingID string err := db.DB.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id `, userID, startTime).Scan(&bookingID) require.NoError(t, err) _, err = db.DB.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id, override_price) VALUES ($1, $2, $3) `, bookingID, serviceID, price) require.NoError(t, err) return bookingID } func createPendingBooking(t *testing.T, userID, serviceID string, startTime time.Time) string { t.Helper() ctx := context.Background() var bookingID string err := db.DB.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'confirmed') RETURNING id `, userID, startTime).Scan(&bookingID) require.NoError(t, err) _, err = db.DB.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) require.NoError(t, err) return bookingID } func completeBooking(t *testing.T, bookingID string) *httptest.ResponseRecorder { t.Helper() progressReq := ProgressBookingRequest{Status: "completed"} handler := http.HandlerFunc(ProgressBookingHandler) w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion") return w } func backdateBooking(t *testing.T, bookingID string, daysAgo int) { t.Helper() if daysAgo > 0 { _, err := db.DB.Exec(context.Background(), ` UPDATE bookings SET updated_at = NOW() - INTERVAL '1 day' * $1 WHERE id = $2 `, daysAgo, bookingID) require.NoError(t, err) } } func getStamps(t *testing.T, userID string) int { t.Helper() var stamps int err := db.DB.QueryRow(context.Background(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps) require.NoError(t, err) return stamps } func getPendingRedemptions(t *testing.T, userID string) int { t.Helper() var count int err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending'`, userID).Scan(&count) require.NoError(t, err) return count } func getDiscountForBooking(t *testing.T, bookingID string) (source string, amount float64, exists bool) { t.Helper() err := db.DB.QueryRow(context.Background(), ` SELECT discount_source, discount_amount FROM booking_discounts WHERE booking_id = $1 `, bookingID).Scan(&source, &amount) if err != nil { return "", 0, false } return source, amount, true } type bookingDiscount struct { Source string Amount float64 CampType string MileType string } func getDiscountRowCount(t *testing.T, bookingID string) int { t.Helper() var count int err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&count) require.NoError(t, err) return count } func getAllDiscountsForBooking(t *testing.T, bookingID string) []bookingDiscount { t.Helper() rows, err := db.DB.Query(context.Background(), ` SELECT discount_source, discount_amount, COALESCE(campaign_type::text, ''), COALESCE(milestone_type::text, '') FROM booking_discounts WHERE booking_id = $1 ORDER BY discount_source, campaign_type, milestone_type `, bookingID) require.NoError(t, err) defer rows.Close() var discounts []bookingDiscount for rows.Next() { var d bookingDiscount require.NoError(t, rows.Scan(&d.Source, &d.Amount, &d.CampType, &d.MileType)) discounts = append(discounts, d) } return discounts } func getTotalDiscountAmount(t *testing.T, bookingID string) float64 { t.Helper() var amount float64 err := db.DB.QueryRow(context.Background(), `SELECT COALESCE(SUM(discount_amount), 0) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&amount) require.NoError(t, err) return amount } func getPaymentDiscountRowCount(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 AND payment_method = 'discount'`, bookingID).Scan(&count) require.NoError(t, err) return count } // ============================================================================= // Loyalty Auto-Redemption Tests // ============================================================================= func TestDiscount_Loyalty_FullCycle(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID := createTestUser(t, 0) 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(), ` 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)) // 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) source, amount, exists := getDiscountForBooking(t, bookingID11) require.True(t, exists, "Expected discount on 11th booking") 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") } func TestDiscount_Loyalty_ExistingRedemptionApplies(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID := createTestUser(t, 10) serviceID := createTestService(t, 100.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) source, amount, exists := getDiscountForBooking(t, bookingID) require.True(t, exists) assert.Equal(t, "loyalty", source) assert.Equal(t, 10.00, amount, "10% of £100 = £10") assert.Equal(t, 1, getStamps(t, userID), "Stamp earned for completing this booking (0+1)") var redemptionStatus string err = db.DB.QueryRow(context.Background(), ` SELECT status FROM loyalty_redemptions WHERE user_id = $1 AND applied_to_booking_id = $2 `, userID, bookingID).Scan(&redemptionStatus) require.NoError(t, err) assert.Equal(t, "applied", redemptionStatus) } func TestDiscount_Loyalty_OneStampPerDay(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID := createTestUser(t, 5) serviceID := createTestService(t, 50.00) // Complete 3 bookings on the same day var sameDayBookings []string for i := 0; i < 3; i++ { bookingID := createPendingBooking(t, userID, serviceID, time.Now().AddDate(0, 0, i+1)) completeBooking(t, bookingID) sameDayBookings = append(sameDayBookings, bookingID) } // Backdate all to same past day for _, bid := range sameDayBookings { backdateBooking(t, bid, 5) } stamps := getStamps(t, userID) assert.Equal(t, 6, stamps, "Only 1 stamp added for same-day completions (5+1=6)") assert.Equal(t, 0, getPendingRedemptions(t, userID), "No pending redemption yet") // Complete a booking on a different day → second stamp bookingID := createPendingBooking(t, userID, serviceID, time.Now().AddDate(0, 0, 4)) completeBooking(t, bookingID) stamps = getStamps(t, userID) assert.Equal(t, 7, stamps, "Second stamp added on different day (6+1=7)") } func TestDiscount_Loyalty_ZeroTotalNoStamp(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID := createTestUser(t, 5) var serviceID string err := db.DB.QueryRow(context.Background(), ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id `, "Free Service", "A free service", 0.00, 60, true, 16).Scan(&serviceID) require.NoError(t, err) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) stamps := getStamps(t, userID) assert.Equal(t, 5, stamps, "Zero-total booking should not earn a stamp") _, _, exists := getDiscountForBooking(t, bookingID) assert.False(t, exists, "Zero-total booking should not get any discount") } func TestDiscount_Loyalty_CycleRepeats(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID := createTestUser(t, 0) serviceID := createTestService(t, 50.00) // Simulate first cycle _, 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) // 11th booking → discount, stamps reset bookingID11 := createPendingBooking(t, userID, serviceID, time.Now().AddDate(0, 0, 12)) completeBooking(t, bookingID11) source, _, exists := getDiscountForBooking(t, bookingID11) require.True(t, exists) assert.Equal(t, "loyalty", source) assert.Equal(t, 0, getPendingRedemptions(t, userID), "Redemption consumed") // Simulate second cycle _, 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) // 22nd booking → second discount bookingID22 := createPendingBooking(t, userID, serviceID, time.Now().AddDate(0, 0, 31)) completeBooking(t, bookingID22) source2, amount2, exists2 := getDiscountForBooking(t, bookingID22) require.True(t, exists2) assert.Equal(t, "loyalty", source2) assert.Equal(t, 5.00, amount2) assert.Equal(t, 0, getPendingRedemptions(t, userID), "Second redemption also consumed") } // ============================================================================= // Time-Based Campaign Tests // ============================================================================= func TestDiscount_TimeBasedCampaign(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) campaignID := createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) userID := createTestUser(t, 0) serviceID := createTestService(t, 100.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) source, campaignType, exists := getDiscountSourceAndType(t, bookingID) require.True(t, exists) assert.Equal(t, "campaign", source) assert.Equal(t, "time_based", campaignType) var timesRedeemed int err := db.DB.QueryRow(context.Background(), `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(×Redeemed) require.NoError(t, err) assert.Equal(t, 1, timesRedeemed) var paymentAmount float64 err = db.DB.QueryRow(context.Background(), ` SELECT amount FROM payments WHERE booking_id = $1 AND payment_method = 'discount' `, bookingID).Scan(&paymentAmount) require.NoError(t, err) assert.Equal(t, 5.00, paymentAmount) } func getDiscountSourceAndType(t *testing.T, bookingID string) (source, campaignType string, exists bool) { t.Helper() err := db.DB.QueryRow(context.Background(), ` SELECT discount_source, campaign_type FROM booking_discounts WHERE booking_id = $1 `, bookingID).Scan(&source, &campaignType) if err != nil { return "", "", false } return source, campaignType, true } // ============================================================================= // Per-User Milestone Tests // ============================================================================= func TestDiscount_PerUserMilestone(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) milestoneValue := 10 milestoneType := "per_user_booking_count" _ = createTestCampaign(t, "10th Visit Bonus", "milestone", 15.0, &milestoneType, nil, &milestoneValue, nil) userID := createTestUser(t, 0) serviceID := createTestService(t, 80.00) for i := 0; i < 9; i++ { startTime := time.Now().AddDate(0, -1, -i*7) _ = createCompletedBooking(t, userID, serviceID, startTime, 80.00) } bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) source, milestoneTypeResult, exists := getDiscountSourceAndMilestone(t, bookingID) require.True(t, exists) assert.Equal(t, "campaign", source) assert.Equal(t, "per_user_booking_count", milestoneTypeResult) var discountAmount float64 err := db.DB.QueryRow(context.Background(), `SELECT discount_amount FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountAmount) require.NoError(t, err) assert.Equal(t, 12.00, discountAmount, "15% of £80 = £12") bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) completeBooking(t, bookingID2) var discountCount int err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount) require.NoError(t, err) assert.Equal(t, 0, discountCount, "No second discount (dedup)") } func getDiscountSourceAndMilestone(t *testing.T, bookingID string) (source, milestoneType string, exists bool) { t.Helper() err := db.DB.QueryRow(context.Background(), ` SELECT discount_source, milestone_type FROM booking_discounts WHERE booking_id = $1 `, bookingID).Scan(&source, &milestoneType) if err != nil { return "", "", false } return source, milestoneType, true } // ============================================================================= // Global Milestone Tests // ============================================================================= func TestDiscount_GlobalMilestone(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) milestoneValue := 5 milestoneType := "global_booking_count" _ = createTestCampaign(t, "5th Customer Milestone", "milestone", 20.0, &milestoneType, nil, &milestoneValue, nil) userID1 := createTestUser(t, 0) userID2 := createTestUser(t, 0) serviceID := createTestService(t, 50.00) for i := 0; i < 4; i++ { startTime := time.Now().AddDate(0, 0, -i-1) _ = createCompletedBooking(t, userID1, serviceID, startTime, 50.00) } bookingID := createPendingBooking(t, userID2, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) _, milestoneTypeResult, exists := getDiscountSourceAndMilestone(t, bookingID) require.True(t, exists) assert.Equal(t, "global_booking_count", milestoneTypeResult) var discountAmount float64 err := db.DB.QueryRow(context.Background(), `SELECT discount_amount FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountAmount) require.NoError(t, err) assert.Equal(t, 10.00, discountAmount, "20% of £50 = £10") } // ============================================================================= // Anniversary Milestone Tests // ============================================================================= func TestDiscount_AnniversaryMilestone(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) milestoneValue := 6 milestoneType := "anniversary" milestoneUnit := "months" _ = createTestCampaign(t, "6 Month Anniversary", "milestone", 10.0, &milestoneType, &milestoneUnit, &milestoneValue, nil) userID := createTestUser(t, 0) serviceID := createTestService(t, 60.00) ctx := context.Background() sevenMonthsAgo := time.Now().AddDate(0, -7, 0) var firstBookingID string err := db.DB.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id `, userID, sevenMonthsAgo).Scan(&firstBookingID) require.NoError(t, err) _, err = db.DB.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id, override_price) VALUES ($1, $2, $3) `, firstBookingID, serviceID, 60.00) require.NoError(t, err) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) _, milestoneTypeResult, exists := getDiscountSourceAndMilestone(t, bookingID) require.True(t, exists) assert.Equal(t, "anniversary", milestoneTypeResult) bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) completeBooking(t, bookingID2) var discountCount int err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount) require.NoError(t, err) assert.Equal(t, 0, discountCount, "No second discount (dedup)") } // ============================================================================= // Loyalty Priority Tests // ============================================================================= func TestDiscount_LoyaltyPriority(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) // Create active time-based campaign with 5% discount _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) // Create user with 10 stamps and a pending redemption userID := createTestUser(t, 10) ctx := context.Background() _, err := db.DB.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) serviceID := createTestService(t, 100.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) // Both loyalty (10%) and time-based campaign (5%) should apply 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) // 10% of £100 = £10 (loyalty) + 5% of £100 = £5 (campaign) = £15 assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total discount should be £15 (10% + 5%)") discounts := getAllDiscountsForBooking(t, bookingID) require.Len(t, discounts, 2) // Verify we have both sources sources := map[string]bool{} for _, d := range discounts { sources[d.Source] = true } assert.True(t, sources["loyalty"], "Should have loyalty discount") assert.True(t, sources["campaign"], "Should have campaign discount") } // ============================================================================= // Stacking Tests // ============================================================================= func TestDiscount_Stacking_LoyaltyPlusTimeBased(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) userID := createTestUser(t, 10) ctx := context.Background() _, err := db.DB.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) serviceID := createTestService(t, 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") assert.Equal(t, 2, getPaymentDiscountRowCount(t, bookingID), "Expected 2 discount payment rows") totalDiscount := getTotalDiscountAmount(t, bookingID) assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total should be £15 (10% + 5%)") discounts := getAllDiscountsForBooking(t, bookingID) require.Len(t, discounts, 2) } func TestDiscount_Stacking_LoyaltyPlusMilestone(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) mt := "per_user_booking_count" mu := "bookings" mv := 5 _ = createTestCampaign(t, "5th Booking", "milestone", 15.0, &mt, &mu, &mv, nil) userID := createTestUser(t, 10) ctx := context.Background() _, err := db.DB.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) 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)) completeBooking(t, bookingID) assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows") totalDiscount := getTotalDiscountAmount(t, bookingID) assert.InDelta(t, 25.00, totalDiscount, 0.01, "Total should be £25 (10% + 15%)") } func TestDiscount_Stacking_LoyaltyPlusAnniversary(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) mt := "anniversary" mu := "years" mv := 1 _ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt, &mu, &mv, nil) userID := createTestUser(t, 10) ctx := context.Background() _, err := db.DB.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) 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)) completeBooking(t, bookingID) assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows") totalDiscount := getTotalDiscountAmount(t, bookingID) assert.InDelta(t, 20.00, totalDiscount, 0.01, "Total should be £20 (10% loyalty + 10% anniversary)") } func TestDiscount_Stacking_AllThreeTypes(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) mt := "anniversary" mu := "years" mv := 1 _ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt, &mu, &mv, nil) userID := createTestUser(t, 10) ctx := context.Background() _, err := db.DB.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) 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)) completeBooking(t, bookingID) assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 discount rows") totalDiscount := getTotalDiscountAmount(t, bookingID) assert.InDelta(t, 25.00, totalDiscount, 0.01, "Total should be £25 (10% + 5% + 10%)") } func TestDiscount_Stacking_MultipleMilestones(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) mt1 := "per_user_booking_count" mu1 := "bookings" mv1 := 5 _ = createTestCampaign(t, "5th Booking", "milestone", 10.0, &mt1, &mu1, &mv1, nil) mt2 := "global_booking_count" mu2 := "bookings" mv2 := 5 _ = createTestCampaign(t, "5th Global", "milestone", 5.0, &mt2, &mu2, &mv2, nil) mt3 := "anniversary" mu3 := "years" mv3 := 1 _ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt3, &mu3, &mv3, nil) userID := createTestUser(t, 0) serviceID := createTestService(t, 100.00) // 4 prior completed bookings, first one backdated 400+ days for i := 0; i < 4; i++ { var startTime time.Time if i == 0 { startTime = time.Now().AddDate(0, 0, -400) } else { startTime = time.Now().AddDate(0, 0, -(i * 7)) } _ = createCompletedBooking(t, userID, serviceID, startTime, 100.00) } bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 discount rows (all milestones)") } func TestDiscount_Stacking_LoyaltyPlusGlobalMilestone(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) mt := "global_booking_count" mu := "bookings" mv := 1 _ = createTestCampaign(t, "First Global", "milestone", 5.0, &mt, &mu, &mv, nil) userID := createTestUser(t, 10) ctx := context.Background() _, err := db.DB.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) serviceID := createTestService(t, 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 (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) _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) mt := "per_user_booking_count" mu := "bookings" mv := 1 _ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, nil) userID := createTestUser(t, 10) ctx := context.Background() _, err := db.DB.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) serviceID := createTestService(t, 200.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 discount rows") 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 { switch d.Source { case "loyalty": assert.InDelta(t, 20.00, d.Amount, 0.01, "Loyalty: 10% of £200") case "campaign": if d.CampType == "time_based" { assert.InDelta(t, 10.00, d.Amount, 0.01, "Time-based: 5% of £200") } else if d.CampType == "milestone" { assert.InDelta(t, 20.00, d.Amount, 0.01, "Milestone: 10% of £200") } } } } func TestDiscount_Stacking_MultiplePaymentRecords(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 := 1 _ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, nil) userID := createTestUser(t, 10) ctx := context.Background() _, err := db.DB.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) serviceID := createTestService(t, 200.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) assert.Equal(t, 3, getPaymentDiscountRowCount(t, bookingID), "Expected 3 discount payment rows") var totalDiscountPayment float64 err = db.DB.QueryRow(context.Background(), ` SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_method = 'discount' `, bookingID).Scan(&totalDiscountPayment) require.NoError(t, err) assert.InDelta(t, 50.00, totalDiscountPayment, 0.01, "Sum of discount payments should be £50") } func TestDiscount_Stacking_MultipleBookingDiscountRows(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 := 1 _ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, nil) userID := createTestUser(t, 10) ctx := context.Background() _, err := db.DB.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) serviceID := createTestService(t, 200.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) 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 MileType string Percent float64 OriginalTotal float64 Amount float64 } rows, err := db.DB.Query(context.Background(), ` SELECT discount_source, COALESCE(campaign_type::text, ''), COALESCE(milestone_type::text, ''), discount_percent, original_total, discount_amount FROM booking_discounts WHERE booking_id = $1 ORDER BY discount_source, campaign_type, milestone_type `, bookingID) require.NoError(t, err) defer rows.Close() var details []discountDetail for rows.Next() { var d discountDetail require.NoError(t, rows.Scan(&d.Source, &d.CampType, &d.MileType, &d.Percent, &d.OriginalTotal, &d.Amount)) details = append(details, d) } require.Len(t, details, 3) for _, d := range details { assert.Equal(t, 200.00, d.OriginalTotal, "original_total should be £200") switch d.Source { case "loyalty": assert.Equal(t, "", d.CampType) assert.Equal(t, "", d.MileType) assert.InDelta(t, 10.00, d.Percent, 0.01) assert.InDelta(t, 20.00, d.Amount, 0.01) case "campaign": if d.CampType == "time_based" { assert.Equal(t, "", d.MileType) assert.InDelta(t, 5.00, d.Percent, 0.01) assert.InDelta(t, 10.00, d.Amount, 0.01) } else if d.CampType == "milestone" { assert.Equal(t, "per_user_booking_count", d.MileType) assert.InDelta(t, 10.00, d.Percent, 0.01) assert.InDelta(t, 20.00, d.Amount, 0.01) } } } } // ============================================================================= // Zero Total Edge Case Tests // ============================================================================= func TestDiscount_NoDiscountOnZeroTotal(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID := createTestUser(t, 10) ctx := context.Background() _, err := db.DB.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, userID) require.NoError(t, err) var serviceID string err = db.DB.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id `, "Free Service", "A free service", 0.00, 60, true, 16).Scan(&serviceID) require.NoError(t, err) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) var paymentCount int err = db.DB.QueryRow(context.Background(), ` SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount' `, bookingID).Scan(&paymentCount) require.NoError(t, err) assert.Equal(t, 0, paymentCount, "No discount payment (total is 0)") var redemptionStatus string err = db.DB.QueryRow(context.Background(), ` SELECT status FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending' `, userID).Scan(&redemptionStatus) require.NoError(t, err) assert.Equal(t, "pending", redemptionStatus, "Redemption stays pending") assert.Equal(t, 10, getStamps(t, userID), "Stamps unchanged") } // ============================================================================= // Max Redemptions Tests // ============================================================================= func TestDiscount_CampaignMaxRedemptions(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) maxRedemptions := 1 campaignID := createTestCampaign(t, "Limited Time Offer", "time_based", 10.0, nil, nil, nil, &maxRedemptions) userID1 := createTestUser(t, 0) userID2 := createTestUser(t, 0) serviceID := createTestService(t, 50.00) bookingID1 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID1) var discountCount1 int err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID1).Scan(&discountCount1) require.NoError(t, err) assert.Equal(t, 1, discountCount1) var timesRedeemed int err = db.DB.QueryRow(context.Background(), `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(×Redeemed) require.NoError(t, err) assert.Equal(t, 1, timesRedeemed) bookingID2 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour)) completeBooking(t, bookingID2) var discountCount2 int err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount2) require.NoError(t, err) assert.Equal(t, 0, discountCount2, "No discount (max reached)") } func createTestCampaignWithStatus(t *testing.T, name, campaignType string, percent float64, status string, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int) string { t.Helper() ctx := context.Background() var id string now := time.Now() startDate := now.Add(-24 * time.Hour) endDate := now.Add(24 * time.Hour) err := db.DB.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit, max_redemptions, times_redeemed) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0) RETURNING id `, name, campaignType, percent, status, startDate, endDate, milestoneType, milestoneValue, milestoneUnit, maxRedemptions).Scan(&id) require.NoError(t, err) return id } // ============================================================================= // Edge Case Tests // ============================================================================= func TestDiscount_ExpiredRedemptionDoesNotApply(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID := createTestUser(t, 10) serviceID := createTestService(t, 100.00) // Insert pending redemption that has already expired _, err := db.DB.Exec(context.Background(), ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at, expires_at) VALUES ($1, 10, 'pending', NOW(), NOW() - INTERVAL '1 day') `, 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, 0, count, "Expired redemption should not apply discount") } func TestDiscount_MultiplePendingRedemptions_UsesOldest(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID := createTestUser(t, 10) serviceID := createTestService(t, 100.00) 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') `, userID) require.NoError(t, err) _, err = db.DB.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW() - INTERVAL '1 day') `, 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, "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 } rows, err := db.DB.Query(ctx, ` SELECT id, status FROM loyalty_redemptions WHERE user_id = $1 ORDER BY redeemed_at ASC `, userID) require.NoError(t, err) defer rows.Close() var redemptions []redemptionRow for rows.Next() { var r redemptionRow require.NoError(t, rows.Scan(&r.ID, &r.Status)) redemptions = append(redemptions, r) } require.NoError(t, rows.Err()) require.Equal(t, 2, len(redemptions)) assert.Equal(t, "applied", redemptions[0].Status, "Oldest redemption should be applied") assert.Equal(t, "pending", redemptions[1].Status, "Newer redemption should remain pending") } func TestDiscount_StampCountAboveTen(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID := createTestUser(t, 9) serviceID := createTestService(t, 50.00) // First booking: stamps 9 → 10, pending redemption auto-created bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID1) backdateBooking(t, bookingID1, 2) 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 bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) completeBooking(t, bookingID2) source, amount, exists := getDiscountForBooking(t, bookingID2) require.True(t, exists, "Expected loyalty discount on second booking") 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") } func TestDiscount_MixedFreeAndPaidServices(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID := createTestUser(t, 0) serviceIDFree := createTestService(t, 0) serviceIDPaid := createTestService(t, 50.00) ctx := context.Background() var bookingID string err := db.DB.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'confirmed') RETURNING id `, userID, time.Now().Add(24*time.Hour)).Scan(&bookingID) require.NoError(t, err) // Insert two booking_services rows: one free, one paid _, err = db.DB.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2), ($1, $3) `, bookingID, serviceIDFree, serviceIDPaid) require.NoError(t, err) completeBooking(t, bookingID) assert.Equal(t, 1, getStamps(t, userID), "Mixed free/paid booking earns 1 stamp (total > 0)") } func TestDiscount_CampaignBoundaryStart(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) ctx := context.Background() var campaignID string err := db.DB.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0) RETURNING id `, "Boundary Start").Scan(&campaignID) require.NoError(t, err) userID := createTestUser(t, 0) serviceID := createTestService(t, 50.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) count := getDiscountRowCount(t, bookingID) assert.Equal(t, 1, count, "Campaign at start_date boundary should apply") } func TestDiscount_CampaignBoundaryEnd(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) ctx := context.Background() var campaignID string err := db.DB.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 minute', 0) RETURNING id `, "Boundary End").Scan(&campaignID) require.NoError(t, err) userID := createTestUser(t, 0) serviceID := createTestService(t, 50.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) count := getDiscountRowCount(t, bookingID) assert.Equal(t, 1, count, "Campaign at end_date boundary should apply") } func TestDiscount_CampaignExpiredDoesNotApply(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) ctx := context.Background() var campaignID string err := db.DB.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '2 days', NOW() - INTERVAL '1 day', 0) RETURNING id `, "Expired Campaign").Scan(&campaignID) require.NoError(t, err) userID := createTestUser(t, 0) serviceID := createTestService(t, 50.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) count := getDiscountRowCount(t, bookingID) assert.Equal(t, 0, count, "Expired campaign should not apply") } func TestDiscount_CampaignDraftDoesNotApply(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) _ = createTestCampaignWithStatus(t, "Draft Campaign", "time_based", 10.0, "draft", nil, nil, nil, nil) userID := createTestUser(t, 0) serviceID := createTestService(t, 50.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) count := getDiscountRowCount(t, bookingID) assert.Equal(t, 0, count, "Draft campaign should not apply") } func TestDiscount_CampaignCancelledDoesNotApply(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) _ = createTestCampaignWithStatus(t, "Cancelled Campaign", "time_based", 10.0, "cancelled", nil, nil, nil, nil) userID := createTestUser(t, 0) serviceID := createTestService(t, 50.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) count := getDiscountRowCount(t, bookingID) assert.Equal(t, 0, count, "Cancelled campaign should not apply") } func TestDiscount_PriceOverrideRespected(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) _ = createTestCampaign(t, "10% Off", "time_based", 10.0, nil, nil, nil, nil) userID := createTestUser(t, 0) serviceID := createTestService(t, 100.00) // Create booking with override_price = £80 ctx := context.Background() var bookingID string err := db.DB.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'confirmed') RETURNING id `, userID, time.Now().Add(24*time.Hour)).Scan(&bookingID) require.NoError(t, err) _, err = db.DB.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id, override_price) VALUES ($1, $2, $3) `, bookingID, serviceID, 80.00) require.NoError(t, err) completeBooking(t, bookingID) source, amount, exists := getDiscountForBooking(t, bookingID) require.True(t, exists) assert.Equal(t, "campaign", source) assert.Equal(t, 8.00, amount, "10%% of £80 override = £8.00") } func TestDiscount_AnniversaryDedupWithStacking(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) milestoneValue := 12 milestoneType := "anniversary" milestoneUnit := "months" _ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &milestoneType, &milestoneUnit, &milestoneValue, nil) _ = createTestCampaign(t, "Spring Sale", "time_based", 5.0, nil, nil, nil, nil) userID := createTestUser(t, 0) serviceID := createTestService(t, 60.00) // Create a completed booking 400 days ago (> 12 months) ctx := context.Background() fourHundredDaysAgo := time.Now().AddDate(0, 0, -400) var firstBookingID string err := db.DB.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id `, userID, fourHundredDaysAgo).Scan(&firstBookingID) require.NoError(t, err) _, err = db.DB.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id, override_price) VALUES ($1, $2, $3) `, firstBookingID, serviceID, 60.00) require.NoError(t, err) // First booking after anniversary threshold: should get anniversary + time_based bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID1) discounts1 := getAllDiscountsForBooking(t, bookingID1) assert.Equal(t, 2, len(discounts1), "First booking should get anniversary + time_based discounts") // Second booking (different day): should only get time_based (anniversary dedup) bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) completeBooking(t, bookingID2) discounts2 := getAllDiscountsForBooking(t, bookingID2) assert.Equal(t, 1, len(discounts2), "Second booking should only get time_based (anniversary dedup)") // Verify the remaining discount is time_based foundTimeBased := false for _, d := range discounts2 { if d.CampType == "time_based" { foundTimeBased = true } } assert.True(t, foundTimeBased, "Remaining discount should be time_based") } func TestDiscount_PerUserMilestoneDedupWithStacking(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) milestoneValue := 3 milestoneType := "per_user_booking_count" _ = createTestCampaign(t, "3rd Visit", "milestone", 10.0, &milestoneType, nil, &milestoneValue, nil) _ = createTestCampaign(t, "May Sale", "time_based", 5.0, nil, nil, nil, nil) userID := createTestUser(t, 0) serviceID := createTestService(t, 50.00) // Create 2 prior completed bookings on different days for i := 0; i < 2; i++ { startTime := time.Now().AddDate(0, 0, -10-i*7) _ = createCompletedBooking(t, userID, serviceID, startTime, 50.00) } // 3rd booking: milestone + time_based bookingID3 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID3) discounts3 := getAllDiscountsForBooking(t, bookingID3) assert.Equal(t, 2, len(discounts3), "3rd booking should get per-user milestone + time_based") // 4th booking (different day): only time_based (milestone dedup) bookingID4 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) completeBooking(t, bookingID4) discounts4 := getAllDiscountsForBooking(t, bookingID4) assert.Equal(t, 1, len(discounts4), "4th booking should only get time_based (milestone dedup)") foundTimeBased := false for _, d := range discounts4 { if d.CampType == "time_based" { foundTimeBased = true } } assert.True(t, foundTimeBased, "Remaining discount should be time_based") } func TestDiscount_GlobalMilestoneMaxRedemptionsWithStacking(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) maxRedemptions := 1 milestoneValue := 5 milestoneType := "global_booking_count" _ = createTestCampaign(t, "5th Customer", "milestone", 5.0, &milestoneType, nil, &milestoneValue, &maxRedemptions) _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) userID1 := createTestUser(t, 0) userID2 := createTestUser(t, 0) serviceID := createTestService(t, 50.00) // Create 4 completed bookings (different days, user1) for i := 0; i < 4; i++ { startTime := time.Now().AddDate(0, 0, -10-i*7) _ = createCompletedBooking(t, userID1, serviceID, startTime, 50.00) } // 5th global booking (user1, different day): milestone + time_based bookingID5 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID5) discounts5 := getAllDiscountsForBooking(t, bookingID5) assert.Equal(t, 2, len(discounts5), "5th global booking should get milestone + time_based") // 6th global booking (user2, different day): only time_based (max_redemptions reached) bookingID6 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour)) completeBooking(t, bookingID6) discounts6 := getAllDiscountsForBooking(t, bookingID6) assert.Equal(t, 1, len(discounts6), "6th global booking should only get time_based (max_redemptions reached)") foundTimeBased := false for _, d := range discounts6 { if d.CampType == "time_based" { foundTimeBased = true } } assert.True(t, foundTimeBased, "Remaining discount should be time_based") } func TestDiscount_BestTimeBasedCampaignSelected(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) _ = createTestCampaign(t, "Low Sale", "time_based", 5.0, nil, nil, nil, nil) _ = createTestCampaign(t, "High Sale", "time_based", 15.0, nil, nil, nil, nil) userID := createTestUser(t, 0) serviceID := createTestService(t, 100.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) count := getDiscountRowCount(t, bookingID) assert.Equal(t, 1, count, "Only 1 campaign discount row (best selected)") source, amount, exists := getDiscountForBooking(t, bookingID) require.True(t, exists) assert.Equal(t, "campaign", source) assert.Equal(t, 15.00, amount, "15%% of £100 = £15 (highest percent selected)") } func TestDiscount_FirstBookingEarnsStamp(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID := createTestUser(t, 0) serviceID := createTestService(t, 50.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) assert.Equal(t, 1, getStamps(t, userID), "First paid booking earns 1 stamp") } func TestDiscount_TenStampsCreatesRedemption(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID := createTestUser(t, 9) serviceID := createTestService(t, 50.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) assert.Equal(t, 10, getStamps(t, userID), "Stamps should reach 10") 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 // ============================================================================= func TestCampaign_CreateAsDraft(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) ctx := context.Background() var campaignID string err := db.DB.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10.0, 'draft', NOW(), NOW() + INTERVAL '1 day', 0) RETURNING id `, "Draft Campaign").Scan(&campaignID) require.NoError(t, err) var status string err = db.DB.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status) require.NoError(t, err) assert.Equal(t, "draft", status) } func TestCampaign_ActivateDraft(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) ctx := context.Background() var campaignID string err := db.DB.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10.0, 'draft', NOW(), NOW() + INTERVAL '1 day', 0) RETURNING id `, "Draft to Active").Scan(&campaignID) require.NoError(t, err) _, err = db.DB.Exec(ctx, `UPDATE discount_campaigns SET status = 'active' WHERE id = $1`, campaignID) require.NoError(t, err) var status string err = db.DB.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status) require.NoError(t, err) assert.Equal(t, "active", status) userID := createTestUser(t, 0) serviceID := createTestService(t, 50.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) count := getDiscountRowCount(t, bookingID) assert.Equal(t, 1, count, "Activated draft campaign should apply") } func TestCampaign_CompleteActive(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) ctx := context.Background() var campaignID string err := db.DB.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0) RETURNING id `, "Active to Completed").Scan(&campaignID) require.NoError(t, err) _, err = db.DB.Exec(ctx, `UPDATE discount_campaigns SET status = 'completed' WHERE id = $1`, campaignID) require.NoError(t, err) userID := createTestUser(t, 0) serviceID := createTestService(t, 50.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) count := getDiscountRowCount(t, bookingID) assert.Equal(t, 0, count, "Completed campaign should not apply") } func TestCampaign_CancelActive(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) ctx := context.Background() var campaignID string err := db.DB.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0) RETURNING id `, "Active to Cancelled").Scan(&campaignID) require.NoError(t, err) _, err = db.DB.Exec(ctx, `UPDATE discount_campaigns SET status = 'cancelled' WHERE id = $1`, campaignID) require.NoError(t, err) userID := createTestUser(t, 0) serviceID := createTestService(t, 50.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) count := getDiscountRowCount(t, bookingID) assert.Equal(t, 0, count, "Cancelled campaign should not apply") } func TestCampaign_RevertToDraft(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) ctx := context.Background() var campaignID string err := db.DB.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0) RETURNING id `, "Active to Draft").Scan(&campaignID) require.NoError(t, err) _, err = db.DB.Exec(ctx, `UPDATE discount_campaigns SET status = 'draft' WHERE id = $1`, campaignID) require.NoError(t, err) userID := createTestUser(t, 0) serviceID := createTestService(t, 50.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) count := getDiscountRowCount(t, bookingID) assert.Equal(t, 0, count, "Reverted-to-draft campaign should not apply") } func TestCampaign_DraftDoesNotApplyDiscounts(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) _ = createTestCampaignWithStatus(t, "Draft Only", "time_based", 10.0, "draft", nil, nil, nil, nil) userID := createTestUser(t, 0) serviceID := createTestService(t, 50.00) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) completeBooking(t, bookingID) count := getDiscountRowCount(t, bookingID) assert.Equal(t, 0, count, "Draft campaign should not apply discounts") }