Files
Crussell/backend/handlers/bookings/bookings_coverage_test.go
T
popertots 3029fd5179
CI / Nginx config check (push) Successful in 13s
CI / Env docs check (push) Successful in 15s
CI / Docker compose check (push) Successful in 15s
CI / Frontend major deps (push) Failing after 24s
CI / Frontend deps check (push) Successful in 30s
CI / Secrets scan (push) Successful in 38s
CI / Go build (push) Successful in 39s
CI / Frontend build (push) Successful in 1m3s
CI / Knip (push) Successful in 45s
CI / Go vet (prod) (push) Failing after 1m42s
CI / Frontend a11y check (push) Successful in 2m34s
CI / Go vet (dev) (push) Successful in 2m29s
CI / Staticcheck (prod) (push) Failing after 2m38s
CI / go mod tidy (push) Successful in 1m3s
CI / Staticcheck (dev) (push) Successful in 2m55s
CI / Frontend QC (audit) (push) Successful in 51s
CI / golangci-lint (push) Successful in 3m22s
CI / Go vulnerabilities (push) Successful in 1m26s
CI / Frontend QC (typecheck) (push) Successful in 2m18s
CI / Security scan (prod) (push) Successful in 4m18s
CI / Security scan (dev) (push) Successful in 4m40s
CI / Tests (prod) (push) Has been skipped
CI / Tests (dev) (push) Has been skipped
CI / Race (prod) (push) Has been skipped
CI / Race (dev) (push) Has been skipped
CI / Frontend QC (lint) (push) Successful in 2m18s
CI / Svelte strict check (push) Successful in 43s
test: add coverage tests across backend + fix mock for PENDING checkout support
New test files cover previously untested paths across DAV, validators,
S3, Square, mw, bookings, user, and payments packages.

Includes mock fix: HoldCheckouts flag on MockClient allows tests to
pause auto-complete goroutine for testing PENDING checkout states.

Coverage: 50.4% → 65.0% (+14.6pp)
2026-07-10 18:13:44 +01:00

1723 lines
57 KiB
Go

//go:build test && dev
package bookings
// Package bookings contains targeted tests for under-covered booking handlers.
//
// Targets:
// - UserCancelBookingHandler: DELETE /api/bookings/{id}
// - AdminCancelBookingHandler: POST /api/admin/bookings/{id}/cancel
// - AdminCancelReservationHandler: DELETE /api/admin/bookings/reserve
// - ConfirmBookingHandler: POST /api/bookings/{id}/confirm
// - AdminCreateBookingForUserHandler: POST /api/admin/bookings
// - AdminRescheduleBookingHandler: PUT /api/admin/bookings/{id}/reschedule
// - UpdateBookingServicesHandler: PUT /api/admin/bookings/{id}/services
// - EditBookingHandler: additional edge cases (cancelled/completed booking)
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
)
// =============================================================================
// UserCancelBookingHandler — DELETE /api/bookings/{id}
// =============================================================================
// TestUserCancelBookingHandler_Success verifies that a user can cancel their
// own booking (status → client_cancelled).
func TestUserCancelBookingHandler_Success(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)
}
// Create a confirmed booking with future start time
futureTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime)
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)
handler := http.HandlerFunc(UserCancelBookingHandler)
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token, ctx)
if w.Code != http.StatusNoContent {
t.Errorf("expected 204, got %d. body: %s", w.Code, w.Body.String())
}
// Verify booking is now client_cancelled
var status string
err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if status != "client_cancelled" {
t.Errorf("expected status 'client_cancelled', got %q", status)
}
}
// TestUserCancelBookingHandler_NotFound verifies that cancelling a non-existent
// booking returns 404.
func TestUserCancelBookingHandler_NotFound(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(UserCancelBookingHandler)
w := makeRequest(handler, "DELETE", "/api/bookings/nonexistent-id", nil, token, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestUserCancelBookingHandler_InvalidID verifies that an invalid booking ID
// returns 404.
func TestUserCancelBookingHandler_InvalidID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(UserCancelBookingHandler)
w := makeRequest(handler, "DELETE", "/api/bookings/", nil, token, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestUserCancelBookingHandler_Unauthorized verifies that cancelling without
// auth returns 401.
func TestUserCancelBookingHandler_Unauthorized(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(UserCancelBookingHandler)
w := makeRequest(handler, "DELETE", "/api/bookings/aaaaaaaaaaaa", nil, "", ctx)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestUserCancelBookingHandler_NotOwnBooking verifies that a user cannot cancel
// another user's booking.
func TestUserCancelBookingHandler_NotOwnBooking(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
ownerID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create owner: %v", err)
}
otherUserID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create other user: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", otherUserID)
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)
}
futureTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, ownerID, serviceID, futureTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
token := jwt.GenerateUserToken(otherUserID)
handler := http.HandlerFunc(UserCancelBookingHandler)
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token, ctx)
// Should return 404 because the booking doesn't belong to otherUserID
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestUserCancelBookingHandler_AlreadyCancelled verifies that cancelling an
// already-cancelled booking returns 404.
func TestUserCancelBookingHandler_AlreadyCancelled(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)
}
futureTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Set status to already cancelled
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to cancel booking: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(UserCancelBookingHandler)
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token, ctx)
// Already cancelled → the UPDATE matches 0 rows → 404
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 for already-cancelled booking, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// AdminCancelBookingHandler — POST /api/admin/bookings/{id}/cancel
// =============================================================================
// TestAdminCancelBookingHandler_Success verifies that an admin can cancel a
// confirmed booking (no payments → simple cancel).
func TestAdminCancelBookingHandler_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
futureTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime)
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", "/"+bookingID+"/cancel", "/{id}/cancel", nil,
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))
})
// No payments → should get 204 No Content
if w.Code != http.StatusNoContent {
t.Errorf("expected 204, got %d. body: %s", w.Code, w.Body.String())
}
var status string
err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if status != "we_cancelled" {
t.Errorf("expected 'we_cancelled', got %q", status)
}
}
// TestAdminCancelBookingHandler_NotFound verifies that admin cancelling a
// non-existent booking returns 404.
func TestAdminCancelBookingHandler_NotFound(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
w := serveChiHandler(AdminCancelBookingHandler, "POST", "/nonexistent-id/cancel", "/{id}/cancel", nil,
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.StatusNotFound {
t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminCancelBookingHandler_EmptyBody verifies that admin cancel works
// with an empty body (no decode error) for bookings without payments.
func TestAdminCancelBookingHandler_EmptyBody(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
futureTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// POST with empty body — AdminCancelBookingHandler handles nil body gracefully
w := serveChiHandler(AdminCancelBookingHandler, "POST", "/"+bookingID+"/cancel", "/{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.StatusNoContent {
t.Errorf("expected 204, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminCancelBookingHandler_InvalidID verifies that invalid booking ID
// returns 404.
func TestAdminCancelBookingHandler_InvalidID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
w := serveChiHandler(AdminCancelBookingHandler, "POST", "//cancel", "/{id}/cancel", nil,
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.StatusNotFound {
t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminCancelBookingHandler_NotCancellable verifies that admin cannot
// cancel a booking that's already in a final state.
func TestAdminCancelBookingHandler_NotCancellable(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
futureTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Set to completed (final state)
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set completed: %v", err)
}
// Fetch payment info first (no payments) — this succeeds but returns zero values
// Then the UPDATE fails because status='completed' is not IN ('pending','confirmed','in_progress')
w := serveChiHandler(AdminCancelBookingHandler, "POST", "/"+bookingID+"/cancel", "/{id}/cancel", nil,
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.StatusNotFound {
t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// AdminCancelReservationHandler — DELETE /api/admin/bookings/reserve
// =============================================================================
// TestAdminCancelReservationHandler_Success verifies that an admin can cancel
// their active reservation.
func TestAdminCancelReservationHandler_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
// Create an admin reservation
startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
_, err = tx.Exec(ctx, fmt.Sprintf(`
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:%d', $2)
`, clock.Now().UnixNano()), startTime, adminID)
if err != nil {
t.Fatalf("failed to create admin reservation: %v", err)
}
// Verify reservation exists before
var countBefore int
err = tx.QueryRow(ctx,
`SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%' AND created_by = $1`,
adminID,
).Scan(&countBefore)
if err != nil {
t.Fatalf("failed to count before: %v", err)
}
if countBefore != 1 {
t.Fatalf("expected 1 reservation before, got %d", countBefore)
}
// Call AdminCancelReservationHandler
req := httptest.NewRequest("DELETE", "/api/admin/bookings/reserve", nil)
rctx := chi.NewRouteContext()
reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx)
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, adminID)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
AdminCancelReservationHandler(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify reservation was deleted
var countAfter int
err = tx.QueryRow(ctx,
`SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%' AND created_by = $1`,
adminID,
).Scan(&countAfter)
if err != nil {
t.Fatalf("failed to count after: %v", err)
}
if countAfter != 0 {
t.Errorf("expected 0 reservations after cancel, got %d", countAfter)
}
// Verify response
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["status"] != "reservation cancelled" {
t.Errorf("expected status 'reservation cancelled', got %q", resp["status"])
}
}
// TestAdminCancelReservationHandler_Unauthorized verifies that calling
// AdminCancelReservationHandler without auth returns 401.
func TestAdminCancelReservationHandler_Unauthorized(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
req := httptest.NewRequest("DELETE", "/api/admin/bookings/reserve", nil)
rctx := chi.NewRouteContext()
reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
AdminCancelReservationHandler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminCancelReservationHandler_NoReservation verifies that cancelling with
// no active reservation is idempotent (returns 200).
func TestAdminCancelReservationHandler_NoReservation(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
// No reservation created
req := httptest.NewRequest("DELETE", "/api/admin/bookings/reserve", nil)
rctx := chi.NewRouteContext()
reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx)
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, adminID)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
AdminCancelReservationHandler(w, req)
// Should still return 200 (idempotent — DELETE affects 0 rows, no error)
if w.Code != http.StatusOK {
t.Errorf("expected 200 (idempotent), got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// ConfirmBookingHandler — POST /api/bookings/{id}/confirm
// =============================================================================
// TestConfirmBookingHandler_Success verifies that an admin can confirm a pending
// booking successfully.
func TestConfirmBookingHandler_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
// Create a pending booking with enough gap so it doesn't overlap anything
futureTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Ensure booking is pending
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set pending: %v", err)
}
w := serveChiHandler(ConfirmBookingHandler, "POST", "/"+bookingID+"/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.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var status string
err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if status != "confirmed" {
t.Errorf("expected 'confirmed', got %q", status)
}
}
// TestConfirmBookingHandler_NotFound verifies that confirming a non-existent
// booking returns 404.
func TestConfirmBookingHandler_NotFound(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
w := serveChiHandler(ConfirmBookingHandler, "POST", "/nonexistent-id/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.StatusNotFound {
t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestConfirmBookingHandler_InvalidID verifies that an invalid booking ID
// returns 404.
func TestConfirmBookingHandler_InvalidID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
w := serveChiHandler(ConfirmBookingHandler, "POST", "//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.StatusNotFound {
t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestConfirmBookingHandler_AlreadyConfirmed verifies that confirming an already
// confirmed booking returns 404.
func TestConfirmBookingHandler_AlreadyConfirmed(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
futureTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Already confirmed
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set confirmed: %v", err)
}
w := serveChiHandler(ConfirmBookingHandler, "POST", "/"+bookingID+"/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))
})
// UPDATE only targets status='pending' → no rows matched → 404
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// AdminCreateBookingForUserHandler — POST /api/admin/bookings
// =============================================================================
// TestAdminCreateBookingForUserHandler_Success verifies that an admin can
// successfully create a booking for a user.
func TestAdminCreateBookingForUserHandler_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
startTime := weekdayTime(time.Wednesday, 10)
body := AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: startTime,
ServiceIDs: []string{serviceID},
}
w := serveChiHandler(AdminCreateBookingForUserHandler, "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.Errorf("expected 201, got %d. body: %s", w.Code, w.Body.String())
}
// Verify the booking was created in the DB
var count int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&count)
if err != nil {
t.Fatalf("failed to query bookings: %v", err)
}
if count != 1 {
t.Errorf("expected 1 booking, got %d", count)
}
}
// TestAdminCreateBookingForUserHandler_EmptyBody verifies that an empty body
// returns 400.
func TestAdminCreateBookingForUserHandler_EmptyBody(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", 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.StatusBadRequest {
t.Errorf("expected 400 for empty body, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminCreateBookingForUserHandler_MissingUserID verifies that missing
// user_id returns 400.
func TestAdminCreateBookingForUserHandler_MissingUserID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
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)
}
body := AdminCreateBookingForUserRequest{
StartTime: weekdayTime(time.Wednesday, 10),
ServiceIDs: []string{serviceID},
// UserID intentionally empty
}
w := serveChiHandler(AdminCreateBookingForUserHandler, "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.StatusBadRequest {
t.Errorf("expected 400 for missing user_id, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminCreateBookingForUserHandler_MissingServices verifies that missing
// service IDs returns 400.
func TestAdminCreateBookingForUserHandler_MissingServices(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
body := AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: weekdayTime(time.Wednesday, 10),
// ServiceIDs intentionally empty
}
w := serveChiHandler(AdminCreateBookingForUserHandler, "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.StatusBadRequest {
t.Errorf("expected 400 for missing services, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminCreateBookingForUserHandler_MissingStartTime verifies that missing
// start_time returns 400.
func TestAdminCreateBookingForUserHandler_MissingStartTime(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
body := AdminCreateBookingForUserRequest{
UserID: userID,
ServiceIDs: []string{serviceID},
// StartTime intentionally zero
}
w := serveChiHandler(AdminCreateBookingForUserHandler, "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.StatusBadRequest {
t.Errorf("expected 400 for missing start_time, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminCreateBookingForUserHandler_InvalidOverridePrice verifies that
// a negative override price returns 400.
func TestAdminCreateBookingForUserHandler_InvalidOverridePrice(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
negPrice := -10.0
body := AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: weekdayTime(time.Wednesday, 10),
ServiceIDs: []string{serviceID},
ServiceOverrides: []ServiceOverride{
{ServiceID: serviceID, OverridePrice: &negPrice},
},
}
w := serveChiHandler(AdminCreateBookingForUserHandler, "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.StatusBadRequest {
t.Errorf("expected 400 for negative override price, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminCreateBookingForUserHandler_OverrideDurationZero verifies that
// override duration <= 0 returns 400.
func TestAdminCreateBookingForUserHandler_OverrideDurationZero(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
zeroDur := 0
body := AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: weekdayTime(time.Wednesday, 10),
ServiceIDs: []string{serviceID},
ServiceOverrides: []ServiceOverride{
{ServiceID: serviceID, OverrideDurationMinutes: &zeroDur},
},
}
w := serveChiHandler(AdminCreateBookingForUserHandler, "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.StatusBadRequest {
t.Errorf("expected 400 for zero override duration, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminCreateBookingForUserHandler_WithOverrideSuccess verifies that an
// admin can create a booking with valid service overrides.
func TestAdminCreateBookingForUserHandler_WithOverrideSuccess(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
overridePrice := 75.0
overrideDur := 45
startTime := weekdayTime(time.Wednesday, 10)
body := AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: startTime,
ServiceIDs: []string{serviceID},
ServiceOverrides: []ServiceOverride{
{ServiceID: serviceID, OverridePrice: &overridePrice, OverrideDurationMinutes: &overrideDur},
},
}
w := serveChiHandler(AdminCreateBookingForUserHandler, "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.Errorf("expected 201, got %d. body: %s", w.Code, w.Body.String())
}
// Verify override was applied
var dbPrice *float64
var dbDur *int
err = tx.QueryRow(ctx,
"SELECT override_price, override_duration_minutes FROM booking_services WHERE booking_id IN (SELECT id FROM bookings WHERE user_id = $1) AND service_id = $2",
userID, serviceID).Scan(&dbPrice, &dbDur)
if err != nil {
t.Fatalf("failed to query override: %v", err)
}
if dbPrice == nil || *dbPrice != overridePrice {
t.Errorf("expected override_price %.2f, got %v", overridePrice, dbPrice)
}
if dbDur == nil || *dbDur != overrideDur {
t.Errorf("expected override_duration %d, got %v", overrideDur, dbDur)
}
}
// TestAdminCreateBookingForUserHandler_WithCustomServices verifies that an
// admin can create a booking with custom services.
func TestAdminCreateBookingForUserHandler_WithCustomServices(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
// Insert a custom service directly
var customSvcID string
err = tx.QueryRow(ctx, `
INSERT INTO custom_services (name, description, price, duration_minutes)
VALUES ($1, $2, $3, $4)
RETURNING id
`, "Test Custom Service", "A custom service for testing", 60.00, 45).Scan(&customSvcID)
if err != nil {
t.Fatalf("failed to create custom service: %v", err)
}
startTime := weekdayTime(time.Wednesday, 10)
body := AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: startTime,
CustomServiceIDs: []string{customSvcID},
}
w := serveChiHandler(AdminCreateBookingForUserHandler, "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.Errorf("expected 201, got %d. body: %s", w.Code, w.Body.String())
}
// Verify custom service was linked
var count int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM booking_custom_services WHERE custom_service_id = $1",
customSvcID).Scan(&count)
if err != nil {
t.Fatalf("failed to query custom services: %v", err)
}
if count != 1 {
t.Errorf("expected 1 custom service link, got %d", count)
}
// Verify usage count was updated
var usageCount int
err = tx.QueryRow(ctx,
"SELECT usage_count FROM custom_services WHERE id = $1", customSvcID).Scan(&usageCount)
if err != nil {
t.Fatalf("failed to query usage count: %v", err)
}
if usageCount != 1 {
t.Errorf("expected usage_count 1, got %d", usageCount)
}
}
// TestAdminCreateBookingForUserHandler_Unauthorized verifies that creating
// a booking for a user without admin auth returns 401.
func TestAdminCreateBookingForUserHandler_Unauthorized(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)
}
body := AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: weekdayTime(time.Wednesday, 10),
ServiceIDs: []string{serviceID},
}
// No admin auth in context — handler will still check mw.UserIDKey
// The handler first checks for admin auth at the top
w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body,
func(baseCtx context.Context) context.Context {
// No admin auth set
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
})
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// AdminRescheduleBookingHandler — PUT /api/admin/bookings/{id}/reschedule
// =============================================================================
// TestAdminRescheduleBookingHandler_Success verifies that an admin can
// reschedule a booking to a future time.
func TestAdminRescheduleBookingHandler_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
baseTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
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 next day
newTime := baseTime.Add(24 * time.Hour)
body := map[string]interface{}{
"start_time": newTime.Format(time.RFC3339),
}
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.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var dbStartTime time.Time
err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&dbStartTime)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if !dbStartTime.Truncate(time.Second).Equal(newTime.Truncate(time.Second)) {
t.Errorf("expected start_time %v, got %v", newTime, dbStartTime)
}
}
// TestAdminRescheduleBookingHandler_InvalidID verifies that an invalid booking
// ID returns 404.
func TestAdminRescheduleBookingHandler_InvalidID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
body := map[string]interface{}{
"start_time": clock.Now().Add(48 * time.Hour).Format(time.RFC3339),
}
w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "//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.StatusNotFound {
t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminRescheduleBookingHandler_PastStart verifies that rescheduling to a
// past time returns 400.
func TestAdminRescheduleBookingHandler_PastStart(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
baseTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
body := map[string]interface{}{
"start_time": clock.Now().Add(-1 * time.Hour).Format(time.RFC3339),
}
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.StatusBadRequest {
t.Errorf("expected 400 for past start time, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminRescheduleBookingHandler_EmptyBody verifies that an empty body
// returns 400.
func TestAdminRescheduleBookingHandler_EmptyBody(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
baseTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingID+"/reschedule", "/{id}/reschedule", 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.StatusBadRequest {
t.Errorf("expected 400 for empty body, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminRescheduleBookingHandler_CompletedBooking verifies that rescheduling
// a completed booking returns 403.
func TestAdminRescheduleBookingHandler_CompletedBooking(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
baseTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set completed: %v", err)
}
body := map[string]interface{}{
"start_time": weekdayTime(time.Thursday, 10).Format(time.RFC3339),
}
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.StatusForbidden {
t.Errorf("expected 403 for completed booking, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminRescheduleBookingHandler_NotFound verifies that rescheduling a
// non-existent booking returns 404.
func TestAdminRescheduleBookingHandler_NotFound(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
body := map[string]interface{}{
"start_time": weekdayTime(time.Wednesday, 10).Format(time.RFC3339),
}
w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/nonexistent/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.StatusNotFound {
t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminRescheduleBookingHandler_Unauthorized verifies that rescheduling
// without admin auth returns 401.
func TestAdminRescheduleBookingHandler_Unauthorized(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)
}
baseTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
body := map[string]interface{}{
"start_time": weekdayTime(time.Thursday, 10).Format(time.RFC3339),
}
// No admin auth
w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingID+"/reschedule", "/{id}/reschedule", body,
func(baseCtx context.Context) context.Context {
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
})
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// UpdateBookingServicesHandler — PUT /api/admin/bookings/{id}/services
// =============================================================================
// TestUpdateBookingServicesHandler_Success verifies that an admin can update
// a booking's services.
func TestUpdateBookingServicesHandler_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
baseTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
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)
}
// Create a second service to replace with
serviceID2, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create second service: %v", err)
}
body := UpdateBookingServicesRequest{
ServiceIDs: []string{serviceID2},
}
w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "/"+bookingID, "/{id}", 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.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify services were replaced
var svcCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_services WHERE booking_id = $1", bookingID).Scan(&svcCount)
if err != nil {
t.Fatalf("failed to query services: %v", err)
}
if svcCount != 1 {
t.Errorf("expected 1 service, got %d", svcCount)
}
var remainingSvcID string
err = tx.QueryRow(ctx, "SELECT service_id FROM booking_services WHERE booking_id = $1", bookingID).Scan(&remainingSvcID)
if err != nil {
t.Fatalf("failed to query remaining service: %v", err)
}
if remainingSvcID != serviceID2 {
t.Errorf("expected service_id %s, got %s", serviceID2, remainingSvcID)
}
}
// TestUpdateBookingServicesHandler_InvalidID verifies that an invalid booking
// ID returns 404.
func TestUpdateBookingServicesHandler_InvalidID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
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)
}
body := UpdateBookingServicesRequest{
ServiceIDs: []string{serviceID},
}
w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "//", "/{id}", 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.StatusNotFound {
t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestUpdateBookingServicesHandler_Unauthorized verifies that updating services
// without admin auth returns 401.
func TestUpdateBookingServicesHandler_Unauthorized(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)
}
baseTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
body := UpdateBookingServicesRequest{
ServiceIDs: []string{serviceID},
}
// No admin auth
w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "/"+bookingID, "/{id}", body,
func(baseCtx context.Context) context.Context {
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
})
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestUpdateBookingServicesHandler_EmptyServiceIDs verifies that providing
// empty service_ids returns 400.
func TestUpdateBookingServicesHandler_EmptyServiceIDs(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
baseTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
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)
}
body := UpdateBookingServicesRequest{
ServiceIDs: []string{},
}
w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "/"+bookingID, "/{id}", 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.StatusBadRequest {
t.Errorf("expected 400 for empty service_ids, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestUpdateBookingServicesHandler_CompletedBooking verifies that updating
// services on a completed booking returns 403.
func TestUpdateBookingServicesHandler_CompletedBooking(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
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)
}
baseTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set completed: %v", err)
}
body := UpdateBookingServicesRequest{
ServiceIDs: []string{serviceID},
}
w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "/"+bookingID, "/{id}", 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.StatusForbidden {
t.Errorf("expected 403 for completed booking, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// EditBookingHandler — Additional edge cases
// =============================================================================
// TestEditBookingHandler_CompletedBooking verifies that editing a completed
// booking returns 403.
func TestEditBookingHandler_CompletedBooking(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)
}
futureTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Set to completed
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set completed: %v", err)
}
token := jwt.GenerateUserToken(userID)
newStartTime := futureTime.Add(48 * time.Hour)
req := EditBookingRequest{
StartTime: newStartTime,
}
handler := http.HandlerFunc(EditBookingHandler)
w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for completed booking, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestEditBookingHandler_CancelledBooking verifies that editing a cancelled
// booking returns 403.
func TestEditBookingHandler_CancelledBooking(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)
}
futureTime := weekdayTime(time.Wednesday, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, futureTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Set to cancelled
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set cancelled: %v", err)
}
token := jwt.GenerateUserToken(userID)
newStartTime := futureTime.Add(48 * time.Hour)
req := EditBookingRequest{
StartTime: newStartTime,
}
handler := http.HandlerFunc(EditBookingHandler)
w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for cancelled booking, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestEditBookingHandler_Unauthorized verifies that editing without auth
// returns 401.
func TestEditBookingHandler_Unauthorized(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
req := EditBookingRequest{
StartTime: clock.Now().Add(96 * time.Hour),
}
handler := http.HandlerFunc(EditBookingHandler)
w := makeRequest(handler, "PUT", "/api/bookings/aaaaaaaaaaaa", req, "", ctx)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d. body: %s", w.Code, w.Body.String())
}
}