//go:build test && dev package bookings import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "time" "crussell/clock" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" ) // cancelReservationRequest creates and serves a DELETE /api/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 userID is non-empty, it sets up the auth context (simulating RequireAuth). func cancelReservationRequest(ctx context.Context, userID, role, token string) *httptest.ResponseRecorder { req := httptest.NewRequest("DELETE", "/api/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 userID != "" { reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID) reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, role) } req = req.WithContext(reqCtx) w := httptest.NewRecorder() http.HandlerFunc(CancelReservationHandler).ServeHTTP(w, req) return w } // TestCancelReservation_Success creates a reservation for the user, cancels it, // and verifies the time_blocker is deleted from the database. func TestCancelReservation_Success(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) startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) // Create a reservation for this user _, err = tx.Exec(ctx, fmt.Sprintf(` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'RESERVATION:user:%s:12345', $2) `, userID), startTime, userID) 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:user:%' AND created_by = $1`, userID, ).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 CancelReservationHandler w := cancelReservationRequest(ctx, userID, "verified_email", 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:user:%' AND created_by = $1`, userID, ).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) } } // TestCancelReservation_NoActiveReservation verifies that calling cancel // without an active reservation returns 200 (idempotent, no error). func TestCancelReservation_NoActiveReservation(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) // Verify no reservations exist var countBefore int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:user:%' AND created_by = $1`, userID, ).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 CancelReservationHandler w := cancelReservationRequest(ctx, userID, "verified_email", 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:user:%' AND created_by = $1`, userID, ).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) } } // TestCancelReservation_Unauthenticated verifies that calling the handler // without a valid user in context returns 401 Unauthorized. func TestCancelReservation_Unauthenticated(t *testing.T) { t.Parallel() w := cancelReservationRequest(context.Background(), "", "", "") if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401 for unauthenticated request, got %d. body: %s", w.Code, w.Body.String()) } } // TestCancelReservation_EmptyUserIDInContext verifies that having a context // with an empty userID string is treated as unauthenticated. func TestCancelReservation_EmptyUserIDInContext(t *testing.T) { t.Parallel() w := cancelReservationRequest(context.Background(), "", "verified_email", "") if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401 for empty userID in context, got %d. body: %s", w.Code, w.Body.String()) } } // TestCancelReservation_DeletesOnlyOwnUserReservation verifies that cancelling // only deletes the requesting user's reservation, not another user's. func TestCancelReservation_DeletesOnlyOwnUserReservation(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) user1ID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user1: %v", err) } user2ID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user2: %v", err) } token1 := jwt.GenerateUserToken(user1ID) startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) // Create reservation for user1 _, 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) `, user1ID), startTime, user1ID) if err != nil { t.Fatalf("failed to create user1 reservation: %v", err) } // Create reservation for user2 _, err = tx.Exec(ctx, fmt.Sprintf(` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'RESERVATION:user:%s:22222', $2) `, user2ID), startTime, user2ID) if err != nil { t.Fatalf("failed to create user2 reservation: %v", err) } // Call CancelReservationHandler as user1 w := cancelReservationRequest(ctx, user1ID, "verified_email", token1) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify user1's reservation is gone var user1Count int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:user:%' AND created_by = $1`, user1ID, ).Scan(&user1Count) if err != nil { t.Fatalf("failed to count user1 reservations: %v", err) } if user1Count != 0 { t.Errorf("expected user1 reservations to be deleted, got %d", user1Count) } // Verify user2's reservation still exists var user2Count int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:user:%' AND created_by = $1`, user2ID, ).Scan(&user2Count) if err != nil { t.Fatalf("failed to count user2 reservations: %v", err) } if user2Count != 1 { t.Errorf("expected user2 reservation to survive, got %d", user2Count) } } // TestCancelReservation_CleanIdempotent verifies calling cancel twice // is safe (second call also succeeds, no error). func TestCancelReservation_CleanIdempotent(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) startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) // Create a reservation for this user _, err = tx.Exec(ctx, fmt.Sprintf(` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'RESERVATION:user:%s:99999', $2) `, userID), startTime, userID) if err != nil { t.Fatalf("failed to create reservation: %v", err) } // First cancel w1 := cancelReservationRequest(ctx, userID, "verified_email", 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 := cancelReservationRequest(ctx, userID, "verified_email", token) if w2.Code != http.StatusOK { t.Errorf("second cancel (idempotent): expected 200, got %d. body: %s", w2.Code, w2.Body.String()) } // Verify no reservations remain var count int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:user:%' AND created_by = $1`, userID, ).Scan(&count) if err != nil { t.Fatalf("failed to count reservations: %v", err) } if count != 0 { 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) } }