From 7756f3af968797f10cc6b738c6eb186fe3d772df Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 22 Jun 2026 17:06:05 +0100 Subject: [PATCH] test(scheduling): add exhaustive blocker, gap, and cross-day tests Add comprehensive time blocker tests: multiple blockers, blocker+booking combination, all-day, non-overlapping, multi-day, boundary start/end, out-of-hours, recurring (cron), reservation, overlapping, adjacent, cross-day midnight, closed days, exceptional hours. Add subtractTimeSlots and normalizeTime unit tests. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../handlers/scheduling/scheduling_test.go | 1289 ++++++++++++++++- 1 file changed, 1287 insertions(+), 2 deletions(-) diff --git a/backend/handlers/scheduling/scheduling_test.go b/backend/handlers/scheduling/scheduling_test.go index 9daf047..46c4bfb 100644 --- a/backend/handlers/scheduling/scheduling_test.go +++ b/backend/handlers/scheduling/scheduling_test.go @@ -20,6 +20,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strconv" @@ -30,6 +31,7 @@ import ( "crussell/db" "crussell/mw" "crussell/testutils" + "crussell/testutils/fixtures" "crussell/testutils/jwt" ) @@ -1082,7 +1084,8 @@ func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin(t *testing.T) { } // TestScheduling_GetAvailableHours_WithBlocker_Admin verifies that admin users -// CAN see blocked time slots in the blockers field. +// see blocked time slots subtracted from available slots AND visible in the +// blockers field for warning display. func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) @@ -1136,7 +1139,14 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) { t.Fatal("expected day 2026-03-16 in response") } - // Verify blocker IS visible in blockers field for admin + // Verify 10:00-11:00 slot is NOT available in slots (subtracted due to blocker) + for _, slot := range targetDay.Slots { + if slot.StartTime == "10:00" { + t.Error("expected 10:00 slot to be blocked and not available for admin users") + } + } + + // Verify blocker IS visible in blockers field for admin (warning display) if len(targetDay.Blockers) == 0 { t.Error("expected blockers field to contain the blocker for admin users") } else { @@ -1207,3 +1217,1278 @@ func TestIsValidTime15Min(t *testing.T) { }) } } + +// ============================================================================= +// Exhaustive Admin Time Blocker Tests +// ============================================================================= +// +// These tests verify that time blockers are subtracted from available slots for +// ALL users (including admins), preventing 409 "blocked" errors on reserve. +// Blockers are also surfaced in the blockers field for admin warning display. + +// makeAdminAvailableHoursRequest is a helper that sends a GET to +// /api/scheduling/available-hours as an admin user and returns the response. +func makeAdminAvailableHoursRequest(t *testing.T, ctx context.Context, start, end string) []DayAvailableHours { + t.Helper() + handler := http.HandlerFunc(GetAvailableHours) + req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start="+start+"&end="+end, nil) + reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001") + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") + req = req.WithContext(reqCtx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + var response []DayAvailableHours + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + return response +} + +// findDayByDate finds a DayAvailableHours by date string in the response. +func findDayByDate(response []DayAvailableHours, date string) *DayAvailableHours { + for i := range response { + if response[i].Date == date { + return &response[i] + } + } + return nil +} + +// slotExists checks if a time falls within any available slot range. +// Slots are continuous ranges (e.g. {StartTime:09:00, EndTime:17:00}) and the +// frontend generates 15-min intervals from them. A time is "available" if it +// falls at or after a slot's StartTime and before its EndTime. +func slotExists(slots []TimeSlot, time string) bool { + for _, s := range slots { + if time >= s.StartTime && time < s.EndTime { + return true + } + } + return false +} + +// getWorkingHoursForDate queries the working hours for the given date via the +// GetWorkingHours handler, using the admin context so the returned context works +// with the test transaction. Returns the hours for the specified date, or empty +// strings / false if the day is closed. +func getWorkingHoursForDate(t *testing.T, ctx context.Context, date string) (start, end string, isOpen bool) { + t.Helper() + handler := http.HandlerFunc(GetWorkingHours) + req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start="+date+"&end="+date, nil) + reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001") + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") + req = req.WithContext(reqCtx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GetWorkingHours returned %d: %s", w.Code, w.Body.String()) + } + type whDay struct { + Date string `json:"date"` + IsOpen bool `json:"isOpen"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + } + var days []whDay + if err := json.Unmarshal(w.Body.Bytes(), &days); err != nil { + t.Fatalf("failed to unmarshal working hours: %v", err) + } + for _, d := range days { + if d.Date == date { + return d.StartTime, d.EndTime, d.IsOpen + } + } + t.Fatalf("date %s not found in working hours response", date) + return "", "", false +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_MultipleBlockers tests +// that multiple time blockers on the same day are ALL subtracted from admin slots. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultipleBlockers(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Create two blockers on Tuesday 2026-03-17 (open 09:00-17:00): + // 10:00-11:00 (Staff Meeting) and 14:00-15:00 (Training) + b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation) + b2 := time.Date(2026, 3, 17, 14, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff Meeting', NULL)`, b1) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Training', NULL)`, b2) + + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17") + targetDay := findDayByDate(response, "2026-03-17") + if targetDay == nil { + t.Fatal("expected day 2026-03-17 in response") + } + + // Both blocked slots should be absent from available slots + if slotExists(targetDay.Slots, "10:00") { + t.Error("expected 10:00 slot to be blocked (Staff Meeting)") + } + if slotExists(targetDay.Slots, "14:00") { + t.Error("expected 14:00 slot to be blocked (Training)") + } + + // Non-blocked slots should still be available (e.g. 09:00, 11:00, 13:00, 15:00) + if !slotExists(targetDay.Slots, "09:00") { + t.Error("expected 09:00 slot to remain available") + } + if !slotExists(targetDay.Slots, "11:00") { + t.Error("expected 11:00 slot to remain available after 10-11 blocker") + } + if !slotExists(targetDay.Slots, "15:00") { + t.Error("expected 15:00 slot to remain available after 14-15 blocker") + } + + // Both blockers visible in blockers field for admin warning display + if len(targetDay.Blockers) != 2 { + t.Errorf("expected 2 blockers, got %d", len(targetDay.Blockers)) + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_BlockerAndBooking tests +// that both time blockers AND existing bookings are subtracted from admin slots. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_BlockerAndBooking(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Tuesday 2026-03-17 (open 09:00-17:00) + // Create a booking at 11:00-12:00 and a blocker at 14:00-15:00 + bookingStart := time.Date(2026, 3, 17, 11, 0, 0, 0, ukLocation) + bookingEnd := bookingStart.Add(60 * time.Minute) + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(tx, userID) + _, err = tx.Exec(ctx, ` + INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time) + VALUES ($1, $2, 'confirmed', 60, $3) + `, userID, bookingStart, bookingEnd) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + blockerTime := time.Date(2026, 3, 17, 14, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Equipment Maintenance', NULL)`, blockerTime) + + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17") + targetDay := findDayByDate(response, "2026-03-17") + if targetDay == nil { + t.Fatal("expected day 2026-03-17 in response") + } + + // 11:00 slot removed by booking, 14:00 slot removed by blocker + if slotExists(targetDay.Slots, "11:00") { + t.Error("expected 11:00 slot to be unavailable (booking)") + } + if slotExists(targetDay.Slots, "14:00") { + t.Error("expected 14:00 slot to be blocked (Equipment Maintenance)") + } + + // Unaffected slots remain + if !slotExists(targetDay.Slots, "09:00") { + t.Error("expected 09:00 to remain available") + } + if !slotExists(targetDay.Slots, "10:00") { + t.Error("expected 10:00 to remain available") + } + + // Only the blocker (not the booking) appears in blockers field + foundBlocker := false + for _, b := range targetDay.Blockers { + if b.StartTime == "14:00" && b.EndTime == "15:00" { + foundBlocker = true + } + } + if !foundBlocker { + t.Error("expected blocker 14:00-15:00 in blockers field") + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_AllDayBlocker tests that +// a blocker covering the entire open period leaves no slots available for admin. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_AllDayBlocker(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Tuesday 2026-03-17 (open 09:00-17:00) — block entire open period + blockerTime := time.Date(2026, 3, 17, 9, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 480, 'All day closure', NULL)`, blockerTime) + + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17") + targetDay := findDayByDate(response, "2026-03-17") + if targetDay == nil { + t.Fatal("expected day 2026-03-17 in response") + } + + // All slots should be removed + if len(targetDay.Slots) > 0 { + t.Errorf("expected no available slots with all-day blocker, got %d slots: %+v", len(targetDay.Slots), targetDay.Slots) + } + + // Blocker is visible in blockers field + if len(targetDay.Blockers) == 0 { + t.Error("expected all-day blocker in blockers field") + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_NonOverlappingBlocker +// tests that a blocker outside open hours does NOT affect admin slot availability. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_NonOverlappingBlocker(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 17:00-18:00 (after close) + blockerTime := time.Date(2026, 3, 17, 17, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'After hours cleaning', NULL)`, blockerTime) + + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17") + targetDay := findDayByDate(response, "2026-03-17") + if targetDay == nil { + t.Fatal("expected day 2026-03-17 in response") + } + + // All normal slots should still be present + expectedSlots := []string{"09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00", "16:00"} + for _, expected := range expectedSlots { + if !slotExists(targetDay.Slots, expected) { + t.Errorf("expected slot %s to remain available (non-overlapping blocker)", expected) + } + } + + // After-hours blocker still visible in blockers field + found := false + for _, b := range targetDay.Blockers { + if b.StartTime == "17:00" && b.EndTime == "18:00" { + found = true + } + } + if !found { + t.Error("expected after-hours blocker in blockers field") + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDay tests that +// blockers on multiple days are independently subtracted for admin users. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDay(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Blockers on Tue 2026-03-17 10:00-11:00 and Wed 2026-03-18 14:00-15:00 + b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation) + b2 := time.Date(2026, 3, 18, 14, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Tue Meeting', NULL)`, b1) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Wed Training', NULL)`, b2) + + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18") + tue := findDayByDate(response, "2026-03-17") + wed := findDayByDate(response, "2026-03-18") + + if tue == nil { + t.Fatal("expected 2026-03-17 in response") + } + if wed == nil { + t.Fatal("expected 2026-03-18 in response") + } + + // Tuesday: 10:00 slot blocked + if slotExists(tue.Slots, "10:00") { + t.Error("expected 2026-03-17 10:00 slot to be blocked") + } + // Wednesday: 14:00 slot blocked + if slotExists(wed.Slots, "14:00") { + t.Error("expected 2026-03-18 14:00 slot to be blocked") + } + // Tuesday: 14:00 still available + if !slotExists(tue.Slots, "14:00") { + t.Error("expected 2026-03-17 14:00 to remain available") + } + // Wednesday: 10:00 still available + if !slotExists(wed.Slots, "10:00") { + t.Error("expected 2026-03-18 10:00 to remain available") + } + + // Both days have blockers in blockers field + if len(tue.Blockers) != 1 { + t.Errorf("expected 1 blocker on Tuesday, got %d", len(tue.Blockers)) + } + if len(wed.Blockers) != 1 { + t.Errorf("expected 1 blocker on Wednesday, got %d", len(wed.Blockers)) + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryStart tests that +// a blocker at the exact opening time correctly removes the first slot. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryStart(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 09:00-10:00 (start of day) + blockerTime := time.Date(2026, 3, 17, 9, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning setup', NULL)`, blockerTime) + + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17") + targetDay := findDayByDate(response, "2026-03-17") + if targetDay == nil { + t.Fatal("expected day 2026-03-17 in response") + } + + // 09:00 slot should be blocked + if slotExists(targetDay.Slots, "09:00") { + t.Error("expected 09:00 slot to be blocked (boundary start)") + } + // 10:00 slot should be available (blocker is 09-10, ends at 10:00) + if !slotExists(targetDay.Slots, "10:00") { + t.Error("expected 10:00 slot to be available (starts after blocker ends)") + } + // Blocker visible in blockers field + found := false + for _, b := range targetDay.Blockers { + if b.StartTime == "09:00" && b.EndTime == "10:00" { + found = true + } + } + if !found { + t.Error("expected blocker 09:00-10:00 in blockers field") + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryEnd tests that +// a blocker at the exact closing time correctly removes the last slot. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryEnd(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 16:00-17:00 (end of day) + blockerTime := time.Date(2026, 3, 17, 16, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'End of day cleanup', NULL)`, blockerTime) + + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17") + targetDay := findDayByDate(response, "2026-03-17") + if targetDay == nil { + t.Fatal("expected day 2026-03-17 in response") + } + + // 16:00 slot should be blocked + if slotExists(targetDay.Slots, "16:00") { + t.Error("expected 16:00 slot to be blocked (boundary end)") + } + // 15:00 slot should be available + if !slotExists(targetDay.Slots, "15:00") { + t.Error("expected 15:00 slot to be available (ends before blocker starts)") + } + // Blocker visible in blockers field + found := false + for _, b := range targetDay.Blockers { + if b.StartTime == "16:00" && b.EndTime == "17:00" { + found = true + } + } + if !found { + t.Error("expected blocker 16:00-17:00 in blockers field") + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_OutOfHours verifies that +// blockers are still subtracted from admin slots when out_of_hours mode is active. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_OutOfHours(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Tuesday 2026-03-17 — blocker at 10:00-11:00 + blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning Meeting', NULL)`, blockerTime) + + // Request with out_of_hours=true (extends to 06:00-22:00 for admin) + handler := http.HandlerFunc(GetAvailableHours) + req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17&out_of_hours=true", nil) + reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001") + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") + req = req.WithContext(reqCtx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var response []DayAvailableHours + json.Unmarshal(w.Body.Bytes(), &response) + targetDay := findDayByDate(response, "2026-03-17") + if targetDay == nil { + t.Fatal("expected day 2026-03-17 in response") + } + + // 10:00 slot should be blocked even in out_of_hours mode + if slotExists(targetDay.Slots, "10:00") { + t.Error("expected 10:00 slot to be blocked in out_of_hours mode") + } + // 07:00 slot (extended hours) should be available (no blocker there) + if !slotExists(targetDay.Slots, "07:00") { + t.Error("expected 07:00 slot to be available in out_of_hours mode") + } + // Blocker visible in blockers field + if len(targetDay.Blockers) == 0 { + t.Error("expected blocker in blockers field for out_of_hours admin") + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_Regression verifies that +// non-admin users still have blockers correctly subtracted (regression check). +func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_Regression(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Tuesday 2026-03-17 — blocker at 10:00-11:00 + blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff Meeting', NULL)`, blockerTime) + + // Request as non-admin + handler := http.HandlerFunc(GetAvailableHours) + req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil) + reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001") + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email") + req = req.WithContext(reqCtx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var response []DayAvailableHours + json.Unmarshal(w.Body.Bytes(), &response) + targetDay := findDayByDate(response, "2026-03-17") + if targetDay == nil { + t.Fatal("expected day 2026-03-17 in response") + } + + // 10:00 slot blocked for non-admin + if slotExists(targetDay.Slots, "10:00") { + t.Error("expected 10:00 slot to be blocked for non-admin user") + } + // 09:00 still available + if !slotExists(targetDay.Slots, "09:00") { + t.Error("expected 09:00 slot to remain available for non-admin user") + } + // Non-admin should NOT see blockers in blockers field + if len(targetDay.Blockers) != 0 { + t.Error("expected blockers field to be empty for non-admin user") + } +} + +// ============================================================================= +// Gap-Filling Tests — Blockers with Recurring, Reservations, Overlaps, etc. +// ============================================================================= + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_Recurring verifies that a +// recurring (cron-based) time blocker is expanded and subtracted from admin slots. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_Recurring(t *testing.T) { + // No t.Parallel() — GetAvailableHours cleanup operations can deadlock with + // concurrent test transactions on the shared test database. + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Daily recurring blocker 12:00-13:00 starting Mon 2026-03-16 + startTime := time.Date(2026, 3, 16, 12, 0, 0, 0, ukLocation) + cronExpr := "0 12 * * *" // Every day at 12:00 + tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by) + VALUES ($1, 60, 'Daily Lunch Blocker', $2, NULL) + `, startTime, cronExpr) + + // Query Tue 2026-03-17 (open 09:00-17:00) and Wed 2026-03-18 (open 09:00-17:00) + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18") + tue := findDayByDate(response, "2026-03-17") + wed := findDayByDate(response, "2026-03-18") + if tue == nil || wed == nil { + t.Fatal("expected both days in response") + } + + // 12:00 slot should be blocked on both days + if slotExists(tue.Slots, "12:00") { + t.Error("expected 12:00 blocked on Tuesday (recurring blocker)") + } + if slotExists(wed.Slots, "12:00") { + t.Error("expected 12:00 blocked on Wednesday (recurring blocker)") + } + // Adjacent slots should be available + if !slotExists(tue.Slots, "11:00") { + t.Error("expected 11:00 available on Tuesday") + } + if !slotExists(wed.Slots, "13:00") { + t.Error("expected 13:00 available on Wednesday") + } + // Both days should show blocker in blockers field + if len(tue.Blockers) == 0 { + t.Error("expected blocker visible in blockers field on Tuesday") + } + if len(wed.Blockers) == 0 { + t.Error("expected blocker visible in blockers field on Wednesday") + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation verifies that +// RESERVATION:admin time_blocker entries are also subtracted from admin slots. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation(t *testing.T) { + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Create a real admin user to satisfy FK constraint, then simulate a reservation + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation) + if _, err := tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 30, 'RESERVATION:admin:callin:guest:1712345678', $2) + `, reservationTime, adminID); err != nil { + t.Fatalf("failed to create reservation blocker: %v", err) + } + + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17") + targetDay := findDayByDate(response, "2026-03-17") + if targetDay == nil { + t.Fatal("expected 2026-03-17 in response") + } + + // 10:00 slot should be blocked by the reservation + if slotExists(targetDay.Slots, "10:00") { + t.Error("expected 10:00 slot blocked by admin reservation") + } + // 09:00 and 11:00 should still be available + if !slotExists(targetDay.Slots, "09:00") { + t.Error("expected 09:00 to remain available") + } + if !slotExists(targetDay.Slots, "11:00") { + t.Error("expected 11:00 to remain available") + } + // Reservation should be visible in blockers field + if len(targetDay.Blockers) == 0 { + t.Error("expected reservation in blockers field for admin") + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation_NonAdmin verifies +// that RESERVATION entries are also subtracted for non-admin users. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation_NonAdmin(t *testing.T) { + // Not parallel (see above) + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation) + tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 30, 'RESERVATION:admin:callin:guest:1712345678', 'admin001') + `, reservationTime) + + handler := http.HandlerFunc(GetAvailableHours) + req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil) + reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001") + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email") + req = req.WithContext(reqCtx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var response []DayAvailableHours + json.Unmarshal(w.Body.Bytes(), &response) + targetDay := findDayByDate(response, "2026-03-17") + if targetDay == nil { + t.Fatal("expected 2026-03-17 in response") + } + + if slotExists(targetDay.Slots, "10:00") { + t.Error("expected 10:00 slot blocked by reservation for non-admin") + } + if len(targetDay.Blockers) != 0 { + t.Error("expected blockers field empty for non-admin") + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_OverlappingBlockers verifies +// that two time blockers that overlap each other are both correctly subtracted. +// Two gaps applied in sequence are equivalent to their union. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_OverlappingBlockers(t *testing.T) { + + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Two overlapping blockers on Tue 2026-03-17: 10:00-12:00 and 11:00-13:00 + b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation) + b2 := time.Date(2026, 3, 17, 11, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 120, 'Long Morning Meeting', NULL)`, b1) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 120, 'Extended Training', NULL)`, b2) + + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17") + targetDay := findDayByDate(response, "2026-03-17") + if targetDay == nil { + t.Fatal("expected 2026-03-17 in response") + } + + // The union of [10-12] and [11-13] is [10-13]. Verify key points. + if slotExists(targetDay.Slots, "10:00") { + t.Error("expected 10:00 blocked (overlapping blockers)") + } + if slotExists(targetDay.Slots, "11:00") { + t.Error("expected 11:00 blocked") + } + if slotExists(targetDay.Slots, "12:00") { + t.Error("expected 12:00 blocked") + } + // 09:00 (before) and 13:00 (after) should remain + if !slotExists(targetDay.Slots, "09:00") { + t.Error("expected 09:00 to remain available") + } + if !slotExists(targetDay.Slots, "13:00") { + t.Error("expected 13:00 to remain available") + } + // Both blockers visible + if len(targetDay.Blockers) != 2 { + t.Errorf("expected 2 blockers in blockers field, got %d", len(targetDay.Blockers)) + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_AdjacentBoundaries verifies +// that a booking ending exactly when a blocker starts (and vice versa) does NOT +// cause false overlap — no slot is removed beyond the exact boundaries. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_AdjacentBoundaries(t *testing.T) { + + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Tue 2026-03-17: booking 10:00-11:00, blocker 11:00-12:00 (adjacent) + bookingStart := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation) + bookingEnd := bookingStart.Add(60 * time.Minute) + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(tx, userID) + tx.Exec(ctx, ` + INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time) + VALUES ($1, $2, 'confirmed', 60, $3) + `, userID, bookingStart, bookingEnd) + + blockerTime := time.Date(2026, 3, 17, 11, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Adjacent Blocker', NULL)`, blockerTime) + + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17") + targetDay := findDayByDate(response, "2026-03-17") + if targetDay == nil { + t.Fatal("expected 2026-03-17 in response") + } + + // Both 10:00 and 11:00 should be unavailable — 10:00 removed by booking, + // 11:00 removed by blocker. But there should be a slot 09:00-10:00 and a + // slot 12:00-17:00. + if slotExists(targetDay.Slots, "10:00") { + t.Error("expected 10:00 unavailable (booking)") + } + if slotExists(targetDay.Slots, "11:00") { + t.Error("expected 11:00 unavailable (blocker)") + } + if !slotExists(targetDay.Slots, "09:00") { + t.Error("expected 09:00 available") + } + if !slotExists(targetDay.Slots, "12:00") { + t.Error("expected 12:00 available") + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_MidnightBlocker verifies that +// a blocker spanning multiple working hours correctly subtracts available slots. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_MidnightBlocker(t *testing.T) { + + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Use the first open day found and the following day + tueStart, tueEnd, tueOpen := getWorkingHoursForDate(t, ctx, "2026-03-17") + wedStart, _, wedOpen := getWorkingHoursForDate(t, ctx, "2026-03-18") + if !tueOpen || !wedOpen { + t.Skip("test requires both Tue 2026-03-17 and Wed 2026-03-18 to be open days") + } + + // Blocker at 1 hour before close on Tuesday + tueBlockHour := mustParseHour(tueEnd) - 1 + tueBlockStart := fmt.Sprintf("%02d:00", tueBlockHour) + tueBlockTime := time.Date(2026, 3, 17, tueBlockHour, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'End-of-day blocker', NULL)`, tueBlockTime) + + // Blocker at opening on Wednesday (first 2 hours) + wedBlockStart := wedStart + wedBlockEnd := fmt.Sprintf("%02d:00", mustParseHour(wedStart)+2) + wedBlockDur := 120 + wedBlockTime := time.Date(2026, 3, 18, mustParseHour(wedStart), 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, $2, 'Opening blocker', NULL)`, wedBlockTime, wedBlockDur) + + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18") + tue := findDayByDate(response, "2026-03-17") + wed := findDayByDate(response, "2026-03-18") + if tue == nil || wed == nil { + t.Fatal("expected both days in response") + } + + // Tuesday: blocker at end of day removes the last hour + if slotExists(tue.Slots, tueBlockStart) { + t.Errorf("expected %s blocked on Tuesday (end-of-day blocker)", tueBlockStart) + } + // The hour before the blocker should be available + hourBefore := fmt.Sprintf("%02d:00", mustParseHour(tueBlockStart)-1) + if hourBefore >= tueStart && !slotExists(tue.Slots, hourBefore) { + t.Errorf("expected %s available on Tuesday (before block)", hourBefore) + } + + // Wednesday: blocker at opening removes first 2 hours + if slotExists(wed.Slots, wedBlockStart) { + t.Errorf("expected %s blocked on Wednesday (opening blocker)", wedBlockStart) + } + // The hour after the blocker should be available + if !slotExists(wed.Slots, wedBlockEnd) { + t.Errorf("expected %s available on Wednesday (after block)", wedBlockEnd) + } +} + +// mustParseHour extracts the hour from a "HH:MM" or "HH:MM:SS" time string. +func mustParseHour(t string) int { + if len(t) >= 2 { + var h int + fmt.Sscanf(t, "%d", &h) + return h + } + return 0 +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_NoBlockers verifies admin +// sees all normal slots when no time blockers exist. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_NoBlockers(t *testing.T) { + + ctx, _ := resetTestData(t) + + // Tue 2026-03-17 — no blockers + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17") + targetDay := findDayByDate(response, "2026-03-17") + if targetDay == nil { + t.Fatal("expected 2026-03-17 in response") + } + + // All standard slots should be present + for _, start := range []string{"09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00", "16:00"} { + if !slotExists(targetDay.Slots, start) { + t.Errorf("expected slot %s to be available (no blockers)", start) + } + } + // No blockers in field + if len(targetDay.Blockers) != 0 { + t.Error("expected empty blockers field when no blockers exist") + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_ClosedDayBlocker tests that +// a time blocker on a closed day does NOT create any phantom slots. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_ClosedDayBlocker(t *testing.T) { + + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Sunday 2026-03-22 is closed. Blocker at 10:00-11:00. + blockerTime := time.Date(2026, 3, 22, 10, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Sunday Maintenance', NULL)`, blockerTime) + + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-22", "2026-03-22") + targetDay := findDayByDate(response, "2026-03-22") + if targetDay == nil { + t.Fatal("expected 2026-03-22 in response") + } + + if targetDay.IsOpen { + t.Error("expected Sunday to be closed") + } + if len(targetDay.Slots) != 0 { + t.Error("expected no slots on closed day even with blocker") + } + // Blockers on closed days are not populated (the day is closed, so + // the blocker is irrelevant; no slots exist to subtract from). + if len(targetDay.Blockers) != 0 { + t.Error("expected no blockers listed on closed day (day is closed)") + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDayRangePartial verifies +// that querying a multi-day range where only some days have blockers correctly +// leaves unblocked days unaffected. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDayRangePartial(t *testing.T) { + + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Blocker only on Tuesday (2026-03-17) at 10:00-11:00 + blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Tue Only Blocker', NULL)`, blockerTime) + + // Query Tue-Thu (17th, 18th, 19th) + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-19") + tue := findDayByDate(response, "2026-03-17") + wed := findDayByDate(response, "2026-03-18") + thu := findDayByDate(response, "2026-03-19") + if tue == nil || wed == nil || thu == nil { + t.Fatal("expected all three days in response") + } + + // Tuesday: 10:00 blocked + if slotExists(tue.Slots, "10:00") { + t.Error("expected 10:00 blocked on Tuesday") + } + if len(tue.Blockers) == 0 { + t.Error("expected blocker visible on Tuesday") + } + + // Wednesday: all slots available, no blockers + for _, start := range []string{"09:00", "10:00", "11:00", "14:00"} { + if !slotExists(wed.Slots, start) { + t.Errorf("expected %s available on Wednesday (no blocker)", start) + } + } + if len(wed.Blockers) != 0 { + t.Error("expected no blockers on Wednesday") + } + + // Thursday (open 09:00-17:00): all slots available, no blockers + for _, start := range []string{"09:00", "10:00", "11:00", "12:00", "14:00", "16:00"} { + if !slotExists(thu.Slots, start) { + t.Errorf("expected %s available on Thursday (no blocker)", start) + } + } + if len(thu.Blockers) != 0 { + t.Error("expected no blockers on Thursday") + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_Admin_ExceptionalHours verifies +// that blockers still subtract from slots on days with exceptional working hours. +func TestScheduling_GetAvailableHours_WithBlocker_Admin_ExceptionalHours(t *testing.T) { + + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Monday 2026-03-16 is normally CLOSED. Add exceptional hours: 10:00-16:00. + // Also add a blocker at 12:00-13:00. + // First create the exceptional group + var groupID int + err := tx.QueryRow(ctx, ` + INSERT INTO exceptional_working_hours_groups (name, description) + VALUES ('Test Exceptional Group', 'Test for blocker on exceptional hours day') + RETURNING id + `).Scan(&groupID) + if err != nil { + t.Fatalf("failed to create exceptional group: %v", err) + } + // Add exceptional hours: Monday (weekday 0) 10:00-16:00 + tx.Exec(ctx, ` + INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) + VALUES ($1, 0, '10:00:00', '16:00:00', true) + `, groupID) + // Apply to week containing 2026-03-16 + tx.Exec(ctx, ` + INSERT INTO exceptional_group_applications (group_id, week_start) + VALUES ($1, '2026-03-16') + `, groupID) + + // Blocker on Monday 12:00-13:00 + blockerTime := time.Date(2026, 3, 16, 12, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Lunch Break', NULL)`, blockerTime) + + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-16", "2026-03-16") + targetDay := findDayByDate(response, "2026-03-16") + if targetDay == nil { + t.Fatal("expected 2026-03-16 in response") + } + + if !targetDay.IsOpen { + t.Error("expected Monday to be open (exceptional hours)") + } + // 12:00 blocked by blocker + if slotExists(targetDay.Slots, "12:00") { + t.Error("expected 12:00 blocked on exceptional hours day") + } + // 10:00 and 14:00 should be available + if !slotExists(targetDay.Slots, "10:00") { + t.Error("expected 10:00 available on exceptional hours day") + } + if !slotExists(targetDay.Slots, "14:00") { + t.Error("expected 14:00 available on exceptional hours day") + } + // Blocker visible + if len(targetDay.Blockers) == 0 { + t.Error("expected blocker visible on exceptional hours day") + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_ClosedDay tests that +// a blocker on a closed day has no effect for non-admin users (day stays closed). +func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_ClosedDay(t *testing.T) { + + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Sunday 2026-03-22 closed, blocker at 10:00-11:00 + blockerTime := time.Date(2026, 3, 22, 10, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Weekend Maintenance', NULL)`, blockerTime) + + handler := http.HandlerFunc(GetAvailableHours) + req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-22&end=2026-03-22", nil) + reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001") + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email") + req = req.WithContext(reqCtx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var response []DayAvailableHours + json.Unmarshal(w.Body.Bytes(), &response) + targetDay := findDayByDate(response, "2026-03-22") + if targetDay == nil { + t.Fatal("expected 2026-03-22 in response") + } + + if targetDay.IsOpen { + t.Error("expected Sunday to be closed for non-admin") + } + if len(targetDay.Slots) != 0 { + t.Error("expected no slots on closed day") + } + if len(targetDay.Blockers) != 0 { + t.Error("expected blockers field empty for non-admin") + } +} + +// TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_BookingAdjacent verifies +// that a booking and blocker that are adjacent (booking 10-11, blocker 11-12) +// correctly list two separate unavailable ranges for non-admin users. +func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_BookingAdjacent(t *testing.T) { + + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + bookingStart := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation) + bookingEnd := bookingStart.Add(60 * time.Minute) + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(tx, userID) + tx.Exec(ctx, ` + INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time) + VALUES ($1, $2, 'confirmed', 60, $3) + `, userID, bookingStart, bookingEnd) + + blockerTime := time.Date(2026, 3, 17, 11, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Maintenance Window', NULL)`, blockerTime) + + handler := http.HandlerFunc(GetAvailableHours) + req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil) + reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001") + reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email") + req = req.WithContext(reqCtx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var response []DayAvailableHours + json.Unmarshal(w.Body.Bytes(), &response) + targetDay := findDayByDate(response, "2026-03-17") + if targetDay == nil { + t.Fatal("expected 2026-03-17 in response") + } + + if slotExists(targetDay.Slots, "10:00") { + t.Error("expected 10:00 unavailable (booking)") + } + if slotExists(targetDay.Slots, "11:00") { + t.Error("expected 11:00 unavailable (blocker)") + } + if !slotExists(targetDay.Slots, "09:00") { + t.Error("expected 09:00 available") + } + if !slotExists(targetDay.Slots, "12:00") { + t.Error("expected 12:00 available") + } + if len(targetDay.Blockers) != 0 { + t.Error("expected blockers field empty for non-admin") + } +} + +// normalizeTime unit tests + +func TestNormalizeTime_StripsSeconds(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"HH:MM:SS", "09:00:00", "09:00"}, + {"already HH:MM", "09:00", "09:00"}, + {"empty string", "", ""}, + {"single digit hour stripped", "9:00:00", "9:00"}, + {"midnight", "00:00:00", "00:00"}, + {"23:59:59", "23:59:59", "23:59"}, + {"12:30:45", "12:30:45", "12:30"}, + {"malformed no colon", "0900", "0900"}, + {"single colon", "09:00", "09:00"}, + {"extra suffix", "09:00:00:extra", "09:00:00:extra"}, + {"short string", "9:00", "9:00"}, + {"minimal HH:MM:SS", "1:2:3", "1:2:3"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeTime(tt.input) + if got != tt.expected { + t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestNormalizeTime_NoChangeForInvalidFormats(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"purely invalid", "invalid", "invalid"}, + // "25:00:00" has ':' at positions 2 and 5 so normalizeTime DOES strip seconds + {"invalid time with pattern", "25:00:00", "25:00"}, + // "ab:cd:ef" has ':' at positions 2 and 5 so normalizeTime DOES strip seconds + {"non-numeric with pattern", "ab:cd:ef", "ab:cd"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeTime(tt.input) + if got != tt.expected { + t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +// Late-night lock boundary tests + +func TestLateNightLock_After22_BlocksNextMorning(t *testing.T) { + available := []TimeSlot{{StartTime: "06:00", EndTime: "22:00"}} + lateBlock := TimeSlot{StartTime: "00:00", EndTime: "11:00"} + result := subtractTimeSlots(available, []TimeSlot{lateBlock}) + expected := []TimeSlot{{StartTime: "11:00", EndTime: "22:00"}} + if len(result) != len(expected) { + t.Fatalf("expected %d slot, got %d: %+v", len(expected), len(result), result) + } + if result[0].StartTime != expected[0].StartTime || result[0].EndTime != expected[0].EndTime { + t.Errorf("expected slot %+v, got %+v", expected[0], result[0]) + } +} + +func TestLateNightLock_Before22_NoBlock(t *testing.T) { + available := []TimeSlot{{StartTime: "09:00", EndTime: "17:00"}} + result := subtractTimeSlots(available, nil) + if len(result) != 1 { + t.Fatalf("expected 1 slot unchanged, got %d: %+v", len(result), result) + } +} + +func TestSubtractTimeSlots_NoOverlap(t *testing.T) { + available := []TimeSlot{ + {StartTime: "09:00", EndTime: "12:00"}, + {StartTime: "13:00", EndTime: "17:00"}, + } + gaps := []TimeSlot{{StartTime: "12:00", EndTime: "13:00"}} + result := subtractTimeSlots(available, gaps) + if len(result) != 2 { + t.Fatalf("expected 2 slots unchanged, got %d: %+v", len(result), result) + } +} + +func TestSubtractTimeSlots_MultipleGaps(t *testing.T) { + available := []TimeSlot{{StartTime: "09:00", EndTime: "18:00"}} + gaps := []TimeSlot{ + {StartTime: "11:00", EndTime: "12:00"}, + {StartTime: "14:00", EndTime: "15:00"}, + } + result := subtractTimeSlots(available, gaps) + expected := []TimeSlot{ + {StartTime: "09:00", EndTime: "11:00"}, + {StartTime: "12:00", EndTime: "14:00"}, + {StartTime: "15:00", EndTime: "18:00"}, + } + if len(result) != 3 { + t.Fatalf("expected 3 slots, got %d: %+v", len(result), result) + } + for i, exp := range expected { + if result[i].StartTime != exp.StartTime || result[i].EndTime != exp.EndTime { + t.Errorf("slot %d: expected %+v, got %+v", i, exp, result[i]) + } + } +} + +func TestSubtractTimeSlots_GapEatsWholeSlot(t *testing.T) { + available := []TimeSlot{{StartTime: "09:00", EndTime: "12:00"}} + gaps := []TimeSlot{{StartTime: "08:00", EndTime: "13:00"}} + result := subtractTimeSlots(available, gaps) + if len(result) != 0 { + t.Errorf("expected 0 slots, got %d: %+v", len(result), result) + } +} + +func TestSubtractTimeSlots_GapPartialOverlap(t *testing.T) { + available := []TimeSlot{{StartTime: "09:00", EndTime: "17:00"}} + gaps := []TimeSlot{{StartTime: "08:00", EndTime: "10:00"}} + result := subtractTimeSlots(available, gaps) + if len(result) != 1 { + t.Fatalf("expected 1 slot, got %d: %+v", len(result), result) + } + if result[0].StartTime != "10:00" || result[0].EndTime != "17:00" { + t.Errorf("expected slot 10:00-17:00, got %+v", result[0]) + } +} + +func TestNormalizeTime_SingleDigitHour(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"single digit hour with seconds", "9:00:00", "9:00"}, + {"single digit hour no seconds", "9:00", "9:00"}, + {"double digit hour with seconds", "09:00:00", "09:00"}, + {"double digit hour no seconds", "09:00", "09:00"}, + {"single digit min with seconds", "09:5:00", "09:5"}, + {"hour only no colon", "0900", "0900"}, + {"empty string", "", ""}, + {"midnight with seconds", "00:00:00", "00:00"}, + {"end of day with seconds", "23:59:59", "23:59"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeTime(tt.input) + if got != tt.expected { + t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestNormalizeTime_NoChangeForEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"no colons", "hello", "hello"}, + {"single colon only", ":", ":"}, + {"trailing colon", "09:", "09:"}, + {"only two chars after colon", "9:0", "9:0"}, + {"three colons no trailing pair", "a:b:c:d", "a:b:c:d"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeTime(tt.input) + if got != tt.expected { + t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestNormalizeTime_Regression_RealWorldFormats(t *testing.T) { + // These are the actual formats returned by the working hours and blocker + // queries. normaliseTime is called on baseStart and baseEnd in + // GetAvailableHours to ensure string comparison consistency. + realWorld := map[string]string{ + "09:00:00": "09:00", + "17:00:00": "17:00", + "09:00": "09:00", + "17:00": "17:00", + "00:00:00": "00:00", + "22:00:00": "22:00", + } + for input, expected := range realWorld { + got := normalizeTime(input) + if got != expected { + t.Errorf("normalizeTime(%q) = %q, want %q", input, got, expected) + } + } +} + +// TestScheduling_GetAvailableHours_CrossDayBlocker verifies that a single time +// blocker with a duration spanning multiple calendar days correctly subtracts +// slots from each affected day. +func TestScheduling_GetAvailableHours_CrossDayBlocker(t *testing.T) { + ctx, tx := resetTestData(t) + ukLocation, _ := time.LoadLocation("Europe/London") + + // Use Tue 2026-03-17 and Wed 2026-03-18 — both open days + _, tueEnd, tueOpen := getWorkingHoursForDate(t, ctx, "2026-03-17") + _, _, wedOpen := getWorkingHoursForDate(t, ctx, "2026-03-18") + if !tueOpen || !wedOpen { + t.Skip("test requires both Tue 2026-03-17 and Wed 2026-03-18 to be open days") + } + + // Blocker starts at 15:00 on Tuesday and lasts 20 hours (covers all of + // Wednesday's working hours up to 11:00). + blockerStart := time.Date(2026, 3, 17, 15, 0, 0, 0, ukLocation) + tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 1200, 'Multi-day blocker', NULL)`, blockerStart) + + response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18") + tue := findDayByDate(response, "2026-03-17") + wed := findDayByDate(response, "2026-03-18") + if tue == nil || wed == nil { + t.Fatal("expected both days in response") + } + + // Tuesday: blocker removes 15:00 through end of day (17:00) + if slotExists(tue.Slots, "15:00") { + t.Errorf("expected 15:00 blocked on Tuesday (cross-day blocker starts)") + } + // Slots before 15:00 should remain + if !slotExists(tue.Slots, "09:00") { + t.Error("expected 09:00 available on Tuesday") + } + if !slotExists(tue.Slots, "10:00") { + t.Error("expected 10:00 available on Tuesday") + } + // End-of-day slot after blocker start should be gone + if mustParseHour(tueEnd) > 15 { + lastSlot := fmt.Sprintf("%02d:00", mustParseHour(tueEnd)-2) + if slotExists(tue.Slots, lastSlot) { + t.Errorf("expected end-of-day slot %s blocked on Tuesday", lastSlot) + } + } + + // Wednesday: blocker still active until 11:00 — removes morning slots + for _, start := range []string{"09:00", "10:00"} { + if slotExists(wed.Slots, start) { + t.Errorf("expected %s blocked on Wednesday (cross-day blocker spillover)", start) + } + } + // 11:00 onwards should be available + if !slotExists(wed.Slots, "11:00") { + t.Errorf("expected 11:00 available on Wednesday (after cross-day blocker ends)") + } + if !slotExists(wed.Slots, "12:00") { + t.Errorf("expected 12:00 available on Wednesday") + } +}