Files
Crussell/backend/handlers/scheduling/scheduling_test.go
T
popertotsandSisyphus 8ad0111954 feat(scheduling): add out_of_hours override and fix timezone handling
Support out_of_hours query param in GetWorkingHours and GetAvailableHours (admin-only, returns 06:00-22:00 for all days). Replace ukLocation with time.Local for date boundaries. Add OptionalAuth middleware to scheduling routes for admin role detection. Add tests for admin/non-admin/no-auth scenarios, booking respect, and exceptional hours interaction.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-22 12:55:08 +01:00

1210 lines
41 KiB
Go

//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)
}
}
})
}
}