Refund system (Round 3 fixes + follow-up + alignment): - Serialize cancellation refunds against the manual handler via per-payment advisory locks taken before the prior-refunds read (pg_advisory_xact_lock, ascending, same crussell:refund: key space) - Aggregate pending cancellation refunds into ONE Square refund per charge (stable charge-level -square-agg key); atomic group UPDATE keeps crash-retry amounts identical for Square key-dedup - Persist paymentID-square-amount idempotency keys on cancellation refunds; scheduler reads the stored key (legacy fallback for old rows) - Add sweep-pending-square-refunds cron (*/5, concurrency 1) with refund_attempts cap; sweep retries stale manual pending refunds with each row's own stored idempotency key - Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every terminal failed transition: tri-state result leaves rows pending on reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED resolves to completed - Move over-refund guard inside the lock, counting completed + pending (excluding failed); ErrRefundDeclined distinguishes definitive vs ambiguous outcomes - forgiveFees now executes a real full refund (forceFullRefund override) with admin_forgiven_fees reason threaded to Square - Surface failed card refunds in the admin notification centre (refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup) - Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key) DO NOTHING without consuming refundRemaining Frontend: - Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token in request bodies; gate new-card entry behind CardEntryUnavailable notice + newCardDisabled prop across all 8 flows - Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI and CardEntryUnavailable fallback - Update cancellation-policy page to in-person cash pickup wording Tests: - Rewrite the two amount-blind dedup tests to assert real money movement (single call, aggregated amount, shared refund ID) - Add coverage: manual refund vs cancellation serialization (concurrent goroutines), reconcile error vs no-match branches, stale manual retry, forgive-fees real refund row + reason, double-cancel dedup, mock refund key dedup, ListPaymentRefunds filtering - Fix time-dependent booking flakes with fixtures.NextWorkingDayAt - 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
1557 lines
52 KiB
Go
1557 lines
52 KiB
Go
//go:build test && dev
|
|
|
|
package bookings
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/handlers/payments"
|
|
"crussell/internal/square"
|
|
"crussell/mw"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
)
|
|
|
|
// =============================================================================
|
|
// 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 24-72h from now (no-show warning tier) at a fixed working-hour slot:
|
|
// 10:00 UTC two days out is always 35-58h away, so the warning fires but the
|
|
// "too close" 403 never does, at any wall-clock hour.
|
|
bookingTime := fixtures.NextWorkingDayAt(2, 10)
|
|
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)
|
|
// 10:00 UTC four days out — same slot as bookingTime, inside working hours.
|
|
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)
|
|
|
|
// 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)
|
|
// Set the new time to 10:00 London time on a weekday that's clearly within
|
|
// working hours. Compute from the current time to avoid DST boundary issues.
|
|
londonNow := clock.Now().In(clock.London)
|
|
nextWeekday := londonNow.AddDate(0, 0, 1)
|
|
for nextWeekday.Weekday() == time.Sunday || nextWeekday.Weekday() == time.Monday {
|
|
nextWeekday = nextWeekday.AddDate(0, 0, 1)
|
|
}
|
|
newTime := time.Date(nextWeekday.Year(), nextWeekday.Month(), nextWeekday.Day(), 10, 0, 0, 0, clock.London).UTC()
|
|
|
|
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)
|
|
}
|
|
|
|
// A real admin user so the refund row's created_by FK 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 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)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
// Give the payment a Square reference so the post-commit sweep does NOT
|
|
// terminal-pre-pass it to 'failed' (NULL square refs are unresolvable).
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_admin_forgive_fees' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
// Commit the setup so the handler runs at pool level: AdminCancelBookingHandler
|
|
// acquires pg_advisory_xact_lock on the card payments, and inside the test-env
|
|
// outer per-test transaction (savepoints don't release xact locks) the locks
|
|
// would deadlock the post-commit sweep's session locks. Pool-level mirrors
|
|
// production, where the handler's tx commits and releases the locks.
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
freshCtx := context.Background()
|
|
|
|
// Make the post-commit refund processing leave the row 'pending' (ambiguous
|
|
// Square transport failure → refund_attempts=1) so the test can assert the
|
|
// row exists as pending with the full amount.
|
|
origSquare := payments.SquareClient
|
|
forgiveFeesClient := &forgiveFeesAmbiguousClient{SquareClient: square.NewDevClient()}
|
|
payments.SquareClient = forgiveFeesClient
|
|
defer func() { payments.SquareClient = origSquare }()
|
|
|
|
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")
|
|
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
|
|
return baseCtx
|
|
})
|
|
|
|
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"])
|
|
}
|
|
|
|
// The forgiven-fees FULL refund must actually execute — a refund row for
|
|
// the whole £100 must exist as 'pending' (created by ProcessCancellationRefundTx,
|
|
// resolved post-commit), not just a synthetic response claim.
|
|
var refundCount int
|
|
err = db.Conn.QueryRow(freshCtx,
|
|
"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 at least 1 refund row for forgiven fees full refund, got %d", refundCount)
|
|
}
|
|
var refundStatus string
|
|
var refundAmount float64
|
|
err = db.Conn.QueryRow(freshCtx,
|
|
"SELECT status, amount FROM refunds WHERE booking_id = $1 LIMIT 1", bookingID).Scan(&refundStatus, &refundAmount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund row: %v", err)
|
|
}
|
|
if refundStatus != "pending" {
|
|
t.Errorf("expected refund status 'pending', got %q", refundStatus)
|
|
}
|
|
if refundAmount != 100 {
|
|
t.Errorf("expected refund amount 100, got %.2f", refundAmount)
|
|
}
|
|
|
|
// The Square sweep must be called with the forgiven-fees reason — the
|
|
// reason is threaded from the refund rows through to the post-commit
|
|
// ProcessPendingSquareRefunds call (P3b), not the generic admin_cancelled.
|
|
calls := forgiveFeesClient.refundCalls()
|
|
if len(calls) == 0 {
|
|
t.Fatal("expected the post-commit sweep to call Square RefundPayment")
|
|
}
|
|
if calls[0].Reason != "admin_forgiven_fees" {
|
|
t.Errorf("expected Square refund reason %q, got %q", "admin_forgiven_fees", calls[0].Reason)
|
|
}
|
|
}
|
|
|
|
// forgiveFeesAmbiguousClient simulates a transport-level Square failure so the
|
|
// ForgiveFees test's post-commit refund processing leaves the row 'pending'.
|
|
// It also records every RefundPayment request so the test can assert the
|
|
// forgiven-fees reason reaches Square.
|
|
type forgiveFeesAmbiguousClient struct {
|
|
square.SquareClient
|
|
mu sync.Mutex
|
|
calls []square.RefundPaymentReq
|
|
}
|
|
|
|
func (c *forgiveFeesAmbiguousClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
|
|
c.mu.Lock()
|
|
c.calls = append(c.calls, req)
|
|
c.mu.Unlock()
|
|
return nil, fmt.Errorf("network error: connection reset by peer")
|
|
}
|
|
|
|
func (c *forgiveFeesAmbiguousClient) refundCalls() []square.RefundPaymentReq {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return append([]square.RefundPaymentReq(nil), c.calls...)
|
|
}
|
|
|
|
func (c *forgiveFeesAmbiguousClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]square.RefundResult, error) {
|
|
return nil, fmt.Errorf("network error: connection reset by peer")
|
|
}
|
|
|
|
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(¬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, 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)
|
|
}
|
|
}
|