CI / Go vulnerabilities (push) Successful in 1m10s
CI / Build & Vet (push) Successful in 1m39s
CI / Frontend build (gate) (push) Successful in 1m42s
CI / Frontend QC (audit) (push) Successful in 56s
CI / Frontend QC (typecheck) (push) Successful in 1m36s
CI / Frontend QC (lint) (push) Successful in 1m51s
CI / Tests (prod) (push) Has been cancelled
CI / Tests (dev) (push) Has been cancelled
CI / Race (prod) (push) Has been cancelled
CI / Race (dev) (push) Has been cancelled
106 files: interface{}→any, strings.Split→SplitSeq, CutPrefix/Cut, strings.Builder, slices.Contains, remove redundant // +build directives, gofmt import ordering and indentation.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
291 lines
10 KiB
Go
291 lines
10 KiB
Go
//go: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")
|
|
}
|