Files
Crussell/backend/handlers/bookings/discount_test.go
T
popertots 6d4bc4d637 feat(loyalty-discount): implement loyalty and discount system
- Add discount campaign management and validation logic
- Update booking handlers with discount application flow
- Add customer relationship endpoints for loyalty tracking
- Update frontend modals (booking, approval, payment, reschedule)
- Add DiscountsManagement and loyalty reference documentation
- Update dev scripts and database init for discount tables
- Clean up completed plan files
2026-06-04 23:13:02 +01:00

1726 lines
59 KiB
Go

//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(&timesRedeemed)
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(&timesRedeemed)
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")
}