Files
Crussell/backend/handlers/bookings/discount_test.go
T
popertots 79a794de58 fix: remove discount_eligible from booking creation, fix modal scrolling, lower default to 5%
- Remove all discount_eligible checks from CreateBookingHandler and AdminCreateBookingForUserHandler
- Discounts are only calculated at completion/payment time, not at booking time
- Remove discount_eligible from frontend Booking type and pending_redemption from BookingUser
- Remove TestDiscount_EligibilityFlag test (no longer relevant)
- Fix modal scrolling: add max-h-[90vh] overflow-y-auto to match ServicesManagement pattern
- Lower default discount from 10% to 5%
- Clarify max_redemptions label as 'campaign total' (per campaign, not per person)
2026-05-08 18:08:59 +01:00

712 lines
27 KiB
Go

//go:build test
// +build 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 (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"crussell/db"
"crussell/handlers/bookings"
"crussell/mw"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"crussell/testutils/testdb"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"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()
pool := testdb.Pool(t)
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() {
db.DB = originalDB
pool.Close()
}
}
// seedDefaultWorkingHours seeds default working hours for tests
func seedDefaultWorkingHours(t *testing.T) {
t.Helper()
hours := []struct {
weekday int
startTime string
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
}
for _, h := range hours {
_, err := db.DB.Exec(context.Background(), `
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
VALUES ($1, $2, $3, $4)
ON CONFLICT (weekday) DO UPDATE SET start_time = $2, end_time = $3, is_open = $4
`, h.weekday, h.startTime, h.endTime, h.isOpen)
if err != nil {
t.Fatalf("failed to seed working hours: %v", err)
}
}
}
// 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 {
bodyBytes, _ := json.Marshal(body)
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
// 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:])
}
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")
}
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
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)
require.NoError(t, err)
if stamps > 0 {
_, err := db.DB.Exec(context.Background(), "UPDATE users SET loyalty_stamps = $1 WHERE id = $2", stamps, userID)
require.NoError(t, err)
}
return userID
}
// createTestService creates a test service with a specific price
func createTestService(t *testing.T, price float64) string {
t.Helper()
ctx := context.Background()
var serviceID string
err := db.DB.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, "Test Service", "A test service", price, 60, true, 16).Scan(&serviceID)
require.NoError(t, err)
return serviceID
}
// 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()
var id string
now := time.Now()
startDate := now.Add(-24 * time.Hour)
endDate := now.Add(24 * time.Hour)
err := db.DB.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit, max_redemptions, times_redeemed)
VALUES ($1, $2, $3, 'active', $4, $5, $6, $7, $8, $9, 0)
RETURNING id
`, name, campaignType, percent, startDate, endDate, milestoneType, milestoneValue, milestoneUnit, maxRedemptions).Scan(&id)
require.NoError(t, err)
return id
}
// 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()
var bookingID string
err := db.DB.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'completed')
RETURNING id
`, userID, startTime).Scan(&bookingID)
require.NoError(t, err)
_, err = db.DB.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id, override_price)
VALUES ($1, $2, $3)
`, bookingID, serviceID, price)
require.NoError(t, err)
return bookingID
}
// createPendingBooking creates a pending booking
func createPendingBooking(t *testing.T, userID, serviceID string, startTime time.Time) string {
t.Helper()
ctx := context.Background()
var bookingID string
err := db.DB.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'confirmed')
RETURNING id
`, userID, startTime).Scan(&bookingID)
require.NoError(t, err)
_, err = db.DB.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
require.NoError(t, err)
return bookingID
}
// =============================================================================
// Loyalty Auto-Redemption Tests
// =============================================================================
// TestDiscount_LoyaltyAutoRedemption tests the full loyalty stamp -> redemption -> discount flow
func TestDiscount_LoyaltyAutoRedemption(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
seedDefaultWorkingHours(t)
// Step 1: Create user with 9 stamps
userID := createTestUser(t, 9)
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)
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)
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))
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")
// 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)
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")
// 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)
require.NoError(t, err)
assert.Equal(t, "applied", redemptionStatus, "Expected redemption status to be 'applied'")
// 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)
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
}
// =============================================================================
// 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))
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 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(&timesRedeemed)
require.NoError(t, err)
assert.Equal(t, 1, timesRedeemed, "Expected times_redeemed to be 1")
// 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
}
// =============================================================================
// 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)
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
_ = createCompletedBooking(t, userID, serviceID, startTime, 80.00)
}
// Create and complete the 10th booking
bookingID := 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/"+bookingID+"/progress", progressReq, "admin-token")
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)
require.NoError(t, err)
assert.Equal(t, 12.00, discountAmount, "Expected 15% discount (£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")
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)")
}
// =============================================================================
// 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)
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))
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 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)
require.NoError(t, err)
assert.Equal(t, 10.00, discountAmount, "Expected 20% discount (£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)
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
err := db.DB.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'completed')
RETURNING id
`, userID, sevenMonthsAgo).Scan(&firstBookingID)
require.NoError(t, err)
_, err = db.DB.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id, override_price)
VALUES ($1, $2, $3)
`, firstBookingID, serviceID, 60.00)
require.NoError(t, err)
// Create and complete new booking
bookingID := 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/"+bookingID+"/progress", progressReq, "admin-token")
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")
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)")
}
// =============================================================================
// 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)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
serviceID := createTestService(t, 100.00)
// Create and complete booking
bookingID := 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/"+bookingID+"/progress", progressReq, "admin-token")
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)")
}
// =============================================================================
// 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)
VALUES ($1, 10, 'pending', NOW())
`, 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)
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)
// Create and complete booking with £0 total
bookingID := 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/"+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)")
// 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")
// 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")
}
// =============================================================================
// 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)
userID1 := createTestUser(t, 0)
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))
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")
// Verify times_redeemed = 1
var timesRedeemed int
err = db.DB.QueryRow(context.Background(), `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&timesRedeemed)
require.NoError(t, err)
assert.Equal(t, 1, timesRedeemed, "Expected times_redeemed = 1")
// 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")
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)")
}
// =============================================================================
// 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()
tables := []string{
"loyalty_redemptions",
"discount_campaigns",
"booking_discounts",
}
for _, table := range tables {
_, err := pool.Exec(ctx, fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table))
if err != nil {
t.Logf("Warning: could not truncate %s: %v", table, err)
}
}
}