- Add TestMain to all 10 test packages (schema DROP+CREATE runs once per package) - Convert per-test setupTestDB to resetTestData (TRUNCATE only, ~60% faster) - Add 3 missing tables to TruncateTables (booking_edit_requests, exceptional_group_applications, business_settings) - Remove dead truncateDiscountTables helper - Consolidate discount_test.go into package bookings (was external test package) - Update testutils.SetupTestDB to truncate-only - Fix unused imports across user, bookings, and handlers packages - Verify: 286 passing, 2 skipped, 0 failures with -count=2 (no state leakage)
653 lines
22 KiB
Go
653 lines
22 KiB
Go
//go:build test
|
|
// +build test
|
|
|
|
package bookings
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/mw"
|
|
"crussell/testutils/fixtures"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func makeProgressRequest(handler http.HandlerFunc, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
|
|
var req *http.Request
|
|
if body != nil {
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
} else {
|
|
req = httptest.NewRequest(method, path, nil)
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
|
|
rctx := chi.NewRouteContext()
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
for i, p := range parts {
|
|
if p == "bookings" && i+1 < len(parts) {
|
|
rctx.URLParams.Add("id", parts[i+1])
|
|
break
|
|
}
|
|
}
|
|
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
|
|
if token != "" {
|
|
ctx = context.WithValue(ctx, mw.UserIDKey, "admin-test-001")
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
|
|
}
|
|
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func createTestUser(t *testing.T, stamps int) string {
|
|
t.Helper()
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
require.NoError(t, err)
|
|
|
|
if stamps > 0 {
|
|
_, err := db.DB.Exec(context.Background(), "UPDATE users SET loyalty_stamps = $1 WHERE id = $2", stamps, userID)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
return userID
|
|
}
|
|
|
|
func createTestService(t *testing.T, price float64) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
var serviceID string
|
|
err := db.DB.QueryRow(ctx, `
|
|
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id
|
|
`, "Test Service", "A test service", price, 60, true, 16).Scan(&serviceID)
|
|
require.NoError(t, err)
|
|
return serviceID
|
|
}
|
|
|
|
func createTestCampaign(t *testing.T, name, campaignType string, percent float64, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
var id string
|
|
now := time.Now()
|
|
startDate := now.Add(-24 * time.Hour)
|
|
endDate := now.Add(24 * time.Hour)
|
|
|
|
err := db.DB.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit, max_redemptions, times_redeemed)
|
|
VALUES ($1, $2, $3, 'active', $4, $5, $6, $7, $8, $9, 0)
|
|
RETURNING id
|
|
`, name, campaignType, percent, startDate, endDate, milestoneType, milestoneValue, milestoneUnit, maxRedemptions).Scan(&id)
|
|
require.NoError(t, err)
|
|
return id
|
|
}
|
|
|
|
func createCompletedBooking(t *testing.T, userID, serviceID string, startTime time.Time, price float64) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
var bookingID string
|
|
err := db.DB.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'completed')
|
|
RETURNING id
|
|
`, userID, startTime).Scan(&bookingID)
|
|
require.NoError(t, err)
|
|
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO booking_services (booking_id, service_id, override_price)
|
|
VALUES ($1, $2, $3)
|
|
`, bookingID, serviceID, price)
|
|
require.NoError(t, err)
|
|
|
|
return bookingID
|
|
}
|
|
|
|
func createPendingBooking(t *testing.T, userID, serviceID string, startTime time.Time) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
var bookingID string
|
|
err := db.DB.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'confirmed')
|
|
RETURNING id
|
|
`, userID, startTime).Scan(&bookingID)
|
|
require.NoError(t, err)
|
|
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO booking_services (booking_id, service_id)
|
|
VALUES ($1, $2)
|
|
`, bookingID, serviceID)
|
|
require.NoError(t, err)
|
|
|
|
return bookingID
|
|
}
|
|
|
|
func completeBooking(t *testing.T, bookingID string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
progressReq := ProgressBookingRequest{Status: "completed"}
|
|
handler := http.HandlerFunc(ProgressBookingHandler)
|
|
w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token")
|
|
require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion")
|
|
return w
|
|
}
|
|
|
|
func backdateBooking(t *testing.T, bookingID string, daysAgo int) {
|
|
t.Helper()
|
|
if daysAgo > 0 {
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
UPDATE bookings SET updated_at = NOW() - INTERVAL '1 day' * $1 WHERE id = $2
|
|
`, daysAgo, bookingID)
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
|
|
func getStamps(t *testing.T, userID string) int {
|
|
t.Helper()
|
|
var stamps int
|
|
err := db.DB.QueryRow(context.Background(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps)
|
|
require.NoError(t, err)
|
|
return stamps
|
|
}
|
|
|
|
func getPendingRedemptions(t *testing.T, userID string) int {
|
|
t.Helper()
|
|
var count int
|
|
err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending'`, userID).Scan(&count)
|
|
require.NoError(t, err)
|
|
return count
|
|
}
|
|
|
|
func getDiscountForBooking(t *testing.T, bookingID string) (source string, amount float64, exists bool) {
|
|
t.Helper()
|
|
err := db.DB.QueryRow(context.Background(), `
|
|
SELECT discount_source, discount_amount FROM booking_discounts WHERE booking_id = $1
|
|
`, bookingID).Scan(&source, &amount)
|
|
if err != nil {
|
|
return "", 0, false
|
|
}
|
|
return source, amount, true
|
|
}
|
|
|
|
// =============================================================================
|
|
// Loyalty Auto-Redemption Tests
|
|
// =============================================================================
|
|
|
|
func TestDiscount_Loyalty_FullCycle(t *testing.T) {
|
|
resetTestData(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
// Simulate completing 10 bookings on different days by directly setting stamps
|
|
_, err := db.DB.Exec(context.Background(), `UPDATE users SET loyalty_stamps = 10 WHERE id = $1`, userID)
|
|
require.NoError(t, err)
|
|
|
|
// Verify pending redemption was created when stamps hit 10
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
|
VALUES ($1, 10, 'pending', NOW())
|
|
`, userID)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 10, getStamps(t, userID))
|
|
assert.Equal(t, 1, getPendingRedemptions(t, userID))
|
|
|
|
// 11th booking → applies discount, resets stamps to 0, then +1 for completion
|
|
bookingID11 := createPendingBooking(t, userID, serviceID, time.Now().AddDate(0, 0, 12))
|
|
completeBooking(t, bookingID11)
|
|
|
|
source, amount, exists := getDiscountForBooking(t, bookingID11)
|
|
require.True(t, exists, "Expected discount on 11th booking")
|
|
assert.Equal(t, "loyalty", source)
|
|
assert.Equal(t, 5.00, amount, "10% of £50 = £5")
|
|
assert.Equal(t, 1, getStamps(t, userID), "Stamps: 0 after redemption + 1 for this booking")
|
|
assert.Equal(t, 0, getPendingRedemptions(t, userID), "No pending redemptions after applying")
|
|
}
|
|
|
|
func TestDiscount_Loyalty_ExistingRedemptionApplies(t *testing.T) {
|
|
resetTestData(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID := createTestUser(t, 10)
|
|
serviceID := createTestService(t, 100.00)
|
|
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
|
VALUES ($1, 10, 'pending', NOW())
|
|
`, userID)
|
|
require.NoError(t, err)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
source, amount, exists := getDiscountForBooking(t, bookingID)
|
|
require.True(t, exists)
|
|
assert.Equal(t, "loyalty", source)
|
|
assert.Equal(t, 10.00, amount, "10% of £100 = £10")
|
|
assert.Equal(t, 1, getStamps(t, userID), "Stamp earned for completing this booking (0+1)")
|
|
|
|
var redemptionStatus string
|
|
err = db.DB.QueryRow(context.Background(), `
|
|
SELECT status FROM loyalty_redemptions WHERE user_id = $1 AND applied_to_booking_id = $2
|
|
`, userID, bookingID).Scan(&redemptionStatus)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "applied", redemptionStatus)
|
|
}
|
|
|
|
func TestDiscount_Loyalty_OneStampPerDay(t *testing.T) {
|
|
resetTestData(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID := createTestUser(t, 5)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
// Complete 3 bookings on the same day
|
|
var sameDayBookings []string
|
|
for i := 0; i < 3; i++ {
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().AddDate(0, 0, i+1))
|
|
completeBooking(t, bookingID)
|
|
sameDayBookings = append(sameDayBookings, bookingID)
|
|
}
|
|
// Backdate all to same past day
|
|
for _, bid := range sameDayBookings {
|
|
backdateBooking(t, bid, 5)
|
|
}
|
|
|
|
stamps := getStamps(t, userID)
|
|
assert.Equal(t, 6, stamps, "Only 1 stamp added for same-day completions (5+1=6)")
|
|
assert.Equal(t, 0, getPendingRedemptions(t, userID), "No pending redemption yet")
|
|
|
|
// Complete a booking on a different day → second stamp
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().AddDate(0, 0, 4))
|
|
completeBooking(t, bookingID)
|
|
|
|
stamps = getStamps(t, userID)
|
|
assert.Equal(t, 7, stamps, "Second stamp added on different day (6+1=7)")
|
|
}
|
|
|
|
func TestDiscount_Loyalty_ZeroTotalNoStamp(t *testing.T) {
|
|
resetTestData(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID := createTestUser(t, 5)
|
|
|
|
var serviceID string
|
|
err := db.DB.QueryRow(context.Background(), `
|
|
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id
|
|
`, "Free Service", "A free service", 0.00, 60, true, 16).Scan(&serviceID)
|
|
require.NoError(t, err)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
stamps := getStamps(t, userID)
|
|
assert.Equal(t, 5, stamps, "Zero-total booking should not earn a stamp")
|
|
|
|
_, _, exists := getDiscountForBooking(t, bookingID)
|
|
assert.False(t, exists, "Zero-total booking should not get any discount")
|
|
}
|
|
|
|
func TestDiscount_Loyalty_CycleRepeats(t *testing.T) {
|
|
resetTestData(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
// Simulate first cycle
|
|
_, err := db.DB.Exec(context.Background(), `UPDATE users SET loyalty_stamps = 10 WHERE id = $1`, userID)
|
|
require.NoError(t, err)
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
|
VALUES ($1, 10, 'pending', NOW())
|
|
`, userID)
|
|
require.NoError(t, err)
|
|
|
|
// 11th booking → discount, stamps reset
|
|
bookingID11 := createPendingBooking(t, userID, serviceID, time.Now().AddDate(0, 0, 12))
|
|
completeBooking(t, bookingID11)
|
|
|
|
source, _, exists := getDiscountForBooking(t, bookingID11)
|
|
require.True(t, exists)
|
|
assert.Equal(t, "loyalty", source)
|
|
assert.Equal(t, 0, getPendingRedemptions(t, userID), "Redemption consumed")
|
|
|
|
// Simulate second cycle
|
|
_, err = db.DB.Exec(context.Background(), `UPDATE users SET loyalty_stamps = 10 WHERE id = $1`, userID)
|
|
require.NoError(t, err)
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
|
VALUES ($1, 10, 'pending', NOW())
|
|
`, userID)
|
|
require.NoError(t, err)
|
|
|
|
// 22nd booking → second discount
|
|
bookingID22 := createPendingBooking(t, userID, serviceID, time.Now().AddDate(0, 0, 31))
|
|
completeBooking(t, bookingID22)
|
|
|
|
source2, amount2, exists2 := getDiscountForBooking(t, bookingID22)
|
|
require.True(t, exists2)
|
|
assert.Equal(t, "loyalty", source2)
|
|
assert.Equal(t, 5.00, amount2)
|
|
assert.Equal(t, 0, getPendingRedemptions(t, userID), "Second redemption also consumed")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Time-Based Campaign Tests
|
|
// =============================================================================
|
|
|
|
func TestDiscount_TimeBasedCampaign(t *testing.T) {
|
|
resetTestData(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
campaignID := createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 100.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
source, campaignType, exists := getDiscountSourceAndType(t, bookingID)
|
|
require.True(t, exists)
|
|
assert.Equal(t, "campaign", source)
|
|
assert.Equal(t, "time_based", campaignType)
|
|
|
|
var timesRedeemed int
|
|
err := db.DB.QueryRow(context.Background(), `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(×Redeemed)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, timesRedeemed)
|
|
|
|
var paymentAmount float64
|
|
err = db.DB.QueryRow(context.Background(), `
|
|
SELECT amount FROM payments WHERE booking_id = $1 AND payment_method = 'discount'
|
|
`, bookingID).Scan(&paymentAmount)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 5.00, paymentAmount)
|
|
}
|
|
|
|
func getDiscountSourceAndType(t *testing.T, bookingID string) (source, campaignType string, exists bool) {
|
|
t.Helper()
|
|
err := db.DB.QueryRow(context.Background(), `
|
|
SELECT discount_source, campaign_type FROM booking_discounts WHERE booking_id = $1
|
|
`, bookingID).Scan(&source, &campaignType)
|
|
if err != nil {
|
|
return "", "", false
|
|
}
|
|
return source, campaignType, true
|
|
}
|
|
|
|
// =============================================================================
|
|
// Per-User Milestone Tests
|
|
// =============================================================================
|
|
|
|
func TestDiscount_PerUserMilestone(t *testing.T) {
|
|
resetTestData(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
milestoneValue := 10
|
|
milestoneType := "per_user_booking_count"
|
|
_ = createTestCampaign(t, "10th Visit Bonus", "milestone", 15.0, &milestoneType, nil, &milestoneValue, nil)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 80.00)
|
|
|
|
for i := 0; i < 9; i++ {
|
|
startTime := time.Now().AddDate(0, -1, -i*7)
|
|
_ = createCompletedBooking(t, userID, serviceID, startTime, 80.00)
|
|
}
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
source, milestoneTypeResult, exists := getDiscountSourceAndMilestone(t, bookingID)
|
|
require.True(t, exists)
|
|
assert.Equal(t, "campaign", source)
|
|
assert.Equal(t, "per_user_booking_count", milestoneTypeResult)
|
|
|
|
var discountAmount float64
|
|
err := db.DB.QueryRow(context.Background(), `SELECT discount_amount FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountAmount)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 12.00, discountAmount, "15% of £80 = £12")
|
|
|
|
bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour))
|
|
completeBooking(t, bookingID2)
|
|
|
|
var discountCount int
|
|
err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, discountCount, "No second discount (dedup)")
|
|
}
|
|
|
|
func getDiscountSourceAndMilestone(t *testing.T, bookingID string) (source, milestoneType string, exists bool) {
|
|
t.Helper()
|
|
err := db.DB.QueryRow(context.Background(), `
|
|
SELECT discount_source, milestone_type FROM booking_discounts WHERE booking_id = $1
|
|
`, bookingID).Scan(&source, &milestoneType)
|
|
if err != nil {
|
|
return "", "", false
|
|
}
|
|
return source, milestoneType, true
|
|
}
|
|
|
|
// =============================================================================
|
|
// Global Milestone Tests
|
|
// =============================================================================
|
|
|
|
func TestDiscount_GlobalMilestone(t *testing.T) {
|
|
resetTestData(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
milestoneValue := 5
|
|
milestoneType := "global_booking_count"
|
|
_ = createTestCampaign(t, "5th Customer Milestone", "milestone", 20.0, &milestoneType, nil, &milestoneValue, nil)
|
|
|
|
userID1 := createTestUser(t, 0)
|
|
userID2 := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
for i := 0; i < 4; i++ {
|
|
startTime := time.Now().AddDate(0, 0, -i-1)
|
|
_ = createCompletedBooking(t, userID1, serviceID, startTime, 50.00)
|
|
}
|
|
|
|
bookingID := createPendingBooking(t, userID2, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
_, milestoneTypeResult, exists := getDiscountSourceAndMilestone(t, bookingID)
|
|
require.True(t, exists)
|
|
assert.Equal(t, "global_booking_count", milestoneTypeResult)
|
|
|
|
var discountAmount float64
|
|
err := db.DB.QueryRow(context.Background(), `SELECT discount_amount FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountAmount)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 10.00, discountAmount, "20% of £50 = £10")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Anniversary Milestone Tests
|
|
// =============================================================================
|
|
|
|
func TestDiscount_AnniversaryMilestone(t *testing.T) {
|
|
resetTestData(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
milestoneValue := 6
|
|
milestoneType := "anniversary"
|
|
milestoneUnit := "months"
|
|
_ = createTestCampaign(t, "6 Month Anniversary", "milestone", 10.0, &milestoneType, &milestoneUnit, &milestoneValue, nil)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 60.00)
|
|
|
|
ctx := context.Background()
|
|
sevenMonthsAgo := time.Now().AddDate(0, -7, 0)
|
|
var firstBookingID string
|
|
err := db.DB.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'completed')
|
|
RETURNING id
|
|
`, userID, sevenMonthsAgo).Scan(&firstBookingID)
|
|
require.NoError(t, err)
|
|
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO booking_services (booking_id, service_id, override_price)
|
|
VALUES ($1, $2, $3)
|
|
`, firstBookingID, serviceID, 60.00)
|
|
require.NoError(t, err)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
_, milestoneTypeResult, exists := getDiscountSourceAndMilestone(t, bookingID)
|
|
require.True(t, exists)
|
|
assert.Equal(t, "anniversary", milestoneTypeResult)
|
|
|
|
bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour))
|
|
completeBooking(t, bookingID2)
|
|
|
|
var discountCount int
|
|
err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, discountCount, "No second discount (dedup)")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Loyalty Priority Tests
|
|
// =============================================================================
|
|
|
|
func TestDiscount_LoyaltyPriority(t *testing.T) {
|
|
resetTestData(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil)
|
|
|
|
userID := createTestUser(t, 10)
|
|
|
|
ctx := context.Background()
|
|
_, err := db.DB.Exec(ctx, `
|
|
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
|
VALUES ($1, 10, 'pending', NOW())
|
|
`, userID)
|
|
require.NoError(t, err)
|
|
|
|
serviceID := createTestService(t, 100.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
source, _, exists := getDiscountForBooking(t, bookingID)
|
|
require.True(t, exists)
|
|
assert.Equal(t, "loyalty", source)
|
|
|
|
var campaignDiscountCount int
|
|
err = db.DB.QueryRow(context.Background(), `
|
|
SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'
|
|
`, bookingID).Scan(&campaignDiscountCount)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, campaignDiscountCount, "No campaign discount when loyalty applies")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Zero Total Edge Case Tests
|
|
// =============================================================================
|
|
|
|
func TestDiscount_NoDiscountOnZeroTotal(t *testing.T) {
|
|
resetTestData(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID := createTestUser(t, 10)
|
|
|
|
ctx := context.Background()
|
|
_, err := db.DB.Exec(ctx, `
|
|
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
|
VALUES ($1, 10, 'pending', NOW())
|
|
`, userID)
|
|
require.NoError(t, err)
|
|
|
|
var serviceID string
|
|
err = db.DB.QueryRow(ctx, `
|
|
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id
|
|
`, "Free Service", "A free service", 0.00, 60, true, 16).Scan(&serviceID)
|
|
require.NoError(t, err)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
var paymentCount int
|
|
err = db.DB.QueryRow(context.Background(), `
|
|
SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'
|
|
`, bookingID).Scan(&paymentCount)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, paymentCount, "No discount payment (total is 0)")
|
|
|
|
var redemptionStatus string
|
|
err = db.DB.QueryRow(context.Background(), `
|
|
SELECT status FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending'
|
|
`, userID).Scan(&redemptionStatus)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "pending", redemptionStatus, "Redemption stays pending")
|
|
|
|
assert.Equal(t, 10, getStamps(t, userID), "Stamps unchanged")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Max Redemptions Tests
|
|
// =============================================================================
|
|
|
|
func TestDiscount_CampaignMaxRedemptions(t *testing.T) {
|
|
resetTestData(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
maxRedemptions := 1
|
|
campaignID := createTestCampaign(t, "Limited Time Offer", "time_based", 10.0, nil, nil, nil, &maxRedemptions)
|
|
|
|
userID1 := createTestUser(t, 0)
|
|
userID2 := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
bookingID1 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID1)
|
|
|
|
var discountCount1 int
|
|
err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID1).Scan(&discountCount1)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, discountCount1)
|
|
|
|
var timesRedeemed int
|
|
err = db.DB.QueryRow(context.Background(), `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(×Redeemed)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, timesRedeemed)
|
|
|
|
bookingID2 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour))
|
|
completeBooking(t, bookingID2)
|
|
|
|
var discountCount2 int
|
|
err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount2)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, discountCount2, "No discount (max reached)")
|
|
}
|