From c2aa0b93cbea3097de86f1d306f9d51dd111961d Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 6 Jul 2026 17:58:03 +0100 Subject: [PATCH] feat(api): add admin reservation cancel endpoint Add DELETE /api/admin/bookings/reserve to release admin walk-in/call-in reservations. New handler AdminCancelReservationHandler targets only RESERVATION:admin:% entries (partitioned from user RESERVATION:user:% by WHERE clause). Includes 12 tests covering walkin + callin success, isolation, no-op, unauth, empty ctx, walkin+callin coexistence, anon untouched, response format parity, overlapping reservations deleted, user reservations untouched, and idempotent double-cancel. Inverse-isolation tests in cancel_reservation_test.go prove the user-side DELETE /api/bookings/reserve does not touch admin or anon reservations. --- .../bookings/admin_cancel_reservation.go | 41 ++ .../bookings/admin_cancel_reservation_test.go | 661 ++++++++++++++++++ .../bookings/cancel_reservation_test.go | 148 ++++ backend/main.go | 1 + 4 files changed, 851 insertions(+) create mode 100644 backend/handlers/bookings/admin_cancel_reservation.go create mode 100644 backend/handlers/bookings/admin_cancel_reservation_test.go diff --git a/backend/handlers/bookings/admin_cancel_reservation.go b/backend/handlers/bookings/admin_cancel_reservation.go new file mode 100644 index 0000000..d8e1b72 --- /dev/null +++ b/backend/handlers/bookings/admin_cancel_reservation.go @@ -0,0 +1,41 @@ +package bookings + +import ( + "log" + "net/http" + + "crussell/db" + "crussell/mw" +) + +// AdminCancelReservationHandler releases the authenticated admin's active +// reservation (walk-in or call-in) by deleting their RESERVATION:admin entries +// from time_blockers. It does NOT create a booking — it only releases the +// temporary slot hold. +// +// Mirrors CancelReservationHandler but targets RESERVATION:admin:% (created by +// AdminReserveSlotHandler). The user-facing DELETE /api/bookings/reserve only +// matches RESERVATION:user:% — it cannot release admin reservations, which +// would otherwise leak the slot for the full 15-min TTL. +// +// DELETE /api/admin/bookings/reserve +func AdminCancelReservationHandler(w http.ResponseWriter, r *http.Request) { + adminID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || adminID == "" { + log.Printf("AdminCancelReservationHandler: admin ID not found in context") + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + _, err := db.Conn.Exec(r.Context(), ` + DELETE FROM time_blockers + WHERE created_by = $1 AND description LIKE 'RESERVATION:admin:%' + `, adminID) + if err != nil { + log.Printf("AdminCancelReservationHandler: failed to delete reservation for admin %s: %v", adminID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + mw.RespondJSON(w, http.StatusOK, map[string]string{"status": "reservation cancelled"}) +} diff --git a/backend/handlers/bookings/admin_cancel_reservation_test.go b/backend/handlers/bookings/admin_cancel_reservation_test.go new file mode 100644 index 0000000..052939a --- /dev/null +++ b/backend/handlers/bookings/admin_cancel_reservation_test.go @@ -0,0 +1,661 @@ +//go:build test && dev +// +build test,dev + +package bookings + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "crussell/clock" + "crussell/mw" + "crussell/testutils" + "crussell/testutils/fixtures" + "crussell/testutils/jwt" + + "github.com/go-chi/chi/v5" +) + +// adminCancelReservationRequest creates and serves a DELETE /api/admin/bookings/reserve request. +// The ctx carries the test transaction from SetupTestTx so the handler's +// db.Conn.Exec call routes through the same transaction. +// If adminID is non-empty, it sets up the auth context (simulating RequireAuth+RequireAdmin). +func adminCancelReservationRequest(ctx context.Context, adminID, token string) *httptest.ResponseRecorder { + req := httptest.NewRequest("DELETE", "/api/admin/bookings/reserve", nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + // Start with the test transaction context so db.Conn.Exec routes through it + rctx := chi.NewRouteContext() + reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx) + if adminID != "" { + reqCtx = context.WithValue(reqCtx, mw.UserIDKey, adminID) + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") + } + req = req.WithContext(reqCtx) + + w := httptest.NewRecorder() + http.HandlerFunc(AdminCancelReservationHandler).ServeHTTP(w, req) + return w +} + +// TestAdminCancelReservation_Success creates a walk-in admin reservation, +// cancels it, and verifies the time_blocker is deleted from the database. +func TestAdminCancelReservation_Success(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + token := jwt.GenerateTestToken(adminID, "admin") + startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) + + // Create a walk-in admin reservation for this admin + _, err = tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:12345', $2) + `, startTime, adminID) + if err != nil { + t.Fatalf("failed to create reservation: %v", err) + } + + // Verify reservation exists before cancel + 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 reservations before: %v", err) + } + if countBefore != 1 { + t.Fatalf("expected 1 reservation before cancel, got %d", countBefore) + } + + // Call AdminCancelReservationHandler + w := adminCancelReservationRequest(ctx, adminID, token) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + // Parse 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"]) + } + + // 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 reservations after: %v", err) + } + if countAfter != 0 { + t.Errorf("expected 0 reservations after cancel, got %d", countAfter) + } +} + +// TestAdminCancelReservation_CallIn_Success creates a call-in admin reservation +// (different description format) and verifies the handler deletes it. +func TestAdminCancelReservation_CallIn_Success(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + customerID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create customer: %v", err) + } + defer fixtures.DeleteUser(tx, customerID) + + token := jwt.GenerateTestToken(adminID, "admin") + startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour) + + // Create a call-in admin reservation for this admin + _, err = tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'RESERVATION:admin:callin:67890:12345', $2) + `, startTime, adminID) + if err != nil { + t.Fatalf("failed to create call-in reservation: %v", err) + } + + // Call AdminCancelReservationHandler + w := adminCancelReservationRequest(ctx, adminID, token) + + if w.Code != http.StatusOK { + t.Errorf("expected status 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:callin:%' AND created_by = $1`, + adminID, + ).Scan(&countAfter) + if err != nil { + t.Fatalf("failed to count call-in reservations after: %v", err) + } + if countAfter != 0 { + t.Errorf("expected 0 call-in reservations after cancel, got %d", countAfter) + } +} + +// TestAdminCancelReservation_NoActiveReservation verifies that calling cancel +// without an active reservation returns 200 (idempotent, no error). +func TestAdminCancelReservation_NoActiveReservation(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + token := jwt.GenerateTestToken(adminID, "admin") + + // Verify no admin reservations exist + 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 reservations before: %v", err) + } + if countBefore != 0 { + t.Fatalf("expected 0 reservations before cancel, got %d", countBefore) + } + + // Call AdminCancelReservationHandler + w := adminCancelReservationRequest(ctx, adminID, token) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 (idempotent), got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify still no reservations + 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 reservations after: %v", err) + } + if countAfter != 0 { + t.Errorf("expected 0 reservations after cancel, got %d", countAfter) + } +} + +// TestAdminCancelReservation_Unauthenticated verifies that calling the handler +// without a valid admin in context returns 401 Unauthorized. +func TestAdminCancelReservation_Unauthenticated(t *testing.T) { + t.Parallel() + + w := adminCancelReservationRequest(context.Background(), "", "") + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status 401 for unauthenticated request, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminCancelReservation_EmptyAdminIDInContext verifies that having a context +// with an empty adminID string is treated as unauthenticated. +func TestAdminCancelReservation_EmptyAdminIDInContext(t *testing.T) { + t.Parallel() + + w := adminCancelReservationRequest(context.Background(), "", "") + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status 401 for empty adminID in context, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminCancelReservation_DeletesOnlyOwnAdminReservation verifies that +// cancelling only deletes the requesting admin's reservation, not another +// admin's. Mirrors the user-side isolation test. +func TestAdminCancelReservation_DeletesOnlyOwnAdminReservation(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + admin1ID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin1: %v", err) + } + defer fixtures.DeleteUser(tx, admin1ID) + admin2ID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin2: %v", err) + } + defer fixtures.DeleteUser(tx, admin2ID) + + token1 := jwt.GenerateTestToken(admin1ID, "admin") + startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) + + // Create reservation for admin1 + _, err = tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:11111', $2) + `, startTime, admin1ID) + if err != nil { + t.Fatalf("failed to create admin1 reservation: %v", err) + } + + // Create reservation for admin2 + _, err = tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:22222', $2) + `, startTime, admin2ID) + if err != nil { + t.Fatalf("failed to create admin2 reservation: %v", err) + } + + // Call AdminCancelReservationHandler as admin1 + w := adminCancelReservationRequest(ctx, admin1ID, token1) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify admin1's reservation is gone + var admin1Count int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%' AND created_by = $1`, + admin1ID, + ).Scan(&admin1Count) + if err != nil { + t.Fatalf("failed to count admin1 reservations: %v", err) + } + if admin1Count != 0 { + t.Errorf("expected admin1 reservations to be deleted, got %d", admin1Count) + } + + // Verify admin2's reservation still exists + var admin2Count int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%' AND created_by = $1`, + admin2ID, + ).Scan(&admin2Count) + if err != nil { + t.Fatalf("failed to count admin2 reservations: %v", err) + } + if admin2Count != 1 { + t.Errorf("expected admin2 reservation to survive, got %d", admin2Count) + } +} + +// TestAdminCancelReservation_DoesNotDeleteUserReservations verifies that the +// admin cancel handler only targets RESERVATION:admin:% — it must NEVER +// touch RESERVATION:user:% entries (those belong to a different endpoint). +func TestAdminCancelReservation_DoesNotDeleteUserReservations(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(tx, userID) + + token := jwt.GenerateTestToken(adminID, "admin") + startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) + + // Create a user reservation (created_by is the user, description RESERVATION:user:%) + _, err = tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'RESERVATION:user:55555:12345', $2) + `, startTime, userID) + if err != nil { + t.Fatalf("failed to create user reservation: %v", err) + } + + // Call admin cancel — must NOT touch the user reservation + w := adminCancelReservationRequest(ctx, adminID, token) + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify the user reservation still exists + var userCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:user:%' AND created_by = $1`, + userID, + ).Scan(&userCount) + if err != nil { + t.Fatalf("failed to count user reservations: %v", err) + } + if userCount != 1 { + t.Errorf("expected user reservation to survive admin cancel (different endpoint), got %d", userCount) + } +} + +// TestAdminCancelReservation_CleanIdempotent verifies calling cancel twice +// is safe (second call also succeeds, no error). +func TestAdminCancelReservation_CleanIdempotent(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + token := jwt.GenerateTestToken(adminID, "admin") + startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) + + // Create a reservation for this admin + _, err = tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:99999', $2) + `, startTime, adminID) + if err != nil { + t.Fatalf("failed to create reservation: %v", err) + } + + // First cancel + w1 := adminCancelReservationRequest(ctx, adminID, token) + if w1.Code != http.StatusOK { + t.Fatalf("first cancel: expected 200, got %d. body: %s", w1.Code, w1.Body.String()) + } + + // Second cancel (no reservation left) + w2 := adminCancelReservationRequest(ctx, adminID, token) + if w2.Code != http.StatusOK { + t.Errorf("second cancel (idempotent): expected 200, got %d. body: %s", w2.Code, w2.Body.String()) + } + + // Verify no admin reservations remain + var count int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%' AND created_by = $1`, + adminID, + ).Scan(&count) + if err != nil { + t.Fatalf("failed to count reservations: %v", err) + } + if count != 0 { + t.Errorf("expected 0 admin reservations after two cancels, got %d", count) + } +} + +// TestAdminCancelReservation_DeletesBothWalkinAndCallin verifies that the +// admin cancel handler removes BOTH walk-in and call-in reservations for the +// same admin in a single call. This matters because an admin may have +// started a walk-in, then opened a call-in modal without releasing the +// walk-in first (the admin_reserve handler auto-replaces, but if the auto- +// replace failed for any reason, both could coexist). +func TestAdminCancelReservation_DeletesBothWalkinAndCallin(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + customerID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create customer: %v", err) + } + defer fixtures.DeleteUser(tx, customerID) + + token := jwt.GenerateTestToken(adminID, "admin") + walkinStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) + callinStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour) + + // Create a walk-in reservation + _, err = tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:11111', $2) + `, walkinStart, adminID) + if err != nil { + t.Fatalf("failed to create walk-in reservation: %v", err) + } + + // Create a call-in reservation for the same admin + _, err = tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'RESERVATION:admin:callin:22222:33333', $2) + `, callinStart, adminID) + if err != nil { + t.Fatalf("failed to create call-in reservation: %v", err) + } + + // Verify both exist + var beforeCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%' AND created_by = $1`, + adminID, + ).Scan(&beforeCount) + if err != nil { + t.Fatalf("failed to count reservations before: %v", err) + } + if beforeCount != 2 { + t.Fatalf("expected 2 admin reservations (walkin + callin) before cancel, got %d", beforeCount) + } + + // Call AdminCancelReservationHandler + w := adminCancelReservationRequest(ctx, adminID, token) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify BOTH are gone + var afterCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%' AND created_by = $1`, + adminID, + ).Scan(&afterCount) + if err != nil { + t.Fatalf("failed to count reservations after: %v", err) + } + if afterCount != 0 { + t.Errorf("expected 0 admin reservations after cancel (walkin + callin both removed), got %d", afterCount) + } +} + +// TestAdminCancelReservation_DoesNotTouchAnonReservations verifies that the +// admin cancel handler only targets RESERVATION:admin:% — it must NEVER +// touch RESERVATION:anon:% entries (which belong to the pre-auth user-side +// reservation flow with a hashed-IP created_by = NULL). +// +// Anon reservations are cleaned up by the next reserve attempt via the +// pre-overlap DELETE in reserve.go and admin_reserve.go. A separate endpoint +// (or TTL) handles them — admin cancel must not interfere. +func TestAdminCancelReservation_DoesNotTouchAnonReservations(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + token := jwt.GenerateTestToken(adminID, "admin") + adminStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) + anonStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour) + + // Create an admin reservation + _, err = tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:11111', $2) + `, adminStart, adminID) + if err != nil { + t.Fatalf("failed to create admin reservation: %v", err) + } + + // Create an anon reservation (created_by = NULL, description RESERVATION:anon:%) + _, err = tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'RESERVATION:anon:abc12345:99999', NULL) + `, anonStart) + if err != nil { + t.Fatalf("failed to create anon reservation: %v", err) + } + + // Call admin cancel + w := adminCancelReservationRequest(ctx, adminID, token) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify admin reservation is gone + var adminCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%' AND created_by = $1`, + adminID, + ).Scan(&adminCount) + if err != nil { + t.Fatalf("failed to count admin reservations: %v", err) + } + if adminCount != 0 { + t.Errorf("expected admin reservation to be deleted, got %d", adminCount) + } + + // Verify anon reservation is untouched + var anonCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:anon:%'`, + ).Scan(&anonCount) + if err != nil { + t.Fatalf("failed to count anon reservations: %v", err) + } + if anonCount != 1 { + t.Errorf("expected anon reservation to survive admin cancel (separate flow), got %d", anonCount) + } +} + +// TestAdminCancelReservation_ResponseFormatParityWithUserHandler verifies +// that the admin cancel response has EXACTLY the same JSON shape and value +// as the user cancel response. Frontend code (and any future tooling) that +// relies on the response format must not break if it switches between the +// two endpoints. +func TestAdminCancelReservation_ResponseFormatParityWithUserHandler(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + token := jwt.GenerateTestToken(adminID, "admin") + + // Call admin cancel (no reservation exists — idempotent path) + w := adminCancelReservationRequest(ctx, adminID, token) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify response is exactly {"status":"reservation cancelled"} + // (matches CancelReservationHandler format at cancel_reservation.go:34) + body := strings.TrimSpace(w.Body.String()) + expected := `{"status":"reservation cancelled"}` + if body != expected { + t.Errorf("admin cancel response format mismatch:\n got: %s\n want: %s", body, expected) + } + + // Also verify Content-Type is JSON (matches user handler) + contentType := w.Header().Get("Content-Type") + if !strings.Contains(contentType, "application/json") { + t.Errorf("expected Content-Type to include application/json, got %q", contentType) + } +} + +// TestAdminCancelReservation_HandlesOverlappingReservations verifies the +// handler works correctly when an admin has multiple time_blocker entries +// for overlapping (or same) time slots — all must be deleted, not just the +// first. This guards against any future query that might add a LIMIT or +// accidentally target only the latest entry. +func TestAdminCancelReservation_HandlesOverlappingReservations(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + token := jwt.GenerateTestToken(adminID, "admin") + baseStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) + + // Create 3 overlapping walk-in reservations for the same admin + // (unusual state, but defensive: e.g. if a previous fix-up migration + // left duplicates, all must be cleared in a single cancel call). + for i := 0; i < 3; i++ { + _, err = tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, $2, $3) + `, baseStart.Add(time.Duration(i)*time.Hour), fmt.Sprintf("RESERVATION:admin:walkin:guest:1111%d", i), adminID) + if err != nil { + t.Fatalf("failed to create reservation %d: %v", i, err) + } + } + + // Verify all 3 exist + var beforeCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%' AND created_by = $1`, + adminID, + ).Scan(&beforeCount) + if err != nil { + t.Fatalf("failed to count reservations before: %v", err) + } + if beforeCount != 3 { + t.Fatalf("expected 3 admin reservations before cancel, got %d", beforeCount) + } + + // Call admin cancel + w := adminCancelReservationRequest(ctx, adminID, token) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify all 3 are gone + var afterCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%' AND created_by = $1`, + adminID, + ).Scan(&afterCount) + if err != nil { + t.Fatalf("failed to count reservations after: %v", err) + } + if afterCount != 0 { + t.Errorf("expected all 3 admin reservations to be deleted, got %d", afterCount) + } +} diff --git a/backend/handlers/bookings/cancel_reservation_test.go b/backend/handlers/bookings/cancel_reservation_test.go index ec730ca..4abeb55 100644 --- a/backend/handlers/bookings/cancel_reservation_test.go +++ b/backend/handlers/bookings/cancel_reservation_test.go @@ -299,3 +299,151 @@ func TestCancelReservation_CleanIdempotent(t *testing.T) { t.Errorf("expected 0 reservations after two cancels, got %d", count) } } + +// TestCancelReservation_DoesNotTouchAnonReservations verifies that the user +// cancel handler only targets RESERVATION:user:% — it must NEVER touch +// RESERVATION:anon:% entries (which belong to the pre-auth reservation flow +// with a hashed-IP created_by = NULL). +// +// Anon reservations are cleaned up by the next reserve attempt via the +// pre-overlap DELETE in reserve.go. A separate endpoint (or TTL) handles +// them — user cancel must not interfere. +func TestCancelReservation_DoesNotTouchAnonReservations(t *testing.T) { + t.Parallel() + 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) + userStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) + anonStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour) + + // Create a user reservation + _, err = tx.Exec(ctx, fmt.Sprintf(` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'RESERVATION:user:%s:11111', $2) + `, userID), userStart, userID) + if err != nil { + t.Fatalf("failed to create user reservation: %v", err) + } + + // Create an anon reservation (created_by = NULL, description RESERVATION:anon:%) + _, err = tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'RESERVATION:anon:abc12345:22222', NULL) + `, anonStart) + if err != nil { + t.Fatalf("failed to create anon reservation: %v", err) + } + + // Call user cancel + w := cancelReservationRequest(ctx, userID, "verified_email", token) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify user reservation is gone + var userCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:user:%' AND created_by = $1`, + userID, + ).Scan(&userCount) + if err != nil { + t.Fatalf("failed to count user reservations: %v", err) + } + if userCount != 0 { + t.Errorf("expected user reservation to be deleted, got %d", userCount) + } + + // Verify anon reservation is untouched + var anonCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:anon:%'`, + ).Scan(&anonCount) + if err != nil { + t.Fatalf("failed to count anon reservations: %v", err) + } + if anonCount != 1 { + t.Errorf("expected anon reservation to survive user cancel (separate flow), got %d", anonCount) + } +} + +// TestCancelReservation_DoesNotTouchAdminReservations verifies that the user +// cancel handler only targets RESERVATION:user:% — it must NEVER touch +// RESERVATION:admin:% entries (which belong to the admin walk-in/call-in +// reservation flow with the admin's user ID as created_by). +// +// This is the inverse guarantee of TestAdminCancelReservation_DoesNotDeleteUserReservations +// and verifies the two endpoints are properly partitioned by the WHERE clause. +func TestCancelReservation_DoesNotTouchAdminReservations(t *testing.T) { + t.Parallel() + 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) + } + defer fixtures.DeleteUser(tx, adminID) + + token := jwt.GenerateUserToken(userID) + userStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) + adminStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour) + + // Create a user reservation + _, err = tx.Exec(ctx, fmt.Sprintf(` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'RESERVATION:user:%s:33333', $2) + `, userID), userStart, userID) + if err != nil { + t.Fatalf("failed to create user reservation: %v", err) + } + + // Create an admin reservation + _, err = tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:44444', $2) + `, adminStart, adminID) + if err != nil { + t.Fatalf("failed to create admin reservation: %v", err) + } + + // Call user cancel as the user (NOT as the admin) + w := cancelReservationRequest(ctx, userID, "verified_email", token) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify user reservation is gone + var userCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:user:%' AND created_by = $1`, + userID, + ).Scan(&userCount) + if err != nil { + t.Fatalf("failed to count user reservations: %v", err) + } + if userCount != 0 { + t.Errorf("expected user reservation to be deleted, got %d", userCount) + } + + // Verify admin reservation is untouched + var adminCount int + err = tx.QueryRow(ctx, + `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%' AND created_by = $1`, + adminID, + ).Scan(&adminCount) + if err != nil { + t.Fatalf("failed to count admin reservations: %v", err) + } + if adminCount != 1 { + t.Errorf("expected admin reservation to survive user cancel (different endpoint), got %d", adminCount) + } +} diff --git a/backend/main.go b/backend/main.go index fce31f5..b5d1c2d 100644 --- a/backend/main.go +++ b/backend/main.go @@ -357,6 +357,7 @@ func main() { r.Post("/{id}/confirm", bookings.ConfirmBookingHandler) r.Post("/{id}/cancel", bookings.AdminCancelBookingHandler) r.Post("/reserve", bookings.AdminReserveSlotHandler) + r.Delete("/reserve", bookings.AdminCancelReservationHandler) // Edit request endpoints r.Get("/edit-requests", bookings.AdminListAllEditRequestsHandler) r.Get("/{id}/edit-request", bookings.AdminGetBookingEditRequestHandler)