//go:build test && dev // +build test,dev package bookings import ( "context" "testing" "time" "crussell/clock" "crussell/db" "crussell/testutils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // ============================================================================= // ProgressBookingHandler — Dedup guards for early-payment discounts // ============================================================================= func setupDedupTest(t *testing.T, tx db.Querier, ctx context.Context) (string, string, string) { t.Helper() userID := createTestUser(t, 0, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx) now := clock.Now() startTime := now.Add(72 * time.Hour) bookingID := createPendingBooking(t, userID, serviceID, startTime, tx, ctx) return userID, serviceID, bookingID } func insertPaymentForBooking(t *testing.T, bookingID, userID string, amount int, tx db.Querier, ctx context.Context) { t.Helper() _, err := tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'full', 'cash', $2, 'completed', NOW(), NOW()) `, bookingID, amount) require.NoError(t, err) } func TestProgressBooking_LoyaltyDedup(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, _, bookingID := setupDedupTest(t, tx, ctx) // Pre-apply loyalty discount (simulating early-payment) _, err := tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'loyalty', 10, 5000, 500) `, bookingID, userID) require.NoError(t, err) insertPaymentForBooking(t, bookingID, userID, 5000, tx, ctx) // Complete the booking — should not double-apply loyalty completeBooking(t, bookingID, ctx) count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 1, count, "Loyalty should not be double-applied at completion") } func TestProgressBooking_TimeBasedCampaignDedup(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, _, bookingID := setupDedupTest(t, tx, ctx) // Create a time-based campaign campaignName := "Time Dedup Test" campaign := createTestCampaign(t, campaignName, "time_based", 10.0, nil, nil, nil, nil, tx, ctx) // Pre-apply the campaign (simulating early-payment auto-apply) _, err := tx.Exec(ctx, ` 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, campaign) require.NoError(t, err) // Increment campaign's times_redeemed as if payment-time applied it _, err = tx.Exec(ctx, ` UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1 `, campaign) require.NoError(t, err) insertPaymentForBooking(t, bookingID, userID, 5000, tx, ctx) // Complete the booking completeBooking(t, bookingID, ctx) count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 1, count, "Time-based campaign should not be double-applied at completion") } func TestProgressBooking_UserMilestoneDedup(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, _, bookingID := setupDedupTest(t, tx, ctx) // Give user 5 completed bookings svcID := createTestService(t, 30.00, tx, ctx) for i := 0; i < 5; i++ { bid := createPendingBooking(t, userID, svcID, clock.Now().Add(-time.Duration(30-i)*24*time.Hour), tx, ctx) insertPaymentForBooking(t, bid, userID, 3000, tx, ctx) completeBooking(t, bid, ctx) } val := 5 maxRed := 1 campaign := createTestCampaign(t, "5th Visit", "milestone", 15.0, strPtr("per_user_booking_count"), strPtr("bookings"), &val, &maxRed, tx, ctx) // Pre-apply the milestone (simulating early-payment) _, err := tx.Exec(ctx, ` 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, 'milestone', 15, 5000, 750) `, bookingID, userID, campaign) require.NoError(t, err) // Mark the campaign as already having 1 redemption _, err = tx.Exec(ctx, ` UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1 `, campaign) require.NoError(t, err) insertPaymentForBooking(t, bookingID, userID, 5000, tx, ctx) completeBooking(t, bookingID, ctx) count := getDiscountRowCount(t, bookingID, tx, ctx) assert.Equal(t, 1, count, "User milestone should not be double-applied at completion") } // ============================================================================= // No-Show Tracking — ApplyDepositsIfNeeded at cancellation time // ============================================================================= func TestNoShowApplyDepositsIfNeeded(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, 0, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx) // Create 2 no-show bookings (confirmed bookings cancelled <24h before start) now := clock.Now() for i := 0; i < 2; i++ { bid := createPendingBooking(t, userID, serviceID, now.Add(-time.Duration(i)*time.Hour), tx, ctx) insertPaymentForBooking(t, bid, userID, 5000, tx, ctx) _, err := tx.Exec(ctx, ` UPDATE bookings SET status = 'no_show' WHERE id = $1 `, bid) require.NoError(t, err) } // Run ApplyDepositsIfNeeded applied, err := ApplyDepositsIfNeeded(ctx, tx, userID) require.NoError(t, err) assert.True(t, applied, "Should have applied deposits_required = 3 after 2 no-shows") var depositsRequired int tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired) assert.Equal(t, 3, depositsRequired, "Expected deposits_required = 3 after 2 no-shows in 6 months") } func TestNoShowSingleNoShowDoesNotTrigger(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, 0, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx) bid := createPendingBooking(t, userID, serviceID, clock.Now().Add(-time.Hour), tx, ctx) insertPaymentForBooking(t, bid, userID, 5000, tx, ctx) _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid) require.NoError(t, err) applied, err := ApplyDepositsIfNeeded(ctx, tx, userID) require.NoError(t, err) assert.False(t, applied, "Single no-show should not trigger deposits_required") var depositsRequired int tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired) assert.Equal(t, 0, depositsRequired, "Expected deposits_required = 0 with only 1 no-show") } func TestNoShowOldNoShowsExcluded(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, 0, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx) // Create a no-show more than 6 months ago — should not count oldBid := createPendingBooking(t, userID, serviceID, clock.Now().Add(-200*24*time.Hour), tx, ctx) insertPaymentForBooking(t, oldBid, userID, 5000, tx, ctx) _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", oldBid) require.NoError(t, err) // Create a recent no-show (within 6 months) recentBid := createPendingBooking(t, userID, serviceID, clock.Now().Add(-time.Hour), tx, ctx) insertPaymentForBooking(t, recentBid, userID, 5000, tx, ctx) _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", recentBid) require.NoError(t, err) // Should only count 1 recent no-show, not trigger applied, err := ApplyDepositsIfNeeded(ctx, tx, userID) require.NoError(t, err) assert.False(t, applied, "1 old + 1 recent = 2 total but only 1 in 6-month window") } func TestNoShowForgivenExcluded(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, 0, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx) for i := 0; i < 2; i++ { bid := createPendingBooking(t, userID, serviceID, clock.Now().Add(-time.Duration(i)*time.Hour), tx, ctx) insertPaymentForBooking(t, bid, userID, 5000, tx, ctx) _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid) require.NoError(t, err) // Forgive the first one if i == 0 { _, err = tx.Exec(ctx, "INSERT INTO forgiven_no_shows (booking_id) VALUES ($1)", bid) require.NoError(t, err) } } applied, err := ApplyDepositsIfNeeded(ctx, tx, userID) require.NoError(t, err) assert.False(t, applied, "1 forgiven + 1 unforgiven = should not trigger") } // ============================================================================= // Discount + 3 paid bookings → no-show records cleared // ============================================================================= func TestThreePaidBookingsClearNoShows(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, 0, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx) // Set deposits_required to 3 (simulating after 2 no-shows triggered it) _, err := tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) require.NoError(t, err) // Create 2 no-show records for i := 0; i < 2; i++ { bid := createPendingBooking(t, userID, serviceID, clock.Now().Add(-time.Duration(i+1)*time.Hour), tx, ctx) insertPaymentForBooking(t, bid, userID, 5000, tx, ctx) _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid) require.NoError(t, err) } // Complete 3 paid bookings — each should decrement deposits_required for i := 0; i < 3; i++ { bid := createPendingBooking(t, userID, serviceID, clock.Now().Add(time.Duration(i+1)*time.Hour), tx, ctx) insertPaymentForBooking(t, bid, userID, 5000, tx, ctx) completeBooking(t, bid, ctx) } // After 3 completions, deposits_required should be 0 var depositsRequired int tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired) assert.Equal(t, 0, depositsRequired, "Expected 0 after 3 paid bookings from 3") // No-show records should be forgiven (inserted into forgiven_no_shows) var forgivenCount int tx.QueryRow(ctx, ` SELECT COUNT(*) FROM forgiven_no_shows fns JOIN bookings b ON b.id = fns.booking_id WHERE b.user_id = $1 `, userID).Scan(&forgivenCount) // Try again — should NOT trigger deposits_required again since no-shows are forgiven applied, _ := ApplyDepositsIfNeeded(ctx, tx, userID) assert.False(t, applied, "Should not trigger: no-shows were forgiven after 3 paid bookings") }