- Database: loyalty_redemptions, discount_campaigns, booking_discounts tables - Backend: auto-create pending redemption at 10 stamps, apply discounts at completion - Backend: discount_eligible flag on booking creation (user + admin flows) - Backend: campaign CRUD handlers (GET/POST/PUT/DELETE + stats) - Backend: milestone campaigns (per-user, global, anniversary) - Frontend: customer account page shows 'card full' status at 10 stamps - Frontend: admin discounts page with campaign management UI - Frontend: TypeScript types for all discount entities - Tests: 9 integration tests covering loyalty, campaigns, milestones, edge cases
769 lines
29 KiB
Go
769 lines
29 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(×Redeemed)
|
|
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(×Redeemed)
|
|
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)")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Discount Eligibility Flag Tests
|
|
// =============================================================================
|
|
|
|
// TestDiscount_EligibilityFlag tests that booking.discount_eligible is set correctly
|
|
func TestDiscount_EligibilityFlag(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
seedDefaultWorkingHours(t)
|
|
|
|
// Test case 1: User with pending redemption should be eligible
|
|
userID1 := 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())
|
|
`, userID1)
|
|
require.NoError(t, err)
|
|
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
// Create a new booking
|
|
bookingID1 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour))
|
|
|
|
// Check discount_eligible flag
|
|
var discountEligible1 bool
|
|
err = db.DB.QueryRow(context.Background(), `SELECT discount_eligible FROM bookings WHERE id = $1`, bookingID1).Scan(&discountEligible1)
|
|
require.NoError(t, err)
|
|
assert.True(t, discountEligible1, "Expected discount_eligible = true for user with pending redemption")
|
|
|
|
// Test case 2: User with no pending redemption and no active campaigns should NOT be eligible
|
|
userID2 := createTestUser(t, 0) // No stamps, no pending redemption
|
|
|
|
bookingID2 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour))
|
|
|
|
var discountEligible2 bool
|
|
err = db.DB.QueryRow(context.Background(), `SELECT discount_eligible FROM bookings WHERE id = $1`, bookingID2).Scan(&discountEligible2)
|
|
require.NoError(t, err)
|
|
assert.False(t, discountEligible2, "Expected discount_eligible = false for user with no pending redemption and no campaigns")
|
|
|
|
// Test case 3: User with active time-based campaign should be eligible
|
|
userID3 := createTestUser(t, 0)
|
|
|
|
// Create active time-based campaign
|
|
_ = createTestCampaign(t, "Active Campaign", "time_based", 5.0, nil, nil, nil, nil)
|
|
|
|
bookingID3 := createPendingBooking(t, userID3, serviceID, time.Now().Add(72*time.Hour))
|
|
|
|
var discountEligible3 bool
|
|
err = db.DB.QueryRow(context.Background(), `SELECT discount_eligible FROM bookings WHERE id = $1`, bookingID3).Scan(&discountEligible3)
|
|
require.NoError(t, err)
|
|
assert.True(t, discountEligible3, "Expected discount_eligible = true when active time-based campaign exists")
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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)
|
|
}
|
|
}
|
|
} |