//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 } // ============================================================================= // 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) _ = 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) source, _, exists := getDiscountForBooking(t, bookingID) require.True(t, exists) assert.Equal(t, "loyalty", source) var campaignDiscountCount int err = db.DB.QueryRow(context.Background(), ` SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' `, bookingID).Scan(&campaignDiscountCount) require.NoError(t, err) assert.Equal(t, 0, campaignDiscountCount, "No campaign discount when loyalty applies") } // ============================================================================= // 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)") }