Migrate all test files from resetTestData(t) to testutils.SetupTestDB(t) for isolated per-package test databases. - Add new feature tests: name history assertions, referral discount preview, time blockers, email validation, GDPR export, loyalty manual redemption - Update existing tests to use batch queries and SetupTestDB - Remove test_helpers.go resetTestData infrastructure - Add comprehensive user profile tests (442 new lines) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1624 lines
56 KiB
Go
1624 lines
56 KiB
Go
//go:build test && dev
|
|
// +build test,dev
|
|
|
|
package bookings
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/testutils"
|
|
"crussell/handlers/payments"
|
|
"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 insertInPersonCardPayment(t *testing.T, bookingID string) {
|
|
t.Helper()
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
|
VALUES ($1, 'full', 'in_person_card', 5000, 'completed', NOW(), NOW())
|
|
`, bookingID)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
type bookingDiscount struct {
|
|
Source string
|
|
Amount float64
|
|
CampType string
|
|
MileType string
|
|
}
|
|
|
|
func getDiscountRowCount(t *testing.T, bookingID string) int {
|
|
t.Helper()
|
|
var count int
|
|
err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&count)
|
|
require.NoError(t, err)
|
|
return count
|
|
}
|
|
|
|
func getAllDiscountsForBooking(t *testing.T, bookingID string) []bookingDiscount {
|
|
t.Helper()
|
|
rows, err := db.DB.Query(context.Background(), `
|
|
SELECT discount_source, discount_amount, COALESCE(campaign_type::text, ''), COALESCE(milestone_type::text, '')
|
|
FROM booking_discounts WHERE booking_id = $1 ORDER BY discount_source, campaign_type, milestone_type
|
|
`, bookingID)
|
|
require.NoError(t, err)
|
|
defer rows.Close()
|
|
var discounts []bookingDiscount
|
|
for rows.Next() {
|
|
var d bookingDiscount
|
|
require.NoError(t, rows.Scan(&d.Source, &d.Amount, &d.CampType, &d.MileType))
|
|
discounts = append(discounts, d)
|
|
}
|
|
return discounts
|
|
}
|
|
|
|
func getTotalDiscountAmount(t *testing.T, bookingID string) float64 {
|
|
t.Helper()
|
|
var amount float64
|
|
err := db.DB.QueryRow(context.Background(), `SELECT COALESCE(SUM(discount_amount), 0) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&amount)
|
|
require.NoError(t, err)
|
|
return amount
|
|
}
|
|
|
|
func getPaymentDiscountRowCount(t *testing.T, bookingID string) int {
|
|
t.Helper()
|
|
var count int
|
|
err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&count)
|
|
require.NoError(t, err)
|
|
return count
|
|
}
|
|
|
|
func getTotalPaymentCount(t *testing.T, bookingID string) int {
|
|
t.Helper()
|
|
var count int
|
|
err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&count)
|
|
require.NoError(t, err)
|
|
return count
|
|
}
|
|
|
|
func getAmountPaid(t *testing.T, bookingID string) float64 {
|
|
t.Helper()
|
|
var amount float64
|
|
err := db.DB.QueryRow(context.Background(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&amount)
|
|
require.NoError(t, err)
|
|
return amount
|
|
}
|
|
|
|
func applyLoyaltyRedemption(t *testing.T, bookingID, userID string) {
|
|
t.Helper()
|
|
handler := http.HandlerFunc(payments.ApplyLoyaltyRedemption)
|
|
req := httptest.NewRequest("POST", "/api/bookings/"+bookingID+"/apply-redemption", nil)
|
|
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", bookingID)
|
|
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, "customer")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
require.Equal(t, http.StatusOK, w.Code, "apply-redemption failed: %s", w.Body.String())
|
|
}
|
|
|
|
// =============================================================================
|
|
// Loyalty Manual-Redemption Tests
|
|
// =============================================================================
|
|
|
|
func TestDiscount_Loyalty_FullCycle(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID := createTestUser(t, 10)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
|
VALUES ($1, 10, 'pending', NOW())
|
|
`, userID)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 10, getStamps(t, userID))
|
|
assert.Equal(t, 1, getPendingRedemptions(t, userID))
|
|
|
|
// Apply loyalty redemption manually on a new booking
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().AddDate(0, 0, 12))
|
|
applyLoyaltyRedemption(t, bookingID, userID)
|
|
|
|
source, amount, exists := getDiscountForBooking(t, bookingID)
|
|
require.True(t, exists, "Expected discount after manual redemption")
|
|
assert.Equal(t, "loyalty", source)
|
|
assert.Equal(t, 5.00, amount, "10% of £50 = £5")
|
|
assert.Equal(t, 0, getStamps(t, userID), "Stamps deducted by 10")
|
|
assert.Equal(t, 0, getPendingRedemptions(t, userID), "Pending redemption consumed")
|
|
|
|
// Complete the booking — no stamp awarded (take or receive, never both)
|
|
completeBooking(t, bookingID)
|
|
assert.Equal(t, 0, getStamps(t, userID), "No stamp for a booking that claimed a reward")
|
|
}
|
|
|
|
func TestDiscount_Loyalty_ExistingRedemptionApplies(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID := createTestUser(t, 10)
|
|
serviceID := createTestService(t, 100.00)
|
|
|
|
milestoneType := "global_booking_count"
|
|
milestoneUnit := "bookings"
|
|
milestoneValue := 1
|
|
_ = createTestCampaign(t, "First Global", "milestone", 5.0, &milestoneType, &milestoneUnit, &milestoneValue, nil)
|
|
|
|
_, 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))
|
|
|
|
// Apply loyalty redemption manually before completion
|
|
applyLoyaltyRedemption(t, bookingID, userID)
|
|
|
|
// Complete booking — milestone campaign applies at completion
|
|
insertInPersonCardPayment(t, bookingID)
|
|
completeBooking(t, bookingID)
|
|
|
|
assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows (loyalty + campaign)")
|
|
assert.Equal(t, 2, getPaymentDiscountRowCount(t, bookingID), "Expected 2 discount payment rows")
|
|
|
|
totalDiscount := getTotalDiscountAmount(t, bookingID)
|
|
assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total should be £15 (10% + 5%)")
|
|
|
|
discounts := getAllDiscountsForBooking(t, bookingID)
|
|
require.Len(t, discounts, 2)
|
|
}
|
|
|
|
func TestDiscount_Stacking_LoyaltyPlusMilestone(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
mt := "per_user_booking_count"
|
|
mu := "bookings"
|
|
mv := 5
|
|
_ = createTestCampaign(t, "5th Booking", "milestone", 15.0, &mt, &mu, &mv, 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)
|
|
|
|
for i := 0; i < 4; i++ {
|
|
startTime := time.Now().AddDate(0, 0, -(i + 10))
|
|
_ = createCompletedBooking(t, userID, serviceID, startTime, 100.00)
|
|
}
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
|
|
// Apply loyalty manually, then complete (milestone applies at completion)
|
|
applyLoyaltyRedemption(t, bookingID, userID)
|
|
completeBooking(t, bookingID)
|
|
|
|
assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows")
|
|
|
|
totalDiscount := getTotalDiscountAmount(t, bookingID)
|
|
assert.InDelta(t, 25.00, totalDiscount, 0.01, "Total should be £25 (10% + 15%)")
|
|
}
|
|
|
|
func TestDiscount_Stacking_LoyaltyPlusAnniversary(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
mt := "anniversary"
|
|
mu := "years"
|
|
mv := 1
|
|
_ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt, &mu, &mv, 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)
|
|
|
|
firstStartTime := time.Now().AddDate(0, 0, -400)
|
|
_ = createCompletedBooking(t, userID, serviceID, firstStartTime, 100.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
|
|
applyLoyaltyRedemption(t, bookingID, userID)
|
|
completeBooking(t, bookingID)
|
|
|
|
assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows")
|
|
|
|
totalDiscount := getTotalDiscountAmount(t, bookingID)
|
|
assert.InDelta(t, 20.00, totalDiscount, 0.01, "Total should be £20 (10% loyalty + 10% anniversary)")
|
|
}
|
|
|
|
func TestDiscount_Stacking_AllThreeTypes(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil)
|
|
|
|
mt := "anniversary"
|
|
mu := "years"
|
|
mv := 1
|
|
_ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt, &mu, &mv, 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)
|
|
|
|
firstStartTime := time.Now().AddDate(0, 0, -400)
|
|
_ = createCompletedBooking(t, userID, serviceID, firstStartTime, 100.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
|
|
applyLoyaltyRedemption(t, bookingID, userID)
|
|
completeBooking(t, bookingID)
|
|
|
|
assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 discount rows")
|
|
|
|
totalDiscount := getTotalDiscountAmount(t, bookingID)
|
|
assert.InDelta(t, 25.00, totalDiscount, 0.01, "Total should be £25 (10% + 5% + 10%)")
|
|
}
|
|
|
|
func TestDiscount_Stacking_MultipleMilestones(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
mt1 := "per_user_booking_count"
|
|
mu1 := "bookings"
|
|
mv1 := 5
|
|
_ = createTestCampaign(t, "5th Booking", "milestone", 10.0, &mt1, &mu1, &mv1, nil)
|
|
|
|
mt2 := "global_booking_count"
|
|
mu2 := "bookings"
|
|
mv2 := 5
|
|
_ = createTestCampaign(t, "5th Global", "milestone", 5.0, &mt2, &mu2, &mv2, nil)
|
|
|
|
mt3 := "anniversary"
|
|
mu3 := "years"
|
|
mv3 := 1
|
|
_ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt3, &mu3, &mv3, nil)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 100.00)
|
|
|
|
// 4 prior completed bookings, first one backdated 400+ days
|
|
for i := 0; i < 4; i++ {
|
|
var startTime time.Time
|
|
if i == 0 {
|
|
startTime = time.Now().AddDate(0, 0, -400)
|
|
} else {
|
|
startTime = time.Now().AddDate(0, 0, -(i * 7))
|
|
}
|
|
_ = createCompletedBooking(t, userID, serviceID, startTime, 100.00)
|
|
}
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
insertInPersonCardPayment(t, bookingID)
|
|
completeBooking(t, bookingID)
|
|
|
|
assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 discount rows (all milestones)")
|
|
}
|
|
|
|
func TestDiscount_Stacking_LoyaltyPlusGlobalMilestone(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
mt := "global_booking_count"
|
|
mu := "bookings"
|
|
mv := 1
|
|
_ = createTestCampaign(t, "First Global", "milestone", 5.0, &mt, &mu, &mv, 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))
|
|
|
|
applyLoyaltyRedemption(t, bookingID, userID)
|
|
insertInPersonCardPayment(t, bookingID)
|
|
completeBooking(t, bookingID)
|
|
|
|
assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows")
|
|
|
|
totalDiscount := getTotalDiscountAmount(t, bookingID)
|
|
assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total should be £15 (10% + 5%)")
|
|
}
|
|
|
|
func TestDiscount_Stacking_DiscountAmountsSumCorrectly(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil)
|
|
|
|
mt := "per_user_booking_count"
|
|
mu := "bookings"
|
|
mv := 1
|
|
_ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, 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, 200.00)
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
|
|
applyLoyaltyRedemption(t, bookingID, userID)
|
|
completeBooking(t, bookingID)
|
|
|
|
assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 discount rows")
|
|
|
|
totalDiscount := getTotalDiscountAmount(t, bookingID)
|
|
assert.InDelta(t, 50.00, totalDiscount, 0.01, "Total should be £50")
|
|
|
|
discounts := getAllDiscountsForBooking(t, bookingID)
|
|
require.Len(t, discounts, 3)
|
|
for _, d := range discounts {
|
|
switch d.Source {
|
|
case "loyalty":
|
|
assert.InDelta(t, 20.00, d.Amount, 0.01, "Loyalty: 10% of £200")
|
|
case "campaign":
|
|
if d.CampType == "time_based" {
|
|
assert.InDelta(t, 10.00, d.Amount, 0.01, "Time-based: 5% of £200")
|
|
} else if d.CampType == "milestone" {
|
|
assert.InDelta(t, 20.00, d.Amount, 0.01, "Milestone: 10% of £200")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDiscount_Stacking_MultiplePaymentRecords(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil)
|
|
|
|
mt := "per_user_booking_count"
|
|
mu := "bookings"
|
|
mv := 1
|
|
_ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, 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, 200.00)
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
|
|
applyLoyaltyRedemption(t, bookingID, userID)
|
|
completeBooking(t, bookingID)
|
|
|
|
assert.Equal(t, 3, getPaymentDiscountRowCount(t, bookingID), "Expected 3 discount payment rows")
|
|
|
|
var totalDiscountPayment float64
|
|
err = db.DB.QueryRow(context.Background(), `
|
|
SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'
|
|
`, bookingID).Scan(&totalDiscountPayment)
|
|
require.NoError(t, err)
|
|
assert.InDelta(t, 50.00, totalDiscountPayment, 0.01, "Sum of discount payments should be £50")
|
|
}
|
|
|
|
func TestDiscount_Stacking_MultipleBookingDiscountRows(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil)
|
|
|
|
mt := "per_user_booking_count"
|
|
mu := "bookings"
|
|
mv := 1
|
|
_ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, 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, 200.00)
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
|
|
applyLoyaltyRedemption(t, bookingID, userID)
|
|
completeBooking(t, bookingID)
|
|
|
|
assert.Equal(t, 3, getDiscountRowCount(t, bookingID), "Expected 3 booking_discounts rows")
|
|
|
|
type discountDetail struct {
|
|
Source string
|
|
CampType string
|
|
MileType string
|
|
Percent float64
|
|
OriginalTotal float64
|
|
Amount float64
|
|
}
|
|
rows, err := db.DB.Query(context.Background(), `
|
|
SELECT discount_source, COALESCE(campaign_type::text, ''), COALESCE(milestone_type::text, ''), discount_percent, original_total, discount_amount
|
|
FROM booking_discounts WHERE booking_id = $1 ORDER BY discount_source, campaign_type, milestone_type
|
|
`, bookingID)
|
|
require.NoError(t, err)
|
|
defer rows.Close()
|
|
var details []discountDetail
|
|
for rows.Next() {
|
|
var d discountDetail
|
|
require.NoError(t, rows.Scan(&d.Source, &d.CampType, &d.MileType, &d.Percent, &d.OriginalTotal, &d.Amount))
|
|
details = append(details, d)
|
|
}
|
|
require.Len(t, details, 3)
|
|
|
|
for _, d := range details {
|
|
assert.Equal(t, 200.00, d.OriginalTotal, "original_total should be £200")
|
|
switch d.Source {
|
|
case "loyalty":
|
|
assert.Equal(t, "", d.CampType)
|
|
assert.Equal(t, "", d.MileType)
|
|
assert.InDelta(t, 10.00, d.Percent, 0.01)
|
|
assert.InDelta(t, 20.00, d.Amount, 0.01)
|
|
case "campaign":
|
|
if d.CampType == "time_based" {
|
|
assert.Equal(t, "", d.MileType)
|
|
assert.InDelta(t, 5.00, d.Percent, 0.01)
|
|
assert.InDelta(t, 10.00, d.Amount, 0.01)
|
|
} else if d.CampType == "milestone" {
|
|
assert.Equal(t, "per_user_booking_count", d.MileType)
|
|
assert.InDelta(t, 10.00, d.Percent, 0.01)
|
|
assert.InDelta(t, 20.00, d.Amount, 0.01)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDiscount_Stacking_TimeBasedPlusMilestone(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil)
|
|
|
|
mt := "per_user_booking_count"
|
|
mu := "bookings"
|
|
mv := 3
|
|
_ = createTestCampaign(t, "3rd Booking", "milestone", 10.0, &mt, &mu, &mv, nil)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 100.00)
|
|
|
|
// 2 prior completed bookings
|
|
startTime1 := time.Now().AddDate(0, 0, -14)
|
|
_ = createCompletedBooking(t, userID, serviceID, startTime1, 100.00)
|
|
|
|
startTime2 := time.Now().AddDate(0, 0, -7)
|
|
_ = createCompletedBooking(t, userID, serviceID, startTime2, 100.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
assert.Equal(t, 2, getDiscountRowCount(t, bookingID), "Expected 2 discount rows")
|
|
|
|
totalDiscount := getTotalDiscountAmount(t, bookingID)
|
|
assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total should be £15 (5% + 10%)")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Zero Total Edge Case Tests
|
|
// =============================================================================
|
|
|
|
func TestDiscount_NoDiscountOnZeroTotal(t *testing.T) {
|
|
testutils.SetupTestDB(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) {
|
|
testutils.SetupTestDB(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)")
|
|
}
|
|
|
|
func createTestCampaignWithStatus(t *testing.T, name, campaignType string, percent float64, status string, 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, $4, $5, $6, $7, $8, $9, $10, 0)
|
|
RETURNING id
|
|
`, name, campaignType, percent, status, startDate, endDate, milestoneType, milestoneValue, milestoneUnit, maxRedemptions).Scan(&id)
|
|
require.NoError(t, err)
|
|
return id
|
|
}
|
|
|
|
// =============================================================================
|
|
// Edge Case Tests
|
|
// =============================================================================
|
|
|
|
func TestDiscount_ExpiredRedemptionDoesNotApply(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID := createTestUser(t, 10)
|
|
serviceID := createTestService(t, 100.00)
|
|
|
|
// Insert pending redemption that has already expired
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at, expires_at)
|
|
VALUES ($1, 10, 'pending', NOW(), NOW() - INTERVAL '1 day')
|
|
`, userID)
|
|
require.NoError(t, err)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
count := getDiscountRowCount(t, bookingID)
|
|
assert.Equal(t, 0, count, "Expired redemption should not apply discount")
|
|
}
|
|
|
|
func TestDiscount_MultiplePendingRedemptions_UsesOldest(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID := createTestUser(t, 10)
|
|
serviceID := createTestService(t, 100.00)
|
|
|
|
ctx := context.Background()
|
|
|
|
_, err := db.DB.Exec(ctx, `
|
|
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
|
VALUES ($1, 10, 'pending', NOW() - INTERVAL '2 days')
|
|
`, userID)
|
|
require.NoError(t, err)
|
|
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
|
VALUES ($1, 10, 'pending', NOW() - INTERVAL '1 day')
|
|
`, userID)
|
|
require.NoError(t, err)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
|
|
// Manual apply-redemption should pick the oldest pending redemption
|
|
applyLoyaltyRedemption(t, bookingID, userID)
|
|
|
|
count := getDiscountRowCount(t, bookingID)
|
|
assert.Equal(t, 1, count, "Exactly 1 loyalty discount row should be applied")
|
|
|
|
type redemptionRow struct {
|
|
ID string
|
|
Status string
|
|
}
|
|
rows, err := db.DB.Query(ctx, `
|
|
SELECT id, status FROM loyalty_redemptions WHERE user_id = $1 ORDER BY redeemed_at ASC
|
|
`, userID)
|
|
require.NoError(t, err)
|
|
defer rows.Close()
|
|
|
|
var redemptions []redemptionRow
|
|
for rows.Next() {
|
|
var r redemptionRow
|
|
require.NoError(t, rows.Scan(&r.ID, &r.Status))
|
|
redemptions = append(redemptions, r)
|
|
}
|
|
require.NoError(t, rows.Err())
|
|
require.Equal(t, 2, len(redemptions))
|
|
assert.Equal(t, "applied", redemptions[0].Status, "Oldest redemption should be applied")
|
|
assert.Equal(t, "pending", redemptions[1].Status, "Newer redemption should remain pending")
|
|
}
|
|
|
|
func TestDiscount_StampCountAboveTen(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID := createTestUser(t, 9)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
// First booking: stamps 9 → 10, pending redemption auto-created at completion
|
|
bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID1)
|
|
backdateBooking(t, bookingID1, 2)
|
|
|
|
assert.Equal(t, 10, getStamps(t, userID), "Stamps should be 10 after first completion")
|
|
assert.Equal(t, 1, getPendingRedemptions(t, userID), "Pending redemption should be auto-created")
|
|
|
|
// Second booking: stamps accumulate to 11 (no auto-deduct)
|
|
bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour))
|
|
|
|
// Second booking: stamps accumulate to 11 (no auto-deduct)
|
|
// Apply redemption while booking is still confirmed (not yet completed)
|
|
applyLoyaltyRedemption(t, bookingID2, userID)
|
|
assert.Equal(t, 0, getStamps(t, userID), "Stamps: 10 - 10 = 0")
|
|
assert.Equal(t, 0, getPendingRedemptions(t, userID), "Redemption consumed")
|
|
|
|
// Complete the booking — no stamp awarded (take or receive, never both)
|
|
completeBooking(t, bookingID2)
|
|
|
|
source, amount, exists := getDiscountForBooking(t, bookingID2)
|
|
require.True(t, exists, "Expected loyalty discount after manual redemption")
|
|
assert.Equal(t, "loyalty", source)
|
|
assert.Equal(t, 5.00, amount, "10% of £50 = £5")
|
|
assert.Equal(t, 0, getStamps(t, userID), "No stamp for a booking that used a loyalty redemption")
|
|
}
|
|
|
|
func TestDiscount_RedemptionAppliedBeforeStampIncrement(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID := createTestUser(t, 10)
|
|
serviceID := createTestService(t, 50.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))
|
|
|
|
// Apply redemption manually before completing
|
|
applyLoyaltyRedemption(t, bookingID, userID)
|
|
|
|
// Complete the booking — should NOT give a stamp (take or receive, never both)
|
|
completeBooking(t, bookingID)
|
|
|
|
assert.Equal(t, 0, getStamps(t, userID), "No stamp awarded when loyalty redemption was used")
|
|
assert.Equal(t, 0, getPendingRedemptions(t, userID), "No pending redemptions")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Additional loyalty flow tests
|
|
// =============================================================================
|
|
|
|
func TestDiscount_NormalEarn_NoRedemption(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
// User starts at 5 stamps, completes 3 bookings without ever redeeming.
|
|
// Stamps should accumulate normally with no discount interference.
|
|
userID := createTestUser(t, 5)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
booking1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, booking1)
|
|
assert.Equal(t, 6, getStamps(t, userID), "5 + 1 = 6")
|
|
assert.Equal(t, 0, getDiscountRowCount(t, booking1), "No discounts applied")
|
|
assert.Equal(t, 0, getPendingRedemptions(t, userID), "No pending redemption (< 10)")
|
|
|
|
backdateBooking(t, booking1, 2)
|
|
booking2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour))
|
|
completeBooking(t, booking2)
|
|
assert.Equal(t, 7, getStamps(t, userID), "6 + 1 = 7")
|
|
assert.Equal(t, 0, getDiscountRowCount(t, booking2), "No discounts applied")
|
|
|
|
backdateBooking(t, booking2, 2)
|
|
booking3 := createPendingBooking(t, userID, serviceID, time.Now().Add(72*time.Hour))
|
|
completeBooking(t, booking3)
|
|
assert.Equal(t, 8, getStamps(t, userID), "7 + 1 = 8")
|
|
assert.Equal(t, 0, getDiscountRowCount(t, booking3), "No discounts applied")
|
|
}
|
|
|
|
func TestDiscount_DepositBeforeRedemption_Rejected(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
// User pays a deposit upfront without loyalty — the first real payment has been
|
|
// made, so loyalty redemption should be rejected by the first-payment guard.
|
|
userID := createTestUser(t, 10)
|
|
serviceID := createTestService(t, 200.00)
|
|
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
|
VALUES ($1, 10, 'pending', NOW())
|
|
`, userID)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 1, getPendingRedemptions(t, userID))
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
|
|
// Pay a deposit first (the first real payment)
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
|
VALUES ($1, 'deposit', 'online_square', 4000, 'completed', $2)
|
|
`, bookingID, userID)
|
|
require.NoError(t, err)
|
|
|
|
// Later at the till, admin tries to apply loyalty — should be rejected
|
|
handler := http.HandlerFunc(payments.ApplyLoyaltyRedemption)
|
|
req := httptest.NewRequest("POST", "/api/bookings/"+bookingID+"/apply-redemption", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", bookingID)
|
|
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, "customer")
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
require.Equal(t, http.StatusBadRequest, w.Code, "Expected 400 after deposit was paid")
|
|
|
|
// Verify nothing was changed — discount not applied, stamps intact, redemption pending
|
|
assert.Equal(t, 0, getDiscountRowCount(t, bookingID), "No loyalty discount applied")
|
|
assert.Equal(t, 10, getStamps(t, userID), "Stamps not deducted")
|
|
assert.Equal(t, 1, getPendingRedemptions(t, userID), "Redemption still pending")
|
|
assert.Equal(t, 1, getTotalPaymentCount(t, bookingID), "Only the deposit payment exists")
|
|
}
|
|
|
|
func TestDiscount_RedemptionBeforeDeposit_DiscountLockedIn(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
// User applies loyalty redemption online first, then pays the deposit.
|
|
// The discount locks in at 10% of total and persists through to completion.
|
|
userID := createTestUser(t, 10)
|
|
serviceID := createTestService(t, 200.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))
|
|
|
|
// Apply loyalty redemption (simulating online payment checkbox)
|
|
applyLoyaltyRedemption(t, bookingID, userID)
|
|
|
|
// Discount should be 10% of total
|
|
source, amount, exists := getDiscountForBooking(t, bookingID)
|
|
require.True(t, exists)
|
|
assert.Equal(t, "loyalty", source)
|
|
assert.InDelta(t, 20.00, amount, 0.01, "Discount is 10%% of £200 = £20")
|
|
|
|
// Pay a deposit after redemption (£40 deposit on the current due)
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
|
VALUES ($1, 'deposit', 'online_square', 4000, 'completed', $2)
|
|
`, bookingID, userID)
|
|
require.NoError(t, err)
|
|
|
|
// Verify: 2 payments (discount + deposit), discount still intact
|
|
assert.Equal(t, 2, getTotalPaymentCount(t, bookingID), "Expected 2 payment records (discount + deposit)")
|
|
|
|
// Complete the booking — no stamp awarded (take or receive)
|
|
completeBooking(t, bookingID)
|
|
|
|
assert.Equal(t, 0, getStamps(t, userID), "No stamp for a booking that used loyalty")
|
|
assert.Equal(t, 1, getPaymentDiscountRowCount(t, bookingID), "Discount payment still present after completion")
|
|
|
|
// Re-read discount after completion to confirm it wasn't altered
|
|
_, amountAfter, existsAfter := getDiscountForBooking(t, bookingID)
|
|
require.True(t, existsAfter)
|
|
assert.InDelta(t, 20.00, amountAfter, 0.01, "Discount amount unchanged after completion")
|
|
}
|
|
|
|
func TestDiscount_MultipleEarnCycles(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
// Two complete earn-and-redeem cycles: earn 10 → redeem → earn 10 more → redeem
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
// Cycle 1: reach 10 stamps
|
|
_, 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)
|
|
|
|
assert.Equal(t, 10, getStamps(t, userID))
|
|
assert.Equal(t, 1, getPendingRedemptions(t, userID))
|
|
|
|
// Redeem on first booking
|
|
booking1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
applyLoyaltyRedemption(t, booking1, userID)
|
|
assert.Equal(t, 0, getStamps(t, userID))
|
|
assert.Equal(t, 0, getPendingRedemptions(t, userID))
|
|
|
|
// Complete booking — no stamp (take or receive)
|
|
completeBooking(t, booking1)
|
|
assert.Equal(t, 0, getStamps(t, userID), "No stamp — this booking used loyalty")
|
|
|
|
// Cycle 2: set stamps back to 10 via direct DB update
|
|
_, err = db.DB.Exec(context.Background(), `UPDATE users SET loyalty_stamps = 10 WHERE id = $1`, userID)
|
|
require.NoError(t, err)
|
|
|
|
// Create a new pending redemption for the second cycle
|
|
_, 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))
|
|
|
|
// Redeem again on a new booking
|
|
booking2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour))
|
|
applyLoyaltyRedemption(t, booking2, userID)
|
|
|
|
assert.Equal(t, 0, getStamps(t, userID), "Stamps deducted again")
|
|
assert.Equal(t, 1, getDiscountRowCount(t, booking2), "Second discount applied")
|
|
|
|
source, amount, exists := getDiscountForBooking(t, booking2)
|
|
require.True(t, exists)
|
|
assert.Equal(t, "loyalty", source)
|
|
assert.InDelta(t, 5.00, amount, 0.01, "10%% of £50 = £5")
|
|
}
|
|
|
|
func TestDiscount_MixedFreeAndPaidServices(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceIDFree := createTestService(t, 0)
|
|
serviceIDPaid := createTestService(t, 50.00)
|
|
|
|
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, time.Now().Add(24*time.Hour)).Scan(&bookingID)
|
|
require.NoError(t, err)
|
|
|
|
// Insert two booking_services rows: one free, one paid
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO booking_services (booking_id, service_id)
|
|
VALUES ($1, $2), ($1, $3)
|
|
`, bookingID, serviceIDFree, serviceIDPaid)
|
|
require.NoError(t, err)
|
|
|
|
completeBooking(t, bookingID)
|
|
|
|
assert.Equal(t, 1, getStamps(t, userID), "Mixed free/paid booking earns 1 stamp (total > 0)")
|
|
}
|
|
|
|
func TestDiscount_CampaignBoundaryStart(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
ctx := context.Background()
|
|
var campaignID string
|
|
err := db.DB.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
|
|
VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0)
|
|
RETURNING id
|
|
`, "Boundary Start").Scan(&campaignID)
|
|
require.NoError(t, err)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
count := getDiscountRowCount(t, bookingID)
|
|
assert.Equal(t, 1, count, "Campaign at start_date boundary should apply")
|
|
}
|
|
|
|
func TestDiscount_CampaignBoundaryEnd(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
ctx := context.Background()
|
|
var campaignID string
|
|
err := db.DB.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
|
|
VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 minute', 0)
|
|
RETURNING id
|
|
`, "Boundary End").Scan(&campaignID)
|
|
require.NoError(t, err)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
count := getDiscountRowCount(t, bookingID)
|
|
assert.Equal(t, 1, count, "Campaign at end_date boundary should apply")
|
|
}
|
|
|
|
func TestDiscount_CampaignExpiredDoesNotApply(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
ctx := context.Background()
|
|
var campaignID string
|
|
err := db.DB.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
|
|
VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '2 days', NOW() - INTERVAL '1 day', 0)
|
|
RETURNING id
|
|
`, "Expired Campaign").Scan(&campaignID)
|
|
require.NoError(t, err)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
count := getDiscountRowCount(t, bookingID)
|
|
assert.Equal(t, 0, count, "Expired campaign should not apply")
|
|
}
|
|
|
|
func TestDiscount_CampaignDraftDoesNotApply(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
_ = createTestCampaignWithStatus(t, "Draft Campaign", "time_based", 10.0, "draft", nil, nil, nil, nil)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
count := getDiscountRowCount(t, bookingID)
|
|
assert.Equal(t, 0, count, "Draft campaign should not apply")
|
|
}
|
|
|
|
func TestDiscount_CampaignCancelledDoesNotApply(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
_ = createTestCampaignWithStatus(t, "Cancelled Campaign", "time_based", 10.0, "cancelled", nil, nil, nil, nil)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
count := getDiscountRowCount(t, bookingID)
|
|
assert.Equal(t, 0, count, "Cancelled campaign should not apply")
|
|
}
|
|
|
|
func TestDiscount_PriceOverrideRespected(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
_ = createTestCampaign(t, "10% Off", "time_based", 10.0, nil, nil, nil, nil)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 100.00)
|
|
|
|
// Create booking with override_price = £80
|
|
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, time.Now().Add(24*time.Hour)).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, 80.00)
|
|
require.NoError(t, err)
|
|
|
|
completeBooking(t, bookingID)
|
|
|
|
source, amount, exists := getDiscountForBooking(t, bookingID)
|
|
require.True(t, exists)
|
|
assert.Equal(t, "campaign", source)
|
|
assert.Equal(t, 8.00, amount, "10%% of £80 override = £8.00")
|
|
}
|
|
|
|
func TestDiscount_AnniversaryDedupWithStacking(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
milestoneValue := 12
|
|
milestoneType := "anniversary"
|
|
milestoneUnit := "months"
|
|
_ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &milestoneType, &milestoneUnit, &milestoneValue, nil)
|
|
_ = createTestCampaign(t, "Spring Sale", "time_based", 5.0, nil, nil, nil, nil)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 60.00)
|
|
|
|
// Create a completed booking 400 days ago (> 12 months)
|
|
ctx := context.Background()
|
|
fourHundredDaysAgo := time.Now().AddDate(0, 0, -400)
|
|
var firstBookingID string
|
|
err := db.DB.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'completed')
|
|
RETURNING id
|
|
`, userID, fourHundredDaysAgo).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)
|
|
|
|
// First booking after anniversary threshold: should get anniversary + time_based
|
|
bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID1)
|
|
|
|
discounts1 := getAllDiscountsForBooking(t, bookingID1)
|
|
assert.Equal(t, 2, len(discounts1), "First booking should get anniversary + time_based discounts")
|
|
|
|
// Second booking (different day): should only get time_based (anniversary dedup)
|
|
bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour))
|
|
completeBooking(t, bookingID2)
|
|
|
|
discounts2 := getAllDiscountsForBooking(t, bookingID2)
|
|
assert.Equal(t, 1, len(discounts2), "Second booking should only get time_based (anniversary dedup)")
|
|
// Verify the remaining discount is time_based
|
|
foundTimeBased := false
|
|
for _, d := range discounts2 {
|
|
if d.CampType == "time_based" {
|
|
foundTimeBased = true
|
|
}
|
|
}
|
|
assert.True(t, foundTimeBased, "Remaining discount should be time_based")
|
|
}
|
|
|
|
func TestDiscount_PerUserMilestoneDedupWithStacking(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
milestoneValue := 3
|
|
milestoneType := "per_user_booking_count"
|
|
_ = createTestCampaign(t, "3rd Visit", "milestone", 10.0, &milestoneType, nil, &milestoneValue, nil)
|
|
_ = createTestCampaign(t, "May Sale", "time_based", 5.0, nil, nil, nil, nil)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
// Create 2 prior completed bookings on different days
|
|
for i := 0; i < 2; i++ {
|
|
startTime := time.Now().AddDate(0, 0, -10-i*7)
|
|
_ = createCompletedBooking(t, userID, serviceID, startTime, 50.00)
|
|
}
|
|
|
|
// 3rd booking: milestone + time_based
|
|
bookingID3 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID3)
|
|
|
|
discounts3 := getAllDiscountsForBooking(t, bookingID3)
|
|
assert.Equal(t, 2, len(discounts3), "3rd booking should get per-user milestone + time_based")
|
|
|
|
// 4th booking (different day): only time_based (milestone dedup)
|
|
bookingID4 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour))
|
|
completeBooking(t, bookingID4)
|
|
|
|
discounts4 := getAllDiscountsForBooking(t, bookingID4)
|
|
assert.Equal(t, 1, len(discounts4), "4th booking should only get time_based (milestone dedup)")
|
|
foundTimeBased := false
|
|
for _, d := range discounts4 {
|
|
if d.CampType == "time_based" {
|
|
foundTimeBased = true
|
|
}
|
|
}
|
|
assert.True(t, foundTimeBased, "Remaining discount should be time_based")
|
|
}
|
|
|
|
func TestDiscount_GlobalMilestoneMaxRedemptionsWithStacking(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
maxRedemptions := 1
|
|
milestoneValue := 5
|
|
milestoneType := "global_booking_count"
|
|
_ = createTestCampaign(t, "5th Customer", "milestone", 5.0, &milestoneType, nil, &milestoneValue, &maxRedemptions)
|
|
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil)
|
|
|
|
userID1 := createTestUser(t, 0)
|
|
userID2 := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
// Create 4 completed bookings (different days, user1)
|
|
for i := 0; i < 4; i++ {
|
|
startTime := time.Now().AddDate(0, 0, -10-i*7)
|
|
_ = createCompletedBooking(t, userID1, serviceID, startTime, 50.00)
|
|
}
|
|
|
|
// 5th global booking (user1, different day): milestone + time_based
|
|
bookingID5 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour))
|
|
insertInPersonCardPayment(t, bookingID5)
|
|
completeBooking(t, bookingID5)
|
|
|
|
discounts5 := getAllDiscountsForBooking(t, bookingID5)
|
|
assert.Equal(t, 2, len(discounts5), "5th global booking should get milestone + time_based")
|
|
|
|
// 6th global booking (user2, different day): only time_based (max_redemptions reached)
|
|
bookingID6 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour))
|
|
insertInPersonCardPayment(t, bookingID6)
|
|
completeBooking(t, bookingID6)
|
|
|
|
discounts6 := getAllDiscountsForBooking(t, bookingID6)
|
|
assert.Equal(t, 1, len(discounts6), "6th global booking should only get time_based (max_redemptions reached)")
|
|
foundTimeBased := false
|
|
for _, d := range discounts6 {
|
|
if d.CampType == "time_based" {
|
|
foundTimeBased = true
|
|
}
|
|
}
|
|
assert.True(t, foundTimeBased, "Remaining discount should be time_based")
|
|
}
|
|
|
|
func TestDiscount_BestTimeBasedCampaignSelected(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
_ = createTestCampaign(t, "Low Sale", "time_based", 5.0, nil, nil, nil, nil)
|
|
_ = createTestCampaign(t, "High Sale", "time_based", 15.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)
|
|
|
|
count := getDiscountRowCount(t, bookingID)
|
|
assert.Equal(t, 1, count, "Only 1 campaign discount row (best selected)")
|
|
|
|
source, amount, exists := getDiscountForBooking(t, bookingID)
|
|
require.True(t, exists)
|
|
assert.Equal(t, "campaign", source)
|
|
assert.Equal(t, 15.00, amount, "15%% of £100 = £15 (highest percent selected)")
|
|
}
|
|
|
|
func TestDiscount_FirstBookingEarnsStamp(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
assert.Equal(t, 1, getStamps(t, userID), "First paid booking earns 1 stamp")
|
|
}
|
|
|
|
func TestDiscount_TenStampsCreatesRedemption(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID := createTestUser(t, 9)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
assert.Equal(t, 10, getStamps(t, userID), "Stamps should reach 10")
|
|
assert.Equal(t, 1, getPendingRedemptions(t, userID), "1 pending redemption should be auto-created")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Campaign Status Lifecycle Tests
|
|
// =============================================================================
|
|
|
|
func TestCampaign_CreateAsDraft(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
ctx := context.Background()
|
|
var campaignID string
|
|
err := db.DB.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
|
|
VALUES ($1, 'time_based', 10.0, 'draft', NOW(), NOW() + INTERVAL '1 day', 0)
|
|
RETURNING id
|
|
`, "Draft Campaign").Scan(&campaignID)
|
|
require.NoError(t, err)
|
|
|
|
var status string
|
|
err = db.DB.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "draft", status)
|
|
}
|
|
|
|
func TestCampaign_ActivateDraft(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
ctx := context.Background()
|
|
var campaignID string
|
|
err := db.DB.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
|
|
VALUES ($1, 'time_based', 10.0, 'draft', NOW(), NOW() + INTERVAL '1 day', 0)
|
|
RETURNING id
|
|
`, "Draft to Active").Scan(&campaignID)
|
|
require.NoError(t, err)
|
|
|
|
_, err = db.DB.Exec(ctx, `UPDATE discount_campaigns SET status = 'active' WHERE id = $1`, campaignID)
|
|
require.NoError(t, err)
|
|
|
|
var status string
|
|
err = db.DB.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "active", status)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
count := getDiscountRowCount(t, bookingID)
|
|
assert.Equal(t, 1, count, "Activated draft campaign should apply")
|
|
}
|
|
|
|
func TestCampaign_CompleteActive(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
ctx := context.Background()
|
|
var campaignID string
|
|
err := db.DB.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
|
|
VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0)
|
|
RETURNING id
|
|
`, "Active to Completed").Scan(&campaignID)
|
|
require.NoError(t, err)
|
|
|
|
_, err = db.DB.Exec(ctx, `UPDATE discount_campaigns SET status = 'completed' WHERE id = $1`, campaignID)
|
|
require.NoError(t, err)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
count := getDiscountRowCount(t, bookingID)
|
|
assert.Equal(t, 0, count, "Completed campaign should not apply")
|
|
}
|
|
|
|
func TestCampaign_CancelActive(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
ctx := context.Background()
|
|
var campaignID string
|
|
err := db.DB.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
|
|
VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0)
|
|
RETURNING id
|
|
`, "Active to Cancelled").Scan(&campaignID)
|
|
require.NoError(t, err)
|
|
|
|
_, err = db.DB.Exec(ctx, `UPDATE discount_campaigns SET status = 'cancelled' WHERE id = $1`, campaignID)
|
|
require.NoError(t, err)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
count := getDiscountRowCount(t, bookingID)
|
|
assert.Equal(t, 0, count, "Cancelled campaign should not apply")
|
|
}
|
|
|
|
func TestCampaign_RevertToDraft(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
ctx := context.Background()
|
|
var campaignID string
|
|
err := db.DB.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
|
|
VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0)
|
|
RETURNING id
|
|
`, "Active to Draft").Scan(&campaignID)
|
|
require.NoError(t, err)
|
|
|
|
_, err = db.DB.Exec(ctx, `UPDATE discount_campaigns SET status = 'draft' WHERE id = $1`, campaignID)
|
|
require.NoError(t, err)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
count := getDiscountRowCount(t, bookingID)
|
|
assert.Equal(t, 0, count, "Reverted-to-draft campaign should not apply")
|
|
}
|
|
|
|
func TestCampaign_DraftDoesNotApplyDiscounts(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
_ = createTestCampaignWithStatus(t, "Draft Only", "time_based", 10.0, "draft", nil, nil, nil, nil)
|
|
|
|
userID := createTestUser(t, 0)
|
|
serviceID := createTestService(t, 50.00)
|
|
|
|
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
|
completeBooking(t, bookingID)
|
|
|
|
count := getDiscountRowCount(t, bookingID)
|
|
assert.Equal(t, 0, count, "Draft campaign should not apply discounts")
|
|
}
|