Replace time.Now() with clock.Now() in bookings handlers and all test files. Includes deposit, discount, dedup, overlap, and edit request test updates. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1454 lines
47 KiB
Go
1454 lines
47 KiB
Go
//go:build test && dev
|
|
// +build test,dev
|
|
|
|
package bookings
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/testutils"
|
|
"crussell/handlers/payments"
|
|
"crussell/mw"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
)
|
|
|
|
func boolPtr(b bool) *bool {
|
|
return &b
|
|
}
|
|
|
|
// =============================================================================
|
|
// populateDepositFields - DepositProtectedAmount
|
|
// =============================================================================
|
|
|
|
func TestPopulateDepositFields_ProtectedAmountCappedAt50Pct(t *testing.T) {
|
|
|
|
b := &Booking{
|
|
TotalAmount: 200,
|
|
StartTime: time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC),
|
|
}
|
|
|
|
// Paid 150 on a 200 total — protected deposit = min(150, 100) = 100
|
|
populateDepositFields(b, false, 150)
|
|
|
|
if b.DepositProtectedAmount != 100 {
|
|
t.Errorf("expected protected deposit 100 (50%% of 200), got %.2f", b.DepositProtectedAmount)
|
|
}
|
|
// Deposit not required, so DepositPaid should be false
|
|
if b.DepositPaid {
|
|
t.Error("expected DepositPaid false when deposit not required")
|
|
}
|
|
}
|
|
|
|
func TestPopulateDepositFields_ProtectedAmountEqualsPaidWhenUnder50Pct(t *testing.T) {
|
|
|
|
b := &Booking{
|
|
TotalAmount: 200,
|
|
StartTime: time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC),
|
|
}
|
|
|
|
// Paid 30 on a 200 total — protected deposit = min(30, 100) = 30
|
|
populateDepositFields(b, true, 30)
|
|
|
|
if b.DepositProtectedAmount != 30 {
|
|
t.Errorf("expected protected deposit 30, got %.2f", b.DepositProtectedAmount)
|
|
}
|
|
// 30 < 40 (20% of 200), so deposit is NOT paid
|
|
if b.DepositPaid {
|
|
t.Error("expected DepositPaid false when pre-payments < 20% deposit")
|
|
}
|
|
}
|
|
|
|
func TestPopulateDepositFields_DepositPaidWhenMet(t *testing.T) {
|
|
|
|
b := &Booking{
|
|
TotalAmount: 200,
|
|
StartTime: time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC),
|
|
}
|
|
|
|
// Paid 50 on a 200 total — 50 >= 40 (20%), so deposit paid
|
|
populateDepositFields(b, true, 50)
|
|
|
|
if !b.DepositPaid {
|
|
t.Error("expected DepositPaid true when pre-payments >= 20%")
|
|
}
|
|
if b.DepositProtectedAmount != 50 {
|
|
t.Errorf("expected protected deposit 50, got %.2f", b.DepositProtectedAmount)
|
|
}
|
|
}
|
|
|
|
func TestPopulateDepositFields_NoDepositRequired(t *testing.T) {
|
|
|
|
b := &Booking{
|
|
TotalAmount: 100,
|
|
StartTime: time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC),
|
|
}
|
|
|
|
// Deposit not required, paid 80 — protected = min(80, 50) = 50
|
|
populateDepositFields(b, false, 80)
|
|
|
|
if b.DepositPaid {
|
|
t.Error("expected DepositPaid false when deposit_required is false")
|
|
}
|
|
if b.DepositProtectedAmount != 50 {
|
|
t.Errorf("expected protected deposit 50, got %.2f", b.DepositProtectedAmount)
|
|
}
|
|
}
|
|
|
|
func TestPopulateDepositFields_DeadlineSet(t *testing.T) {
|
|
|
|
b := &Booking{
|
|
TotalAmount: 100,
|
|
StartTime: time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC),
|
|
}
|
|
|
|
populateDepositFields(b, true, 0)
|
|
|
|
if b.DepositDeadline == nil {
|
|
t.Fatal("expected non-nil DepositDeadline")
|
|
}
|
|
deadline, err := time.Parse(time.RFC3339, *b.DepositDeadline)
|
|
if err != nil {
|
|
t.Fatalf("failed to parse deadline: %v", err)
|
|
}
|
|
expectedDeadline := b.StartTime.Add(-payments.DepositDeadlineWindow)
|
|
if !deadline.Equal(expectedDeadline) {
|
|
t.Errorf("expected deadline %v, got %v", expectedDeadline, deadline)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// RequestEditHandler - Notice period validation
|
|
// =============================================================================
|
|
|
|
func TestRequestEditHandler_NoticePeriod_BlocksPaymentUnder72h(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
// Booking starting in 2 hours (<48h)
|
|
soon := clock.Now().Add(2 * time.Hour)
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
// Add a payment so hasPayments = true
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 50, "online_square", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
newTime := soon.Add(48 * time.Hour)
|
|
|
|
handler := http.HandlerFunc(RequestEditHandler)
|
|
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{
|
|
"new_start_time": newTime.Format(time.RFC3339),
|
|
}, token, ctx)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("expected 403 for <48h with payments, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRequestEditHandler_NoticePeriod_BlocksNoPaymentUnder24h(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposits_required: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
// Booking starting in 2 hours (<24h, no payments)
|
|
soon := clock.Now().Add(2 * time.Hour)
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
newTime := soon.Add(48 * time.Hour)
|
|
|
|
handler := http.HandlerFunc(RequestEditHandler)
|
|
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{
|
|
"new_start_time": newTime.Format(time.RFC3339),
|
|
}, token, ctx)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("expected 403 for <24h without payments, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAdminCreateBookingForUser_EvictsPendingReleaseOnOverlap(t *testing.T) {
|
|
// AdminCreateBookingForUserHandler evicts any pending_release booking that
|
|
// overlaps the new booking's time slot before creating the booking.
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
// Create a booking that will end up in pending_release (deposit not paid).
|
|
future := clock.Now().Add(48 * time.Hour)
|
|
existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
|
|
if err != nil {
|
|
t.Fatalf("failed to create existing booking: %v", err)
|
|
}
|
|
|
|
// Set it to pending_release (as if deposit deadline passed).
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", existingBookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set status to pending_release: %v", err)
|
|
}
|
|
|
|
// Admin creates a new booking at the same time — pending_release should be evicted
|
|
// to deposit_lapsed, and the new booking should succeed.
|
|
body := AdminCreateBookingForUserRequest{
|
|
UserID: userID,
|
|
StartTime: future,
|
|
ServiceIDs: []string{serviceID},
|
|
}
|
|
|
|
handler := AdminCreateBookingForUserHandler
|
|
w := serveChiHandler(handler, "POST", "/", "/", body, func(baseCtx context.Context) context.Context {
|
|
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
|
|
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
|
|
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
|
|
})
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201 Created (pending_release was evicted), got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify the existing booking was evicted to deposit_lapsed.
|
|
var newStatus string
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus)
|
|
if err != nil {
|
|
t.Fatalf("failed to query existing booking: %v", err)
|
|
}
|
|
if newStatus != "deposit_lapsed" {
|
|
t.Errorf("expected existing booking to be evicted to 'deposit_lapsed', got %q", newStatus)
|
|
}
|
|
}
|
|
|
|
func TestAdminRescheduleBookingHandler_ForgiveNoShow(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
// Create a confirmed booking far enough away that the reschedule is valid.
|
|
future := clock.Now().Add(96 * time.Hour)
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
// Reschedule to a different slot with forgive_noshow=true.
|
|
body := map[string]interface{}{
|
|
"start_time": future.Add(48 * time.Hour).Format(time.RFC3339),
|
|
"forgive_noshow": true,
|
|
"forgive_fees": true,
|
|
}
|
|
|
|
w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingID+"/reschedule", "/{id}/reschedule", body, func(baseCtx context.Context) context.Context {
|
|
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
|
|
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
|
|
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
|
|
})
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify the booking was rescheduled.
|
|
var newStartTime time.Time
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&newStartTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to query rescheduled booking: %v", err)
|
|
}
|
|
|
|
// Verify forgiven_no_shows record was created.
|
|
var forgivenCount int
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM forgiven_no_shows WHERE booking_id = $1", bookingID).Scan(&forgivenCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query forgiven_no_shows: %v", err)
|
|
}
|
|
if forgivenCount != 1 {
|
|
t.Errorf("expected 1 forgiven_no_shows record, got %d", forgivenCount)
|
|
}
|
|
}
|
|
|
|
func TestRequestEditHandler_NoticePeriod_SetsNoShowWarningHeader(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposits_required: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
// Booking starting in 48 hours (24-72h window, no payments).
|
|
midRange := clock.Now().Add(48 * time.Hour)
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, midRange)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
newTime := midRange.Add(48 * time.Hour)
|
|
|
|
handler := http.HandlerFunc(RequestEditHandler)
|
|
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{
|
|
"new_start_time": newTime.Format(time.RFC3339),
|
|
}, token, ctx)
|
|
|
|
// With 48h remaining and no payments: allowed (no 403), but warning header set.
|
|
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200/201 for 48h notice without payments, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
header := w.Header().Get("X-No-Show-Warning")
|
|
if header == "" {
|
|
t.Error("expected X-No-Show-Warning header to be set for 24-72h reschedule")
|
|
return
|
|
}
|
|
if !strings.Contains(header, "no-show") {
|
|
t.Errorf("expected X-No-Show-Warning header to mention no-show, got: %s", header)
|
|
}
|
|
}
|
|
|
|
func TestRequestEditHandler_NoticePeriod_AllowsWhenEnoughNotice(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposits_required: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
// Booking starting in 36 hours (within 48h threshold so auto-approval does not fire)
|
|
bookingTime := clock.Now().Add(36 * time.Hour).Truncate(time.Second)
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
newTime := bookingTime.Add(48 * time.Hour)
|
|
|
|
handler := http.HandlerFunc(RequestEditHandler)
|
|
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{
|
|
"new_start_time": newTime.Format(time.RFC3339),
|
|
}, token, ctx)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected 201 when enough notice, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// DeleteBookingHandler - Refund calculation in cancellation response
|
|
// =============================================================================
|
|
|
|
func TestDeleteBookingHandler_RefundResponse(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
// Booking in the far future (full refund expected)
|
|
farFuture := clock.Now().Add(200 * time.Hour)
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farFuture)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
handler := http.HandlerFunc(DeleteBookingHandler)
|
|
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID+"/cancel", map[string]interface{}{
|
|
"reason": "client_cancelled",
|
|
}, token, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]interface{}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp["message"] != "Booking cancelled successfully" {
|
|
t.Errorf("expected cancellation message, got: %v", resp["message"])
|
|
}
|
|
if resp["status"] != "client_cancelled" {
|
|
t.Errorf("expected status 'client_cancelled', got: %v", resp["status"])
|
|
}
|
|
if id, ok := resp["id"].(string); !ok || id != bookingID {
|
|
t.Errorf("expected booking id %q, got: %v", bookingID, resp["id"])
|
|
}
|
|
}
|
|
|
|
func TestDeleteBookingHandler_NoRefundForUnder24h(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
// Booking starting in 1 hour (<24h)
|
|
soon := clock.Now().Add(1 * time.Hour)
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
handler := http.HandlerFunc(DeleteBookingHandler)
|
|
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID+"/cancel", map[string]interface{}{
|
|
"reason": "client_cancelled",
|
|
}, token, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]interface{}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp["message"] != "Booking cancelled successfully" {
|
|
t.Errorf("expected cancellation message, got: %v", resp["message"])
|
|
}
|
|
if resp["status"] != "client_cancelled" {
|
|
t.Errorf("expected status 'client_cancelled', got: %v", resp["status"])
|
|
}
|
|
if id, ok := resp["id"].(string); !ok || id != bookingID {
|
|
t.Errorf("expected booking id %q, got: %v", bookingID, resp["id"])
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// AdminCancelBookingHandler - Forgive fees and no-show
|
|
// =============================================================================
|
|
|
|
func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
// Booking starting in 1 hour (<24h, normally no refund)
|
|
soon := clock.Now().Add(1 * time.Hour)
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
w := serveChiHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{
|
|
"forgive_fees": true,
|
|
}, func(baseCtx context.Context) context.Context {
|
|
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
|
|
adminToken := jwt.GenerateAdminToken()
|
|
if info := extractUserFromTestJWT(adminToken); info != nil {
|
|
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, info.userID)
|
|
}
|
|
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
|
|
})
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]interface{}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
refundCalc, ok := resp["refund_calculation"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("refund_calculation missing, response: %s", w.Body.String())
|
|
}
|
|
if refundCalc["tier"] != "admin_full_refund" {
|
|
t.Errorf("expected tier 'admin_full_refund', got %v", refundCalc["tier"])
|
|
}
|
|
if refundCalc["refundable_amount"] != 100.0 {
|
|
t.Errorf("expected refundable_amount 100, got %v", refundCalc["refundable_amount"])
|
|
}
|
|
}
|
|
|
|
func TestAdminCancelBookingHandler_NormalRefundOver72h(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
// Create a real admin user so the refund record FK on created_by is satisfied.
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
// Booking starting far in the future (>72h — full refund tier without forgiveness).
|
|
farFuture := clock.Now().Add(200 * time.Hour)
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farFuture)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 80, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
// Cancel WITHOUT forgive_fees — ProcessCancellationRefund should fire.
|
|
// Use a chi router with a real admin token to satisfy the refund FK.
|
|
w := serveChiHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{}, func(baseCtx context.Context) context.Context {
|
|
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
|
|
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
|
|
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
|
|
})
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]interface{}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
|
|
refundCalc, ok := resp["refund_calculation"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("refund_calculation missing, response: %s", w.Body.String())
|
|
}
|
|
if refundCalc["tier"] != "full_refund_72h" {
|
|
t.Errorf("expected tier 'full_refund_72h', got %v", refundCalc["tier"])
|
|
}
|
|
if refundCalc["refundable_amount"] != 80.0 {
|
|
t.Errorf("expected refundable_amount 80, got %v", refundCalc["refundable_amount"])
|
|
}
|
|
|
|
// Verify a refund record was created.
|
|
var refundCount int
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds: %v", err)
|
|
}
|
|
if refundCount != 1 {
|
|
t.Errorf("expected 1 refund record, got %d", refundCount)
|
|
}
|
|
}
|
|
|
|
func TestAdminCancelBookingHandler_ForgiveNoShow(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
w := serveChiHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{
|
|
"forgive_noshow": true,
|
|
}, func(baseCtx context.Context) context.Context {
|
|
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
|
|
adminToken := jwt.GenerateAdminToken()
|
|
if info := extractUserFromTestJWT(adminToken); info != nil {
|
|
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, info.userID)
|
|
}
|
|
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
|
|
})
|
|
|
|
if w.Code != http.StatusNoContent && w.Code != http.StatusOK {
|
|
t.Fatalf("expected 204 or 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var count int
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM forgiven_no_shows WHERE booking_id = $1", bookingID).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("failed to query forgiven_no_shows: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 forgiven_no_shows entry, got %d", count)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// AdminRescheduleBookingHandler - Notice period warning header
|
|
// =============================================================================
|
|
|
|
func TestAdminRescheduleBookingHandler_NormalReschedule(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
clock.Now().Add(100*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
handler := http.HandlerFunc(AdminRescheduleBookingHandler)
|
|
newTime := clock.Now().Add(200 * time.Hour)
|
|
|
|
w := makeRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/reschedule", map[string]interface{}{
|
|
"start_time": newTime.Format(time.RFC3339),
|
|
}, adminToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAdminRescheduleBookingHandler_ForgiveFees_Succeeds(t *testing.T) {
|
|
// forgive_fees on a reschedule records an audit log but should not create
|
|
// an admin_notification (admins know what they did). This test verifies
|
|
// the reschedule succeeds with the flag present.
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
future := clock.Now().Add(96 * time.Hour)
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
newTime := future.Add(48 * time.Hour)
|
|
w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingID+"/reschedule", "/{id}/reschedule", map[string]interface{}{
|
|
"start_time": newTime.Format(time.RFC3339),
|
|
"forgive_fees": true,
|
|
}, func(baseCtx context.Context) context.Context {
|
|
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
|
|
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
|
|
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
|
|
})
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify the booking was rescheduled to the new time (DB truncates to seconds).
|
|
var actualStart time.Time
|
|
tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&actualStart)
|
|
expectedStart := newTime.Truncate(time.Second)
|
|
if !actualStart.Equal(expectedStart) {
|
|
t.Errorf("expected start_time %v, got %v", expectedStart, actualStart)
|
|
}
|
|
|
|
// No admin_notification should be created for the admin's own action.
|
|
var notifCount int
|
|
tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1", bookingID).Scan(¬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)
|
|
}
|
|
}
|