//go:build test // +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" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "crussell/db" "crussell/mw" "crussell/testutils" "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) } break } } // --- 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.Local) _, 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 := time.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) ukLocation, _ := time.LoadLocation("Europe/London") blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, ukLocation) _, 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 // CAN see blocked time slots in the blockers field. 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) ukLocation, _ := time.LoadLocation("Europe/London") blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, ukLocation) _, 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 blocker IS visible in blockers field for admin 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) } } }) } }