Files
Crussell/backend/handlers/bookings/admin_reserve_test.go
T
popertotsandSisyphus 510828c924
CI / Go vulnerabilities (push) Successful in 1m10s
CI / Build & Vet (push) Successful in 1m39s
CI / Frontend build (gate) (push) Successful in 1m42s
CI / Frontend QC (audit) (push) Successful in 56s
CI / Frontend QC (typecheck) (push) Successful in 1m36s
CI / Frontend QC (lint) (push) Successful in 1m51s
CI / Tests (prod) (push) Has been cancelled
CI / Tests (dev) (push) Has been cancelled
CI / Race (prod) (push) Has been cancelled
CI / Race (dev) (push) Has been cancelled
chore: run go fix for Go 1.26 modernization
106 files: interface{}→any, strings.Split→SplitSeq, CutPrefix/Cut, strings.Builder, slices.Contains, remove redundant // +build directives, gofmt import ordering and indentation.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-09 17:25:23 +01:00

981 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build test && dev
package bookings
// Package bookings contains tests for admin booking reservation endpoints.
//
// Test Coverage:
// - AdminReserveSlotHandler: POST /api/admin/bookings/reserve - Admin slot reservation (walk-in or call-in)
//
// Tests cover walk-in and call-in reservation types, validation, slot overlaps, and replacement behavior.
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"crussell/clock"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"github.com/go-chi/chi/v5"
)
// makeAdminReserveRequest creates a request with admin context for admin reserve slot handler
// It sets mw.UserIDKey and mw.UserRoleKey to "admin" in the context
func makeAdminReserveRequest(handler http.Handler, body interface{}, adminID string, requestCtx ...context.Context) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
bodyBytes, _ := json.Marshal(body)
req = httptest.NewRequest("POST", "/api/admin/bookings/reserve", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest("POST", "/api/admin/bookings/reserve", nil)
}
baseCtx := req.Context()
if len(requestCtx) > 0 {
baseCtx = requestCtx[0]
}
rctx := chi.NewRouteContext()
ctx := context.WithValue(baseCtx, chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, mw.UserIDKey, adminID)
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
// =============================================================================
// Walk-in Reservation Tests
// =============================================================================
// TestAdminReserveSlot_WalkIn_Success tests that an admin can successfully
// create a walk-in reservation with a valid duration. The test verifies
// the reservation is created in the database with the correct duration.
func TestAdminReserveSlot_WalkIn_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)
tomorrow := clock.Now().Add(24 * time.Hour)
now := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 12, 0, 0, 0, tomorrow.Location())
req := AdminReserveSlotRequest{
ReservationType: "walkin",
StartTime: now,
DurationMinutes: 30,
TTLMinutes: 15,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
// Parse response and verify duration
var resp AdminReserveSlotResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.DurationMinutes != 30 {
t.Errorf("expected duration 30, got %d", resp.DurationMinutes)
}
// Verify time_blocker was created with correct description pattern
var desc string
err = tx.QueryRow(ctx,
"SELECT description FROM time_blockers WHERE description LIKE 'RESERVATION:admin:walkin:%'",
).Scan(&desc)
if err != nil {
t.Errorf("failed to query time_blocker: %v", err)
}
if !strings.HasPrefix(desc, "RESERVATION:admin:walkin:") {
t.Errorf("expected description to start with 'RESERVATION:admin:walkin:', got %s", desc)
}
}
// =============================================================================
// Call-in Reservation Tests
// =============================================================================
// TestAdminReserveSlot_CallIn_Success tests that an admin can successfully
// create a call-in reservation with valid service IDs. The test verifies
// the reservation duration matches the service duration.
func TestAdminReserveSlot_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)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(tx, serviceID)
_, err = tx.Exec(ctx, "UPDATE services SET duration_minutes = 30 WHERE id = $1", serviceID)
if err != nil {
t.Fatalf("failed to update service duration: %v", err)
}
tomorrow := clock.Now().Add(24 * time.Hour).Truncate(time.Second)
tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location())
req := AdminReserveSlotRequest{
UserID: &userID,
ReservationType: "callin",
StartTime: tomorrow,
ServiceIDs: []string{serviceID},
TTLMinutes: 15,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
// Parse response and verify duration matches service (30 min)
var resp AdminReserveSlotResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.DurationMinutes != 30 {
t.Errorf("expected duration 30, got %d", resp.DurationMinutes)
}
// Verify time_blocker was created with correct description pattern
var desc string
err = tx.QueryRow(ctx,
"SELECT description FROM time_blockers WHERE description LIKE 'RESERVATION:admin:callin:%'",
).Scan(&desc)
if err != nil {
t.Errorf("failed to query time_blocker: %v", err)
}
if !strings.HasPrefix(desc, "RESERVATION:admin:callin:") {
t.Errorf("expected description to start with 'RESERVATION:admin:callin:', got %s", desc)
}
}
// =============================================================================
// Validation Tests - Missing Duration
// =============================================================================
// TestAdminReserveSlot_WalkIn_MissingDuration tests that walk-in reservations
// fail with HTTP 400 when duration_minutes is missing or zero.
func TestAdminReserveSlot_WalkIn_MissingDuration(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)
now := clock.Now()
req := AdminReserveSlotRequest{
ReservationType: "walkin",
StartTime: now,
TTLMinutes: 15,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
// Verify error message mentions duration_minutes
body := w.Body.String()
if !strings.Contains(body, "duration_minutes") {
t.Errorf("expected body to contain 'duration_minutes', got %s", body)
}
}
// =============================================================================
// Validation Tests - Missing Services
// =============================================================================
// TestAdminReserveSlot_CallIn_MissingServices tests that call-in reservations
// fail with HTTP 400 when service_ids is empty.
func TestAdminReserveSlot_CallIn_MissingServices(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)
tomorrow := clock.Now().Add(24 * time.Hour).Truncate(time.Second)
tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location())
req := AdminReserveSlotRequest{
ReservationType: "callin",
StartTime: tomorrow,
ServiceIDs: []string{},
TTLMinutes: 15,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
// Verify error message mentions service
body := w.Body.String()
if !strings.Contains(body, "service") {
t.Errorf("expected body to contain 'service', got %s", body)
}
}
// =============================================================================
// Validation Tests - Invalid Reservation Type
// =============================================================================
// TestAdminReserveSlot_InvalidReservationType tests that reservations
// fail with HTTP 400 when reservation_type is invalid.
func TestAdminReserveSlot_InvalidReservationType(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)
req := AdminReserveSlotRequest{
ReservationType: "invalid",
StartTime: clock.Now(),
DurationMinutes: 30,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
// Verify error message mentions valid types
body := w.Body.String()
if !strings.Contains(body, "walkin") && !strings.Contains(body, "callin") {
t.Errorf("expected body to contain 'walkin' or 'callin', got %s", body)
}
}
// =============================================================================
// Slot Overlap Tests
// =============================================================================
// TestAdminReserveSlot_SlotOverlap tests that a reservation fails
// with HTTP 409 when the slot overlaps with an existing booking.
func TestAdminReserveSlot_SlotOverlap(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// Create test admin user (for the booking)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
// Create test regular user
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(tx, serviceID)
// Set deposits_required=0 for test user
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
tomorrow := clock.Now().Add(24 * time.Hour).Truncate(time.Second)
tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location())
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(tx, bookingID)
_, err = tx.Exec(ctx,
"UPDATE bookings SET start_time = $1, status = 'confirmed' WHERE id = $2",
tomorrow, bookingID)
if err != nil {
t.Fatalf("failed to update booking time: %v", err)
}
// Now try to reserve a call-in that overlaps (tomorrow 10:15 - 15 min after start)
overlapTime := tomorrow.Add(15 * time.Minute)
req := AdminReserveSlotRequest{
ReservationType: "callin",
StartTime: overlapTime,
ServiceIDs: []string{serviceID},
TTLMinutes: 15,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
// Should return 409 Conflict due to overlap
if w.Code != http.StatusConflict {
t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Reservation Replacement Tests
// =============================================================================
// TestAdminReserveSlot_ReplacesExisting tests that reserving twice
// on the same admin replaces the previous reservation.
func TestAdminReserveSlot_ReplacesExisting(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)
tomorrow := clock.Now().Add(24 * time.Hour)
now := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 12, 0, 0, 0, tomorrow.Location())
req := AdminReserveSlotRequest{
ReservationType: "walkin",
StartTime: now,
DurationMinutes: 30,
TTLMinutes: 15,
}
// First reservation
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var firstResp AdminReserveSlotResponse
if err := json.Unmarshal(w.Body.Bytes(), &firstResp); err != nil {
t.Fatalf("failed to parse first response: %v", err)
}
// Count reservations before second request
var countBefore int
var qerr error
qerr = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%'",
).Scan(&countBefore)
if qerr != nil {
t.Fatalf("failed to count reservations: %v", qerr)
}
// Second reservation (same admin, new time)
laterTime := now.Add(1 * time.Hour)
req2 := AdminReserveSlotRequest{
ReservationType: "walkin",
StartTime: laterTime,
DurationMinutes: 45,
TTLMinutes: 15,
}
w = makeAdminReserveRequest(handler, req2, adminID, ctx)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var secondResp AdminReserveSlotResponse
if err := json.Unmarshal(w.Body.Bytes(), &secondResp); err != nil {
t.Fatalf("failed to parse second response: %v", err)
}
// Count reservations after second request - should still be 1
var countAfter int
qerr = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%'",
).Scan(&countAfter)
if qerr != nil {
t.Fatalf("failed to count reservations: %v", qerr)
}
// Old one should be deleted, new one exists (id should be different)
if countAfter != 1 {
t.Errorf("expected 1 reservation after replacement, got %d", countAfter)
}
if firstResp.ID == secondResp.ID {
t.Errorf("expected new reservation ID to be different from old one")
}
}
// =============================================================================
// Past Start Time Tests
// =============================================================================
// TestAdminReserveSlot_WalkIn_PastStart tests that walk-in reservations
func TestAdminReserveSlot_WalkIn_PastStart(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)
pastTime := clock.Now().Add(-5 * time.Minute)
req := AdminReserveSlotRequest{
ReservationType: "walkin",
StartTime: pastTime,
DurationMinutes: 30,
TTLMinutes: 15,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
// Verify error message mentions the past time limit
body := w.Body.String()
if !strings.Contains(body, "past") {
t.Errorf("expected body to contain 'past', got %s", body)
}
}
// TestAdminReserveSlot_OutOfHours_CallIn_Success verifies that an admin can
// successfully create a call-in reservation with out_of_hours=true, bypassing
// the closing hours check even when booking outside normal hours.
func TestAdminReserveSlot_OutOfHours_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)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
defer fixtures.DeleteService(tx, serviceID)
// Baseline test DB has 08:00-20:00 hours. Book 19:30 + 60min = 20:30 (> 20:00 closing)
// Without out_of_hours this would fail; with out_of_hours=true it should succeed.
tomorrow := clock.Now().Add(24 * time.Hour)
lateBooking := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 19, 30, 0, 0, tomorrow.Location())
req := AdminReserveSlotRequest{
ReservationType: "callin",
StartTime: lateBooking,
ServiceIDs: []string{serviceID},
TTLMinutes: 15,
OutOfHours: true,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201 for out-of-hours reservation, got %d. body: %s", w.Code, w.Body.String())
}
var resp AdminReserveSlotResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.StartTime.Format("15:04") != "19:30" {
t.Errorf("expected start time 19:30, got %s", resp.StartTime.Format("15:04"))
}
}
// TestAdminReserveSlot_OutOfHours_WithoutFlag_Fails verifies that an admin
// attempting to book a slot that ends after closing hours WITHOUT the
// out_of_hours flag is rejected, ensuring the flag is required.
func TestAdminReserveSlot_OutOfHours_WithoutFlag_Fails(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)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
defer fixtures.DeleteService(tx, serviceID)
// Baseline test DB has 08:00-20:00 hours. Book 19:30 + 60min = 20:30 (> 20:00 closing)
// Without out_of_hours this should be rejected.
tomorrow := clock.Now().Add(24 * time.Hour)
lateBooking := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 19, 30, 0, 0, tomorrow.Location())
req := AdminReserveSlotRequest{
ReservationType: "callin",
StartTime: lateBooking,
ServiceIDs: []string{serviceID},
TTLMinutes: 15,
// OutOfHours intentionally NOT set (defaults to false)
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 when booking beyond closing hours without out_of_hours flag, got %d. body: %s",
w.Code, w.Body.String())
}
}
// TestAdminReserveSlot_OutOfHours_WalkIn_Success verifies that an admin can
// successfully create a walk-in reservation with out_of_hours=true, bypassing
// the closing hours check and using explicit duration.
func TestAdminReserveSlot_OutOfHours_WalkIn_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)
// Baseline test DB has 08:00-20:00 hours. Book walk-in 19:30 + 60min = 20:30 (> 20:00 closing)
// Without out_of_hours this would fail; with out_of_hours=true it should succeed.
tomorrow := clock.Now().Add(24 * time.Hour)
lateBooking := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 19, 30, 0, 0, tomorrow.Location())
req := AdminReserveSlotRequest{
ReservationType: "walkin",
StartTime: lateBooking,
DurationMinutes: 60,
TTLMinutes: 15,
OutOfHours: true,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201 for out-of-hours walk-in reservation, got %d. body: %s",
w.Code, w.Body.String())
}
var resp AdminReserveSlotResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.DurationMinutes != 60 {
t.Errorf("expected duration 60, got %d", resp.DurationMinutes)
}
// Verify reservation time_blocker was created
var desc string
err = tx.QueryRow(ctx,
"SELECT description FROM time_blockers WHERE description LIKE 'RESERVATION:admin:walkin:%'",
).Scan(&desc)
if err != nil {
t.Errorf("failed to query time_blocker: %v", err)
}
if !strings.HasPrefix(desc, "RESERVATION:admin:walkin:") {
t.Errorf("expected RESERVATION:admin:walkin: prefix, got %s", desc)
}
}
// TestAdminReserveSlot_OutOfHours_TimeBlockerBlocks verifies that even when
// out_of_hours=true is set, a time blocker at the requested time still causes
// the reservation to be rejected with 409 Conflict.
func TestAdminReserveSlot_OutOfHours_TimeBlockerBlocks(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)
// Create a time blocker at a specific future time
tomorrow := clock.Now().Add(24 * time.Hour)
blockerStart := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 14, 0, 0, 0, tomorrow.Location())
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Admin Blocked Time', NULL)
`, blockerStart)
if err != nil {
t.Fatalf("failed to create time blocker: %v", err)
}
// Try reserving during the blocked time with out_of_hours=true
req := AdminReserveSlotRequest{
ReservationType: "walkin",
StartTime: blockerStart,
DurationMinutes: 30,
TTLMinutes: 15,
OutOfHours: true,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code != http.StatusConflict {
t.Errorf("expected 409 Conflict when blocker overlaps with out_of_hours reservation, got %d. body: %s",
w.Code, w.Body.String())
}
}
// TestAdminReserveSlot_BST_ClosingBoundary verifies that during BST (UTC+1),
// the closing-time check uses London local time, not UTC. A booking ending at
// 20:01 BST (19:01 UTC) should be rejected when closing is 20:00 BST, even
// though the UTC hour (19) is before the closing hour (20). Seed data has
// working hours 08:00-20:00 for all days.
func TestAdminReserveSlot_BST_ClosingBoundary(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)
// Use a Monday in BST (2099-06-15 is a Monday in BST). Seed working hours
// for Monday are 08:00-20:00.
// Start at 18:31 UTC (= 19:31 BST), 30 min duration → ends at 19:01 UTC (= 20:01 BST).
// This should be rejected because 20:01 BST > 20:00 BST closing.
// Without .In(londonLocation), UTC hour 19 < closing 20, so this would
// incorrectly pass — the fix catches it.
bstDay := time.Date(2099, 6, 15, 18, 31, 0, 0, time.UTC)
req := AdminReserveSlotRequest{
ReservationType: "walkin",
StartTime: bstDay,
DurationMinutes: 30,
TTLMinutes: 15,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
// Must be rejected (exceeds closing hours in London time).
// Without .In(londonLocation), UTC hour 19 would be < closing 20 and pass.
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 BadRequest (exceeds closing hours in BST), got %d. body: %s", w.Code, w.Body.String())
}
// Verify the error message mentions closing hours.
if !strings.Contains(w.Body.String(), "closing hours") && !strings.Contains(w.Body.String(), "closing") {
t.Errorf("expected error about closing hours, got: %s", w.Body.String())
}
// Now test a booking that ends within closing hours (19:30 BST < 20:00 BST).
// Start at 18:00 UTC (= 19:00 BST), 30 min duration → ends at 18:30 UTC (= 19:30 BST).
bstDayOK := time.Date(2099, 6, 15, 18, 0, 0, 0, time.UTC)
req2 := AdminReserveSlotRequest{
ReservationType: "walkin",
StartTime: bstDayOK,
DurationMinutes: 30,
TTLMinutes: 15,
}
w2 := makeAdminReserveRequest(handler, req2, adminID, ctx)
if w2.Code != http.StatusCreated {
t.Errorf("expected 201 Created (within closing hours in BST), got %d. body: %s", w2.Code, w2.Body.String())
}
}
// =============================================================================
// Autumn DST (BST→GMT Transition) Tests
// =============================================================================
// TestAdminReserveSlot_AutumnDST_ClosingBoundary verifies that during autumn DST
// (BST→GMT transition on Oct 25, 2026), the closing-time check works correctly
// when London is in GMT (UTC+0). After the transition at 02:00 BST (→ 01:00 GMT),
// local time equals UTC. This test sets Sunday's closing to 17:00 and verifies:
// 1. A slot ending exactly at 17:00 GMT (= 17:00 UTC) is allowed (end == closing)
// 2. A slot ending 1 minute after 17:00 GMT (= 17:01 UTC) is rejected
//
// Use of londonLocation for time.Date construction ensures the test time is
// interpreted in the local timezone context of the autumn DST transition day.
func TestAdminReserveSlot_AutumnDST_ClosingBoundary(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)
// Oct 25, 2026 is a Sunday (DB weekday 6). BST ends at 02:00 BST (→ 01:00 GMT),
// so the entire working day is in GMT. Override Sunday's closing to 17:00 to test
// the closing boundary on the autumn DST transition day.
_, err = tx.Exec(ctx, `
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
VALUES (6, '08:00', '17:00', true)
ON CONFLICT (weekday) DO UPDATE SET start_time = '08:00', end_time = '17:00', is_open = true
`)
if err != nil {
t.Fatalf("failed to set Sunday working hours: %v", err)
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
// Test 1: End exactly at 17:00 GMT (= 17:00 UTC, since GMT = UTC+0).
// Start 16:30 GMT + 30 min → ends 17:00 GMT → end == closing, allowed.
closingSlot := time.Date(2026, 10, 25, 16, 30, 0, 0, londonLocation)
req1 := AdminReserveSlotRequest{
ReservationType: "walkin",
StartTime: closingSlot,
DurationMinutes: 30,
TTLMinutes: 15,
}
w1 := makeAdminReserveRequest(handler, req1, adminID, ctx)
if w1.Code != http.StatusCreated {
t.Errorf("ending exactly at 17:00 GMT: expected 201 (end == closing allowed), got %d. body: %s",
w1.Code, w1.Body.String())
}
// Parse response and verify duration
var resp1 AdminReserveSlotResponse
if err := json.Unmarshal(w1.Body.Bytes(), &resp1); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp1.DurationMinutes != 30 {
t.Errorf("expected duration 30, got %d", resp1.DurationMinutes)
}
// Test 2: End 1 min after 17:00 GMT (= 17:01 UTC).
// Start 16:31 GMT + 30 min → ends 17:01 GMT → rejected (past closing).
pastClose := time.Date(2026, 10, 25, 16, 31, 0, 0, londonLocation)
req2 := AdminReserveSlotRequest{
ReservationType: "walkin",
StartTime: pastClose,
DurationMinutes: 30,
TTLMinutes: 15,
}
w2 := makeAdminReserveRequest(handler, req2, adminID, ctx)
if w2.Code != http.StatusBadRequest {
t.Errorf("ending 1 min after 17:00 GMT: expected 400, got %d. body: %s",
w2.Code, w2.Body.String())
}
// Verify error message mentions closing hours
if !strings.Contains(w2.Body.String(), "closing hours") && !strings.Contains(w2.Body.String(), "closing") {
t.Errorf("expected error about closing hours, got: %s", w2.Body.String())
}
}
// TestAdminReserveSlot_BST_WeekdayLookup verifies that the weekday used for
// working-hours lookup uses London time, not UTC. At 23:30 UTC on a Sunday
// in BST (= 00:30 BST Monday), UTC says Sunday (DB weekday 6) but London says
// Monday (DB weekday 0). Deleting Sunday's row should cause failure WITHOUT
// the fix, but succeed WITH the fix (London-time weekday=Monday, row exists).
func TestAdminReserveSlot_BST_WeekdayLookup(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)
// Delete Sunday's (DB weekday 6) working hours row.
_, err = tx.Exec(ctx, "DELETE FROM working_hours WHERE weekday = 6")
if err != nil {
t.Fatalf("failed to delete Sunday hours: %v", err)
}
// Also seed Monday (weekday 0) explicitly so the test doesn't depend on fixture defaults.
_, err = tx.Exec(ctx, `
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
VALUES (0, '09:00', '17:00', true)
ON CONFLICT (weekday) DO UPDATE SET start_time = '09:00', end_time = '17:00', is_open = true
`)
if err != nil {
t.Fatalf("failed to seed Monday hours: %v", err)
}
// Book at 23:30 UTC on a Sunday in BST (2099-06-14 is Sunday, 2099-06-15 is Monday).
// 23:30 UTC Sunday = 00:30 BST Monday. London time = Monday (DB weekday 0, exists).
// UTC time = Sunday (DB weekday 6, deleted).
sunday2330UTC := time.Date(2099, 6, 14, 23, 30, 0, 0, time.UTC)
req := AdminReserveSlotRequest{
ReservationType: "walkin",
StartTime: sunday2330UTC,
DurationMinutes: 30,
TTLMinutes: 15,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code != http.StatusCreated {
t.Errorf("expected 201 Created (London weekday=Monday, row exists), got %d. body: %s — UTC weekday=Sunday (deleted), London weekday=Monday (exists)", w.Code, w.Body.String())
}
}
// TestAdminReserveSlot_ClosingComparison_EdgeCases verifies that the closing-time
// string comparison correctly handles edge cases at the boundary (BUG 5 fix).
// Tests: ending exactly at closing, 1 min before, and 1 min after.
func TestAdminReserveSlot_ClosingComparison_EdgeCases(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)
// Seed data has all days open 08:00-20:00. Test various closing edges.
// Use a BST Monday (2099-06-15, BST period so UTC != London).
// 20:00 BST = 19:00 UTC. A service ending at 19:59 UTC = 20:59 BST (after closing).
// Actually seed data is 08:00-20:00 BST, so 20:00 BST closing = 19:00 UTC.
// Test 1: End exactly at closing (20:00 BST = 19:00 UTC) — should be allowed
// (end == closing is not "beyond" closing — the check is strict greater-than).
endAtClose := time.Date(2099, 6, 15, 18, 30, 0, 0, time.UTC) // start 18:30 UTC
req1 := AdminReserveSlotRequest{
ReservationType: "walkin",
StartTime: endAtClose,
DurationMinutes: 30,
TTLMinutes: 15,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w1 := makeAdminReserveRequest(handler, req1, adminID, ctx)
if w1.Code != http.StatusCreated {
t.Errorf("closing exactly at 20:00 BST: expected 201 (end == closing is allowed), got %d", w1.Code)
}
// Test 2: End 1 minute after closing (20:01 BST = 19:01 UTC) — should reject
oneMinAfter := time.Date(2099, 6, 15, 18, 31, 0, 0, time.UTC)
req2 := AdminReserveSlotRequest{
ReservationType: "walkin",
StartTime: oneMinAfter,
DurationMinutes: 30,
TTLMinutes: 15,
}
w2 := makeAdminReserveRequest(handler, req2, adminID, ctx)
if w2.Code != http.StatusBadRequest {
t.Errorf("closing 1 min after 20:00 BST: expected 400, got %d", w2.Code)
}
}
// TestAdminReserveSlot_PendingRelease_DoesNotBlock verifies that a
// pending_release booking does NOT block the admin reserve endpoint.
// Like the user-facing reserve, admin reserves are pre-checks — eviction
// happens when the actual booking is created.
func TestAdminReserveSlot_PendingRelease_DoesNotBlock(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)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
future := clock.Now().Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
// Create a pending_release booking at this time slot
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
VALUES ($1, $2, 'pending_release', false)
`, userID, future)
if err != nil {
t.Fatalf("failed to create pending_release booking: %v", err)
}
// Admin reserves the same slot — should succeed
req := AdminReserveSlotRequest{
ReservationType: "callin",
StartTime: future,
ServiceIDs: []string{serviceID},
TTLMinutes: 15,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code == http.StatusConflict {
t.Errorf("pending_release should NOT block admin reserve it is evictable, got 409")
}
if w.Code != http.StatusCreated {
t.Errorf("expected 201 for admin reserve with only pending_release overlap, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminReserveSlot_SlotOverlap tests that a reservation fails when the
// requested time slot overlaps with an existing booking.