Files
Crussell/backend/handlers/payments/discount_preview_test.go
T
popertotsandSisyphus 7b24f8e484 refactor(payments): integrate VAT into gift card buy flow and wrap in transactions
Refactor BuyGiftCard to insert pending payment before Square call with VAT applied. Add transaction wrapping to gift card handlers. Remove redundant Content-Type header sets. Migrate all time.Now() to clock.Now().

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:40 +01:00

791 lines
25 KiB
Go

//go:build test && dev
// +build test,dev
package payments
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/testutils"
"crussell/mw"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
)
// =============================================================================
// GetDiscountPreviewHandler — GET /api/bookings/{id}/discount-preview
// =============================================================================
// setupDiscountPreviewTest creates a user, service, and booking for discount preview tests.
func setupDiscountPreviewTest(t *testing.T, ctx context.Context, q db.Querier) (string, string, string) {
t.Helper()
userID, err := fixtures.CreateTestUser(q)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(q)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(q, 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 = q.Exec(ctx, `UPDATE bookings SET status = 'confirmed' WHERE id = $1`, bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
token := jwt.GenerateUserToken(userID)
return userID, bookingID, token
}
// serveDiscountPreviewHandler wires chi context with {id} param and serves the handler.
// Pass userID explicitly so the handler sees the correct user in context.
func serveDiscountPreviewHandler(bookingID, userID, token string, baseCtx ...context.Context) *httptest.ResponseRecorder {
handler := http.HandlerFunc(GetDiscountPreviewHandler)
req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/discount-preview", nil)
req.Header.Set("Authorization", "Bearer "+token)
base := context.Background()
if len(baseCtx) > 0 {
base = baseCtx[0]
}
ctx := context.WithValue(base, mw.UserIDKey, userID)
ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email")
req = req.WithContext(ctx)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
// TestDiscountPreview_NoCampaigns returns eligible=false when no active campaigns exist.
func TestDiscountPreview_NoCampaigns(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp DiscountPreviewResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Eligible {
t.Error("expected eligible=false with no active campaigns")
}
if len(resp.Discounts) != 0 {
t.Errorf("expected 0 discounts, got %d", len(resp.Discounts))
}
if resp.OriginalTotal <= 0 {
t.Errorf("expected positive original_total, got %f", resp.OriginalTotal)
}
if resp.DiscountedTotal != resp.OriginalTotal {
t.Errorf("expected discounted_total == original_total with no discounts, got %f vs %f",
resp.DiscountedTotal, resp.OriginalTotal)
}
}
// TestDiscountPreview_TimeBasedCampaign returns the correct discount for an active time-based campaign.
func TestDiscountPreview_TimeBasedCampaign(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
now := clock.Now()
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ('Test Sale', 'time_based', 15, 'active', $1, $2, 0)
RETURNING id
`, now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp DiscountPreviewResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.Eligible {
t.Fatal("expected eligible=true with active time-based campaign")
}
if len(resp.Discounts) != 1 {
t.Fatalf("expected 1 discount, got %d", len(resp.Discounts))
}
d := resp.Discounts[0]
if d.Source != "campaign" {
t.Errorf("expected source 'campaign', got %q", d.Source)
}
if d.Percent != 15 {
t.Errorf("expected 15 percent, got %f", d.Percent)
}
if d.Amount <= 0 {
t.Errorf("expected positive discount amount, got %f", d.Amount)
}
expectedDiscount := resp.OriginalTotal * 15 / 100
if d.Amount != expectedDiscount {
t.Errorf("expected discount amount %f, got %f", expectedDiscount, d.Amount)
}
if resp.DiscountedTotal != resp.OriginalTotal-d.Amount {
t.Errorf("discounted_total %f should be original %f - discount %f",
resp.DiscountedTotal, resp.OriginalTotal, d.Amount)
}
// Verify preview did NOT create any actual booking_discounts or payment records
var discountCount int
tx.QueryRow(ctx,
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1", bookingID).Scan(&discountCount)
if discountCount != 0 {
t.Errorf("preview should not create booking_discounts, found %d", discountCount)
}
var paymentCount int
tx.QueryRow(ctx,
"SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'", bookingID).Scan(&paymentCount)
if paymentCount != 0 {
t.Errorf("preview should not create discount payment records, found %d", paymentCount)
}
// Verify campaign redemption count was NOT incremented
var timesRedeemed int
tx.QueryRow(ctx,
"SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(&timesRedeemed)
if timesRedeemed != 0 {
t.Errorf("preview should not increment times_redeemed, got %d", timesRedeemed)
}
}
// TestDiscountPreview_CampaignExhausted returns not eligible when a campaign has reached max_redemptions.
func TestDiscountPreview_CampaignExhausted(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
now := clock.Now()
_, err := tx.Exec(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
VALUES ('Exhausted Campaign', 'time_based', 20, 'active', $1, $2, 5, 5)
`, now.Add(-24*time.Hour), now.Add(24*time.Hour))
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp DiscountPreviewResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Eligible {
t.Error("expected eligible=false when campaign is exhausted")
}
}
// TestDiscountPreview_CampaignExpired returns not eligible for a past campaign.
func TestDiscountPreview_CampaignExpired(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
_, err := tx.Exec(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
VALUES ('Expired Campaign', 'time_based', 10, 'active', $1, $2)
`, clock.Now().Add(-72*time.Hour), clock.Now().Add(-24*time.Hour))
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
var resp DiscountPreviewResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.Eligible {
t.Error("expected eligible=false when campaign has expired")
}
}
// TestDiscountPreview_CampaignNotStarted returns not eligible for a future campaign.
func TestDiscountPreview_CampaignNotStarted(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
_, err := tx.Exec(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
VALUES ('Future Campaign', 'time_based', 10, 'active', $1, $2)
`, clock.Now().Add(24*time.Hour), clock.Now().Add(72*time.Hour))
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
var resp DiscountPreviewResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.Eligible {
t.Error("expected eligible=false when campaign has not started")
}
}
// TestDiscountPreview_MilestoneCampaign returns the discount for a milestone campaign the user has reached.
func TestDiscountPreview_MilestoneCampaign(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
// Create a completed booking so the user has a booking count of at least 1 (plus the current test booking)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
pastBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create completed booking: %v", err)
}
// The fixture creates bookings with status 'pending' — mark as completed for milestone counting
_, err = tx.Exec(ctx, `UPDATE bookings SET status = 'completed' WHERE id = $1`, pastBookingID)
if err != nil {
t.Fatalf("failed to mark booking as completed: %v", err)
}
// Create milestone campaign for 1st booking
_, err = tx.Exec(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value)
VALUES ('First Booking Bonus', 'milestone', 25, 'active', $1, $2, 'per_user_booking_count', 1)
`, clock.Now().Add(-24*time.Hour), clock.Now().Add(24*time.Hour))
if err != nil {
t.Fatalf("failed to create milestone campaign: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp DiscountPreviewResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.Eligible {
t.Fatal("expected eligible=true for milestone campaign")
}
foundMilestone := false
for _, d := range resp.Discounts {
if d.Percent == 25 {
foundMilestone = true
break
}
}
if !foundMilestone {
t.Error("expected a 25% milestone discount in the preview")
}
}
// TestDiscountPreview_AlreadyApplied excludes discounts already applied to this booking.
func TestDiscountPreview_AlreadyApplied(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
now := clock.Now()
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ('Already Applied', 'time_based', 10, 'active', $1, $2, 0)
RETURNING id
`, now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
// Simulate the discount already being applied by inserting a booking_discounts record
var bookingTotal float64
tx.QueryRow(ctx, `
SELECT COALESCE(SUM(price_val), 0) FROM (
SELECT COALESCE(bs.override_price, s.price) AS price_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
) sub
`, bookingID).Scan(&bookingTotal)
discountAmount := bookingTotal * 10 / 100
_, err = tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 10, $4, $5)
`, bookingID, userID, campaignID, bookingTotal, discountAmount)
if err != nil {
t.Fatalf("failed to apply discount: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
var resp DiscountPreviewResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Eligible {
t.Error("expected eligible=false when discount already applied to this booking")
}
}
// TestDiscountPreview_InvalidBookingID returns 400 for invalid booking ID.
func TestDiscountPreview_InvalidBookingID(t *testing.T) {
handler := http.HandlerFunc(GetDiscountPreviewHandler)
req := httptest.NewRequest("GET", "/api/bookings/invalid/discount-preview", nil)
ctx := context.WithValue(req.Context(), mw.UserIDKey, "test-user-id")
ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email")
req = req.WithContext(ctx)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "invalid-id")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid booking ID, got %d", w.Code)
}
}
// TestDiscountPreview_MultipleCampaigns returns all eligible discounts.
func TestDiscountPreview_MultipleCampaigns(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
now := clock.Now()
// Create two active campaigns
_, err := tx.Exec(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
VALUES ('Summer Sale', 'time_based', 15, 'active', $1, $2)
`, now.Add(-48*time.Hour), now.Add(48*time.Hour))
if err != nil {
t.Fatalf("failed to create first campaign: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
VALUES ('Flash Sale', 'time_based', 20, 'active', $1, $2, 100, 0)
`, now.Add(-24*time.Hour), now.Add(24*time.Hour))
if err != nil {
t.Fatalf("failed to create second campaign: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp DiscountPreviewResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.Eligible {
t.Fatal("expected eligible=true with multiple active campaigns")
}
if len(resp.Discounts) < 1 {
t.Fatalf("expected at least 1 discount, got %d", len(resp.Discounts))
}
}
// TestDiscountPreview_AnniversaryCampaign returns the correct discount for an anniversary milestone.
func TestDiscountPreview_AnniversaryCampaign(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
// Set first booking to be years ago so anniversary qualifies
tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed')
`, userID, time.Date(2020, 1, 15, 10, 0, 0, 0, time.UTC))
// Create anniversary milestone campaign
_, err := tx.Exec(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit)
VALUES ($1, 'milestone', 20, 'active', NOW(), NOW() + INTERVAL '1 year', 'anniversary', 1, 'years')
`, "One Year Anniversary")
if err != nil {
t.Fatalf("failed to create anniversary campaign: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp DiscountPreviewResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.Eligible {
t.Fatal("expected eligible=true for anniversary campaign")
}
foundAnniversary := false
for _, d := range resp.Discounts {
if d.Source == "campaign" && d.Percent == 20 {
foundAnniversary = true
break
}
}
if !foundAnniversary {
t.Error("expected a 20% anniversary discount in the preview")
}
}
// TestDiscountPreview_PaymentLock prevents new discounts after a completed payment exists.
// After the first payment, the apply function applies discounts. After a second payment,
// no new discounts should be added.
func TestDiscountPreview_PaymentLock(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupDiscountPreviewTest(t, ctx, tx)
now := clock.Now()
var campaignID string
err := tx.QueryRow(ctx, `
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
`, "Payment Lock Test", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
// First payment — discount should be applied
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 1000, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create first payment: %v", err)
}
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID)
var discountCount int
tx.QueryRow(ctx,
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1", bookingID).Scan(&discountCount)
if discountCount != 1 {
t.Errorf("expected 1 discount after first payment, got %d", discountCount)
}
// Second payment — NO new discounts should be added (lock active)
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'full', 'online_square', 4000, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create second payment: %v", err)
}
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID)
tx.QueryRow(ctx,
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1", bookingID).Scan(&discountCount)
if discountCount != 1 {
t.Errorf("expected still 1 discount after second payment (lock active), got %d", discountCount)
}
// Verify campaign was only redeemed once
var timesRedeemed int
tx.QueryRow(ctx,
"SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(&timesRedeemed)
if timesRedeemed != 1 {
t.Errorf("expected 1 redemption, got %d", timesRedeemed)
}
}
// TestDiscountPreview_BookingNoServices returns not eligible for a booking with no services.
func TestDiscountPreview_BookingNoServices(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create a booking without any services
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status) VALUES ($1, NOW(), 'confirmed') RETURNING id
`, userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
now := clock.Now()
_, err = tx.Exec(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
VALUES ('Test Sale', 'time_based', 10, 'active', $1, $2)
`, now.Add(-24*time.Hour), now.Add(24*time.Hour))
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
token := jwt.GenerateUserToken(userID)
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
var resp DiscountPreviewResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Eligible {
t.Error("expected eligible=false for booking with no services")
}
if resp.OriginalTotal != 0 {
t.Errorf("expected original_total=0, got %f", resp.OriginalTotal)
}
}
// TestDiscountPreview_ReturnsReadOnly confirms the preview endpoint never modifies state
// even when eligible campaigns exist.
func TestDiscountPreview_ReturnsReadOnly(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
now := clock.Now()
// Create an active campaign
var campaignID string
err := tx.QueryRow(ctx, `
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)
}
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp DiscountPreviewResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if !resp.Eligible {
t.Error("expected eligible=true when there's an existing applied campaign discount")
}
if len(resp.Discounts) != 1 {
t.Fatalf("expected 1 discount (applied at payment), got %d", len(resp.Discounts))
}
if len(resp.Discounts) > 0 && resp.Discounts[0].Source != "campaign" {
t.Errorf("expected campaign discount source, got %q", resp.Discounts[0].Source)
}
}
func TestDiscountPreview_ReferralDiscount(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
var refID string
err := tx.QueryRow(ctx, `
INSERT INTO user_referrals (referrer_id, referred_id)
VALUES ($1, $2)
RETURNING id
`, userID, userID).Scan(&refID)
if err != nil {
t.Fatalf("failed to create referral: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used)
VALUES ($1, $2, 10.00, false)
`, userID, refID)
if err != nil {
t.Fatalf("failed to insert referral discount: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp DiscountPreviewResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if !resp.Eligible {
t.Error("expected eligible=true when there's an unused referral discount")
}
foundReferral := false
for _, d := range resp.Discounts {
if d.Source == "referral" {
foundReferral = true
if d.Percent != 10.00 {
t.Errorf("expected referral discount percent 10.00, got %f", d.Percent)
}
if d.Name != "Referral Discount (10%)" {
t.Errorf("expected name 'Referral Discount (10%%)', got %q", d.Name)
}
}
}
if !foundReferral {
t.Errorf("expected referral discount in discounts list, got %+v", resp.Discounts)
}
}
func TestDiscountPreview_ReferralDiscount_AlreadyUsed(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
// Insert a used referral discount
var refID string
err := tx.QueryRow(ctx, `
INSERT INTO user_referrals (referrer_id, referred_id)
VALUES ($1, $2)
RETURNING id
`, userID, userID).Scan(&refID)
if err != nil {
t.Fatalf("failed to create referral: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used)
VALUES ($1, $2, 10.00, true)
`, userID, refID)
if err != nil {
t.Fatalf("failed to insert used referral discount: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp DiscountPreviewResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
for _, d := range resp.Discounts {
if d.Source == "referral" {
t.Error("expected no referral discount when already used")
}
}
}
func TestDiscountPreview_ReferralDiscount_AlreadyApplied(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
var refID string
err := tx.QueryRow(ctx, `
INSERT INTO user_referrals (referrer_id, referred_id)
VALUES ($1, $2)
RETURNING id
`, userID, userID).Scan(&refID)
if err != nil {
t.Fatalf("failed to create referral: %v", err)
}
var rdID string
err = tx.QueryRow(ctx, `
INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used)
VALUES ($1, $2, 10.00, false)
RETURNING id
`, userID, refID).Scan(&rdID)
if err != nil {
t.Fatalf("failed to insert referral discount: %v", err)
}
// Apply it to the booking already
_, err = tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'referral', $3, 10.00, 100.00, 10.00)
`, bookingID, userID, rdID)
if err != nil {
t.Fatalf("failed to insert booking discount: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp DiscountPreviewResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
for _, d := range resp.Discounts {
if d.Source == "referral" {
t.Error("expected no referral discount when already applied to booking")
}
}
}