From e1c815ed09717e96b2c1a2b2a800e72388523fa8 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sun, 10 May 2026 13:17:08 +0100 Subject: [PATCH] fix(loyalty): correct stamp accumulation, one-per-day guard, and test coverage - Handler: apply existing redemption BEFORE incrementing stamps (was creating and applying redemption to same booking) - Handler: guard stamp increment behind bookingTotal > 0 (free bookings don't earn stamps) - Handler: fix global milestone off-by-one (globalCount already includes current booking since status updated before discount logic) - Schema: chk_milestone constraint only requires milestone_unit for anniversary type, not per_user_booking_count or global_booking_count - Tests: rewrite from scratch with 12 focused tests: - FullCycle, ExistingRedemptionApplies, OneStampPerDay, ZeroTotalNoStamp, CycleRepeats, TimeBasedCampaign, PerUserMilestone, GlobalMilestone, AnniversaryMilestone, LoyaltyPriority, NoDiscountOnZeroTotal, CampaignMaxRedemptions - Tests: fix path parameter extraction in makeProgressRequest - Vendor: go mod tidy + vendor for testify dependency --- backend/go.mod | 6 + backend/go.sum | 9 + backend/handlers/bookings/bookings.go | 59 +-- backend/handlers/bookings/discount_test.go | 521 +++++++++++---------- init-scripts/init-script.sql | 2 +- 5 files changed, 322 insertions(+), 275 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index cbebf86..02f0353 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -11,6 +11,7 @@ require ( github.com/kovidgoyal/imaging v1.8.19 github.com/lib/pq v1.11.2 github.com/robfig/cron/v3 v3.0.1 + github.com/stretchr/testify v1.11.1 golang.org/x/text v0.34.0 ) @@ -30,16 +31,21 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/smithy-go v1.24.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kovidgoyal/go-parallel v1.1.1 // indirect github.com/kovidgoyal/go-shm v1.0.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/image v0.36.0 // indirect golang.org/x/sync v0.19.0 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( diff --git a/backend/go.sum b/backend/go.sum index 821d3b4..11c2c45 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -36,6 +36,7 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -63,6 +64,10 @@ github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPg github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds= github.com/kovidgoyal/imaging v1.8.19 h1:zWJdQqF2tfSKjvoB7XpLRhVGbYsze++M0iaqZ4ZkhNk= github.com/kovidgoyal/imaging v1.8.19/go.mod h1:I0q8RdoEuyc4G8GFOF9CaluTUHQSf68d6TmsqpvfRI8= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= @@ -83,6 +88,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= @@ -109,6 +116,8 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 0827ae0..0e50910 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -1626,32 +1626,6 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { } } - if _, err := db.DB.Exec(r.Context(), ` - UPDATE users - SET loyalty_stamps = loyalty_stamps + 1 - WHERE id = $1 - AND NOT EXISTS ( - SELECT 1 FROM bookings b - WHERE b.user_id = users.id - AND b.status = 'completed' - AND b.updated_at >= CURRENT_DATE - INTERVAL '1 day' - AND b.id != $2 - ) - `, booking.User.ID, bookingID); err != nil { - log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err) - } - - var newStampCount int - if err := db.DB.QueryRow(r.Context(), `SELECT loyalty_stamps FROM users WHERE id = $1`, booking.User.ID).Scan(&newStampCount); err == nil && newStampCount == 10 { - _, err = db.DB.Exec(r.Context(), ` - INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) - VALUES ($1, 10, 'pending', NOW()) - `, booking.User.ID) - if err != nil { - log.Printf("Failed to create loyalty redemption for user %s: %v", booking.User.ID, err) - } - } - var bookingTotal float64 if err := db.DB.QueryRow(r.Context(), ` SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) @@ -1662,6 +1636,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { log.Printf("Failed to calculate booking total for %s: %v", bookingID, err) } + // Apply existing pending loyalty redemption (earned from previous 10 bookings) if bookingTotal > 0 { var redemptionID string if err := db.DB.QueryRow(r.Context(), ` @@ -1692,6 +1667,36 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { } } + // Increment stamps (max 1 per day, only for paid bookings) + if bookingTotal > 0 { + if _, err := db.DB.Exec(r.Context(), ` + UPDATE users + SET loyalty_stamps = loyalty_stamps + 1 + WHERE id = $1 + AND NOT EXISTS ( + SELECT 1 FROM bookings b + WHERE b.user_id = users.id + AND b.status = 'completed' + AND b.updated_at >= CURRENT_DATE - INTERVAL '1 day' + AND b.id != $2 + ) + `, booking.User.ID, bookingID); err != nil { + log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err) + } + } + + // Create pending redemption when stamps reach 10 + var newStampCount int + if err := db.DB.QueryRow(r.Context(), `SELECT loyalty_stamps FROM users WHERE id = $1`, booking.User.ID).Scan(&newStampCount); err == nil && newStampCount == 10 { + _, err = db.DB.Exec(r.Context(), ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW()) + `, booking.User.ID) + if err != nil { + log.Printf("Failed to create loyalty redemption for user %s: %v", booking.User.ID, err) + } + } + if bookingTotal > 0 { var hasLoyaltyDiscount bool _ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&hasLoyaltyDiscount) @@ -1767,7 +1772,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count' AND milestone_value = $1 AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) - `, globalCount+1).Scan(&globalCampaignID, &globalPercent) + `, globalCount).Scan(&globalCampaignID, &globalPercent) if globalCampaignID != "" { discountAmount := roundTo2(bookingTotal * globalPercent / 100) diff --git a/backend/handlers/bookings/discount_test.go b/backend/handlers/bookings/discount_test.go index a4151bc..22654eb 100644 --- a/backend/handlers/bookings/discount_test.go +++ b/backend/handlers/bookings/discount_test.go @@ -3,17 +3,6 @@ package bookings_test -// Integration tests for the loyalty/discount system. -// -// Test Coverage: -// - Loyalty auto-redemption: stamps -> pending redemption -> applied discount -// - Time-based campaign discounts -// - Per-user milestone discounts (10th booking) -// - Global milestone discounts -// - Anniversary milestone discounts -// - Discount priority (loyalty > campaign > milestone) -// - Edge cases: zero total, max redemptions, eligibility flag - import ( "bytes" "context" @@ -38,7 +27,6 @@ import ( "github.com/stretchr/testify/require" ) -// setupTestDB replaces the global db.DB with a test pool and returns a cleanup function func setupTestDB(t *testing.T) func() { t.Helper() @@ -46,11 +34,9 @@ func setupTestDB(t *testing.T) func() { testdb.Migrate(t, pool) testdb.TruncateTables(t, pool) - // Replace global db.DB with test pool originalDB := db.DB db.DB = pool - // Initialize JWT for tests jwt.Init() return func() { @@ -59,7 +45,6 @@ func setupTestDB(t *testing.T) func() { } } -// seedDefaultWorkingHours seeds default working hours for tests func seedDefaultWorkingHours(t *testing.T) { t.Helper() @@ -69,13 +54,13 @@ func seedDefaultWorkingHours(t *testing.T) { endTime string isOpen bool }{ - {0, "08:00", "20:00", true}, // Monday - {1, "08:00", "20:00", true}, // Tuesday - {2, "08:00", "20:00", true}, // Wednesday - {3, "08:00", "20:00", true}, // Thursday - {4, "08:00", "20:00", true}, // Friday - {5, "08:00", "20:00", true}, // Saturday - {6, "08:00", "20:00", true}, // Sunday + {0, "08:00", "20:00", true}, + {1, "08:00", "20:00", true}, + {2, "08:00", "20:00", true}, + {3, "08:00", "20:00", true}, + {4, "08:00", "20:00", true}, + {5, "08:00", "20:00", true}, + {6, "08:00", "20:00", true}, } for _, h := range hours { @@ -90,7 +75,6 @@ func seedDefaultWorkingHours(t *testing.T) { } } -// makeProgressRequest creates a request to progress a booking status func makeProgressRequest(handler http.HandlerFunc, method, path string, body interface{}, token string) *httptest.ResponseRecorder { var req *http.Request if body != nil { @@ -104,14 +88,16 @@ func makeProgressRequest(handler http.HandlerFunc, method, path string, body int req.Header.Set("Authorization", "Bearer "+token) } - // Set up chi routing context for path params rctx := chi.NewRouteContext() - if idx := strings.LastIndex(path, "/"); idx > 0 { - rctx.URLParams.Add("id", path[idx+1:]) + 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) - // Set user context for admin if token != "" { ctx = context.WithValue(ctx, mw.UserIDKey, "admin-test-001") ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") @@ -124,7 +110,6 @@ func makeProgressRequest(handler http.HandlerFunc, method, path string, body int return w } -// createTestUser creates a test user with optional loyalty stamps func createTestUser(t *testing.T, stamps int) string { t.Helper() userID, err := fixtures.CreateTestUser(db.DB) @@ -138,7 +123,6 @@ func createTestUser(t *testing.T, stamps int) string { return userID } -// createTestService creates a test service with a specific price func createTestService(t *testing.T, price float64) string { t.Helper() ctx := context.Background() @@ -152,7 +136,6 @@ func createTestService(t *testing.T, price float64) string { return serviceID } -// createTestCampaign creates a discount campaign with the specified parameters func createTestCampaign(t *testing.T, name, campaignType string, percent float64, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int) string { t.Helper() ctx := context.Background() @@ -170,7 +153,6 @@ func createTestCampaign(t *testing.T, name, campaignType string, percent float64 return id } -// createCompletedBooking creates a completed booking directly in the database func createCompletedBooking(t *testing.T, userID, serviceID string, startTime time.Time, price float64) string { t.Helper() ctx := context.Background() @@ -191,7 +173,6 @@ func createCompletedBooking(t *testing.T, userID, serviceID string, startTime ti return bookingID } -// createPendingBooking creates a pending booking func createPendingBooking(t *testing.T, userID, serviceID string, startTime time.Time) string { t.Helper() ctx := context.Background() @@ -212,268 +193,376 @@ func createPendingBooking(t *testing.T, userID, serviceID string, startTime time return bookingID } +func completeBooking(t *testing.T, bookingID string) *httptest.ResponseRecorder { + t.Helper() + progressReq := bookings.ProgressBookingRequest{Status: "completed"} + handler := http.HandlerFunc(bookings.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 // ============================================================================= -// TestDiscount_LoyaltyAutoRedemption tests the full loyalty stamp -> redemption -> discount flow -func TestDiscount_LoyaltyAutoRedemption(t *testing.T) { +func TestDiscount_Loyalty_FullCycle(t *testing.T) { cleanup := setupTestDB(t) defer cleanup() - seedDefaultWorkingHours(t) - // Step 1: Create user with 9 stamps - userID := createTestUser(t, 9) + userID := createTestUser(t, 0) serviceID := createTestService(t, 50.00) - // Step 2: Create and complete first booking (adds 1 stamp -> 10) - bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) - - progressReq := bookings.ProgressBookingRequest{Status: "completed"} - handler := http.HandlerFunc(bookings.ProgressBookingHandler) - w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID1+"/progress", progressReq, "admin-token") - - require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion") - - // Verify pending loyalty_redemption was created - var redemptionCount int - err := db.DB.QueryRow(context.Background(), ` - SELECT COUNT(*) FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending' - `, userID).Scan(&redemptionCount) + // 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) - assert.Equal(t, 1, redemptionCount, "Expected 1 pending loyalty redemption") - // Verify user now has 10 stamps - var stamps int - err = db.DB.QueryRow(context.Background(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps) + // 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, stamps, "Expected 10 stamps after first completed booking") - // Step 3: Create and complete second booking (should apply discount) - bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) + assert.Equal(t, 10, getStamps(t, userID)) + assert.Equal(t, 1, getPendingRedemptions(t, userID)) - w = makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID2+"/progress", progressReq, "admin-token") + // 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) - require.Equal(t, http.StatusOK, w.Code, "Expected 200 on second booking completion") + 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") +} - // Verify booking_discounts row exists with loyalty source - var discountCount int - var discountSource string - err = db.DB.QueryRow(context.Background(), ` - SELECT COUNT(*), discount_source FROM booking_discounts WHERE booking_id = $1 GROUP BY discount_source - `, bookingID2).Scan(&discountCount, &discountSource) +func TestDiscount_Loyalty_ExistingRedemptionApplies(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + 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) - assert.Equal(t, 1, discountCount, "Expected 1 discount applied") - assert.Equal(t, "loyalty", discountSource, "Expected discount source to be 'loyalty'") - // Verify stamps reduced to 0 - err = db.DB.QueryRow(context.Background(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps) - require.NoError(t, err) - assert.Equal(t, 0, stamps, "Expected 0 stamps after redemption applied") + 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)") - // Verify redemption status = 'applied' var redemptionStatus string err = db.DB.QueryRow(context.Background(), ` - SELECT status FROM loyalty_redemptions WHERE user_id = $1 AND status = 'applied' - `, userID).Scan(&redemptionStatus) + 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, "Expected redemption status to be 'applied'") + assert.Equal(t, "applied", redemptionStatus) +} - // Verify discount payment was created - var paymentMethod string - var paymentAmount float64 - err = db.DB.QueryRow(context.Background(), ` - SELECT payment_method, amount FROM payments WHERE booking_id = $1 AND payment_method = 'discount' - `, bookingID2).Scan(&paymentMethod, &paymentAmount) +func TestDiscount_Loyalty_OneStampPerDay(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + 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) { + cleanup := setupTestDB(t) + defer cleanup() + 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) - assert.Equal(t, "discount", paymentMethod, "Expected payment method to be 'discount'") - assert.Equal(t, 5.00, paymentAmount, "Expected 10% discount on £50 booking") // 10% of 50 = 5 + + 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) { + cleanup := setupTestDB(t) + defer cleanup() + 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 // ============================================================================= -// TestDiscount_TimeBasedCampaign tests that time-based campaign discounts are applied func TestDiscount_TimeBasedCampaign(t *testing.T) { cleanup := setupTestDB(t) defer cleanup() - seedDefaultWorkingHours(t) - // Create active time-based campaign campaignID := createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) userID := createTestUser(t, 0) serviceID := createTestService(t, 100.00) - // Create and complete booking bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) - progressReq := bookings.ProgressBookingRequest{Status: "completed"} - handler := http.HandlerFunc(bookings.ProgressBookingHandler) - w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") + source, campaignType, exists := getDiscountSourceAndType(t, bookingID) + require.True(t, exists) + assert.Equal(t, "campaign", source) + assert.Equal(t, "time_based", campaignType) - require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion") - - // Verify booking_discounts row with campaign source - var discountSource, campaignType string - err := db.DB.QueryRow(context.Background(), ` - SELECT discount_source, campaign_type FROM booking_discounts WHERE booking_id = $1 - `, bookingID).Scan(&discountSource, &campaignType) - require.NoError(t, err) - assert.Equal(t, "campaign", discountSource, "Expected discount source to be 'campaign'") - assert.Equal(t, "time_based", campaignType, "Expected campaign type to be 'time_based'") - - // Verify campaign times_redeemed incremented var timesRedeemed int - err = db.DB.QueryRow(context.Background(), `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(×Redeemed) + 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, "Expected times_redeemed to be 1") + assert.Equal(t, 1, timesRedeemed) - // Verify discount payment 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, "Expected 5% discount on £100 booking") // 5% of 100 = 5 + 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 // ============================================================================= -// TestDiscount_PerUserMilestone tests that per-user booking count milestones work func TestDiscount_PerUserMilestone(t *testing.T) { cleanup := setupTestDB(t) defer cleanup() - seedDefaultWorkingHours(t) - // Create "10th booking" per-user milestone campaign milestoneValue := 10 milestoneType := "per_user_booking_count" - campaignID := createTestCampaign(t, "10th Visit Bonus", "milestone", 15.0, &milestoneType, nil, &milestoneValue, nil) + _ = createTestCampaign(t, "10th Visit Bonus", "milestone", 15.0, &milestoneType, nil, &milestoneValue, nil) userID := createTestUser(t, 0) serviceID := createTestService(t, 80.00) - // Create 9 completed bookings (direct SQL to avoid triggering discounts) for i := 0; i < 9; i++ { - startTime := time.Now().AddDate(0, -1, -i*7) // Space them out over time + startTime := time.Now().AddDate(0, -1, -i*7) _ = createCompletedBooking(t, userID, serviceID, startTime, 80.00) } - // Create and complete the 10th booking bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) - progressReq := bookings.ProgressBookingRequest{Status: "completed"} - handler := http.HandlerFunc(bookings.ProgressBookingHandler) - w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") + source, milestoneTypeResult, exists := getDiscountSourceAndMilestone(t, bookingID) + require.True(t, exists) + assert.Equal(t, "campaign", source) + assert.Equal(t, "per_user_booking_count", milestoneTypeResult) - require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion") - - // Verify discount applied with milestone_type='per_user_booking_count' - var discountSource, milestoneTypeResult string - err := db.DB.QueryRow(context.Background(), ` - SELECT discount_source, milestone_type FROM booking_discounts WHERE booking_id = $1 - `, bookingID).Scan(&discountSource, &milestoneTypeResult) - require.NoError(t, err) - assert.Equal(t, "campaign", discountSource, "Expected discount source to be 'campaign'") - assert.Equal(t, "per_user_booking_count", milestoneTypeResult, "Expected milestone type 'per_user_booking_count'") - - // Verify discount amount (15% of £80 = £12) var discountAmount float64 - err = db.DB.QueryRow(context.Background(), `SELECT discount_amount FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountAmount) + 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, "Expected 15% discount (£12)") + assert.Equal(t, 12.00, discountAmount, "15% of £80 = £12") - // Complete another booking - verify NO second discount (dedup works) bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) - w = makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID2+"/progress", progressReq, "admin-token") + completeBooking(t, bookingID2) - require.Equal(t, http.StatusOK, w.Code, "Expected 200 on second booking completion") - - // Should have no discount (already used this milestone) 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, "Expected no discount on 11th booking (dedup)") + 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 // ============================================================================= -// TestDiscount_GlobalMilestone tests that global booking count milestones work func TestDiscount_GlobalMilestone(t *testing.T) { cleanup := setupTestDB(t) defer cleanup() - seedDefaultWorkingHours(t) - // Create global milestone campaign for 5th total booking milestoneValue := 5 milestoneType := "global_booking_count" - campaignID := createTestCampaign(t, "5th Customer Milestone", "milestone", 20.0, &milestoneType, nil, &milestoneValue, nil) + _ = 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) - // Create 4 completed bookings (any users) for i := 0; i < 4; i++ { startTime := time.Now().AddDate(0, 0, -i-1) _ = createCompletedBooking(t, userID1, serviceID, startTime, 50.00) } - // Create and complete the 5th booking bookingID := createPendingBooking(t, userID2, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) - progressReq := bookings.ProgressBookingRequest{Status: "completed"} - handler := http.HandlerFunc(bookings.ProgressBookingHandler) - w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") + _, milestoneTypeResult, exists := getDiscountSourceAndMilestone(t, bookingID) + require.True(t, exists) + assert.Equal(t, "global_booking_count", milestoneTypeResult) - require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion") - - // Verify discount applied with milestone_type='global_booking_count' - var milestoneTypeResult string - err := db.DB.QueryRow(context.Background(), ` - SELECT milestone_type FROM booking_discounts WHERE booking_id = $1 - `, bookingID).Scan(&milestoneTypeResult) - require.NoError(t, err) - assert.Equal(t, "global_booking_count", milestoneTypeResult, "Expected milestone type 'global_booking_count'") - - // Verify discount amount (20% of £50 = £10) var discountAmount float64 - err = db.DB.QueryRow(context.Background(), `SELECT discount_amount FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountAmount) + 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, "Expected 20% discount (£10)") + assert.Equal(t, 10.00, discountAmount, "20% of £50 = £10") } // ============================================================================= // Anniversary Milestone Tests // ============================================================================= -// TestDiscount_AnniversaryMilestone tests that anniversary-based milestones work func TestDiscount_AnniversaryMilestone(t *testing.T) { cleanup := setupTestDB(t) defer cleanup() - seedDefaultWorkingHours(t) - // Create anniversary campaign (6 months) milestoneValue := 6 milestoneType := "anniversary" milestoneUnit := "months" - campaignID := createTestCampaign(t, "6 Month Anniversary", "milestone", 10.0, &milestoneType, &milestoneUnit, &milestoneValue, nil) + _ = createTestCampaign(t, "6 Month Anniversary", "milestone", 10.0, &milestoneType, &milestoneUnit, &milestoneValue, nil) userID := createTestUser(t, 0) serviceID := createTestService(t, 60.00) - // Create first booking 7 months ago (using direct SQL to set start_time) ctx := context.Background() sevenMonthsAgo := time.Now().AddDate(0, -7, 0) var firstBookingID string @@ -490,53 +579,35 @@ func TestDiscount_AnniversaryMilestone(t *testing.T) { `, firstBookingID, serviceID, 60.00) require.NoError(t, err) - // Create and complete new booking bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) - progressReq := bookings.ProgressBookingRequest{Status: "completed"} - handler := http.HandlerFunc(bookings.ProgressBookingHandler) - w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") + _, milestoneTypeResult, exists := getDiscountSourceAndMilestone(t, bookingID) + require.True(t, exists) + assert.Equal(t, "anniversary", milestoneTypeResult) - require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion") - - // Verify discount applied with milestone_type='anniversary' - var milestoneTypeResult string - err = db.DB.QueryRow(context.Background(), ` - SELECT milestone_type FROM booking_discounts WHERE booking_id = $1 - `, bookingID).Scan(&milestoneTypeResult) - require.NoError(t, err) - assert.Equal(t, "anniversary", milestoneTypeResult, "Expected milestone type 'anniversary'") - - // Complete another booking - verify NO second discount (dedup via booking_discounts) bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) - w = makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID2+"/progress", progressReq, "admin-token") - - require.Equal(t, http.StatusOK, w.Code, "Expected 200 on second booking completion") + 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, "Expected no discount on second booking (dedup)") + assert.Equal(t, 0, discountCount, "No second discount (dedup)") } // ============================================================================= // Loyalty Priority Tests // ============================================================================= -// TestDiscount_LoyaltyPriority tests that loyalty discounts take priority over campaigns func TestDiscount_LoyaltyPriority(t *testing.T) { cleanup := setupTestDB(t) defer cleanup() - seedDefaultWorkingHours(t) - // Create active time-based campaign _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) - // Create user with 10 stamps (pending redemption) userID := createTestUser(t, 10) - // Create pending redemption ctx := context.Background() _, err := db.DB.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) @@ -546,47 +617,32 @@ func TestDiscount_LoyaltyPriority(t *testing.T) { serviceID := createTestService(t, 100.00) - // Create and complete booking bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) - progressReq := bookings.ProgressBookingRequest{Status: "completed"} - handler := http.HandlerFunc(bookings.ProgressBookingHandler) - w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") + source, _, exists := getDiscountForBooking(t, bookingID) + require.True(t, exists) + assert.Equal(t, "loyalty", source) - require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion") - - // Verify ONLY loyalty discount applied (not campaign) - var discountSource string - err = db.DB.QueryRow(context.Background(), ` - SELECT discount_source FROM booking_discounts WHERE booking_id = $1 - `, bookingID).Scan(&discountSource) - require.NoError(t, err) - assert.Equal(t, "loyalty", discountSource, "Expected only loyalty discount (priority)") - - // Verify NOT campaign 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, "Expected no campaign discount (loyalty takes priority)") + assert.Equal(t, 0, campaignDiscountCount, "No campaign discount when loyalty applies") } // ============================================================================= // Zero Total Edge Case Tests // ============================================================================= -// TestDiscount_NoDiscountOnZeroTotal tests that no discount is applied when booking total is 0 func TestDiscount_NoDiscountOnZeroTotal(t *testing.T) { cleanup := setupTestDB(t) defer cleanup() - seedDefaultWorkingHours(t) - // Create user with 10 stamps (pending redemption) userID := createTestUser(t, 10) - // Create pending redemption ctx := context.Background() _, err := db.DB.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) @@ -594,7 +650,6 @@ func TestDiscount_NoDiscountOnZeroTotal(t *testing.T) { `, userID) require.NoError(t, err) - // Create a free service (price = 0) var serviceID string err = db.DB.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) @@ -603,50 +658,35 @@ func TestDiscount_NoDiscountOnZeroTotal(t *testing.T) { `, "Free Service", "A free service", 0.00, 60, true, 16).Scan(&serviceID) require.NoError(t, err) - // Create and complete booking with £0 total bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID) - progressReq := bookings.ProgressBookingRequest{Status: "completed"} - handler := http.HandlerFunc(bookings.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") - - // Verify NO discount payment created (booking total is 0, discount would be 0) 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, "Expected no discount payment (total is 0)") + assert.Equal(t, 0, paymentCount, "No discount payment (total is 0)") - // Verify redemption stays pending (not applied) 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, "Expected redemption to stay pending") + assert.Equal(t, "pending", redemptionStatus, "Redemption stays pending") - // Verify stamps still at 10 - var stamps int - err = db.DB.QueryRow(context.Background(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps) - require.NoError(t, err) - assert.Equal(t, 10, stamps, "Expected stamps to remain at 10") + assert.Equal(t, 10, getStamps(t, userID), "Stamps unchanged") } // ============================================================================= // Max Redemptions Tests // ============================================================================= -// TestDiscount_CampaignMaxRedemptions tests that campaigns respect max_redemptions limit func TestDiscount_CampaignMaxRedemptions(t *testing.T) { cleanup := setupTestDB(t) defer cleanup() - seedDefaultWorkingHours(t) - // Create campaign with max_redemptions=1 maxRedemptions := 1 campaignID := createTestCampaign(t, "Limited Time Offer", "time_based", 10.0, nil, nil, nil, &maxRedemptions) @@ -654,45 +694,32 @@ func TestDiscount_CampaignMaxRedemptions(t *testing.T) { userID2 := createTestUser(t, 0) serviceID := createTestService(t, 50.00) - // First booking - should get discount bookingID1 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour)) + completeBooking(t, bookingID1) - progressReq := bookings.ProgressBookingRequest{Status: "completed"} - handler := http.HandlerFunc(bookings.ProgressBookingHandler) - w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID1+"/progress", progressReq, "admin-token") - - require.Equal(t, http.StatusOK, w.Code, "Expected 200 on first booking completion") - - // Verify discount applied 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, "Expected discount on first booking") + assert.Equal(t, 1, discountCount1) - // Verify times_redeemed = 1 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, "Expected times_redeemed = 1") + assert.Equal(t, 1, timesRedeemed) - // Second booking - should NOT get discount (max reached) bookingID2 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour)) - w = makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID2+"/progress", progressReq, "admin-token") + completeBooking(t, bookingID2) - require.Equal(t, http.StatusOK, w.Code, "Expected 200 on second booking completion") - - // Verify NO discount applied 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, "Expected no discount on second booking (max reached)") + assert.Equal(t, 0, discountCount2, "No discount (max reached)") } // ============================================================================= // Helper: Truncate discount-specific tables // ============================================================================= -// truncateDiscountTables truncates discount-related tables that aren't in the standard truncate list func truncateDiscountTables(t *testing.T, pool *pgxpool.Pool) { t.Helper() ctx := context.Background() @@ -709,4 +736,4 @@ func truncateDiscountTables(t *testing.T, pool *pgxpool.Pool) { t.Logf("Warning: could not truncate %s: %v", table, err) } } -} \ No newline at end of file +} diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index b5f5a87..f614970 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -433,7 +433,7 @@ CREATE TABLE discount_campaigns ( created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT chk_dates CHECK (campaign_type = 'milestone' OR (start_date IS NOT NULL AND end_date IS NOT NULL AND end_date > start_date)), CONSTRAINT chk_discount CHECK (discount_percent > 0 AND discount_percent <= 100), - CONSTRAINT chk_milestone CHECK (campaign_type = 'time_based' OR (milestone_type IS NOT NULL AND milestone_value IS NOT NULL AND milestone_unit IS NOT NULL)) + CONSTRAINT chk_milestone CHECK (campaign_type = 'time_based' OR (milestone_type IS NOT NULL AND milestone_value IS NOT NULL AND (milestone_type != 'anniversary' OR milestone_unit IS NOT NULL))) ); CREATE INDEX idx_discount_campaigns_dates ON discount_campaigns(start_date, end_date) WHERE start_date IS NOT NULL;