//go:build test && dev // +build test,dev package bookings import ( "bytes" "context" "encoding/json" "net/http" "strings" "testing" "time" "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 := time.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 := time.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 := time.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 — should evict the existing booking. 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 && 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 = 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 := time.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 := time.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 := time.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 := time.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 := time.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 := time.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 := time.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, time.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 := 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, 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 := time.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(¬ifCount) 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, time.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": time.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: 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) { 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 := time.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) london, err := time.LoadLocation("Europe/London") if err != nil { t.Fatalf("Europe/London not available: %v", err) } // Use next Wednesday at 10:00 — always >72h from now, well past the 36h window. farTime := nextWeekday(time.Wednesday, london).Add(10 * 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 := 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, 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) 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(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) 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(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, london).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 := time.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 := time.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 := time.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 := time.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. 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(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) } }