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>
797 lines
26 KiB
Go
797 lines
26 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/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) (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 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) *httptest.ResponseRecorder {
|
|
handler := http.HandlerFunc(GetDiscountPreviewHandler)
|
|
|
|
req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/discount-preview", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
ctx := req.Context()
|
|
ctx = context.WithValue(ctx, 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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, bookingID, token := setupDiscountPreviewTest(t)
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
w := serveDiscountPreviewHandler(bookingID, userID, token)
|
|
|
|
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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, bookingID, token := setupDiscountPreviewTest(t)
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
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 ('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)
|
|
|
|
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
|
|
db.DB.QueryRow(context.Background(),
|
|
"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
|
|
db.DB.QueryRow(context.Background(),
|
|
"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
|
|
db.DB.QueryRow(context.Background(),
|
|
"SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(×Redeemed)
|
|
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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, bookingID, token := setupDiscountPreviewTest(t)
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
now := time.Now()
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
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)
|
|
|
|
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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, bookingID, token := setupDiscountPreviewTest(t)
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
|
|
VALUES ('Expired Campaign', 'time_based', 10, 'active', $1, $2)
|
|
`, time.Now().Add(-72*time.Hour), time.Now().Add(-24*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create campaign: %v", err)
|
|
}
|
|
|
|
w := serveDiscountPreviewHandler(bookingID, userID, token)
|
|
|
|
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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, bookingID, token := setupDiscountPreviewTest(t)
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
|
|
VALUES ('Future Campaign', 'time_based', 10, 'active', $1, $2)
|
|
`, time.Now().Add(24*time.Hour), time.Now().Add(72*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create campaign: %v", err)
|
|
}
|
|
|
|
w := serveDiscountPreviewHandler(bookingID, userID, token)
|
|
|
|
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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, bookingID, token := setupDiscountPreviewTest(t)
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
// Create a completed booking so the user has a booking count of at least 1 (plus the current test booking)
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
pastBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, 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 = db.DB.Exec(context.Background(), `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 = db.DB.Exec(context.Background(), `
|
|
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)
|
|
`, time.Now().Add(-24*time.Hour), time.Now().Add(24*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create milestone campaign: %v", err)
|
|
}
|
|
|
|
w := serveDiscountPreviewHandler(bookingID, userID, token)
|
|
|
|
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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, bookingID, token := setupDiscountPreviewTest(t)
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
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 ('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
|
|
db.DB.QueryRow(context.Background(), `
|
|
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 = db.DB.Exec(context.Background(), `
|
|
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)
|
|
|
|
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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, bookingID, token := setupDiscountPreviewTest(t)
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
now := time.Now()
|
|
|
|
// Create two active campaigns
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
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 = db.DB.Exec(context.Background(), `
|
|
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)
|
|
|
|
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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, bookingID, token := setupDiscountPreviewTest(t)
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
// Set first booking to be years ago so anniversary qualifies
|
|
db.DB.Exec(context.Background(), `
|
|
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 := db.DB.Exec(context.Background(), `
|
|
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)
|
|
|
|
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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, bookingID, _ := setupDiscountPreviewTest(t)
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
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
|
|
`, "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 = 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', 1000, 'completed', NOW(), NOW())
|
|
`, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create first 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", 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 = db.DB.Exec(context.Background(), `
|
|
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(context.Background(), bookingID, userID)
|
|
|
|
db.DB.QueryRow(context.Background(),
|
|
"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
|
|
db.DB.QueryRow(context.Background(),
|
|
"SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(×Redeemed)
|
|
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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
// Create a booking without any services
|
|
var bookingID string
|
|
err = db.DB.QueryRow(context.Background(), `
|
|
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 := time.Now()
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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)
|
|
|
|
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) {
|
|
testutils.SetupTestDB(t)
|
|
userID, bookingID, token := setupDiscountPreviewTest(t)
|
|
|
|
now := time.Now()
|
|
|
|
// Create an active campaign
|
|
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)
|
|
}
|
|
|
|
w := serveDiscountPreviewHandler(bookingID, userID, token)
|
|
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) {
|
|
testutils.SetupTestDB(t)
|
|
userID, bookingID, token := setupDiscountPreviewTest(t)
|
|
|
|
// Insert a referral discount for the user
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used)
|
|
VALUES ($1, (SELECT id FROM user_referrals LIMIT 1), 10.00, false)
|
|
`, userID)
|
|
// If no user_referrals exist, create a minimal one
|
|
if err != nil {
|
|
// Create a minimal referral so the FK works
|
|
var refUserID string
|
|
db.DB.QueryRow(context.Background(), `SELECT id FROM users WHERE id != $1 LIMIT 1`, userID).Scan(&refUserID)
|
|
if refUserID == "" {
|
|
refUserID = userID
|
|
}
|
|
var refID string
|
|
err = db.DB.QueryRow(context.Background(), `
|
|
INSERT INTO user_referrals (referrer_id, referred_id)
|
|
VALUES ($1, $2)
|
|
RETURNING id
|
|
`, refUserID, userID).Scan(&refID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create referral: %v", err)
|
|
}
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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)
|
|
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) {
|
|
testutils.SetupTestDB(t)
|
|
userID, bookingID, token := setupDiscountPreviewTest(t)
|
|
|
|
// Insert a used referral discount
|
|
var refID string
|
|
err := db.DB.QueryRow(context.Background(), `
|
|
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 = db.DB.Exec(context.Background(), `
|
|
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)
|
|
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) {
|
|
testutils.SetupTestDB(t)
|
|
userID, bookingID, token := setupDiscountPreviewTest(t)
|
|
|
|
var refID string
|
|
err := db.DB.QueryRow(context.Background(), `
|
|
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 = db.DB.QueryRow(context.Background(), `
|
|
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 = db.DB.Exec(context.Background(), `
|
|
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)
|
|
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")
|
|
}
|
|
}
|
|
}
|