Files
Crussell/backend/handlers/payments/loyalty_test.go
T

447 lines
16 KiB
Go

//go:build test && dev
// +build test,dev
package payments
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/db"
"crussell/mw"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
)
// =============================================================================
// ApplyLoyaltyRedemption — POST /api/bookings/{id}/apply-redemption
// =============================================================================
func setupLoyaltyUser(t *testing.T, stamps int) (string, string, string) {
t.Helper()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = db.DB.Exec(context.Background(),
"UPDATE users SET loyalty_stamps = $1 WHERE id = $2", stamps, userID)
if err != nil {
t.Fatalf("failed to set loyalty_stamps: %v", err)
}
// Create a pending loyalty_redemption if stamps >= 10
if stamps >= 10 {
_, err = db.DB.Exec(context.Background(), `
INSERT INTO loyalty_redemptions (user_id, status, redeemed_at, expires_at)
VALUES ($1, 'pending', NOW(), NOW() + INTERVAL '6 months')
`, userID)
if err != nil {
t.Fatalf("failed to create loyalty redemption: %v", err)
}
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set booking status: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
return userID, bookingID, userToken
}
func makeApplyRedemptionRequest(bookingID, token string) *httptest.ResponseRecorder {
handler := http.HandlerFunc(ApplyLoyaltyRedemption)
req := httptest.NewRequest("POST", "/api/bookings/"+bookingID+"/apply-redemption", nil)
req.Header.Set("Authorization", "Bearer "+token)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
userID := extractUserFromTestJWT(token)
if userID != nil {
ctx = context.WithValue(ctx, mw.UserIDKey, userID.userID)
ctx = context.WithValue(ctx, mw.UserRoleKey, userID.role)
}
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
func TestApplyLoyaltyRedemption_Success(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupLoyaltyUser(t, 10)
w := makeApplyRedemptionRequest(bookingID, userToken)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["success"] != true {
t.Error("expected success=true")
}
discountAmount, ok := resp["discount_amount"].(float64)
if !ok || discountAmount <= 0 {
t.Errorf("expected positive discount_amount, got %v", resp["discount_amount"])
}
// Verify booking_discounts was created
var discountCount int
err := db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'", bookingID).Scan(&discountCount)
if err != nil {
t.Fatalf("failed to query booking_discounts: %v", err)
}
if discountCount != 1 {
t.Errorf("expected 1 loyalty discount record, got %d", discountCount)
}
// Verify stamps were deducted (10 - 10 = 0)
var stamps int
err = db.DB.QueryRow(context.Background(), "SELECT loyalty_stamps FROM users WHERE id = (SELECT user_id FROM bookings WHERE id = $1)", bookingID).Scan(&stamps)
if err != nil {
t.Fatalf("failed to query stamps: %v", err)
}
if stamps != 0 {
t.Errorf("expected 0 stamps after redemption, got %d", stamps)
}
// Verify a discount payment record was created
var paymentCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'", bookingID).Scan(&paymentCount)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
if paymentCount != 1 {
t.Errorf("expected 1 discount payment record, got %d", paymentCount)
}
}
func TestApplyLoyaltyRedemption_InsufficientStamps(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupLoyaltyUser(t, 5)
w := makeApplyRedemptionRequest(bookingID, userToken)
if w.Code != http.StatusBadRequest && w.Code != http.StatusConflict {
t.Fatalf("expected 4xx for insufficient stamps, got %d: %s", w.Code, w.Body.String())
}
}
func TestApplyLoyaltyRedemption_AlreadyApplied(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupLoyaltyUser(t, 10)
// First call should succeed
w := makeApplyRedemptionRequest(bookingID, userToken)
if w.Code != http.StatusOK {
t.Fatalf("first call expected 200, got %d: %s", w.Code, w.Body.String())
}
// Second call should be rejected
w = makeApplyRedemptionRequest(bookingID, userToken)
if w.Code != http.StatusConflict && w.Code != http.StatusBadRequest {
t.Fatalf("second call expected 4xx, got %d: %s", w.Code, w.Body.String())
}
// Verify still only 1 discount record
var discountCount int
db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'", bookingID).Scan(&discountCount)
if discountCount != 1 {
t.Errorf("expected 1 loyalty discount record, got %d", discountCount)
}
}
func TestApplyLoyaltyRedemption_TerminalBooking(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupLoyaltyUser(t, 10)
// Set booking to a terminal status
_, err := db.DB.Exec(context.Background(),
"UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set booking status: %v", err)
}
w := makeApplyRedemptionRequest(bookingID, userToken)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for terminal booking, got %d: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// applyEligibleCampaignsAtPayment — campaign auto-apply at payment time
// =============================================================================
func setupCampaignTest(t *testing.T) (string, string, string) {
t.Helper()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set booking status: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
return userID, bookingID, userToken
}
func TestCampaignAutoApply_TimeBased(t *testing.T) {
resetTestData(t)
userID, bookingID, _ := setupCampaignTest(t)
// Create an active time-based campaign
now := time.Now()
var campaignID string
err := db.DB.QueryRow(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ($1, 'time_based', 10, 'active', $2, $3, 0)
RETURNING id
`, "Early Payment Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
// Insert a deposit payment to trigger campaign auto-apply
_, err = db.DB.Exec(context.Background(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Call applyEligibleCampaignsAtPayment
applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID)
// Verify booking_discounts was created
var discountCount int
db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount)
if discountCount != 1 {
t.Errorf("expected 1 campaign discount, got %d", discountCount)
}
// Verify times_redeemed was incremented
var timesRedeemed int
db.DB.QueryRow(context.Background(),
"SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(&timesRedeemed)
if timesRedeemed != 1 {
t.Errorf("expected 1 redemption, got %d", timesRedeemed)
}
}
func TestCampaignAutoApply_UserMilestone(t *testing.T) {
resetTestData(t)
userID, bookingID, _ := setupCampaignTest(t)
// Give user 5 completed bookings to match milestone_value=5
for i := 0; i < 5; i++ {
var bid string
db.DB.QueryRow(context.Background(), `
INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id
`, userID, time.Date(2024, time.Month(i+1), 15, 10, 0, 0, 0, time.UTC)).Scan(&bid)
}
// Create user milestone campaign for 5th booking
var campaignID string
err := db.DB.QueryRow(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed)
VALUES ($1, 'milestone', 15, 'active', NOW(), NOW() + INTERVAL '1 year', 'per_user_booking_count', 5, 1, 0)
RETURNING id
`, "5th Booking Bonus").Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
// Insert a payment
_, err = db.DB.Exec(context.Background(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID)
var discountCount int
db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount)
if discountCount != 1 {
t.Errorf("expected 1 campaign discount, got %d", discountCount)
}
}
func TestCampaignAutoApply_GlobalMilestoneSkippedOnline(t *testing.T) {
resetTestData(t)
userID, bookingID, _ := setupCampaignTest(t)
// Set global completed count high enough
now := time.Now()
for i := 0; i < 100; i++ {
db.DB.QueryRow(context.Background(), `
INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id
`, userID, now.Add(-time.Duration(i)*24*time.Hour)).Scan(new(string))
}
// Create global milestone campaign at milestone_value=100
var campaignID string
err := db.DB.QueryRow(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed)
VALUES ($1, 'milestone', 20, 'active', NOW(), NOW() + INTERVAL '1 year', 'global_booking_count', 100, 5, 0)
RETURNING id
`, "100th Booking Celebration").Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
// Insert an ONLINE payment first (not in_person_card)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID)
// Verify NO discount was applied (global milestone skipped for online payment)
var discountCount int
db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount)
if discountCount != 0 {
t.Errorf("expected 0 campaign discounts (global milestone skipped for online), got %d", discountCount)
}
}
func TestCampaignAutoApply_GlobalMilestoneAppliedInPerson(t *testing.T) {
resetTestData(t)
userID, bookingID, _ := setupCampaignTest(t)
for i := 0; i < 100; i++ {
db.DB.QueryRow(context.Background(), `
INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id
`, userID, time.Date(2024, time.Month(i%12+1), 15, 10, 0, 0, 0, time.UTC)).Scan(new(string))
}
var campaignID string
err := db.DB.QueryRow(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed)
VALUES ($1, 'milestone', 20, 'active', NOW(), NOW() + INTERVAL '1 year', 'global_booking_count', 100, 5, 0)
RETURNING id
`, "100th Booking Celebration").Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
// Insert an IN-PERSON payment
_, 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)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Simulate what CreateBookingPayment does: set status to confirmed after payment
db.DB.Exec(context.Background(),
"UPDATE bookings SET status = 'confirmed', updated_at = NOW() WHERE id = $1", bookingID)
applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID)
var discountCount int
db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount)
if discountCount != 1 {
t.Errorf("expected 1 campaign discount (in-person global milestone), got %d", discountCount)
}
}
func TestCampaignAutoApply_DoubleApplyGuard(t *testing.T) {
resetTestData(t)
userID, bookingID, _ := setupCampaignTest(t)
now := time.Now()
var campaignID string
err := db.DB.QueryRow(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ($1, 'time_based', 10, 'active', $2, $3, 0)
RETURNING id
`, "Test Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
// Manually insert a booking_discount to simulate it was already applied at payment time
_, err = db.DB.Exec(context.Background(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'time_based', 10, 5000, 500)
`, bookingID, userID, campaignID)
if err != nil {
t.Fatalf("failed to insert existing discount: %v", err)
}
// Pretend campaign was already redeemed
db.DB.Exec(context.Background(),
"UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1", campaignID)
// Insert payment to trigger applyEligibleCampaignsAtPayment
db.DB.Exec(context.Background(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
`, bookingID)
applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID)
// Verify still only 1 discount
var discountCount int
db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount)
if discountCount != 1 {
t.Errorf("expected 1 campaign discount (no double-apply), got %d", discountCount)
}
}