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.
This commit is contained in:
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user