Files
Crussell/backend/handlers/bookings/deposit_test.go
T
popertotsandSisyphus 40bbd9ba49 refactor(bookings): migrate remaining handlers and tests to clock.Now()
Replace time.Now() with clock.Now() in bookings handlers and all test files. Includes deposit, discount, dedup, overlap, and edit request test updates.

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

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

1454 lines
47 KiB
Go

//go:build test && dev
// +build test,dev
package bookings
import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/testutils"
"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) {
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) {
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) {
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) {
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) {
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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Booking starting in 2 hours (<48h)
soon := clock.Now().Add(2 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "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
_, err = fixtures.CreateTestPayment(tx, bookingID, 50, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create payment: %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, ctx)
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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, "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(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Booking starting in 2 hours (<24h, no payments)
soon := clock.Now().Add(2 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
token := jwt.GenerateUserToken(userID)
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, ctx)
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.
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create a booking that will end up in pending_release (deposit not paid).
future := clock.Now().Add(48 * time.Hour)
existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
if err != nil {
t.Fatalf("failed to create existing booking: %v", err)
}
// Set it to pending_release (as if deposit deadline passed).
_, err = tx.Exec(ctx, "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 — pending_release should be evicted
// to deposit_lapsed, and the new booking should succeed.
body := AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: future,
ServiceIDs: []string{serviceID},
}
handler := AdminCreateBookingForUserHandler
w := serveChiHandler(handler, "POST", "/", "/", body, func(baseCtx context.Context) context.Context {
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
})
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 Created (pending_release was evicted), got %d: %s", w.Code, w.Body.String())
}
// Verify the existing booking was evicted to deposit_lapsed.
var newStatus string
err = tx.QueryRow(ctx,
"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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create a confirmed booking far enough away that the reschedule is valid.
future := clock.Now().Add(96 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "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(baseCtx context.Context) context.Context {
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
return db.ContextWithTx(baseCtx, db.TxFromContext(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 = tx.QueryRow(ctx,
"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 = tx.QueryRow(ctx,
"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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, "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(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Booking starting in 48 hours (24-72h window, no payments).
midRange := clock.Now().Add(48 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, midRange)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
token := jwt.GenerateUserToken(userID)
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, ctx)
// 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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, "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(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Booking starting in 36 hours (within 48h threshold so auto-approval does not fire)
bookingTime := clock.Now().Add(36 * time.Hour).Truncate(time.Second)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
token := jwt.GenerateUserToken(userID)
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, ctx)
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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Booking in the far future (full refund expected)
farFuture := clock.Now().Add(200 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farFuture)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
_, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(DeleteBookingHandler)
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID+"/cancel", map[string]interface{}{
"reason": "client_cancelled",
}, token, ctx)
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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Booking starting in 1 hour (<24h)
soon := clock.Now().Add(1 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
_, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(DeleteBookingHandler)
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID+"/cancel", map[string]interface{}{
"reason": "client_cancelled",
}, token, 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)
}
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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Booking starting in 1 hour (<24h, normally no refund)
soon := clock.Now().Add(1 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
_, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
w := serveChiHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{
"forgive_fees": true,
}, func(baseCtx context.Context) context.Context {
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
adminToken := jwt.GenerateAdminToken()
if info := extractUserFromTestJWT(adminToken); info != nil {
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, info.userID)
}
return db.ContextWithTx(baseCtx, db.TxFromContext(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"] != "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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create a real admin user so the refund record FK on created_by is satisfied.
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Booking starting far in the future (>72h — full refund tier without forgiveness).
farFuture := clock.Now().Add(200 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farFuture)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
_, err = fixtures.CreateTestPayment(tx, bookingID, 80, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// 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(baseCtx context.Context) context.Context {
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
return db.ContextWithTx(baseCtx, db.TxFromContext(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 = tx.QueryRow(ctx,
"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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, 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 = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
w := serveChiHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{
"forgive_noshow": true,
}, func(baseCtx context.Context) context.Context {
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
adminToken := jwt.GenerateAdminToken()
if info := extractUserFromTestJWT(adminToken); info != nil {
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, info.userID)
}
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
})
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 = tx.QueryRow(ctx,
"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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
clock.Now().Add(100*time.Hour))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "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 := clock.Now().Add(200 * time.Hour)
w := makeRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/reschedule", map[string]interface{}{
"start_time": newTime.Format(time.RFC3339),
}, adminToken, ctx)
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.
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
future := clock.Now().Add(96 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "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(baseCtx context.Context) context.Context {
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
return db.ContextWithTx(baseCtx, db.TxFromContext(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
tx.QueryRow(ctx, "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
tx.QueryRow(ctx,
"SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1", bookingID).Scan(&notifCount)
if notifCount != 0 {
t.Errorf("expected 0 admin_notifications (admin action), got %d", notifCount)
}
}
func TestAdminRescheduleBookingHandler_MissingAuth_Returns401(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, clock.Now().Add(100*time.Hour))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Send request WITHOUT admin auth context.
w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingID+"/reschedule", "/{id}/reschedule", map[string]interface{}{
"start_time": clock.Now().Add(200 * time.Hour).Format(time.RFC3339),
}, func(baseCtx context.Context) context.Context {
return db.ContextWithTx(baseCtx, db.TxFromContext(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: clock.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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
_, err = tx.Exec(ctx, "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(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
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 := clock.Now().Add(10 * time.Hour).Truncate(time.Second)
req := CreateBookingRequest{
StartTime: nearTime,
ServiceIDs: []string{serviceID},
}
w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx)
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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
_, err = tx.Exec(ctx, "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(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(CreateBookingHandler)
// Use next weekday >48h from now during working hours (well past 36h deposit window).
// Use 13:00 (1pm) to ensure the 36h deposit window from ANY overnight test time
// is always cleared — 36h from midnight UTC Wednesday = 12:00 UTC Thursday.
farTime := nextWeekday(time.Thursday).Add(13 * time.Hour)
req := CreateBookingRequest{
StartTime: farTime,
ServiceIDs: []string{serviceID},
}
w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx)
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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
// deposits_required = 0 — advance window should not apply
_, err = tx.Exec(ctx, "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(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
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 := clock.Now().Truncate(24 * time.Hour).Add(29 * time.Hour)
if midday.Hour() < 8 || midday.Hour() >= 20 {
midday = nextWeekday(clock.Now().Weekday()).Add(12 * time.Hour)
}
nearTime := midday.Truncate(time.Second)
req := CreateBookingRequest{
StartTime: nearTime,
ServiceIDs: []string{serviceID},
}
w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx)
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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
_, err = tx.Exec(ctx, "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(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(CreateBookingHandler)
// Create a booking far in the future that we'll mark as pending_release.
farTime := nextWeekday(time.Monday).Add(10 * time.Hour)
existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farTime)
if err != nil {
t.Fatalf("failed to create existing booking: %v", err)
}
// Mark the existing booking as pending_release (deposit deadline passed).
_, err = tx.Exec(ctx, "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, ctx)
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 = tx.QueryRow(ctx, "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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
_, err = tx.Exec(ctx, "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(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(CreateBookingHandler)
// Create a pending_release booking at time A.
timeA := nextWeekday(time.Monday).Add(10 * time.Hour)
existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, timeA)
if err != nil {
t.Fatalf("failed to create existing booking: %v", err)
}
_, err = tx.Exec(ctx, "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).Add(10 * time.Hour)
req := CreateBookingRequest{
StartTime: timeB,
ServiceIDs: []string{serviceID},
}
w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx)
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 = tx.QueryRow(ctx, "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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create a booking far in the future.
future := clock.Now().Add(72 * time.Hour)
existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
if err != nil {
t.Fatalf("failed to create existing booking: %v", err)
}
// Mark it as pending_release.
_, err = tx.Exec(ctx,
"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.
evicted, err := EvictPendingReleaseOverlapping(ctx, db.TxFromContext(ctx), future.Add(30*time.Minute), future.Add(90*time.Minute))
if err != nil {
t.Fatalf("EvictPendingReleaseOverlapping failed: %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 = tx.QueryRow(ctx,
"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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
future := clock.Now().Add(72 * time.Hour)
existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx,
"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 = tx.Exec(ctx, `
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)
}
evicted, err := EvictPendingReleaseOverlapping(ctx, db.TxFromContext(ctx), future.Add(30*time.Minute), future.Add(90*time.Minute))
if err != nil {
t.Fatalf("EvictPendingReleaseOverlapping failed: %v", err)
}
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 = tx.QueryRow(ctx,
"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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
future := clock.Now().Add(72 * time.Hour)
existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx,
"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].
evicted, err := EvictPendingReleaseOverlapping(ctx, db.TxFromContext(ctx), future.Add(120*time.Minute), future.Add(180*time.Minute))
if err != nil {
t.Fatalf("EvictPendingReleaseOverlapping failed: %v", err)
}
if len(evicted) != 0 {
t.Errorf("expected 0 evicted bookings (no overlap), got %d", len(evicted))
}
var newStatus string
err = tx.QueryRow(ctx,
"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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create a pending_release booking at a far-future time slot.
future := clock.Now().Add(72 * time.Hour)
pendingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
if err != nil {
t.Fatalf("failed to create pending booking: %v", err)
}
_, err = tx.Exec(ctx,
"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(tx, userID, serviceID, future)
if err != nil {
t.Fatalf("failed to create confirmable booking: %v", err)
}
_, err = tx.Exec(ctx,
"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(baseCtx context.Context) context.Context {
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
return db.ContextWithTx(baseCtx, db.TxFromContext(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 = tx.QueryRow(ctx,
"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) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create a pending_release booking at a specific time slot.
slotTime := nextWeekday(time.Wednesday).Add(10 * time.Hour)
pendingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, slotTime)
if err != nil {
t.Fatalf("failed to create pending booking: %v", err)
}
_, err = tx.Exec(ctx,
"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(tx, userID, serviceID, slotTime.Add(-48*time.Hour))
if err != nil {
t.Fatalf("failed to create reschedule booking: %v", err)
}
_, err = tx.Exec(ctx,
"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(baseCtx context.Context) context.Context {
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
return db.ContextWithTx(baseCtx, db.TxFromContext(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 = tx.QueryRow(ctx,
"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)
}
}