Files
Crussell/backend/handlers/scheduling/scheduling_test.go
T
popertots 291868f11a test: add conflict detection and preview available hours tests
Add 12 new tests for GetConflictingBookingsForExceptionHandler (closed day, within hours, before-opening, after-closing, multiple weeks, excluded statuses, invalid payload, wrong method) and GetPreviewAvailableHours (no proposed, proposed override, invalid JSON, missing params). Fix pre-existing test with FK violation (non-existent admin_id) by using fixture-created admin user.
2026-08-22 00:34:48 +01:00

4224 lines
149 KiB
Go

//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)
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)
}
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)")
}
}
// =============================================================================
// Gap-Filling Tests for Uncovered Branches
// =============================================================================
//
// These tests exercise error paths and edge cases in the scheduling handlers
// that were not covered by the original test suite.
// TestScheduling_ListExceptionalGroups_WithHoursAndApps verifies that when a
// group has hours and week applications, they are populated in the response.
func TestScheduling_ListExceptionalGroups_WithHoursAndApps(t *testing.T) {
ctx, tx := resetTestData(t)
var groupID int
err := tx.QueryRow(ctx, `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ('Holiday Group', 'Christmas schedule')
RETURNING id
`).Scan(&groupID)
if err != nil {
t.Fatalf("failed to create group: %v", err)
}
// Insert hours for all 7 days
for _, h := range []struct{ wd int; start, end string; open bool }{
{0, "09:00", "17:00", true},
{1, "09:00", "17:00", true},
{2, "09:00", "17:00", true},
{3, "09:00", "17:00", true},
{4, "09:00", "17:00", true},
{5, "10:00", "16:00", true},
{6, "00:00", "00:00", false},
} {
_, err = tx.Exec(ctx, `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, $2, $3, $4, $5)
`, groupID, h.wd, h.start, h.end, h.open)
if err != nil {
t.Fatalf("failed to insert hours: %v", err)
}
}
// Insert week application
_, err = tx.Exec(ctx, `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, '2026-06-01')
`, groupID)
if err != nil {
t.Fatalf("failed to insert application: %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.Fatal("expected at least one group in response")
}
// Find our group and verify hours and weekStarts are populated
var found bool
for _, g := range response {
if g.Name == "Holiday Group" {
found = true
if len(g.Hours) != 7 {
t.Errorf("expected 7 hours, got %d", len(g.Hours))
}
if len(g.WeekStarts) != 1 {
t.Errorf("expected 1 week start, got %d", len(g.WeekStarts))
}
if len(g.WeekStarts) > 0 && g.WeekStarts[0] != "2026-06-01" {
t.Errorf("expected week_start 2026-06-01, got %s", g.WeekStarts[0])
}
break
}
}
if !found {
t.Error("expected to find 'Holiday Group' in response")
}
}
// TestScheduling_GetWorkingHours_MissingStart verifies that GetWorkingHours
// returns 400 when the start query param is missing.
func TestScheduling_GetWorkingHours_MissingStart(t *testing.T) {
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(GetWorkingHours)
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?end=2026-02-22", nil)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestScheduling_GetWorkingHours_MissingEnd verifies that GetWorkingHours
// returns 400 when the end query param is missing.
func TestScheduling_GetWorkingHours_MissingEnd(t *testing.T) {
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(GetWorkingHours)
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16", nil)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestScheduling_GetWorkingHours_InvalidStart verifies that GetWorkingHours
// returns 400 for an invalid start date format.
func TestScheduling_GetWorkingHours_InvalidStart(t *testing.T) {
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(GetWorkingHours)
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=invalid&end=2026-02-22", nil)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestScheduling_GetWorkingHours_InvalidEnd verifies that GetWorkingHours
// returns 400 for an invalid end date format.
func TestScheduling_GetWorkingHours_InvalidEnd(t *testing.T) {
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(GetWorkingHours)
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=invalid", nil)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestScheduling_GetWorkingHours_WithExceptional verifies that GetWorkingHours
// correctly applies exceptional hours and returns source="exceptional" for
// affected days.
func TestScheduling_GetWorkingHours_WithExceptional(t *testing.T) {
ctx, tx := resetTestData(t)
// Create an exceptional group that closes Tuesday (weekday 1)
var groupID int
err := tx.QueryRow(ctx, `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ('Test Exception', 'Closed Tuesday')
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, 1, '00:00', '00:00', false)
`, groupID)
if err != nil {
t.Fatalf("failed to insert exceptional hours: %v", err)
}
// Apply to week starting 2026-02-16 (Monday)
_, err = tx.Exec(ctx, `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, '2026-02-16')
`, groupID)
if err != nil {
t.Fatalf("failed to insert application: %v", err)
}
handler := http.HandlerFunc(GetWorkingHours)
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22", nil)
req = req.WithContext(ctx)
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)
}
// Find Tuesday (2026-02-17) - should have source="exceptional" and isOpen=false
var tuesday *DayWorkingHours
for i := range response {
if response[i].Date == "2026-02-17" {
tuesday = &response[i]
break
}
}
if tuesday == nil {
t.Fatal("expected Tuesday 2026-02-17 in response")
}
if tuesday.Source != "exceptional" {
t.Errorf("expected source 'exceptional' for Tuesday, got %s", tuesday.Source)
}
if tuesday.IsOpen {
t.Error("expected Tuesday to be closed (exceptional override)")
}
}
// TestScheduling_UpdateDefaultHours_InvalidJSON verifies that UpdateDefaultHours
// returns 400 for invalid JSON payload.
func TestScheduling_UpdateDefaultHours_InvalidJSON(t *testing.T) {
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(UpdateDefaultHours)
req := httptest.NewRequest("PUT", "/api/scheduling/default-hours", strings.NewReader("not json"))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestScheduling_UpdateDefaultHours_NullJSON verifies that UpdateDefaultHours
// returns 400 when the JSON payload decodes to something other than an array.
func TestScheduling_UpdateDefaultHours_NullJSON(t *testing.T) {
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(UpdateDefaultHours)
req := httptest.NewRequest("PUT", "/api/scheduling/default-hours", strings.NewReader("null"))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
// null decodes to nil slice — technically valid but produces 0 items.
// The handler returns 204 (NoContent) because the loop doesn't run.
if w.Code != http.StatusNoContent {
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestScheduling_CreateExceptionalGroup_InvalidJSON verifies that
// CreateExceptionalGroup returns 400 for invalid JSON payload.
func TestScheduling_CreateExceptionalGroup_InvalidJSON(t *testing.T) {
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(CreateExceptionalGroup)
req := httptest.NewRequest("POST", "/api/scheduling/exceptional-groups", strings.NewReader("not json"))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestScheduling_CreateExceptionalGroup_NotEnoughHours verifies that
// CreateExceptionalGroup returns 400 when fewer than 7 hours are provided.
func TestScheduling_CreateExceptionalGroup_NotEnoughHours(t *testing.T) {
ctx, _ := resetTestData(t)
group := ExceptionalGroup{
Name: "Partial Group",
Description: "Only 3 days",
Hours: []ExceptionalHours{
{Weekday: 0, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 1, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 2, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
},
WeekStarts: []string{"2026-06-01"},
}
handler := http.HandlerFunc(CreateExceptionalGroup)
w := makeRequest(handler, "POST", "/api/scheduling/exceptional-groups", group, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestScheduling_CreateExceptionalGroup_DuplicateWeekday verifies that
// CreateExceptionalGroup returns 400 when duplicate weekdays are provided.
func TestScheduling_CreateExceptionalGroup_DuplicateWeekday(t *testing.T) {
ctx, _ := resetTestData(t)
group := ExceptionalGroup{
Name: "Dupe Group",
Description: "Duplicate weekdays",
Hours: []ExceptionalHours{
{Weekday: 0, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 0, StartTime: "10:00", EndTime: "18:00", IsOpen: true},
{Weekday: 1, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 2, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 3, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 4, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
},
WeekStarts: []string{"2026-06-01"},
}
handler := http.HandlerFunc(CreateExceptionalGroup)
w := makeRequest(handler, "POST", "/api/scheduling/exceptional-groups", group, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestScheduling_CreateExceptionalGroup_WeekdayOutOfRange verifies that
// CreateExceptionalGroup returns 400 when weekday is < 0 or > 6.
func TestScheduling_CreateExceptionalGroup_WeekdayOutOfRange(t *testing.T) {
ctx, _ := resetTestData(t)
group := ExceptionalGroup{
Name: "Bad Weekday",
Description: "Weekday out of range",
Hours: []ExceptionalHours{
{Weekday: 7, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 0, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 1, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 2, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 3, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 4, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
},
WeekStarts: []string{"2026-06-01"},
}
handler := http.HandlerFunc(CreateExceptionalGroup)
w := makeRequest(handler, "POST", "/api/scheduling/exceptional-groups", group, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestScheduling_CreateExceptionalGroup_InvalidWeekStart verifies that
// CreateExceptionalGroup returns 400 for an invalid week_start date format.
func TestScheduling_CreateExceptionalGroup_InvalidWeekStart(t *testing.T) {
ctx, _ := resetTestData(t)
group := ExceptionalGroup{
Name: "Bad Week Start",
Description: "Invalid format",
Hours: []ExceptionalHours{
{Weekday: 0, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 1, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 2, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 3, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 4, StartTime: "09:00", EndTime: "17: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{"not-a-date"},
}
handler := http.HandlerFunc(CreateExceptionalGroup)
w := makeRequest(handler, "POST", "/api/scheduling/exceptional-groups", group, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestScheduling_CreateExceptionalGroup_WeekStartNotMonday verifies that
// CreateExceptionalGroup returns 400 when week_start is not a Monday.
func TestScheduling_CreateExceptionalGroup_WeekStartNotMonday(t *testing.T) {
ctx, _ := resetTestData(t)
group := ExceptionalGroup{
Name: "Bad Week Start",
Description: "Not Monday",
Hours: []ExceptionalHours{
{Weekday: 0, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 1, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 2, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 3, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
{Weekday: 4, StartTime: "09:00", EndTime: "17: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-02"}, // Tuesday, not Monday
}
handler := http.HandlerFunc(CreateExceptionalGroup)
w := makeRequest(handler, "POST", "/api/scheduling/exceptional-groups", group, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Gap-Filling Tests for DeleteExceptionalGroup
// =============================================================================
// TestScheduling_DeleteExceptionalGroup_MissingID verifies that DELETE
// without the id parameter returns 400 Bad Request.
func TestScheduling_DeleteExceptionalGroup_MissingID(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(DeleteExceptionalGroup)
req := httptest.NewRequest("DELETE", "/api/scheduling/exceptional-groups", nil)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestScheduling_DeleteExceptionalGroup_InvalidID verifies that DELETE
// with a non-numeric id parameter returns 400 Bad Request.
func TestScheduling_DeleteExceptionalGroup_InvalidID(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(DeleteExceptionalGroup)
req := httptest.NewRequest("DELETE", "/api/scheduling/exceptional-groups?id=abc", nil)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestScheduling_DeleteExceptionalGroup_NotFound verifies that DELETE
// with a valid but non-existent id returns 404 Not Found.
func TestScheduling_DeleteExceptionalGroup_NotFound(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(DeleteExceptionalGroup)
req := httptest.NewRequest("DELETE", "/api/scheduling/exceptional-groups?id=99999", nil)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Gap-Filling Tests for UpdateExceptionalApplications
// =============================================================================
// TestScheduling_UpdateExceptionalApplications_InvalidDate verifies that
// PUT with a non-Monday week_start returns 400 Bad Request.
func TestScheduling_UpdateExceptionalApplications_InvalidDate(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(UpdateExceptionalApplications)
reqBody := map[string]interface{}{
"groupId": 1,
"weekStarts": []string{"2026-03-03"}, // Tuesday, not Monday
}
w := makeRequest(handler, "PUT", "/api/scheduling/exceptional-applications", reqBody, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// --- Tests for GetConflictingBookingsForExceptionHandler ---
// makeAllClosedProposedHours returns 7 entries with all days closed (weekday 0-6).
func makeAllClosedProposedHours() []map[string]interface{} {
hours := make([]map[string]interface{}, 7)
for i := 0; i < 7; i++ {
hours[i] = map[string]interface{}{
"weekday": i,
"startTime": "00:00",
"endTime": "00:00",
"isOpen": false,
}
}
return hours
}
// makeDefaultOpenProposedHours returns 7 entries with Monday-Saturday open 09:00-17:00, Sunday closed.
func makeDefaultOpenProposedHours() []map[string]interface{} {
hours := make([]map[string]interface{}, 7)
for i := 0; i < 7; i++ {
if i == 6 {
hours[i] = map[string]interface{}{
"weekday": i, "startTime": "00:00", "endTime": "00:00", "isOpen": false,
}
} else {
hours[i] = map[string]interface{}{
"weekday": i, "startTime": "09:00", "endTime": "17:00", "isOpen": true,
}
}
}
return hours
}
func createTestBooking(t *testing.T, ctx context.Context, tx db.Querier, userID string, start time.Time, durationMinutes int) {
t.Helper()
_, err := tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time)
VALUES ($1, $2, 'confirmed', $3, $4)
`, userID, start, durationMinutes, start.Add(time.Duration(durationMinutes)*time.Minute))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
}
// TestConflictingBookings_ClosedDay_AllConflict verifies that when a day is closed,
// all active bookings on that day are returned as conflicts.
func TestConflictingBookings_ClosedDay_AllConflict(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), 60)
payload := map[string]interface{}{
"weekStarts": []string{"2026-03-16"},
"proposedHours": makeAllClosedProposedHours(),
}
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var response OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(response.Bookings) != 1 {
t.Errorf("expected 1 conflict, got %d", len(response.Bookings))
}
}
// TestConflictingBookings_OpenDay_BookingWithinHours_NoConflict verifies that
// a booking fully within the proposed open window is NOT flagged as conflicting.
func TestConflictingBookings_OpenDay_BookingWithinHours_NoConflict(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), 60)
payload := map[string]interface{}{
"weekStarts": []string{"2026-03-16"},
"proposedHours": makeDefaultOpenProposedHours(),
}
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var response OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(response.Bookings) != 0 {
t.Errorf("expected 0 conflicts, got %d", len(response.Bookings))
}
}
// TestConflictingBookings_StartsBeforeOpening verifies bookings starting before
// the proposed opening time are flagged as conflicts.
func TestConflictingBookings_StartsBeforeOpening(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 8, 30, 0, 0, time.UTC), 60)
payload := map[string]interface{}{
"weekStarts": []string{"2026-03-16"},
"proposedHours": makeDefaultOpenProposedHours(),
}
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var response OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(response.Bookings) != 1 {
t.Errorf("expected 1 conflict, got %d", len(response.Bookings))
}
}
// TestConflictingBookings_EndsAfterClosing verifies bookings ending after the
// proposed closing time are flagged as conflicts.
func TestConflictingBookings_EndsAfterClosing(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 16, 30, 0, 0, time.UTC), 60)
payload := map[string]interface{}{
"weekStarts": []string{"2026-03-16"},
"proposedHours": makeDefaultOpenProposedHours(),
}
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var response OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(response.Bookings) != 1 {
t.Errorf("expected 1 conflict, got %d", len(response.Bookings))
}
}
// TestConflictingBookings_MultipleWeeks verifies that bookings across two
// different weeks are both detected.
func TestConflictingBookings_MultipleWeeks(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), 60)
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 23, 10, 0, 0, 0, time.UTC), 60)
payload := map[string]interface{}{
"weekStarts": []string{"2026-03-16", "2026-03-23"},
"proposedHours": makeAllClosedProposedHours(),
}
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var response OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(response.Bookings) != 2 {
t.Errorf("expected 2 conflicts, got %d", len(response.Bookings))
}
}
// TestConflictingBookings_ExcludedStatuses verifies that cancelled bookings are
// not returned.
func TestConflictingBookings_ExcludedStatuses(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), 60)
cancelledTime := time.Date(2026, 3, 16, 14, 0, 0, 0, time.UTC)
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time)
VALUES ($1, $2, 'client_cancelled', 60, $3)
`, userID, cancelledTime, cancelledTime.Add(60*time.Minute))
if err != nil {
t.Fatalf("failed to create cancelled booking: %v", err)
}
payload := map[string]interface{}{
"weekStarts": []string{"2026-03-16"},
"proposedHours": makeAllClosedProposedHours(),
}
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var response OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(response.Bookings) != 1 {
t.Errorf("expected 1 conflict (active booking), got %d", len(response.Bookings))
}
}
// TestConflictingBookings_InvalidPayload verifies 400 for bad input.
func TestConflictingBookings_InvalidPayload(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", map[string]interface{}{}, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid payload, got %d", w.Code)
}
}
// TestConflictingBookings_WrongMethod verifies that GET to a POST-only handler
// fails with a non-200 status (chi enforces method routing; the handler without
// chi reaches JSON decode and returns 400 for missing body).
func TestConflictingBookings_WrongMethod(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
w := makeRequest(handler, "GET", "/api/admin/bookings/conflicting-for-exception", nil, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for GET without body, got %d", w.Code)
}
}
// --- Tests for GetPreviewAvailableHours ---
// TestPreviewAvailableHours_NoProposed verifies preview works without
// proposed hours (should match normal GetAvailableHours behaviour).
func TestPreviewAvailableHours_NoProposed(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(GetPreviewAvailableHours)
req := httptest.NewRequest("GET", "/api/scheduling/preview-available-hours?start=2026-02-16&end=2026-02-22", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req.WithContext(ctx))
if w.Code != http.StatusOK {
t.Fatalf("expected 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: %v", err)
}
if len(response) == 0 {
t.Error("expected available hours")
}
}
// TestPreviewAvailableHours_ProposedOverride verifies proposed hours override
// the normal schedule.
func TestPreviewAvailableHours_ProposedOverride(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
proposedJSON := `[{"weekday":0,"startTime":"10:00","endTime":"16:00","isOpen":true}]`
weeksJSON := `["2026-03-16"]`
req := httptest.NewRequest("GET",
"/api/scheduling/preview-available-hours?start=2026-03-16&end=2026-03-16"+
"&proposed_hours="+strings.ReplaceAll(strings.ReplaceAll(proposedJSON, "[", "%5B"), "]", "%5D")+
"&proposed_weeks="+strings.ReplaceAll(strings.ReplaceAll(weeksJSON, "[", "%5B"), "]", "%5D"),
nil)
w := httptest.NewRecorder()
handler := http.HandlerFunc(GetPreviewAvailableHours)
handler.ServeHTTP(w, req.WithContext(ctx))
if w.Code != http.StatusOK {
t.Fatalf("expected 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: %v", err)
}
if len(response) != 1 {
t.Fatalf("expected 1 day, got %d", len(response))
}
if !response[0].IsOpen {
t.Error("expected Monday to be open (proposed override)")
}
if response[0].Source != "proposed" {
t.Errorf("expected source 'proposed', got '%s'", response[0].Source)
}
}
// TestPreviewAvailableHours_InvalidProposedHours verifies 400 for bad JSON.
func TestPreviewAvailableHours_InvalidProposedHours(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
req := httptest.NewRequest("GET",
"/api/scheduling/preview-available-hours?start=2026-03-16&end=2026-03-16&proposed_hours=not-json",
nil)
w := httptest.NewRecorder()
handler := http.HandlerFunc(GetPreviewAvailableHours)
handler.ServeHTTP(w, req.WithContext(ctx))
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid JSON, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestPreviewAvailableHours_MissingParams verifies 400 when start/end missing.
func TestPreviewAvailableHours_MissingParams(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(GetPreviewAvailableHours)
req := httptest.NewRequest("GET", "/api/scheduling/preview-available-hours", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req.WithContext(ctx))
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
// --- Tests for parseTimeToMinutes ---
func TestParseTimeToMinutes_Normal(t *testing.T) {
result := parseTimeToMinutes("09:30")
if result != 570 {
t.Errorf("expected 570, got %d", result)
}
}
func TestParseTimeToMinutes_Midnight(t *testing.T) {
result := parseTimeToMinutes("00:00")
if result != 0 {
t.Errorf("expected 0, got %d", result)
}
}
func TestParseTimeToMinutes_EndOfDay(t *testing.T) {
result := parseTimeToMinutes("23:59")
if result != 1439 {
t.Errorf("expected 1439, got %d", result)
}
}
func TestParseTimeToMinutes_SingleDigitHour(t *testing.T) {
result := parseTimeToMinutes("9:05")
if result != 545 {
t.Errorf("expected 545, got %d", result)
}
}
func TestParseTimeToMinutes_EmptyString(t *testing.T) {
result := parseTimeToMinutes("")
if result != -1 {
t.Errorf("expected -1, got %d", result)
}
}
func TestParseTimeToMinutes_NoColon(t *testing.T) {
result := parseTimeToMinutes("1230")
if result != -1 {
t.Errorf("expected -1, got %d", result)
}
}
func TestParseTimeToMinutes_HourOutOfRange(t *testing.T) {
result := parseTimeToMinutes("24:00")
if result != -1 {
t.Errorf("expected -1, got %d", result)
}
}
func TestParseTimeToMinutes_MinuteOutOfRange(t *testing.T) {
result := parseTimeToMinutes("10:60")
if result != -1 {
t.Errorf("expected -1, got %d", result)
}
}
func TestParseTimeToMinutes_TextInput(t *testing.T) {
result := parseTimeToMinutes("abc:def")
if result != -1 {
t.Errorf("expected -1, got %d", result)
}
}
func TestParseTimeToMinutes_NegativeHour(t *testing.T) {
result := parseTimeToMinutes("-1:00")
if result != -1 {
t.Errorf("expected -1, got %d", result)
}
}
func TestParseTimeToMinutes_JustColon(t *testing.T) {
result := parseTimeToMinutes(":")
if result != -1 {
t.Errorf("expected -1, got %d", result)
}
}
// --- Tests for sqlIn ---
func TestSQLIn_MultipleArgs(t *testing.T) {
query, args := sqlIn("SELECT * FROM foo WHERE id IN (%s)", []int{1, 2, 3})
expected := "SELECT * FROM foo WHERE id IN ($1,$2,$3)"
if query != expected {
t.Errorf("sqlIn query = %q, want %q", query, expected)
}
if len(args) != 3 {
t.Errorf("sqlIn args count = %d, want 3", len(args))
}
if args[0] != 1 || args[1] != 2 || args[2] != 3 {
t.Errorf("sqlIn args = %v, want [1 2 3]", args)
}
}
func TestSQLIn_SingleArg(t *testing.T) {
query, args := sqlIn("SELECT * FROM foo WHERE id IN (%s)", []int{42})
expected := "SELECT * FROM foo WHERE id IN ($1)"
if query != expected {
t.Errorf("sqlIn query = %q, want %q", query, expected)
}
if len(args) != 1 || args[0] != 42 {
t.Errorf("sqlIn args = %v, want [42]", args)
}
}
func TestSQLIn_EmptyArgs(t *testing.T) {
query, args := sqlIn("SELECT * FROM foo WHERE id IN (%s)", []int{})
expected := "SELECT * FROM foo WHERE id IN ()"
if query != expected {
t.Errorf("sqlIn query = %q, want %q", query, expected)
}
if len(args) != 0 {
t.Errorf("sqlIn args count = %d, want 0", len(args))
}
}
// --- Edge-case tests for GetConflictingBookingsForExceptionHandler ---
// TestConflictingBookings_BookingExactlyAtOpeningTime verifies a booking
// starting exactly at the proposed opening time does NOT conflict.
func TestConflictingBookings_BookingExactlyAtOpeningTime(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
// Monday 09:00 start — proposed open is 09:00
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 9, 0, 0, 0, time.UTC), 60)
payload := map[string]interface{}{
"weekStarts": []string{"2026-03-16"},
"proposedHours": makeDefaultOpenProposedHours(),
}
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var response OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(response.Bookings) != 0 {
t.Errorf("expected 0 conflicts (booking starts exactly at opening), got %d", len(response.Bookings))
}
}
// TestConflictingBookings_BookingExactlyAtClosingTime verifies a booking
// ending exactly at the proposed closing time does NOT conflict.
func TestConflictingBookings_BookingExactlyAtClosingTime(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
// Monday 16:00 start (60min → ends 17:00) — proposed closing is 17:00
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 16, 0, 0, 0, time.UTC), 60)
payload := map[string]interface{}{
"weekStarts": []string{"2026-03-16"},
"proposedHours": makeDefaultOpenProposedHours(),
}
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var response OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(response.Bookings) != 0 {
t.Errorf("expected 0 conflicts (booking ends exactly at closing), got %d", len(response.Bookings))
}
}
// TestConflictingBookings_BookingCrossesMidnight verifies that a booking
// spanning midnight is always flagged as a conflict (open hours can't span past midnight).
func TestConflictingBookings_BookingCrossesMidnight(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
// Monday 23:00 start, 120min duration → ends 01:00 Tuesday (crosses midnight)
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 23, 0, 0, 0, time.UTC), 120)
payload := map[string]interface{}{
"weekStarts": []string{"2026-03-16"},
"proposedHours": makeDefaultOpenProposedHours(),
}
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var response OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(response.Bookings) != 1 {
t.Errorf("expected 1 conflict (midnight-crossing booking), got %d", len(response.Bookings))
}
}
// TestConflictingBookings_InvalidWeekStartFormat verifies 400 for bad week_start.
func TestConflictingBookings_InvalidWeekStartFormat(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
payload := map[string]interface{}{
"weekStarts": []string{"not-a-date"},
"proposedHours": makeAllClosedProposedHours(),
}
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid week_start, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestConflictingBookings_BookingOnSundayClosed verifies a conflict on Sunday
// (weekday 6 in our convention) when it's marked closed.
func TestConflictingBookings_BookingOnSundayClosed(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
// Sunday March 22, 2026 — 10:00 booking
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC), 60)
payload := map[string]interface{}{
"weekStarts": []string{"2026-03-16"},
"proposedHours": makeDefaultOpenProposedHours(), // Sunday (idx 6) is closed
}
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var response OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(response.Bookings) != 1 {
t.Errorf("expected 1 conflict (Sunday closed), got %d", len(response.Bookings))
}
}
// TestConflictingBookings_EmptyResultReturnsEmptyArray verifies that the
// response always has a JSON array (never null) when there are no conflicts.
func TestConflictingBookings_EmptyResultReturnsEmptyArray(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
payload := map[string]interface{}{
"weekStarts": []string{"2026-03-16"},
"proposedHours": makeDefaultOpenProposedHours(),
}
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var response OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if response.Bookings == nil {
t.Error("expected non-nil bookings array, got nil")
}
if len(response.Bookings) != 0 {
t.Errorf("expected 0 conflicts, got %d", len(response.Bookings))
}
}
// --- Edge-case tests for GetPreviewAvailableHours ---
// TestPreviewAvailableHours_ProposedOverrideWithBookings verifies that when
// proposed hours are used, existing bookings still reduce available slots.
func TestPreviewAvailableHours_ProposedOverrideWithBookings(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
// Monday 10:00-11:00 booking
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), 60)
// Proposed: Monday open 09:00-17:00
proposedJSON := `[{"weekday":0,"startTime":"09:00","endTime":"17:00","isOpen":true}]`
weeksJSON := `["2026-03-16"]`
req := httptest.NewRequest("GET",
"/api/scheduling/preview-available-hours?start=2026-03-16&end=2026-03-16"+
"&proposed_hours="+strings.ReplaceAll(strings.ReplaceAll(proposedJSON, "[", "%5B"), "]", "%5D")+
"&proposed_weeks="+strings.ReplaceAll(strings.ReplaceAll(weeksJSON, "[", "%5B"), "]", "%5D"),
nil)
w := httptest.NewRecorder()
handler := http.HandlerFunc(GetPreviewAvailableHours)
handler.ServeHTTP(w, req.WithContext(ctx))
if w.Code != http.StatusOK {
t.Fatalf("expected 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: %v", err)
}
if len(response) != 1 {
t.Fatalf("expected 1 day, got %d", len(response))
}
if response[0].Source != "proposed" {
t.Errorf("expected source 'proposed', got '%s'", response[0].Source)
}
if !response[0].IsOpen {
t.Error("expected Monday to be open")
}
// With a 10:00-11:00 booking removed from 09:00-17:00, we should see 2 slots: 09:00-10:00 and 11:00-17:00
if len(response[0].Slots) != 2 {
t.Errorf("expected 2 slots (booking removed), got %d: %+v", len(response[0].Slots), response[0].Slots)
}
}
// TestPreviewAvailableHours_InvalidProposedWeeks verifies 400 for bad weeks JSON.
func TestPreviewAvailableHours_InvalidProposedWeeks(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
req := httptest.NewRequest("GET",
"/api/scheduling/preview-available-hours?start=2026-03-16&end=2026-03-16&proposed_weeks=not-json",
nil)
w := httptest.NewRecorder()
handler := http.HandlerFunc(GetPreviewAvailableHours)
handler.ServeHTTP(w, req.WithContext(ctx))
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid weeks JSON, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestPreviewAvailableHours_OutOfHours_Admin verifies out-of-hours override
// works in preview mode for admin users.
func TestPreviewAvailableHours_OutOfHours_Admin(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
proposedJSON := `[{"weekday":0,"startTime":"10:00","endTime":"16:00","isOpen":true}]`
weeksJSON := `["2026-03-16"]`
req := httptest.NewRequest("GET",
"/api/scheduling/preview-available-hours?start=2026-03-16&end=2026-03-16"+
"&out_of_hours=true"+
"&proposed_hours="+strings.ReplaceAll(strings.ReplaceAll(proposedJSON, "[", "%5B"), "]", "%5D")+
"&proposed_weeks="+strings.ReplaceAll(strings.ReplaceAll(weeksJSON, "[", "%5B"), "]", "%5D"),
nil)
req = req.WithContext(ctx)
// Set admin role in context
req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin"))
w := httptest.NewRecorder()
handler := http.HandlerFunc(GetPreviewAvailableHours)
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 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: %v", err)
}
if len(response) != 1 {
t.Fatalf("expected 1 day, got %d", len(response))
}
// Out-of-hours should override to 06:00-22:00
if response[0].Source != "out_of_hours" {
t.Errorf("expected source 'out_of_hours', got '%s'", response[0].Source)
}
if !response[0].IsOpen {
t.Error("expected out_of_hours to be open")
}
}