//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) } }