//go:build test package scheduling // Package scheduling contains tests for working hours and availability endpoints. // // Test Coverage: // - GetDefaultHours: GET /api/scheduling/default-hours - Get default weekly hours // - UpdateDefaultHours: PUT /api/scheduling/default-hours - Update default hours (admin) // - ListExceptionalGroups: GET /api/scheduling/exceptional-groups - List holiday hour groups // - CreateExceptionalGroup: POST /api/scheduling/exceptional-groups - Create group (admin) // - DeleteExceptionalGroup: DELETE /api/scheduling/exceptional-groups?id=X - Delete (admin) // - GetWorkingHours: GET /api/scheduling/working-hours?start=X&end=Y - Get hours for date range // - GetAvailableHours: GET /api/scheduling/available-hours?start=X&end=Y - Get available slots // - UpdateExceptionalApplications: PUT /api/scheduling/exceptional-applications - Apply holidays // // Authentication: Update/Create/Delete endpoints require admin role (403 for non-admins). import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "crussell/clock" "crussell/db" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" ) func resetTestData(t *testing.T) (context.Context, db.Querier) { t.Helper() ctx, tx := testutils.SetupTestTx(t) return ctx, tx } func makeRequest(handler http.HandlerFunc, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } func makeAuthRequest(handler http.Handler, method, path, token string, body interface{}, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } // --- Tests for GetDefaultHours --- // TestScheduling_GetDefaultHours verifies that the default weekly working hours // can be retrieved. The test checks that all 7 days are returned with correct // opening times, closing times, and is_open status. func TestScheduling_GetDefaultHours(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) handler := http.HandlerFunc(GetDefaultHours) w := makeRequest(handler, "GET", "/api/scheduling/default-hours", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var response []DefaultHours if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(response) != 7 { t.Errorf("expected 7 days of hours, got %d", len(response)) } // Verify Monday (weekday 0) has our seeded hours var monday *DefaultHours for i := range response { if response[i].Weekday == 0 { monday = &response[i] break } } if monday == nil { t.Fatal("expected Monday hours in response") } // Database returns HH:MM:SS format if monday.StartTime != "09:00:00" { t.Errorf("expected Monday start time 09:00:00, got %s", monday.StartTime) } if monday.EndTime != "17:00:00" { t.Errorf("expected Monday end time 17:00:00, got %s", monday.EndTime) } if !monday.IsOpen { t.Error("expected Monday to be open") } } // --- Tests for UpdateDefaultHours --- // TestScheduling_UpdateDefaultHours_Admin tests that an admin can update // the default weekly working hours. The new schedule is persisted to the // database and returned on subsequent requests. func TestScheduling_UpdateDefaultHours_Admin(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) adminToken := jwt.GenerateAdminToken() handler := http.HandlerFunc(UpdateDefaultHours) newHours := []DefaultHours{ {Weekday: 0, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 1, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 2, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 3, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 4, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true}, {Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false}, } w := makeAuthRequest(handler, "PUT", "/api/scheduling/default-hours", adminToken, newHours, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) } // Verify the update persisted var hours []DefaultHours rows, err := tx.Query(ctx, `SELECT weekday, start_time::text, end_time::text, is_open FROM working_hours ORDER BY weekday`) if err != nil { t.Fatalf("failed to query hours: %v", err) } defer rows.Close() for rows.Next() { var h DefaultHours if err := rows.Scan(&h.Weekday, &h.StartTime, &h.EndTime, &h.IsOpen); err != nil { t.Fatalf("failed to scan hours: %v", err) } hours = append(hours, h) } if hours[0].StartTime != "08:00:00" { t.Errorf("expected Monday start time 08:00:00, got %s", hours[0].StartTime) } } // TestScheduling_UpdateDefaultHours_NonAdmin verifies that non-admin users // receive HTTP 403 Forbidden when attempting to update default hours. func TestScheduling_UpdateDefaultHours_NonAdmin(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) userToken := jwt.GenerateUserToken("user-123") newHours := []DefaultHours{ {Weekday: 0, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 1, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 2, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 3, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 4, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true}, {Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false}, } // Wrap handler with RequireAuth + RequireAdmin middleware (auth first to populate context) w := makeAuthRequest(mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(UpdateDefaultHours))), "PUT", "/api/scheduling/default-hours", userToken, newHours, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) } } // --- Tests for ListExceptionalGroups --- // TestScheduling_ListExceptionalGroups verifies that admins can list all // exceptional working hours groups (holidays, special events). func TestScheduling_ListExceptionalGroups(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Create an exceptional group _, err := tx.Exec(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ('Holiday Hours', 'Christmas holiday schedule') `) if err != nil { t.Fatalf("failed to create group: %v", err) } handler := http.HandlerFunc(ListExceptionalGroups) w := makeRequest(handler, "GET", "/api/scheduling/exceptional-groups", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var response []ExceptionalGroup if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(response) == 0 { t.Error("expected at least one group in response") } if response[0].Name != "Holiday Hours" { t.Errorf("expected group name 'Holiday Hours', got %s", response[0].Name) } } // --- Tests for CreateExceptionalGroup --- // TestScheduling_CreateExceptionalGroup_Admin tests that an admin can // create a new exceptional working hours group with specific hours for each day. func TestScheduling_CreateExceptionalGroup_Admin(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) adminToken := jwt.GenerateAdminToken() handler := http.HandlerFunc(CreateExceptionalGroup) newGroup := ExceptionalGroup{ Name: "Summer Hours", Description: "Extended summer schedule", Hours: []ExceptionalHours{ {Weekday: 0, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 1, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 2, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 3, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 4, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true}, {Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false}, }, WeekStarts: []string{"2026-06-01"}, } w := makeAuthRequest(handler, "POST", "/api/scheduling/exceptional-groups", adminToken, newGroup, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var response ExceptionalGroup if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if response.Name != "Summer Hours" { t.Errorf("expected group name 'Summer Hours', got %s", response.Name) } if len(response.Hours) != 7 { t.Errorf("expected 7 hours, got %d", len(response.Hours)) } } // TestScheduling_CreateExceptionalGroup_NonAdmin verifies that non-admin // users receive HTTP 403 when attempting to create exceptional groups. func TestScheduling_CreateExceptionalGroup_NonAdmin(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) userToken := jwt.GenerateUserToken("user-123") newGroup := ExceptionalGroup{ Name: "Summer Hours", Description: "Extended summer schedule", Hours: []ExceptionalHours{ {Weekday: 0, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 1, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 2, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 3, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 4, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true}, {Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false}, }, WeekStarts: []string{"2026-06-01"}, } w := makeAuthRequest(mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(CreateExceptionalGroup))), "POST", "/api/scheduling/exceptional-groups", userToken, newGroup, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) } } // --- Tests for 15-minute interval validation --- // TestScheduling_UpdateDefaultHours_InvalidTimes verifies that UpdateDefaultHours // rejects start_time/end_time with minutes not in {00, 15, 30, 45}. func TestScheduling_UpdateDefaultHours_InvalidTimes(t *testing.T) { handler := http.HandlerFunc(UpdateDefaultHours) adminToken := jwt.GenerateAdminToken() tests := []struct { name string hours []DefaultHours wantStatus int }{ {"valid 00 minutes", []DefaultHours{{Weekday: 0, StartTime: "09:00", EndTime: "17:00", IsOpen: true}}, http.StatusNoContent}, {"valid 15 minutes", []DefaultHours{{Weekday: 0, StartTime: "09:15", EndTime: "17:15", IsOpen: true}}, http.StatusNoContent}, {"valid 30 minutes", []DefaultHours{{Weekday: 0, StartTime: "09:30", EndTime: "17:30", IsOpen: true}}, http.StatusNoContent}, {"valid 45 minutes", []DefaultHours{{Weekday: 0, StartTime: "09:45", EndTime: "17:45", IsOpen: true}}, http.StatusNoContent}, {"valid HH:MM:SS 00", []DefaultHours{{Weekday: 0, StartTime: "09:00:00", EndTime: "17:00:00", IsOpen: true}}, http.StatusNoContent}, {"valid HH:MM:SS 15", []DefaultHours{{Weekday: 0, StartTime: "09:15:00", EndTime: "17:15:00", IsOpen: true}}, http.StatusNoContent}, {"valid HH:MM:SS 30", []DefaultHours{{Weekday: 0, StartTime: "09:30:00", EndTime: "17:30:00", IsOpen: true}}, http.StatusNoContent}, {"valid HH:MM:SS 45", []DefaultHours{{Weekday: 0, StartTime: "09:45:00", EndTime: "17:45:00", IsOpen: true}}, http.StatusNoContent}, {"invalid start_time :07", []DefaultHours{{Weekday: 0, StartTime: "09:07", EndTime: "17:00", IsOpen: true}}, http.StatusBadRequest}, {"invalid start_time :22", []DefaultHours{{Weekday: 0, StartTime: "09:22", EndTime: "17:00", IsOpen: true}}, http.StatusBadRequest}, {"invalid end_time :59", []DefaultHours{{Weekday: 0, StartTime: "09:00", EndTime: "17:59", IsOpen: true}}, http.StatusBadRequest}, {"invalid HH:MM:SS :07", []DefaultHours{{Weekday: 0, StartTime: "09:07:00", EndTime: "17:00:00", IsOpen: true}}, http.StatusBadRequest}, {"invalid HH:MM:SS :22", []DefaultHours{{Weekday: 0, StartTime: "09:22:00", EndTime: "17:00:00", IsOpen: true}}, http.StatusBadRequest}, {"invalid HH:MM:SS :59", []DefaultHours{{Weekday: 0, StartTime: "09:00:00", EndTime: "17:59:00", IsOpen: true}}, http.StatusBadRequest}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) w := makeAuthRequest(handler, "PUT", "/api/scheduling/default-hours", adminToken, tt.hours, ctx) if w.Code != tt.wantStatus { t.Errorf("expected status %d, got %d. body: %s", tt.wantStatus, w.Code, w.Body.String()) } }) } } // TestScheduling_CreateExceptionalGroup_InvalidTimes verifies that CreateExceptionalGroup // rejects start_time/end_time with minutes not in {00, 15, 30, 45}. func TestScheduling_CreateExceptionalGroup_InvalidTimes(t *testing.T) { handler := http.HandlerFunc(CreateExceptionalGroup) adminToken := jwt.GenerateAdminToken() baseHours := func() []ExceptionalHours { return []ExceptionalHours{ {Weekday: 0, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 1, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 2, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 3, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 4, StartTime: "08:00", EndTime: "18:00", IsOpen: true}, {Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true}, {Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false}, } } tests := []struct { name string modify func([]ExceptionalHours) []ExceptionalHours wantStatus int }{ {"valid times (00,15,30,45)", func(h []ExceptionalHours) []ExceptionalHours { return h }, http.StatusCreated}, {"valid HH:MM:SS (00,15,30,45)", func(h []ExceptionalHours) []ExceptionalHours { h[0].StartTime = "08:00:00" h[0].EndTime = "18:00:00" h[1].StartTime = "09:15:00" h[1].EndTime = "17:30:00" return h }, http.StatusCreated}, {"invalid start_time :07", func(h []ExceptionalHours) []ExceptionalHours { h[0].StartTime = "09:07"; return h }, http.StatusBadRequest}, {"invalid end_time :22", func(h []ExceptionalHours) []ExceptionalHours { h[0].EndTime = "17:22"; return h }, http.StatusBadRequest}, {"invalid start_time :59", func(h []ExceptionalHours) []ExceptionalHours { h[1].StartTime = "10:59"; return h }, http.StatusBadRequest}, {"invalid HH:MM:SS :07", func(h []ExceptionalHours) []ExceptionalHours { h[0].StartTime = "09:07:00" return h }, http.StatusBadRequest}, {"invalid HH:MM:SS :59", func(h []ExceptionalHours) []ExceptionalHours { h[1].EndTime = "18:59:00" return h }, http.StatusBadRequest}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) hours := tt.modify(baseHours()) group := ExceptionalGroup{ Name: "Test Group", Description: "Test", Hours: hours, WeekStarts: []string{"2026-06-01"}, } w := makeAuthRequest(handler, "POST", "/api/scheduling/exceptional-groups", adminToken, group, ctx) if w.Code != tt.wantStatus { t.Errorf("expected status %d, got %d. body: %s", tt.wantStatus, w.Code, w.Body.String()) } }) } } // --- Tests for DeleteExceptionalGroup --- // TestScheduling_DeleteExceptionalGroup_Admin tests that an admin can delete // an exceptional working hours group. This removes the group and its associated // hours from the system. func TestScheduling_DeleteExceptionalGroup_Admin(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) adminToken := jwt.GenerateAdminToken() // Create a group to delete var groupID int err := tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ('To Delete', 'Will be deleted') RETURNING id `).Scan(&groupID) if err != nil { t.Fatalf("failed to create group: %v", err) } handler := http.HandlerFunc(DeleteExceptionalGroup) // Use proper URL query with strconv.Itoa req := httptest.NewRequest("DELETE", "/api/scheduling/exceptional-groups?id="+strconv.Itoa(groupID), nil) req.Header.Set("Authorization", "Bearer "+adminToken) req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) } // Verify group was deleted var count int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM exceptional_working_hours_groups WHERE id = $1`, groupID).Scan(&count) if err != nil { t.Fatalf("failed to check group: %v", err) } if count != 0 { t.Error("expected group to be deleted") } } // TestScheduling_DeleteExceptionalGroup_NonAdmin verifies that non-admin // users receive HTTP 403 when attempting to delete exceptional groups. func TestScheduling_DeleteExceptionalGroup_NonAdmin(t *testing.T) { t.Parallel() _, _ = resetTestData(t) userToken := jwt.GenerateUserToken("user-123") handler := mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(DeleteExceptionalGroup))) req := httptest.NewRequest("DELETE", "/api/scheduling/exceptional-groups?id=1", nil) req.Header.Set("Authorization", "Bearer "+userToken) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) } } // --- Tests for GetWorkingHours --- // TestScheduling_GetWorkingHours verifies that working hours can be // retrieved for a given date range. The response includes whether hours come // from default schedule or exceptional groups. func TestScheduling_GetWorkingHours(t *testing.T) { t.Parallel() _, _ = resetTestData(t) handler := http.HandlerFunc(GetWorkingHours) req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var response []DayWorkingHours if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(response) == 0 { t.Error("expected working hours in response") } // Verify source is "default" for seeded hours for _, day := range response { if day.Source != "default" { t.Errorf("expected source 'default', got %s", day.Source) } } } // --- Tests for GetAvailableHours --- // TestScheduling_GetAvailableHours tests that available appointment // slots can be calculated for a date range based on working hours and service // durations. func TestScheduling_GetAvailableHours(t *testing.T) { t.Parallel() _, _ = resetTestData(t) handler := http.HandlerFunc(GetAvailableHours) req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("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) } if len(response) == 0 { t.Error("expected available hours in response") } // Verify we have slots for open days for _, day := range response { if day.IsOpen { if len(day.Slots) == 0 { t.Error("expected slots for open days") } break } } } // TestScheduling_GetWorkingHours_OutOfHours_Admin verifies that when an admin // calls GetWorkingHours with out_of_hours=true, ALL days return isOpen=true // with startTime=06:00 and endTime=22:00 (including normally-closed days). func TestScheduling_GetWorkingHours_OutOfHours_Admin(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) handler := http.HandlerFunc(GetWorkingHours) req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil) req = req.WithContext(ctx) // Set admin role in context (simulates OptionalAuth setting the role) req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin")) 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 []DayWorkingHours if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(response) == 0 { t.Fatal("expected working hours in response") } for _, day := range response { if !day.IsOpen { t.Errorf("expected all days to be open with out_of_hours, got isOpen=false for %s", day.Date) } if day.StartTime != "06:00" { t.Errorf("expected startTime=06:00 for %s, got %s", day.Date, day.StartTime) } if day.EndTime != "22:00" { t.Errorf("expected endTime=22:00 for %s, got %s", day.Date, day.EndTime) } } } // TestScheduling_GetWorkingHours_OutOfHours_NonAdmin verifies that when a // non-admin calls GetWorkingHours with out_of_hours=true, the flag is silently // ignored and normal hours are returned. func TestScheduling_GetWorkingHours_OutOfHours_NonAdmin(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) handler := http.HandlerFunc(GetWorkingHours) req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil) req = req.WithContext(ctx) // Set non-admin role req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "user")) 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 []DayWorkingHours if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(response) == 0 { t.Fatal("expected working hours in response") } for _, day := range response { if day.StartTime == "06:00" { t.Errorf("non-admin should not get out_of_hours hours, got startTime=06:00 for %s", day.Date) break } } } // TestScheduling_GetWorkingHours_OutOfHours_NoAuth verifies that when no auth // context is present (unauthenticated user), out_of_hours=true is silently ignored. func TestScheduling_GetWorkingHours_OutOfHours_NoAuth(t *testing.T) { t.Parallel() _, _ = resetTestData(t) handler := http.HandlerFunc(GetWorkingHours) req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil) 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 []DayWorkingHours if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } for _, day := range response { if day.StartTime == "06:00" { t.Errorf("unauthenticated user should not get out_of_hours hours, got startTime=06:00 for %s", day.Date) break } } } // TestScheduling_GetAvailableHours_OutOfHours_Admin verifies that when an // admin calls GetAvailableHours with out_of_hours=true, slots are generated // for ALL days (including normally-closed ones like weekends). func TestScheduling_GetAvailableHours_OutOfHours_Admin(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) handler := http.HandlerFunc(GetAvailableHours) req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil) req = req.WithContext(ctx) req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin")) 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) } if len(response) == 0 { t.Fatal("expected available hours in response") } for _, day := range response { if !day.IsOpen { t.Errorf("expected all days isOpen=true with out_of_hours, got isOpen=false for %s", day.Date) } if len(day.Slots) == 0 { t.Errorf("expected slots for all days with out_of_hours, got none for %s", day.Date) } } } // TestScheduling_GetAvailableHours_OutOfHours_NonAdmin verifies that when a // non-admin calls GetAvailableHours with out_of_hours=true, the flag is ignored // and normal availability is returned (closed days have no slots). func TestScheduling_GetAvailableHours_OutOfHours_NonAdmin(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) handler := http.HandlerFunc(GetAvailableHours) req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil) req = req.WithContext(ctx) req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "user")) 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) } for _, day := range response { // Closed days should still be closed for non-admin even with out_of_hours flag if !day.IsOpen && len(day.Slots) > 0 { t.Errorf("non-admin should not get slots for closed day %s", day.Date) } // All source should be "default" not "out_of_hours" if day.Source == "out_of_hours" { t.Errorf("non-admin should not get source=out_of_hours for %s", day.Date) } } } // TestScheduling_GetAvailableHours_OutOfHours_RespectsBookings verifies that // out-of-hours mode still subtracts existing bookings from available slots. // Ensuring the available-hours response is the source of truth for slot data. func TestScheduling_GetAvailableHours_OutOfHours_RespectsBookings(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Create a user + booking starting at 09:00 for 60 min on a weekday var userID, serviceID string err := tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id `).Scan(&userID) if err != nil { t.Fatalf("failed to create user: %v", err) } err = tx.QueryRow(ctx, ` INSERT INTO services (name, price, duration_minutes) VALUES ('Test Service', 10, 60) RETURNING id `).Scan(&serviceID) if err != nil { t.Fatalf("failed to create service: %v", err) } // Create a booking on Tuesday 2026-02-17 at 09:00, 60min (blocks 09:00-10:00) bookingTime := time.Date(2026, 2, 17, 9, 0, 0, 0, time.UTC) _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, created_at) VALUES ($1, $2, 'confirmed', NOW()) `, userID, bookingTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Add booking service so end_time trigger computes correctly _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ((SELECT id FROM bookings WHERE user_id = $1 AND start_time = $2), $3) `, userID, bookingTime, serviceID) if err != nil { t.Fatalf("failed to link booking service: %v", err) } // Call GetAvailableHours with out_of_hours=true for a range including Tuesday handler := http.HandlerFunc(GetAvailableHours) req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil) req = req.WithContext(ctx) req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin")) 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) } // Find Tuesday 2026-02-17 and verify the 09:00-10:00 slot is excluded var tuesday DayAvailableHours for _, day := range response { if day.Date == "2026-02-17" { tuesday = day break } } if tuesday.Date == "" { t.Fatal("expected Tuesday 2026-02-17 in response") } if !tuesday.IsOpen { t.Fatal("expected Tuesday to be open with out_of_hours") } // Verify no slot starts between 09:00 and 10:00 (the booking blocks it) for _, slot := range tuesday.Slots { startMin := timeToMinutesForTest(slot.StartTime) if startMin >= 540 && startMin < 600 { // 09:00-10:00 in minutes t.Errorf("expected booking at 09:00 to block slots, but found slot at %s", slot.StartTime) } } // Verify we still have slots outside the booking window (e.g. 06:00-09:00) hasPreBookingSlot := false for _, slot := range tuesday.Slots { startMin := timeToMinutesForTest(slot.StartTime) if startMin < 540 { // before 09:00 hasPreBookingSlot = true break } } if !hasPreBookingSlot { t.Error("expected slots before 09:00 (pre-booking) with out_of_hours on Tuesday") } } // TestScheduling_GetAvailableHours_OutOfHours_ExceptionalOpen verifies that // out-of-hours mode correctly reflects exceptional hours data - the available // slots come from available-hours API (which includes exceptional hours adjustments), // not just from the default 06:00-22:00 range. func TestScheduling_GetAvailableHours_OutOfHours_ExceptionalOpen(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) today := clock.Now() weekday := int(today.Weekday()) if weekday == 0 { weekday = 6 } else { weekday -= 1 } // Override working hours - today is closed by default _, err := tx.Exec(ctx, ` INSERT INTO working_hours (weekday, start_time, end_time, is_open) VALUES ($1, '09:00', '17:00', false) ON CONFLICT (weekday) DO UPDATE SET start_time = '09:00', end_time = '17:00', is_open = false `, weekday) if err != nil { t.Fatalf("failed to seed working hours: %v", err) } // Add exceptional open hours for today (09:00-13:00, open) daysSinceMonday := int(today.Weekday()) - 1 if daysSinceMonday < 0 { daysSinceMonday = 6 } monday := today.AddDate(0, 0, -daysSinceMonday) mondayStr := monday.Format("2006-01-02") var groupID int err = tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ('Test Holiday', 'Exceptional open day') RETURNING id `).Scan(&groupID) if err != nil { t.Fatalf("failed to create group: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, $2, '09:00', '13:00', true) `, groupID, weekday) if err != nil { t.Fatalf("failed to seed exceptional hours: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2::date) `, groupID, mondayStr) if err != nil { t.Fatalf("failed to seed application: %v", err) } // Call normal GetAvailableHours (no out_of_hours) - should return slots based on exceptional hours handler := http.HandlerFunc(GetAvailableHours) todayStr := today.Format("2006-01-02") req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start="+todayStr+"&end="+todayStr, nil) req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("normal request failed: %d", w.Code) } var normalResponse []DayAvailableHours json.Unmarshal(w.Body.Bytes(), &normalResponse) // Call with out_of_hours=true - should still generate 06:00-22:00 range req2 := httptest.NewRequest("GET", "/api/scheduling/available-hours?start="+todayStr+"&end="+todayStr+"&out_of_hours=true", nil) req2 = req2.WithContext(ctx) req2 = req2.WithContext(context.WithValue(req2.Context(), mw.UserRoleKey, "admin")) w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) if w2.Code != http.StatusOK { t.Fatalf("out_of_hours request failed: %d", w2.Code) } var oohResponse []DayAvailableHours json.Unmarshal(w2.Body.Bytes(), &oohResponse) // Normal request should have 09:00-13:00 range (exceptional hours) or empty if closed // Out-of-hours request should have 06:00-22:00 range if len(oohResponse) > 0 { day := oohResponse[0] if !day.IsOpen { t.Error("expected out_of_hours to make day open") } if len(day.Slots) == 0 { t.Error("expected out_of_hours to generate slots") } // Verify we have pre-09:00 slots (6am-9am) which are only available in out_of_hours mode hasEarlySlot := false for _, slot := range day.Slots { if timeToMinutesForTest(slot.StartTime) < 540 { // before 09:00 hasEarlySlot = true break } } if !hasEarlySlot { t.Error("expected out_of_hours slots before 09:00 (pre-exceptional-hours)") } } } // Helper to convert time string to minutes for test assertions func timeToMinutesForTest(time string) int { parts := strings.Split(time, ":") if len(parts) < 2 { return 0 } h, _ := strconv.Atoi(parts[0]) m, _ := strconv.Atoi(parts[1]) return h*60 + m } // TestScheduling_UpdateExceptionalApplications_Admin verifies that an // admin can apply an exceptional hours group to specific weeks, activating // holiday schedules for those periods. func TestScheduling_UpdateExceptionalApplications_Admin(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) adminToken := jwt.GenerateAdminToken() // Create a group var groupID int err := tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ('Test Group', 'Test') RETURNING id `).Scan(&groupID) if err != nil { t.Fatalf("failed to create group: %v", err) } handler := http.HandlerFunc(UpdateExceptionalApplications) reqBody := map[string]interface{}{ "groupId": groupID, "weekStarts": []string{"2026-03-02", "2026-03-09"}, } w := makeAuthRequest(handler, "PUT", "/api/scheduling/exceptional-applications", adminToken, reqBody, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) } // Verify applications were created var count int err = tx.QueryRow(ctx, ` SELECT COUNT(*) FROM exceptional_group_applications WHERE group_id = $1 `, groupID).Scan(&count) if err != nil { t.Fatalf("failed to check applications: %v", err) } if count != 2 { t.Errorf("expected 2 applications, got %d", count) } } // TestScheduling_UpdateExceptionalApplications_NonAdmin verifies that // non-admin users receive HTTP 403 when attempting to apply exceptional hours. func TestScheduling_UpdateExceptionalApplications_NonAdmin(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) userToken := jwt.GenerateUserToken("user-123") handler := mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(UpdateExceptionalApplications))) reqBody := map[string]interface{}{ "groupId": 1, "weekStarts": []string{"2026-03-02"}, } w := makeAuthRequest(handler, "PUT", "/api/scheduling/exceptional-applications", userToken, reqBody, ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Time Blocker Tests for GetAvailableHours // ============================================================================= // TestScheduling_GetAvailableHours_WithBlocker_NonAdmin verifies that non-admin // users do NOT see blocked time slots in their available hours. func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Create a time blocker for 2026-03-16 10:00-11:00 (Monday - an open day) blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC) _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff Meeting', NULL) `, blockerTime) if err != nil { t.Fatalf("failed to create time blocker: %v", err) } // Make request as non-admin user handler := http.HandlerFunc(GetAvailableHours) req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-16&end=2026-03-16", nil) // Set non-admin context 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.Errorf("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) } if len(response) == 0 { t.Fatal("expected at least one day in response") } // Find the day with the blocker (2026-03-16) var targetDay *DayAvailableHours for i := range response { if response[i].Date == "2026-03-16" { targetDay = &response[i] break } } if targetDay == nil { t.Fatal("expected day 2026-03-16 in response") } // Verify blocker is NOT visible in blockers field for non-admin if len(targetDay.Blockers) > 0 { t.Error("expected blockers field to be empty for non-admin users") } // Verify 10:00-11:00 slot is NOT available (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") } } } // TestScheduling_GetAvailableHours_WithBlocker_Admin verifies that admin users // 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) // Create a time blocker for 2026-03-16 10:00-11:00 (Monday - open day) blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC) _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff Meeting', NULL) `, blockerTime) if err != nil { t.Fatalf("failed to create time blocker: %v", err) } // Make request as admin user handler := http.HandlerFunc(GetAvailableHours) req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-16&end=2026-03-16", nil) // Set admin context 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.Errorf("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) } if len(response) == 0 { t.Fatal("expected at least one day in response") } // Find the day with the blocker (2026-03-16) var targetDay *DayAvailableHours for i := range response { if response[i].Date == "2026-03-16" { targetDay = &response[i] break } } if targetDay == nil { t.Fatal("expected day 2026-03-16 in response") } // 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 { // Verify the blocker time range found := false for _, blocker := range targetDay.Blockers { if blocker.StartTime == "10:00" && blocker.EndTime == "11:00" { found = true break } } if !found { t.Error("expected blocker 10:00-11:00 in blockers field") } } } // --- Direct unit tests for isValidTime15Min --- // TestIsValidTime15Min tests the time validation helper directly for all // supported formats (HH:MM, HH:MM:SS) and edge cases. func TestIsValidTime15Min(t *testing.T) { t.Parallel() tests := []struct { name string time string valid bool }{ {"HH:MM 00", "09:00", true}, {"HH:MM 15", "09:15", true}, {"HH:MM 30", "09:30", true}, {"HH:MM 45", "09:45", true}, {"HH:MM :01", "09:01", false}, {"HH:MM :07", "09:07", false}, {"HH:MM :22", "09:22", false}, {"HH:MM :59", "09:59", false}, {"HH:MM:SS 00", "09:00:00", true}, {"HH:MM:SS 15", "09:15:00", true}, {"HH:MM:SS 30", "09:30:00", true}, {"HH:MM:SS 45", "09:45:00", true}, {"HH:MM:SS :01", "09:01:00", false}, {"HH:MM:SS :07", "09:07:00", false}, {"HH:MM:SS :22", "09:22:00", false}, {"HH:MM:SS :59", "09:59:00", false}, {"single-digit hour valid", "9:00", true}, {"single-digit hour invalid", "9:07", false}, {"midnight 00:00", "00:00", true}, {"midnight 00:00:00", "00:00:00", true}, {"empty string", "", false}, {"not a time", "abc", false}, {"single colon only", ":", false}, {"only colon numbers", ":15", false}, {"extra parts", "09:00:00:00", false}, {"hour only", "09", false}, {"garbage after colon", "09:xx", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := isValidTime15Min(tt.time) if got != tt.valid { if tt.valid { t.Errorf("isValidTime15Min(%q) = false, want true", tt.time) } else { t.Errorf("isValidTime15Min(%q) = true, want false", tt.time) } } }) } } // ============================================================================= // 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) // 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, time.UTC) b2 := time.Date(2026, 3, 17, 14, 0, 0, 0, time.UTC) 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) // 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, time.UTC) 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, time.UTC) 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) // Tuesday 2026-03-17 (open 09:00-17:00) — block entire open period blockerTime := time.Date(2026, 3, 17, 9, 0, 0, 0, time.UTC) 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) // 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, time.UTC) 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) // 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, time.UTC) b2 := time.Date(2026, 3, 18, 14, 0, 0, 0, time.UTC) 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) // 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, time.UTC) 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) // 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, time.UTC) 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) // Tuesday 2026-03-17 — blocker at 10:00-11:00 blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) 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) // Tuesday 2026-03-17 — blocker at 10:00-11:00 blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) 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) // Daily recurring blocker 12:00-13:00 starting Mon 2026-03-16 startTime := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC) 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) // 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, time.UTC) 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) reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) 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) // 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, time.UTC) b2 := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC) 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) // 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, time.UTC) 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, time.UTC) 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) // 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, time.UTC) 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, time.UTC) 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) // Sunday 2026-03-22 is closed. Blocker at 10:00-11:00. blockerTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC) 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) // Blocker only on Tuesday (2026-03-17) at 10:00-11:00 blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) 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) // 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, time.UTC) 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) // Sunday 2026-03-22 closed, blocker at 10:00-11:00 blockerTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC) 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) bookingStart := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) 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, time.UTC) 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", "09: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"}, {"short string", "9:00", "09:00"}, {"minimal HH:MM:SS", "1:2:3", "01:02"}, } 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", "09:00"}, {"single digit hour no seconds", "9:00", "09: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:05"}, {"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", "09:00"}, {"three colons no trailing pair", "a:b:c:d", "a:b"}, } 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) // 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, time.UTC) 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") } } // ============================================================================= // DST Transition Tests // ============================================================================= // // These tests verify that timezone handling is correct during BST (summer) // when the wall-clock time differs from UTC by +1 hour. // TestScheduling_DST_BlockerTimeFormatting verifies that blocker times are // formatted in Europe/London during BST, so the blocker correctly subtracts // wall-clock slots. A blocker at 15:00 BST (= 14:00 UTC) should block the // 15:00-16:00 BST slot, not 14:00-15:00 BST. func TestScheduling_DST_BlockerTimeFormatting(t *testing.T) { ctx, tx := resetTestData(t) // Monday 2026-06-15 is in BST (UTC+1). Working hours: 09:00-17:00. // Insert a blocker at 15:00 BST = 14:00 UTC. blockerTime := time.Date(2026, 6, 15, 14, 0, 0, 0, time.UTC) _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'DST Blocker', NULL) `, blockerTime) if err != nil { t.Fatalf("failed to create DST blocker: %v", err) } response := makeAdminAvailableHoursRequest(t, ctx, "2026-06-15", "2026-06-15") targetDay := findDayByDate(response, "2026-06-15") if targetDay == nil { t.Fatal("expected day 2026-06-15 in response") } // 15:00 BST slot should be blocked (wall-clock time) if slotExists(targetDay.Slots, "15:00") { t.Error("expected 15:00 BST slot to be blocked by DST blocker") } // 14:00 BST slot should still be available (blocker starts at 15:00) if !slotExists(targetDay.Slots, "14:00") { t.Error("expected 14:00 BST slot to remain available") } // 16:00 BST slot should be available (blocker ends at 16:00) if !slotExists(targetDay.Slots, "16:00") { t.Error("expected 16:00 BST slot to be available after blocker") } // Verify blocker appears in blockers field with correct wall-clock times found := false for _, b := range targetDay.Blockers { if b.StartTime == "15:00" && b.EndTime == "16:00" { found = true break } } if !found { t.Error("expected blocker 15:00-16:00 in blockers field (BST wall-clock time)") } } // TestScheduling_DST_MultipleBlockers verifies multiple blockers during BST // are all correctly applied to wall-clock time slots. func TestScheduling_DST_MultipleBlockers(t *testing.T) { ctx, tx := resetTestData(t) // Monday 2026-06-15 BST: two blockers at 10:00 BST (= 09:00 UTC) // and 14:00 BST (= 13:00 UTC). b1 := time.Date(2026, 6, 15, 9, 0, 0, 0, time.UTC) b2 := time.Date(2026, 6, 15, 13, 0, 0, 0, time.UTC) tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning blocker', NULL)`, b1) tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Afternoon blocker', NULL)`, b2) response := makeAdminAvailableHoursRequest(t, ctx, "2026-06-15", "2026-06-15") targetDay := findDayByDate(response, "2026-06-15") if targetDay == nil { t.Fatal("expected day 2026-06-15 in response") } // Morning blocker at 10:00 BST if slotExists(targetDay.Slots, "10:00") { t.Error("expected 10:00 BST slot blocked (morning blocker)") } // Afternoon blocker at 14:00 BST if slotExists(targetDay.Slots, "14:00") { t.Error("expected 14:00 BST slot blocked (afternoon blocker)") } // 09:00 should be available (before any blocker) if !slotExists(targetDay.Slots, "09:00") { t.Error("expected 09:00 BST to remain available") } // 11:00 should be available (between blockers) if !slotExists(targetDay.Slots, "11:00") { t.Error("expected 11:00 BST to remain available") } // 15:00 should be available (after both blockers) if !slotExists(targetDay.Slots, "15:00") { t.Error("expected 15:00 BST to remain available") } // Both blockers visible in wall-clock time found1, found2 := false, false for _, b := range targetDay.Blockers { if b.StartTime == "10:00" && b.EndTime == "11:00" { found1 = true } if b.StartTime == "14:00" && b.EndTime == "15:00" { found2 = true } } if !found1 { t.Error("expected morning blocker 10:00-11:00 in blockers field") } if !found2 { t.Error("expected afternoon blocker 14:00-15:00 in blockers field") } } // TestScheduling_DST_BlockerOnSpringForward verifies time blocker handling // during the March BST transition (clocks spring forward 01:00→02:00). // 2026-03-29 is the spring-forward date. The blocker is placed within // working hours (10:00-11:00 BST) to verify it correctly blocks wall-clock // time on the transition day. func TestScheduling_DST_BlockerOnSpringForward(t *testing.T) { ctx, tx := resetTestData(t) // 2026-03-29 is the BST start date (clocks spring forward). Sunday is closed // by default. Use an exceptional hours override to open 09:00-17:00. var groupID int err := tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ('Spring Forward Test', 'Test BST transition on 2026-03-29') RETURNING id `).Scan(&groupID) if err != nil { t.Fatalf("failed to create exceptional group: %v", err) } tx.Exec(ctx, ` INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, 6, '09:00:00', '17:00:00', true) `, groupID) tx.Exec(ctx, ` INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, '2026-03-23') `, groupID) // Blocker at 10:00 BST on 2026-03-29 = 09:00 UTC (spring-forward day, // clocks jump 01:00→02:00, so 10:00 BST = 09:00 UTC as usual). blockerTime := time.Date(2026, 3, 29, 9, 0, 0, 0, time.UTC) tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Spring Forward Blocker', NULL)`, blockerTime) response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-29", "2026-03-29") targetDay := findDayByDate(response, "2026-03-29") if targetDay == nil { t.Fatal("expected day 2026-03-29 in response") } if !targetDay.IsOpen { t.Fatal("expected 2026-03-29 to be open (exceptional hours)") } // 10:00 BST slot should be blocked (wall-clock time within working hours) if slotExists(targetDay.Slots, "10:00") { t.Error("expected 10:00 BST slot to be blocked (spring-forward blocker)") } // 09:00 BST should be available (before blocker) if !slotExists(targetDay.Slots, "09:00") { t.Error("expected 09:00 BST to be available before blocker") } // 11:00 BST should be available (after blocker ends) if !slotExists(targetDay.Slots, "11:00") { t.Error("expected 11:00 BST to be available after blocker") } // Verify blocker in wall-clock time found := false for _, b := range targetDay.Blockers { if b.StartTime == "10:00" && b.EndTime == "11:00" { found = true break } } if !found { t.Error("expected blocker 10:00-11:00 in blockers field (spring-forward wall-clock)") } } // TestNormalizeTime_NonNumericInput verifies that normalizeTime does not // silently pad non-numeric single-character segments (defensive guard). func TestNormalizeTime_NonNumericInput(t *testing.T) { tests := []struct { name string input string expected string }{ {"non-numeric hour single char", "a:00", "a:00"}, {"non-numeric minute single char", "09:b", "09:b"}, {"both non-numeric single char", "a:b", "a:b"}, {"numeric still works", "9:00", "09:00"}, {"single digit minute", "09:5", "09:05"}, {"both single digit", "9:5", "09:05"}, {"non-numeric multi-char hour passes through", "ab:00", "ab:00"}, {"non-numeric with seconds stripped", "a:b:c", "a:b"}, } 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) } }) } } // TestScheduling_DST_DateBoundary verifies that GetAvailableHours includes // BST early-morning bookings (00:00-00:59 BST = 23:00-23:59 UTC previous day) // in the correct date range. Uses londonLocation for date boundaries. func TestScheduling_DST_DateBoundary(t *testing.T) { ctx, tx := resetTestData(t) // Monday 2026-06-15 is BST. Create a booking at 00:30 BST (= 23:30 UTC June 14). // This booking should appear in the June 15 results (BST date). userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } defer fixtures.DeleteUser(tx, userID) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } defer fixtures.DeleteService(tx, serviceID) // 00:30 BST on June 15 = 23:30 UTC on June 14 bkStart := time.Date(2026, 6, 15, 0, 30, 0, 0, londonLocation) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bkStart) if err != nil { t.Fatalf("failed to create booking: %v", err) } defer fixtures.DeleteBooking(tx, bookingID) // Query available hours for June 15. The query uses londonLocation for // date boundaries, so it should see the 00:30 BST booking. response := makeAdminAvailableHoursRequest(t, ctx, "2026-06-15", "2026-06-15") targetDay := findDayByDate(response, "2026-06-15") if targetDay == nil { t.Fatal("expected day 2026-06-15 in response") } if !targetDay.IsOpen { t.Fatal("expected June 15 to be open") } // The booking at 00:30 BST should consume a slot before it. // If the date boundary was UTC-based, the booking would be invisible // (23:30 UTC June 14 < 00:00 UTC June 15 query start). // With londonLocation boundary, 00:30 BST is within the range. // Verify the 00:00 slot is NOT available (blocked by the 00:30 booking // because available-hours represents open slots, not individual bookings). // Actually, available-hours shows slots that ARE available, so a booking // at 00:30 means the 00:00 slot's 30-min window is partially taken. // For a 30-min service, 00:00 would be blocked by the 00:30 booking. if slotExists(targetDay.Slots, "00:00") { t.Error("expected 00:00 BST slot to be unavailable (booked at 00:30 BST)") } } // TestScheduling_DST_AutumnBack_BookingAt0130BST verifies that a booking at // 01:30 BST (= 00:30 UTC) on Oct 25, 2026 (BST→GMT transition) is correctly // handled. Oct 25 is the autumn DST date where clocks go back at 02:00 BST → // 01:00 GMT, creating a duplicated 01:00-02:00 hour. func TestScheduling_DST_AutumnBack_BookingAt0130BST(t *testing.T) { ctx, tx := resetTestData(t) // 2026-10-25 is the autumn DST date (BST→GMT). Sunday is closed // by default. Use exceptional hours override to open 09:00-17:00. var groupID int err := tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ('Autumn DST BST Test', 'Test BST→GMT transition on 2026-10-25') RETURNING id `).Scan(&groupID) if err != nil { t.Fatalf("failed to create exceptional group: %v", err) } tx.Exec(ctx, ` INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, 6, '09:00:00', '17:00:00', true) `, groupID) tx.Exec(ctx, ` INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, '2026-10-19') `, groupID) // Create a user and service for the booking userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } defer fixtures.DeleteUser(tx, userID) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } defer fixtures.DeleteService(tx, serviceID) // Booking at 01:30 BST = 00:30 UTC on 2026-10-25 (spans DST transition) bookingTime := time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC) var bookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'pending', 'Autumn DST BST booking at 01:30 BST') RETURNING id `, userID, bookingTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) if err != nil { t.Fatalf("failed to link service to booking: %v", err) } response := makeAdminAvailableHoursRequest(t, ctx, "2026-10-25", "2026-10-25") targetDay := findDayByDate(response, "2026-10-25") if targetDay == nil { t.Fatal("expected day 2026-10-25 in response") } if !targetDay.IsOpen { t.Fatal("expected 2026-10-25 to be open (exceptional hours)") } // 01:30 BST slot should be blocked (booking at 01:30 BST = 00:30 UTC) if slotExists(targetDay.Slots, "01:30") { t.Error("expected 01:30 BST slot to be blocked (booking at 01:30 BST = 00:30 UTC)") } } // TestScheduling_DST_AutumnBack_BookingAt0130GMT verifies that a booking at // 01:30 GMT (= 01:30 UTC) on Oct 25, 2026 (BST→GMT transition) is also // correctly handled. This is the second occurrence of 01:30 during the // duplicated hour on the autumn DST day. func TestScheduling_DST_AutumnBack_BookingAt0130GMT(t *testing.T) { ctx, tx := resetTestData(t) // Same exceptional hours setup as the BST variant var groupID int err := tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ('Autumn DST GMT Test', 'Test BST→GMT transition on 2026-10-25') RETURNING id `).Scan(&groupID) if err != nil { t.Fatalf("failed to create exceptional group: %v", err) } tx.Exec(ctx, ` INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, 6, '09:00:00', '17:00:00', true) `, groupID) tx.Exec(ctx, ` INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, '2026-10-19') `, groupID) // Create a user and service for the booking userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } defer fixtures.DeleteUser(tx, userID) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } defer fixtures.DeleteService(tx, serviceID) // Booking at 01:30 GMT = 01:30 UTC on 2026-10-25 bookingTime := time.Date(2026, 10, 25, 1, 30, 0, 0, time.UTC) var bookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'pending', 'Autumn DST GMT booking at 01:30 GMT') RETURNING id `, userID, bookingTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) if err != nil { t.Fatalf("failed to link service to booking: %v", err) } response := makeAdminAvailableHoursRequest(t, ctx, "2026-10-25", "2026-10-25") targetDay := findDayByDate(response, "2026-10-25") if targetDay == nil { t.Fatal("expected day 2026-10-25 in response") } if !targetDay.IsOpen { t.Fatal("expected 2026-10-25 to be open (exceptional hours)") } // 01:30 GMT slot should be blocked (booking at 01:30 GMT = 01:30 UTC) if slotExists(targetDay.Slots, "01:30") { t.Error("expected 01:30 GMT slot to be blocked (booking at 01:30 GMT = 01:30 UTC)") } } // TestScheduling_GetAvailableHours_ExcludesOwnReservation verifies that an // authenticated user's own RESERVATION entry is excluded from time blockers // via the excludeUserID parameter in GetTimeBlockersInRange. func TestScheduling_GetAvailableHours_ExcludesOwnReservation(t *testing.T) { ctx, tx := resetTestData(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'RESERVATION:user:' || $2 || ':' || EXTRACT(epoch FROM NOW())::bigint::text, $2) `, reservationTime, userID) if err != nil { t.Fatalf("failed to create reservation: %v", err) } // Request available hours as THIS user — their own reservation should be excluded 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, userID) 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") } // The user's own reservation at 10:00 should NOT block the slot if !slotExists(targetDay.Slots, "10:00") { t.Error("expected 10:00 to be available (own reservation excluded)") } }