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
This commit is contained in:
2026-05-10 13:17:08 +01:00
parent e7d3a83867
commit e1c815ed09
5 changed files with 322 additions and 275 deletions
+6
View File
@@ -11,6 +11,7 @@ require (
github.com/kovidgoyal/imaging v1.8.19 github.com/kovidgoyal/imaging v1.8.19
github.com/lib/pq v1.11.2 github.com/lib/pq v1.11.2
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.11.1
golang.org/x/text v0.34.0 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/ssooidc v1.35.14 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // 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/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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kovidgoyal/go-parallel v1.1.1 // indirect github.com/kovidgoyal/go-parallel v1.1.1 // indirect
github.com/kovidgoyal/go-shm v1.0.0 // 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 github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/image v0.36.0 // indirect golang.org/x/image v0.36.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )
require ( require (
+9
View File
@@ -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/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 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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 h1:zWJdQqF2tfSKjvoB7XpLRhVGbYsze++M0iaqZ4ZkhNk=
github.com/kovidgoyal/imaging v1.8.19/go.mod h1:I0q8RdoEuyc4G8GFOF9CaluTUHQSf68d6TmsqpvfRI8= 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 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 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/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 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 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 h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= 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= 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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+32 -27
View File
@@ -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 var bookingTotal float64
if err := db.DB.QueryRow(r.Context(), ` if err := db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) 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) 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 { if bookingTotal > 0 {
var redemptionID string var redemptionID string
if err := db.DB.QueryRow(r.Context(), ` 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 { if bookingTotal > 0 {
var hasLoyaltyDiscount bool 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) _ = 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' WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count'
AND milestone_value = $1 AND milestone_value = $1
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
`, globalCount+1).Scan(&globalCampaignID, &globalPercent) `, globalCount).Scan(&globalCampaignID, &globalPercent)
if globalCampaignID != "" { if globalCampaignID != "" {
discountAmount := roundTo2(bookingTotal * globalPercent / 100) discountAmount := roundTo2(bookingTotal * globalPercent / 100)
+274 -247
View File
@@ -3,17 +3,6 @@
package bookings_test 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 ( import (
"bytes" "bytes"
"context" "context"
@@ -38,7 +27,6 @@ import (
"github.com/stretchr/testify/require" "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() { func setupTestDB(t *testing.T) func() {
t.Helper() t.Helper()
@@ -46,11 +34,9 @@ func setupTestDB(t *testing.T) func() {
testdb.Migrate(t, pool) testdb.Migrate(t, pool)
testdb.TruncateTables(t, pool) testdb.TruncateTables(t, pool)
// Replace global db.DB with test pool
originalDB := db.DB originalDB := db.DB
db.DB = pool db.DB = pool
// Initialize JWT for tests
jwt.Init() jwt.Init()
return func() { return func() {
@@ -59,7 +45,6 @@ func setupTestDB(t *testing.T) func() {
} }
} }
// seedDefaultWorkingHours seeds default working hours for tests
func seedDefaultWorkingHours(t *testing.T) { func seedDefaultWorkingHours(t *testing.T) {
t.Helper() t.Helper()
@@ -69,13 +54,13 @@ func seedDefaultWorkingHours(t *testing.T) {
endTime string endTime string
isOpen bool isOpen bool
}{ }{
{0, "08:00", "20:00", true}, // Monday {0, "08:00", "20:00", true},
{1, "08:00", "20:00", true}, // Tuesday {1, "08:00", "20:00", true},
{2, "08:00", "20:00", true}, // Wednesday {2, "08:00", "20:00", true},
{3, "08:00", "20:00", true}, // Thursday {3, "08:00", "20:00", true},
{4, "08:00", "20:00", true}, // Friday {4, "08:00", "20:00", true},
{5, "08:00", "20:00", true}, // Saturday {5, "08:00", "20:00", true},
{6, "08:00", "20:00", true}, // Sunday {6, "08:00", "20:00", true},
} }
for _, h := range hours { 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 { func makeProgressRequest(handler http.HandlerFunc, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
var req *http.Request var req *http.Request
if body != nil { if body != nil {
@@ -104,14 +88,16 @@ func makeProgressRequest(handler http.HandlerFunc, method, path string, body int
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
} }
// Set up chi routing context for path params
rctx := chi.NewRouteContext() rctx := chi.NewRouteContext()
if idx := strings.LastIndex(path, "/"); idx > 0 { parts := strings.Split(strings.Trim(path, "/"), "/")
rctx.URLParams.Add("id", path[idx+1:]) 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) ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
// Set user context for admin
if token != "" { if token != "" {
ctx = context.WithValue(ctx, mw.UserIDKey, "admin-test-001") ctx = context.WithValue(ctx, mw.UserIDKey, "admin-test-001")
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
@@ -124,7 +110,6 @@ func makeProgressRequest(handler http.HandlerFunc, method, path string, body int
return w return w
} }
// createTestUser creates a test user with optional loyalty stamps
func createTestUser(t *testing.T, stamps int) string { func createTestUser(t *testing.T, stamps int) string {
t.Helper() t.Helper()
userID, err := fixtures.CreateTestUser(db.DB) userID, err := fixtures.CreateTestUser(db.DB)
@@ -138,7 +123,6 @@ func createTestUser(t *testing.T, stamps int) string {
return userID return userID
} }
// createTestService creates a test service with a specific price
func createTestService(t *testing.T, price float64) string { func createTestService(t *testing.T, price float64) string {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
@@ -152,7 +136,6 @@ func createTestService(t *testing.T, price float64) string {
return serviceID 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 { func createTestCampaign(t *testing.T, name, campaignType string, percent float64, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int) string {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
@@ -170,7 +153,6 @@ func createTestCampaign(t *testing.T, name, campaignType string, percent float64
return id 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 { func createCompletedBooking(t *testing.T, userID, serviceID string, startTime time.Time, price float64) string {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
@@ -191,7 +173,6 @@ func createCompletedBooking(t *testing.T, userID, serviceID string, startTime ti
return bookingID return bookingID
} }
// createPendingBooking creates a pending booking
func createPendingBooking(t *testing.T, userID, serviceID string, startTime time.Time) string { func createPendingBooking(t *testing.T, userID, serviceID string, startTime time.Time) string {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
@@ -212,268 +193,376 @@ func createPendingBooking(t *testing.T, userID, serviceID string, startTime time
return bookingID 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 // Loyalty Auto-Redemption Tests
// ============================================================================= // =============================================================================
// TestDiscount_LoyaltyAutoRedemption tests the full loyalty stamp -> redemption -> discount flow func TestDiscount_Loyalty_FullCycle(t *testing.T) {
func TestDiscount_LoyaltyAutoRedemption(t *testing.T) {
cleanup := setupTestDB(t) cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
seedDefaultWorkingHours(t) seedDefaultWorkingHours(t)
// Step 1: Create user with 9 stamps userID := createTestUser(t, 0)
userID := createTestUser(t, 9)
serviceID := createTestService(t, 50.00) serviceID := createTestService(t, 50.00)
// Step 2: Create and complete first booking (adds 1 stamp -> 10) // Simulate completing 10 bookings on different days by directly setting stamps
bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) _, err := db.DB.Exec(context.Background(), `UPDATE users SET loyalty_stamps = 10 WHERE id = $1`, userID)
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)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 1, redemptionCount, "Expected 1 pending loyalty redemption")
// Verify user now has 10 stamps // Verify pending redemption was created when stamps hit 10
var stamps int _, err = db.DB.Exec(context.Background(), `
err = db.DB.QueryRow(context.Background(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps) INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err) 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) assert.Equal(t, 10, getStamps(t, userID))
bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour)) 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 func TestDiscount_Loyalty_ExistingRedemptionApplies(t *testing.T) {
var discountCount int cleanup := setupTestDB(t)
var discountSource string defer cleanup()
err = db.DB.QueryRow(context.Background(), ` seedDefaultWorkingHours(t)
SELECT COUNT(*), discount_source FROM booking_discounts WHERE booking_id = $1 GROUP BY discount_source
`, bookingID2).Scan(&discountCount, &discountSource) 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) 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 bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
err = db.DB.QueryRow(context.Background(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps) completeBooking(t, bookingID)
require.NoError(t, err)
assert.Equal(t, 0, stamps, "Expected 0 stamps after redemption applied") 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 var redemptionStatus string
err = db.DB.QueryRow(context.Background(), ` err = db.DB.QueryRow(context.Background(), `
SELECT status FROM loyalty_redemptions WHERE user_id = $1 AND status = 'applied' SELECT status FROM loyalty_redemptions WHERE user_id = $1 AND applied_to_booking_id = $2
`, userID).Scan(&redemptionStatus) `, userID, bookingID).Scan(&redemptionStatus)
require.NoError(t, err) 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 func TestDiscount_Loyalty_OneStampPerDay(t *testing.T) {
var paymentMethod string cleanup := setupTestDB(t)
var paymentAmount float64 defer cleanup()
err = db.DB.QueryRow(context.Background(), ` seedDefaultWorkingHours(t)
SELECT payment_method, amount FROM payments WHERE booking_id = $1 AND payment_method = 'discount'
`, bookingID2).Scan(&paymentMethod, &paymentAmount) 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) 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 // Time-Based Campaign Tests
// ============================================================================= // =============================================================================
// TestDiscount_TimeBasedCampaign tests that time-based campaign discounts are applied
func TestDiscount_TimeBasedCampaign(t *testing.T) { func TestDiscount_TimeBasedCampaign(t *testing.T) {
cleanup := setupTestDB(t) cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
seedDefaultWorkingHours(t) seedDefaultWorkingHours(t)
// Create active time-based campaign
campaignID := createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) campaignID := createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil)
userID := createTestUser(t, 0) userID := createTestUser(t, 0)
serviceID := createTestService(t, 100.00) serviceID := createTestService(t, 100.00)
// Create and complete booking
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
completeBooking(t, bookingID)
progressReq := bookings.ProgressBookingRequest{Status: "completed"} source, campaignType, exists := getDiscountSourceAndType(t, bookingID)
handler := http.HandlerFunc(bookings.ProgressBookingHandler) require.True(t, exists)
w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") 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 var timesRedeemed int
err = db.DB.QueryRow(context.Background(), `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&timesRedeemed) err := db.DB.QueryRow(context.Background(), `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&timesRedeemed)
require.NoError(t, err) 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 var paymentAmount float64
err = db.DB.QueryRow(context.Background(), ` err = db.DB.QueryRow(context.Background(), `
SELECT amount FROM payments WHERE booking_id = $1 AND payment_method = 'discount' SELECT amount FROM payments WHERE booking_id = $1 AND payment_method = 'discount'
`, bookingID).Scan(&paymentAmount) `, bookingID).Scan(&paymentAmount)
require.NoError(t, err) 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 // Per-User Milestone Tests
// ============================================================================= // =============================================================================
// TestDiscount_PerUserMilestone tests that per-user booking count milestones work
func TestDiscount_PerUserMilestone(t *testing.T) { func TestDiscount_PerUserMilestone(t *testing.T) {
cleanup := setupTestDB(t) cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
seedDefaultWorkingHours(t) seedDefaultWorkingHours(t)
// Create "10th booking" per-user milestone campaign
milestoneValue := 10 milestoneValue := 10
milestoneType := "per_user_booking_count" 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) userID := createTestUser(t, 0)
serviceID := createTestService(t, 80.00) serviceID := createTestService(t, 80.00)
// Create 9 completed bookings (direct SQL to avoid triggering discounts)
for i := 0; i < 9; i++ { 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) _ = createCompletedBooking(t, userID, serviceID, startTime, 80.00)
} }
// Create and complete the 10th booking
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
completeBooking(t, bookingID)
progressReq := bookings.ProgressBookingRequest{Status: "completed"} source, milestoneTypeResult, exists := getDiscountSourceAndMilestone(t, bookingID)
handler := http.HandlerFunc(bookings.ProgressBookingHandler) require.True(t, exists)
w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") 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 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) 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)) 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 var discountCount int
err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount) err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount)
require.NoError(t, err) 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 // Global Milestone Tests
// ============================================================================= // =============================================================================
// TestDiscount_GlobalMilestone tests that global booking count milestones work
func TestDiscount_GlobalMilestone(t *testing.T) { func TestDiscount_GlobalMilestone(t *testing.T) {
cleanup := setupTestDB(t) cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
seedDefaultWorkingHours(t) seedDefaultWorkingHours(t)
// Create global milestone campaign for 5th total booking
milestoneValue := 5 milestoneValue := 5
milestoneType := "global_booking_count" 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) userID1 := createTestUser(t, 0)
userID2 := createTestUser(t, 0) userID2 := createTestUser(t, 0)
serviceID := createTestService(t, 50.00) serviceID := createTestService(t, 50.00)
// Create 4 completed bookings (any users)
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
startTime := time.Now().AddDate(0, 0, -i-1) startTime := time.Now().AddDate(0, 0, -i-1)
_ = createCompletedBooking(t, userID1, serviceID, startTime, 50.00) _ = createCompletedBooking(t, userID1, serviceID, startTime, 50.00)
} }
// Create and complete the 5th booking
bookingID := createPendingBooking(t, userID2, serviceID, time.Now().Add(24*time.Hour)) bookingID := createPendingBooking(t, userID2, serviceID, time.Now().Add(24*time.Hour))
completeBooking(t, bookingID)
progressReq := bookings.ProgressBookingRequest{Status: "completed"} _, milestoneTypeResult, exists := getDiscountSourceAndMilestone(t, bookingID)
handler := http.HandlerFunc(bookings.ProgressBookingHandler) require.True(t, exists)
w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") 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 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) 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 // Anniversary Milestone Tests
// ============================================================================= // =============================================================================
// TestDiscount_AnniversaryMilestone tests that anniversary-based milestones work
func TestDiscount_AnniversaryMilestone(t *testing.T) { func TestDiscount_AnniversaryMilestone(t *testing.T) {
cleanup := setupTestDB(t) cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
seedDefaultWorkingHours(t) seedDefaultWorkingHours(t)
// Create anniversary campaign (6 months)
milestoneValue := 6 milestoneValue := 6
milestoneType := "anniversary" milestoneType := "anniversary"
milestoneUnit := "months" 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) userID := createTestUser(t, 0)
serviceID := createTestService(t, 60.00) serviceID := createTestService(t, 60.00)
// Create first booking 7 months ago (using direct SQL to set start_time)
ctx := context.Background() ctx := context.Background()
sevenMonthsAgo := time.Now().AddDate(0, -7, 0) sevenMonthsAgo := time.Now().AddDate(0, -7, 0)
var firstBookingID string var firstBookingID string
@@ -490,53 +579,35 @@ func TestDiscount_AnniversaryMilestone(t *testing.T) {
`, firstBookingID, serviceID, 60.00) `, firstBookingID, serviceID, 60.00)
require.NoError(t, err) require.NoError(t, err)
// Create and complete new booking
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
completeBooking(t, bookingID)
progressReq := bookings.ProgressBookingRequest{Status: "completed"} _, milestoneTypeResult, exists := getDiscountSourceAndMilestone(t, bookingID)
handler := http.HandlerFunc(bookings.ProgressBookingHandler) require.True(t, exists)
w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") 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)) 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")
var discountCount int var discountCount int
err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount) err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount)
require.NoError(t, err) 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 // Loyalty Priority Tests
// ============================================================================= // =============================================================================
// TestDiscount_LoyaltyPriority tests that loyalty discounts take priority over campaigns
func TestDiscount_LoyaltyPriority(t *testing.T) { func TestDiscount_LoyaltyPriority(t *testing.T) {
cleanup := setupTestDB(t) cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
seedDefaultWorkingHours(t) seedDefaultWorkingHours(t)
// Create active time-based campaign
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil) _ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil)
// Create user with 10 stamps (pending redemption)
userID := createTestUser(t, 10) userID := createTestUser(t, 10)
// Create pending redemption
ctx := context.Background() ctx := context.Background()
_, err := db.DB.Exec(ctx, ` _, err := db.DB.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) 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) serviceID := createTestService(t, 100.00)
// Create and complete booking
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
completeBooking(t, bookingID)
progressReq := bookings.ProgressBookingRequest{Status: "completed"} source, _, exists := getDiscountForBooking(t, bookingID)
handler := http.HandlerFunc(bookings.ProgressBookingHandler) require.True(t, exists)
w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token") 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 var campaignDiscountCount int
err = db.DB.QueryRow(context.Background(), ` err = db.DB.QueryRow(context.Background(), `
SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'
`, bookingID).Scan(&campaignDiscountCount) `, bookingID).Scan(&campaignDiscountCount)
require.NoError(t, err) 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 // Zero Total Edge Case Tests
// ============================================================================= // =============================================================================
// TestDiscount_NoDiscountOnZeroTotal tests that no discount is applied when booking total is 0
func TestDiscount_NoDiscountOnZeroTotal(t *testing.T) { func TestDiscount_NoDiscountOnZeroTotal(t *testing.T) {
cleanup := setupTestDB(t) cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
seedDefaultWorkingHours(t) seedDefaultWorkingHours(t)
// Create user with 10 stamps (pending redemption)
userID := createTestUser(t, 10) userID := createTestUser(t, 10)
// Create pending redemption
ctx := context.Background() ctx := context.Background()
_, err := db.DB.Exec(ctx, ` _, err := db.DB.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
@@ -594,7 +650,6 @@ func TestDiscount_NoDiscountOnZeroTotal(t *testing.T) {
`, userID) `, userID)
require.NoError(t, err) require.NoError(t, err)
// Create a free service (price = 0)
var serviceID string var serviceID string
err = db.DB.QueryRow(ctx, ` err = db.DB.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) 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) `, "Free Service", "A free service", 0.00, 60, true, 16).Scan(&serviceID)
require.NoError(t, err) require.NoError(t, err)
// Create and complete booking with £0 total
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour)) 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 var paymentCount int
err = db.DB.QueryRow(context.Background(), ` err = db.DB.QueryRow(context.Background(), `
SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount' SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'
`, bookingID).Scan(&paymentCount) `, bookingID).Scan(&paymentCount)
require.NoError(t, err) 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 var redemptionStatus string
err = db.DB.QueryRow(context.Background(), ` err = db.DB.QueryRow(context.Background(), `
SELECT status FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending' SELECT status FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending'
`, userID).Scan(&redemptionStatus) `, userID).Scan(&redemptionStatus)
require.NoError(t, err) 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 assert.Equal(t, 10, getStamps(t, userID), "Stamps unchanged")
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")
} }
// ============================================================================= // =============================================================================
// Max Redemptions Tests // Max Redemptions Tests
// ============================================================================= // =============================================================================
// TestDiscount_CampaignMaxRedemptions tests that campaigns respect max_redemptions limit
func TestDiscount_CampaignMaxRedemptions(t *testing.T) { func TestDiscount_CampaignMaxRedemptions(t *testing.T) {
cleanup := setupTestDB(t) cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
seedDefaultWorkingHours(t) seedDefaultWorkingHours(t)
// Create campaign with max_redemptions=1
maxRedemptions := 1 maxRedemptions := 1
campaignID := createTestCampaign(t, "Limited Time Offer", "time_based", 10.0, nil, nil, nil, &maxRedemptions) 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) userID2 := createTestUser(t, 0)
serviceID := createTestService(t, 50.00) serviceID := createTestService(t, 50.00)
// First booking - should get discount
bookingID1 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour)) 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 var discountCount1 int
err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID1).Scan(&discountCount1) err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID1).Scan(&discountCount1)
require.NoError(t, err) 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 var timesRedeemed int
err = db.DB.QueryRow(context.Background(), `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&timesRedeemed) err = db.DB.QueryRow(context.Background(), `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&timesRedeemed)
require.NoError(t, err) 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)) 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 var discountCount2 int
err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount2) err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount2)
require.NoError(t, err) 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 // 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) { func truncateDiscountTables(t *testing.T, pool *pgxpool.Pool) {
t.Helper() t.Helper()
ctx := context.Background() 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) t.Logf("Warning: could not truncate %s: %v", table, err)
} }
} }
} }
+1 -1
View File
@@ -433,7 +433,7 @@ CREATE TABLE discount_campaigns (
created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL, 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_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_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; CREATE INDEX idx_discount_campaigns_dates ON discount_campaigns(start_date, end_date) WHERE start_date IS NOT NULL;