From e1f51420f7d8ed9ba6e0ed5db874d98eda5c6897 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 18 Jun 2026 16:26:24 +0100 Subject: [PATCH] feat(backend): add booking dedup, deposit tests and test main Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/bookings/dedup_test.go | 287 ++++ backend/handlers/bookings/deposit_test.go | 1577 ++++++++++++++++++++ backend/handlers/bookings/testmain_test.go | 12 +- 3 files changed, 1872 insertions(+), 4 deletions(-) create mode 100644 backend/handlers/bookings/dedup_test.go create mode 100644 backend/handlers/bookings/deposit_test.go diff --git a/backend/handlers/bookings/dedup_test.go b/backend/handlers/bookings/dedup_test.go new file mode 100644 index 0000000..acb9ef9 --- /dev/null +++ b/backend/handlers/bookings/dedup_test.go @@ -0,0 +1,287 @@ +//go:build test && dev +// +build test,dev + +package bookings + +import ( + "context" + "testing" + "time" + + "crussell/db" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================================= +// ProgressBookingHandler — Dedup guards for early-payment discounts +// ============================================================================= + +func setupDedupTest(t *testing.T) (string, string, string) { + t.Helper() + seedDefaultWorkingHours(t) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + now := time.Now() + startTime := now.Add(72 * time.Hour) + bookingID := createPendingBooking(t, userID, serviceID, startTime) + + return userID, serviceID, bookingID +} + +func insertPaymentForBooking(t *testing.T, bookingID, userID string, amount int) { + t.Helper() + _, err := db.DB.Exec(context.Background(), ` + 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) { + resetTestData(t) + userID, _, bookingID := setupDedupTest(t) + + // Pre-apply loyalty discount (simulating early-payment) + _, err := db.DB.Exec(context.Background(), ` + 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) + + // Complete the booking — should not double-apply loyalty + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + assert.Equal(t, 1, count, "Loyalty should not be double-applied at completion") +} + +func TestProgressBooking_TimeBasedCampaignDedup(t *testing.T) { + resetTestData(t) + userID, _, bookingID := setupDedupTest(t) + + // Create a time-based campaign + campaignName := "Time Dedup Test" + campaign := createTestCampaign(t, campaignName, "time_based", 10.0, nil, nil, nil, nil) + + // Pre-apply the campaign (simulating early-payment auto-apply) + _, 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, campaign) + require.NoError(t, err) + + // Increment campaign's times_redeemed as if payment-time applied it + _, err = db.DB.Exec(context.Background(), ` + UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1 + `, campaign) + require.NoError(t, err) + + insertPaymentForBooking(t, bookingID, userID, 5000) + + // Complete the booking + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + assert.Equal(t, 1, count, "Time-based campaign should not be double-applied at completion") +} + +func TestProgressBooking_UserMilestoneDedup(t *testing.T) { + resetTestData(t) + userID, _, bookingID := setupDedupTest(t) + + // Give user 5 completed bookings + svcID := createTestService(t, 30.00) + for i := 0; i < 5; i++ { + bid := createPendingBooking(t, userID, svcID, time.Now().Add(-time.Duration(30-i)*24*time.Hour)) + insertPaymentForBooking(t, bid, userID, 3000) + completeBooking(t, bid) + } + + val := 5 + maxRed := 1 + campaign := createTestCampaign(t, "5th Visit", "milestone", 15.0, strPtr("per_user_booking_count"), strPtr("bookings"), &val, &maxRed) + + // Pre-apply the milestone (simulating early-payment) + _, 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, 'milestone', 15, 5000, 750) + `, bookingID, userID, campaign) + require.NoError(t, err) + + // Mark the campaign as already having 1 redemption + _, err = db.DB.Exec(context.Background(), ` + UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1 + `, campaign) + require.NoError(t, err) + + insertPaymentForBooking(t, bookingID, userID, 5000) + completeBooking(t, bookingID) + + count := getDiscountRowCount(t, bookingID) + 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) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + // Create 2 no-show bookings (confirmed bookings cancelled <24h before start) + now := time.Now() + for i := 0; i < 2; i++ { + bid := createPendingBooking(t, userID, serviceID, now.Add(-time.Duration(i)*time.Hour)) + insertPaymentForBooking(t, bid, userID, 5000) + + _, err := db.DB.Exec(context.Background(), ` + UPDATE bookings SET status = 'no_show' WHERE id = $1 + `, bid) + require.NoError(t, err) + } + + // Run ApplyDepositsIfNeeded + applied, err := ApplyDepositsIfNeeded(context.Background(), userID) + require.NoError(t, err) + assert.True(t, applied, "Should have applied deposits_required = 3 after 2 no-shows") + + var depositsRequired int + db.DB.QueryRow(context.Background(), + "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) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Hour)) + insertPaymentForBooking(t, bid, userID, 5000) + + _, err := db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid) + require.NoError(t, err) + + applied, err := ApplyDepositsIfNeeded(context.Background(), userID) + require.NoError(t, err) + assert.False(t, applied, "Single no-show should not trigger deposits_required") + + var depositsRequired int + db.DB.QueryRow(context.Background(), + "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) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + // Create a no-show more than 6 months ago — should not count + oldBid := createPendingBooking(t, userID, serviceID, time.Now().Add(-200*24*time.Hour)) + insertPaymentForBooking(t, oldBid, userID, 5000) + + _, err := db.DB.Exec(context.Background(), "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, time.Now().Add(-time.Hour)) + insertPaymentForBooking(t, recentBid, userID, 5000) + + _, err = db.DB.Exec(context.Background(), "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(context.Background(), 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) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + for i := 0; i < 2; i++ { + bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Duration(i)*time.Hour)) + insertPaymentForBooking(t, bid, userID, 5000) + _, err := db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid) + require.NoError(t, err) + + // Forgive the first one + if i == 0 { + _, err = db.DB.Exec(context.Background(), "INSERT INTO forgiven_no_shows (booking_id) VALUES ($1)", bid) + require.NoError(t, err) + } + } + + applied, err := ApplyDepositsIfNeeded(context.Background(), 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) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID := createTestUser(t, 0) + serviceID := createTestService(t, 50.00) + + // Set deposits_required to 3 (simulating after 2 no-shows triggered it) + _, err := db.DB.Exec(context.Background(), + "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, time.Now().Add(-time.Duration(i+1)*time.Hour)) + insertPaymentForBooking(t, bid, userID, 5000) + _, err := db.DB.Exec(context.Background(), "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, time.Now().Add(time.Duration(i+1)*time.Hour)) + insertPaymentForBooking(t, bid, userID, 5000) + completeBooking(t, bid) + } + + // After 3 completions, deposits_required should be 0 + var depositsRequired int + db.DB.QueryRow(context.Background(), + "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 + db.DB.QueryRow(context.Background(), ` + 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(context.Background(), userID) + assert.False(t, applied, "Should not trigger: no-shows were forgiven after 3 paid bookings") +} diff --git a/backend/handlers/bookings/deposit_test.go b/backend/handlers/bookings/deposit_test.go new file mode 100644 index 0000000..34d3cd8 --- /dev/null +++ b/backend/handlers/bookings/deposit_test.go @@ -0,0 +1,1577 @@ +//go:build test && dev +// +build test,dev + +package bookings + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "crussell/db" + "crussell/handlers/payments" + "crussell/mw" + "crussell/testutils/fixtures" + "crussell/testutils/jwt" +) + +func boolPtr(b bool) *bool { + return &b +} + +// ============================================================================= +// populateDepositFields - DepositProtectedAmount +// ============================================================================= + +func TestPopulateDepositFields_ProtectedAmountCappedAt50Pct(t *testing.T) { + resetTestData(t) + + b := &Booking{ + TotalAmount: 200, + StartTime: time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC), + } + + // Paid 150 on a 200 total — protected deposit = min(150, 100) = 100 + populateDepositFields(b, false, 150) + + if b.DepositProtectedAmount != 100 { + t.Errorf("expected protected deposit 100 (50%% of 200), got %.2f", b.DepositProtectedAmount) + } + // Deposit not required, so DepositPaid should be false + if b.DepositPaid { + t.Error("expected DepositPaid false when deposit not required") + } +} + +func TestPopulateDepositFields_ProtectedAmountEqualsPaidWhenUnder50Pct(t *testing.T) { + resetTestData(t) + + b := &Booking{ + TotalAmount: 200, + StartTime: time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC), + } + + // Paid 30 on a 200 total — protected deposit = min(30, 100) = 30 + populateDepositFields(b, true, 30) + + if b.DepositProtectedAmount != 30 { + t.Errorf("expected protected deposit 30, got %.2f", b.DepositProtectedAmount) + } + // 30 < 40 (20% of 200), so deposit is NOT paid + if b.DepositPaid { + t.Error("expected DepositPaid false when pre-payments < 20% deposit") + } +} + +func TestPopulateDepositFields_DepositPaidWhenMet(t *testing.T) { + resetTestData(t) + + b := &Booking{ + TotalAmount: 200, + StartTime: time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC), + } + + // Paid 50 on a 200 total — 50 >= 40 (20%), so deposit paid + populateDepositFields(b, true, 50) + + if !b.DepositPaid { + t.Error("expected DepositPaid true when pre-payments >= 20%") + } + if b.DepositProtectedAmount != 50 { + t.Errorf("expected protected deposit 50, got %.2f", b.DepositProtectedAmount) + } +} + +func TestPopulateDepositFields_NoDepositRequired(t *testing.T) { + resetTestData(t) + + b := &Booking{ + TotalAmount: 100, + StartTime: time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC), + } + + // Deposit not required, paid 80 — protected = min(80, 50) = 50 + populateDepositFields(b, false, 80) + + if b.DepositPaid { + t.Error("expected DepositPaid false when deposit_required is false") + } + if b.DepositProtectedAmount != 50 { + t.Errorf("expected protected deposit 50, got %.2f", b.DepositProtectedAmount) + } +} + +func TestPopulateDepositFields_DeadlineSet(t *testing.T) { + resetTestData(t) + + b := &Booking{ + TotalAmount: 100, + StartTime: time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC), + } + + populateDepositFields(b, true, 0) + + if b.DepositDeadline == nil { + t.Fatal("expected non-nil DepositDeadline") + } + deadline, err := time.Parse(time.RFC3339, *b.DepositDeadline) + if err != nil { + t.Fatalf("failed to parse deadline: %v", err) + } + expectedDeadline := b.StartTime.Add(-payments.DepositDeadlineWindow) + if !deadline.Equal(expectedDeadline) { + t.Errorf("expected deadline %v, got %v", expectedDeadline, deadline) + } +} + +// ============================================================================= +// RequestEditHandler - Notice period validation +// ============================================================================= + +func TestRequestEditHandler_NoticePeriod_BlocksPaymentUnder72h(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + // Booking starting in 2 hours (<48h) + soon := time.Now().Add(2 * time.Hour) + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, soon) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, 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) + } + + // Add a payment so hasPayments = true + paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50, "online_square", "deposit", "completed") + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) + + token := jwt.GenerateUserToken(userID) + newTime := soon.Add(48 * time.Hour) + + handler := http.HandlerFunc(RequestEditHandler) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{ + "new_start_time": newTime.Format(time.RFC3339), + }, token) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for <48h with payments, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestRequestEditHandler_NoticePeriod_BlocksNoPaymentUnder24h(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + // Booking starting in 2 hours (<24h, no payments) + soon := time.Now().Add(2 * time.Hour) + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, soon) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, 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) + newTime := soon.Add(48 * time.Hour) + + handler := http.HandlerFunc(RequestEditHandler) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{ + "new_start_time": newTime.Format(time.RFC3339), + }, token) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for <24h without payments, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAdminCreateBookingForUser_EvictsPendingReleaseOnOverlap(t *testing.T) { + // AdminCreateBookingForUserHandler evicts any pending_release booking that + // overlaps the new booking's time slot before creating the booking. + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + // Create a booking that will end up in pending_release (deposit not paid). + future := time.Now().Add(48 * time.Hour) + existingBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, future) + if err != nil { + t.Fatalf("failed to create existing booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, existingBookingID) }) + + // Set it to pending_release (as if deposit deadline passed). + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'pending_release' WHERE id = $1", existingBookingID) + if err != nil { + t.Fatalf("failed to set status to pending_release: %v", err) + } + + // Admin creates a new booking at the same time — should evict the existing booking. + body := AdminCreateBookingForUserRequest{ + UserID: userID, + StartTime: future, + ServiceIDs: []string{serviceID}, + } + + handler := AdminCreateBookingForUserHandler + w := serveChiHandler(handler, "POST", "/", "/", body, func(ctx context.Context) context.Context { + ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") + ctx = context.WithValue(ctx, mw.UserIDKey, adminID) + return ctx + }) + + if w.Code != http.StatusCreated && w.Code != http.StatusOK { + t.Fatalf("expected 201/200 for admin create, got %d: %s", w.Code, w.Body.String()) + } + + // Verify the old booking was evicted to deposit_lapsed. + var newStatus string + err = db.DB.QueryRow(context.Background(), + "SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus) + if err != nil { + t.Fatalf("failed to query existing booking: %v", err) + } + if newStatus != "deposit_lapsed" { + t.Errorf("expected existing booking to be evicted to 'deposit_lapsed', got %q", newStatus) + } +} + +func TestAdminRescheduleBookingHandler_ForgiveNoShow(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + // Create a confirmed booking far enough away that the reschedule is valid. + future := time.Now().Add(96 * time.Hour) + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, future) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, 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) + } + + // Reschedule to a different slot with forgive_noshow=true. + body := map[string]interface{}{ + "start_time": future.Add(48 * time.Hour).Format(time.RFC3339), + "forgive_noshow": true, + "forgive_fees": true, + } + + w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingID+"/reschedule", "/{id}/reschedule", body, func(ctx context.Context) context.Context { + ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") + ctx = context.WithValue(ctx, mw.UserIDKey, adminID) + return ctx + }) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify the booking was rescheduled. + var newStartTime time.Time + err = db.DB.QueryRow(context.Background(), + "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&newStartTime) + if err != nil { + t.Fatalf("failed to query rescheduled booking: %v", err) + } + + // Verify forgiven_no_shows record was created. + var forgivenCount int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM forgiven_no_shows WHERE booking_id = $1", bookingID).Scan(&forgivenCount) + if err != nil { + t.Fatalf("failed to query forgiven_no_shows: %v", err) + } + if forgivenCount != 1 { + t.Errorf("expected 1 forgiven_no_shows record, got %d", forgivenCount) + } +} + +func TestRequestEditHandler_NoticePeriod_SetsNoShowWarningHeader(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + // Booking starting in 48 hours (24-72h window, no payments). + midRange := time.Now().Add(48 * time.Hour) + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, midRange) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, 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) + newTime := midRange.Add(48 * time.Hour) + + handler := http.HandlerFunc(RequestEditHandler) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{ + "new_start_time": newTime.Format(time.RFC3339), + }, token) + + // With 48h remaining and no payments: allowed (no 403), but warning header set. + if w.Code != http.StatusCreated && w.Code != http.StatusOK { + t.Fatalf("expected 200/201 for 48h notice without payments, got %d: %s", w.Code, w.Body.String()) + } + header := w.Header().Get("X-No-Show-Warning") + if header == "" { + t.Error("expected X-No-Show-Warning header to be set for 24-72h reschedule") + return + } + if !strings.Contains(header, "no-show") { + t.Errorf("expected X-No-Show-Warning header to mention no-show, got: %s", header) + } +} + +func TestRequestEditHandler_NoticePeriod_AllowsWhenEnoughNotice(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + // Booking starting in 36 hours (within 48h threshold so auto-approval does not fire) + bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, 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) + newTime := bookingTime.Add(48 * time.Hour) + + handler := http.HandlerFunc(RequestEditHandler) + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{ + "new_start_time": newTime.Format(time.RFC3339), + }, token) + + if w.Code != http.StatusCreated { + t.Errorf("expected 201 when enough notice, got %d: %s", w.Code, w.Body.String()) + } +} + +// ============================================================================= +// DeleteBookingHandler - Refund calculation in cancellation response +// ============================================================================= + +func TestDeleteBookingHandler_RefundResponse(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + // Booking in the far future (full refund expected) + farFuture := time.Now().Add(200 * time.Hour) + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, farFuture) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, 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) + } + + paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 100, "online_square", "full", "completed") + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) + + token := jwt.GenerateUserToken(userID) + + handler := http.HandlerFunc(DeleteBookingHandler) + w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID+"/cancel", map[string]interface{}{ + "reason": "client_cancelled", + }, token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %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["message"] != "Booking cancelled successfully" { + t.Errorf("expected cancellation message, got: %v", resp["message"]) + } + if resp["status"] != "client_cancelled" { + t.Errorf("expected status 'client_cancelled', got: %v", resp["status"]) + } + if id, ok := resp["id"].(string); !ok || id != bookingID { + t.Errorf("expected booking id %q, got: %v", bookingID, resp["id"]) + } +} + +func TestDeleteBookingHandler_NoRefundForUnder24h(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + // Booking starting in 1 hour (<24h) + soon := time.Now().Add(1 * time.Hour) + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, soon) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, 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) + } + + paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 100, "online_square", "full", "completed") + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) + + token := jwt.GenerateUserToken(userID) + + handler := http.HandlerFunc(DeleteBookingHandler) + w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID+"/cancel", map[string]interface{}{ + "reason": "client_cancelled", + }, token) + + 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["message"] != "Booking cancelled successfully" { + t.Errorf("expected cancellation message, got: %v", resp["message"]) + } + if resp["status"] != "client_cancelled" { + t.Errorf("expected status 'client_cancelled', got: %v", resp["status"]) + } + if id, ok := resp["id"].(string); !ok || id != bookingID { + t.Errorf("expected booking id %q, got: %v", bookingID, resp["id"]) + } +} + +// ============================================================================= +// AdminCancelBookingHandler - Forgive fees and no-show +// ============================================================================= + +func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + // Booking starting in 1 hour (<24h, normally no refund) + soon := time.Now().Add(1 * time.Hour) + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, soon) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, 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) + } + + paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 100, "online_square", "full", "completed") + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) + + w := serveAdminHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{ + "forgive_fees": true, + }) + + 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) + } + refundCalc, ok := resp["refund_calculation"].(map[string]interface{}) + if !ok { + t.Fatalf("refund_calculation missing, response: %s", w.Body.String()) + } + if refundCalc["tier"] != "admin_full_refund" { + t.Errorf("expected tier 'admin_full_refund', got %v", refundCalc["tier"]) + } + if refundCalc["refundable_amount"] != 100.0 { + t.Errorf("expected refundable_amount 100, got %v", refundCalc["refundable_amount"]) + } +} + +func TestAdminCancelBookingHandler_NormalRefundOver72h(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + // Create a real admin user so the refund record FK on created_by is satisfied. + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + // Booking starting far in the future (>72h — full refund tier without forgiveness). + farFuture := time.Now().Add(200 * time.Hour) + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, farFuture) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, 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) + } + + paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 80, "online_square", "full", "completed") + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) + + // Cancel WITHOUT forgive_fees — ProcessCancellationRefund should fire. + // Use a chi router with a real admin token to satisfy the refund FK. + w := serveChiHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{}, func(ctx context.Context) context.Context { + ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") + ctx = context.WithValue(ctx, mw.UserIDKey, adminID) + return ctx + }) + + 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) + } + + refundCalc, ok := resp["refund_calculation"].(map[string]interface{}) + if !ok { + t.Fatalf("refund_calculation missing, response: %s", w.Body.String()) + } + if refundCalc["tier"] != "full_refund_72h" { + t.Errorf("expected tier 'full_refund_72h', got %v", refundCalc["tier"]) + } + if refundCalc["refundable_amount"] != 80.0 { + t.Errorf("expected refundable_amount 80, got %v", refundCalc["refundable_amount"]) + } + + // Verify a refund record was created. + var refundCount int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount) + if err != nil { + t.Fatalf("failed to query refunds: %v", err) + } + if refundCount != 1 { + t.Errorf("expected 1 refund record, got %d", refundCount) + } +} + +func TestAdminCancelBookingHandler_ForgiveNoShow(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + 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) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, 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) + } + + w := serveAdminHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{ + "forgive_noshow": true, + }) + + if w.Code != http.StatusNoContent && w.Code != http.StatusOK { + t.Fatalf("expected 204 or 200, got %d: %s", w.Code, w.Body.String()) + } + + var count int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM forgiven_no_shows WHERE booking_id = $1", bookingID).Scan(&count) + if err != nil { + t.Fatalf("failed to query forgiven_no_shows: %v", err) + } + if count != 1 { + t.Errorf("expected 1 forgiven_no_shows entry, got %d", count) + } +} + +// ============================================================================= +// AdminRescheduleBookingHandler - Notice period warning header +// ============================================================================= + +func TestAdminRescheduleBookingHandler_NormalReschedule(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + time.Now().Add(100*time.Hour)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, 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) + } + + adminToken := jwt.GenerateAdminToken() + + handler := http.HandlerFunc(AdminRescheduleBookingHandler) + newTime := time.Now().Add(200 * time.Hour) + + w := makeRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/reschedule", map[string]interface{}{ + "start_time": newTime.Format(time.RFC3339), + }, adminToken) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAdminRescheduleBookingHandler_ForgiveFees_Succeeds(t *testing.T) { + // forgive_fees on a reschedule records an audit log but should not create + // an admin_notification (admins know what they did). This test verifies + // the reschedule succeeds with the flag present. + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + future := time.Now().Add(96 * time.Hour) + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, future) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, 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) + } + + newTime := future.Add(48 * time.Hour) + w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingID+"/reschedule", "/{id}/reschedule", map[string]interface{}{ + "start_time": newTime.Format(time.RFC3339), + "forgive_fees": true, + }, func(ctx context.Context) context.Context { + ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") + ctx = context.WithValue(ctx, mw.UserIDKey, adminID) + return ctx + }) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify the booking was rescheduled to the new time (DB truncates to seconds). + var actualStart time.Time + db.DB.QueryRow(context.Background(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&actualStart) + expectedStart := newTime.Truncate(time.Second) + if !actualStart.Equal(expectedStart) { + t.Errorf("expected start_time %v, got %v", expectedStart, actualStart) + } + + // No admin_notification should be created for the admin's own action. + var notifCount int + db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1", bookingID).Scan(¬ifCount) + if notifCount != 0 { + t.Errorf("expected 0 admin_notifications (admin action), got %d", notifCount) + } +} + +func TestAdminRescheduleBookingHandler_MissingAuth_Returns401(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Now().Add(100*time.Hour)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + // Send request WITHOUT admin auth context. + w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingID+"/reschedule", "/{id}/reschedule", map[string]interface{}{ + "start_time": time.Now().Add(200 * time.Hour).Format(time.RFC3339), + }, func(ctx context.Context) context.Context { + return ctx + }) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d: %s", w.Code, w.Body.String()) + } +} + +// TestPopulateDepositFields_NegativeAmount_Safeguarded ensures that a negative +// preStartAmountPaid value is clamped to zero to prevent a negative DepositProtectedAmount. +func TestPopulateDepositFields_NegativeAmount_Safeguarded(t *testing.T) { + b := &Booking{ + TotalAmount: 100, + StartTime: time.Now(), + } + + // Negative amount should be clamped to 0. + populateDepositFields(b, true, -50) + if b.DepositProtectedAmount < 0 { + t.Errorf("expected non-negative DepositProtectedAmount, got %.2f", b.DepositProtectedAmount) + } + if b.DepositProtectedAmount != 0 { + t.Errorf("expected DepositProtectedAmount 0 (clamped from -50), got %.2f", b.DepositProtectedAmount) + } +} + +// ============================================================================= +// CreateBookingHandler — deposit advance window (payments.DepositAdvanceWindow) +// ============================================================================= + +func TestCreateBooking_DepositAdvanceWindow_BlocksUnder36h(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + token := jwt.GenerateUserToken(userID) + handler := http.HandlerFunc(CreateBookingHandler) + + // Use a time 10 hours from now — well within the 36h deposit advance window + // but past the 1h minimum advance check. + nearTime := time.Now().Add(10 * time.Hour).Truncate(time.Second) + req := CreateBookingRequest{ + StartTime: nearTime, + ServiceIDs: []string{serviceID}, + } + + w := makeRequest(handler, "POST", "/api/bookings", req, token) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for booking within deposit advance window, got %d. body: %s", w.Code, w.Body.String()) + } + if !bytes.Contains(w.Body.Bytes(), []byte("deposit")) && !bytes.Contains(w.Body.Bytes(), []byte("hours in advance")) { + t.Errorf("expected error about deposit advance window, got: %s", w.Body.String()) + } +} + +func TestCreateBooking_DepositAdvanceWindow_AllowsOver36h(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + token := jwt.GenerateUserToken(userID) + handler := http.HandlerFunc(CreateBookingHandler) + + london, err := time.LoadLocation("Europe/London") + if err != nil { + t.Fatalf("Europe/London not available: %v", err) + } + + // Use next Monday at 10:00 — always >48h from now, well past the 36h window. + farTime := nextWeekday(time.Monday, london).Add(10 * time.Hour) + req := CreateBookingRequest{ + StartTime: farTime, + ServiceIDs: []string{serviceID}, + } + + w := makeRequest(handler, "POST", "/api/bookings", req, token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201 for booking outside deposit advance window, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestCreateBooking_DepositAdvanceWindow_SkipsWhenNoDepositRequired(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + // deposits_required = 0 — advance window should not apply + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + token := jwt.GenerateUserToken(userID) + handler := http.HandlerFunc(CreateBookingHandler) + + // Booking within 36h but with no deposits required — should be allowed. + // Use a time within working hours (midday on tomorrow or next weekday). + midday := time.Now().Truncate(24 * time.Hour).Add(29 * time.Hour).In(londonLocation) + if midday.Hour() < 8 || midday.Hour() >= 20 { + midday = nextWeekday(time.Now().Weekday(), londonLocation).Add(12 * time.Hour) + } + nearTime := midday.Truncate(time.Second) + req := CreateBookingRequest{ + StartTime: nearTime, + ServiceIDs: []string{serviceID}, + } + + w := makeRequest(handler, "POST", "/api/bookings", req, token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201 when no deposits required, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// ============================================================================= +// CreateBookingHandler — pending_release eviction on slot overlap +// ============================================================================= + +func TestCreateBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + token := jwt.GenerateUserToken(userID) + handler := http.HandlerFunc(CreateBookingHandler) + + london, err := time.LoadLocation("Europe/London") + if err != nil { + t.Fatalf("Europe/London not available: %v", err) + } + + // Create a booking far in the future that we'll mark as pending_release. + farTime := nextWeekday(time.Monday, london).Add(10 * time.Hour) + existingBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, farTime) + if err != nil { + t.Fatalf("failed to create existing booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, existingBookingID) + + // Mark the existing booking as pending_release (deposit deadline passed). + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'pending_release' WHERE id = $1", existingBookingID) + if err != nil { + t.Fatalf("failed to set existing booking to pending_release: %v", err) + } + + // Now create a new booking at the same time slot — should evict the pending_release one. + req := CreateBookingRequest{ + StartTime: farTime, + ServiceIDs: []string{serviceID}, + } + + w := makeRequest(handler, "POST", "/api/bookings", req, token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201 for new booking, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify the old booking was evicted to deposit_lapsed. + var newStatus string + err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus) + if err != nil { + t.Fatalf("failed to query existing booking status: %v", err) + } + if newStatus != "deposit_lapsed" { + t.Errorf("expected existing booking status 'deposit_lapsed', got %q", newStatus) + } + + // TODO: notify the affected user that their slot was released — not yet implemented. +} + +func TestCreateBooking_LeftUnchangedWhenNoOverlapWithPendingRelease(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + token := jwt.GenerateUserToken(userID) + handler := http.HandlerFunc(CreateBookingHandler) + + london, err := time.LoadLocation("Europe/London") + if err != nil { + t.Fatalf("Europe/London not available: %v", err) + } + + // Create a pending_release booking at time A. + timeA := nextWeekday(time.Monday, london).Add(10 * time.Hour) + existingBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, timeA) + if err != nil { + t.Fatalf("failed to create existing booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, existingBookingID) + + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'pending_release' WHERE id = $1", existingBookingID) + if err != nil { + t.Fatalf("failed to set existing booking to pending_release: %v", err) + } + + // Create a new booking at a different time B that does NOT overlap. + timeB := nextWeekday(time.Tuesday, london).Add(10 * time.Hour) + req := CreateBookingRequest{ + StartTime: timeB, + ServiceIDs: []string{serviceID}, + } + + w := makeRequest(handler, "POST", "/api/bookings", req, token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201 for non-overlapping booking, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify the old pending_release booking was NOT evicted. + var newStatus string + err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus) + if err != nil { + t.Fatalf("failed to query existing booking status: %v", err) + } + if newStatus != "pending_release" { + t.Errorf("expected existing booking to remain 'pending_release', got %q", newStatus) + } +} + +// ============================================================================= +// EvictPendingReleaseOverlapping — direct function tests +// ============================================================================= + +func TestEvictPendingReleaseOverlapping_Basic(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + // Create a booking far in the future. + future := time.Now().Add(72 * time.Hour) + existingBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, future) + if err != nil { + t.Fatalf("failed to create existing booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, existingBookingID) + + // Mark it as pending_release. + _, err = db.DB.Exec(context.Background(), + "UPDATE bookings SET status = 'pending_release' WHERE id = $1", existingBookingID) + if err != nil { + t.Fatalf("failed to set pending_release: %v", err) + } + + // Call the shared eviction function with an overlapping slot. + // Service is 60 min, so [future, future+60min] is the existing slot. + // New slot [future+30min, future+90min] overlaps -> should evict. + ctx := context.Background() + tx, err := db.DB.Begin(ctx) + if err != nil { + t.Fatalf("failed to begin tx: %v", err) + } + defer tx.Rollback(ctx) + + evicted, err := EvictPendingReleaseOverlapping(ctx, tx, future.Add(30*time.Minute), future.Add(90*time.Minute)) + if err != nil { + t.Fatalf("EvictPendingReleaseOverlapping failed: %v", err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatalf("failed to commit tx: %v", err) + } + + if len(evicted) != 1 { + t.Fatalf("expected 1 evicted booking, got %d", len(evicted)) + } + if evicted[0].ID != existingBookingID { + t.Errorf("expected evicted booking ID %s, got %s", existingBookingID, evicted[0].ID) + } + if evicted[0].UserID != userID { + t.Errorf("expected evicted user ID %s, got %s", userID, evicted[0].UserID) + } + + var newStatus string + err = db.DB.QueryRow(context.Background(), + "SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus) + if err != nil { + t.Fatalf("failed to query booking status: %v", err) + } + if newStatus != "deposit_lapsed" { + t.Errorf("expected booking to be 'deposit_lapsed', got %q", newStatus) + } +} + +func TestEvictPendingReleaseOverlapping_PaymentLockGuard(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + future := time.Now().Add(72 * time.Hour) + existingBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, future) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, existingBookingID) + + _, err = db.DB.Exec(context.Background(), + "UPDATE bookings SET status = 'pending_release' WHERE id = $1", existingBookingID) + if err != nil { + t.Fatalf("failed to set pending_release: %v", err) + } + + // Insert a PAYMENT_IN_FLIGHT time_blocker for this booking — should + // prevent eviction even though the slot overlaps. + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES (NOW(), 5, $1, $2) + `, "PAYMENT_IN_FLIGHT:"+existingBookingID, userID) + if err != nil { + t.Fatalf("failed to create PAYMENT_IN_FLIGHT blocker: %v", err) + } + + ctx := context.Background() + tx, err := db.DB.Begin(ctx) + if err != nil { + t.Fatalf("failed to begin tx: %v", err) + } + defer tx.Rollback(ctx) + + evicted, err := EvictPendingReleaseOverlapping(ctx, tx, future.Add(30*time.Minute), future.Add(90*time.Minute)) + if err != nil { + t.Fatalf("EvictPendingReleaseOverlapping failed: %v", err) + } + tx.Rollback(ctx) + + if len(evicted) != 0 { + t.Errorf("expected 0 evicted bookings (PAYMENT_IN_FLIGHT guard), got %d", len(evicted)) + } + + // Verify the booking was NOT evicted. + var newStatus string + err = db.DB.QueryRow(context.Background(), + "SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus) + if err != nil { + t.Fatalf("failed to query booking status: %v", err) + } + if newStatus != "pending_release" { + t.Errorf("expected booking to remain 'pending_release' (guarded), got %q", newStatus) + } +} + +func TestEvictPendingReleaseOverlapping_NoOverlap(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + future := time.Now().Add(72 * time.Hour) + existingBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, future) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, existingBookingID) + + _, err = db.DB.Exec(context.Background(), + "UPDATE bookings SET status = 'pending_release' WHERE id = $1", existingBookingID) + if err != nil { + t.Fatalf("failed to set pending_release: %v", err) + } + + // Call eviction with a slot that does NOT overlap [future, future+60min]. + ctx := context.Background() + tx, err := db.DB.Begin(ctx) + if err != nil { + t.Fatalf("failed to begin tx: %v", err) + } + defer tx.Rollback(ctx) + + evicted, err := EvictPendingReleaseOverlapping(ctx, tx, future.Add(120*time.Minute), future.Add(180*time.Minute)) + if err != nil { + t.Fatalf("EvictPendingReleaseOverlapping failed: %v", err) + } + tx.Rollback(ctx) + + if len(evicted) != 0 { + t.Errorf("expected 0 evicted bookings (no overlap), got %d", len(evicted)) + } + + var newStatus string + err = db.DB.QueryRow(context.Background(), + "SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus) + if err != nil { + t.Fatalf("failed to query booking status: %v", err) + } + if newStatus != "pending_release" { + t.Errorf("expected booking to remain 'pending_release' (no overlap), got %q", newStatus) + } +} + +// ============================================================================= +// Handler-level eviction tests — verify eviction happens via the handler path +// ============================================================================= + +func TestConfirmBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + // Create a pending_release booking at a far-future time slot. + future := time.Now().Add(72 * time.Hour) + pendingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, future) + if err != nil { + t.Fatalf("failed to create pending booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, pendingID) + + _, err = db.DB.Exec(context.Background(), + "UPDATE bookings SET status = 'pending_release' WHERE id = $1", pendingID) + if err != nil { + t.Fatalf("failed to set pending_release: %v", err) + } + + // Create a second booking at the same time slot that we'll confirm. + confirmedID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, future) + if err != nil { + t.Fatalf("failed to create confirmable booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, confirmedID) + + _, err = db.DB.Exec(context.Background(), + "UPDATE bookings SET status = 'pending' WHERE id = $1", confirmedID) + if err != nil { + t.Fatalf("failed to set pending: %v", err) + } + + // Confirm the second booking — should evict the pending_release one. + handler := ConfirmBookingHandler + w := serveChiHandler(handler, "POST", "/"+confirmedID+"/confirm", "/{id}/confirm", map[string]interface{}{}, + func(ctx context.Context) context.Context { + ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") + ctx = context.WithValue(ctx, mw.UserIDKey, adminID) + return ctx + }) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for confirm, got %d: %s", w.Code, w.Body.String()) + } + + // Verify the pending_release booking was evicted to deposit_lapsed. + var newStatus string + err = db.DB.QueryRow(context.Background(), + "SELECT status FROM bookings WHERE id = $1", pendingID).Scan(&newStatus) + if err != nil { + t.Fatalf("failed to query evicted booking: %v", err) + } + if newStatus != "deposit_lapsed" { + t.Errorf("expected evicted booking to be 'deposit_lapsed', got %q", newStatus) + } +} + +func TestAdminRescheduleBooking_EvictsPendingReleaseOnOverlap(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + // Create a pending_release booking at a specific time slot. + london, err := time.LoadLocation("Europe/London") + if err != nil { + t.Fatalf("Europe/London not available: %v", err) + } + slotTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour) + + pendingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, slotTime) + if err != nil { + t.Fatalf("failed to create pending booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, pendingID) + + _, err = db.DB.Exec(context.Background(), + "UPDATE bookings SET status = 'pending_release' WHERE id = $1", pendingID) + if err != nil { + t.Fatalf("failed to set pending_release: %v", err) + } + + // Create a confirmed booking to reschedule INTO the pending_release slot. + rescheduleID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, slotTime.Add(-48*time.Hour)) + if err != nil { + t.Fatalf("failed to create reschedule booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, rescheduleID) + + _, err = db.DB.Exec(context.Background(), + "UPDATE bookings SET status = 'confirmed' WHERE id = $1", rescheduleID) + if err != nil { + t.Fatalf("failed to confirm reschedule booking: %v", err) + } + + // Reschedule the confirmed booking INTO the pending_release slot. + body := map[string]interface{}{ + "start_time": slotTime.Format(time.RFC3339), + } + + handler := AdminRescheduleBookingHandler + w := serveChiHandler(handler, "PUT", "/"+rescheduleID+"/reschedule", "/{id}/reschedule", body, + func(ctx context.Context) context.Context { + ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") + ctx = context.WithValue(ctx, mw.UserIDKey, adminID) + return ctx + }) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for reschedule, got %d: %s", w.Code, w.Body.String()) + } + + // Verify the pending_release booking was evicted. + var newStatus string + err = db.DB.QueryRow(context.Background(), + "SELECT status FROM bookings WHERE id = $1", pendingID).Scan(&newStatus) + if err != nil { + t.Fatalf("failed to query evicted booking: %v", err) + } + if newStatus != "deposit_lapsed" { + t.Errorf("expected evicted booking to be 'deposit_lapsed', got %q", newStatus) + } +} diff --git a/backend/handlers/bookings/testmain_test.go b/backend/handlers/bookings/testmain_test.go index 92feddc..372883b 100644 --- a/backend/handlers/bookings/testmain_test.go +++ b/backend/handlers/bookings/testmain_test.go @@ -1,5 +1,5 @@ -//go:build test -// +build test +//go:build test && dev +// +build test,dev package bookings @@ -8,8 +8,10 @@ import ( "testing" "crussell/db" - "crussell/testutils/testdb" + "crussell/handlers/payments" + "crussell/internal/square" "crussell/testutils/jwt" + "crussell/testutils/testdb" ) func TestMain(m *testing.M) { @@ -20,7 +22,9 @@ func TestMain(m *testing.M) { testdb.Migrate(&testing.T{}, pool) db.DB = pool jwt.Init() + square.Client = square.NewDevClient() + payments.SquareClient = square.Client code := m.Run() pool.Close() os.Exit(code) -} \ No newline at end of file +}