//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.