Files
Crussell/backend/handlers/scheduling/scheduling_test.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

2885 lines
103 KiB
Go

//go:build test
// +build test
package scheduling
// Package scheduling contains tests for working hours and availability endpoints.
//
// Test Coverage:
// - GetDefaultHours: GET /api/scheduling/default-hours - Get default weekly hours
// - UpdateDefaultHours: PUT /api/scheduling/default-hours - Update default hours (admin)
// - ListExceptionalGroups: GET /api/scheduling/exceptional-groups - List holiday hour groups
// - CreateExceptionalGroup: POST /api/scheduling/exceptional-groups - Create group (admin)
// - DeleteExceptionalGroup: DELETE /api/scheduling/exceptional-groups?id=X - Delete (admin)
// - GetWorkingHours: GET /api/scheduling/working-hours?start=X&end=Y - Get hours for date range
// - GetAvailableHours: GET /api/scheduling/available-hours?start=X&end=Y - Get available slots
// - UpdateExceptionalApplications: PUT /api/scheduling/exceptional-applications - Apply holidays
//
// Authentication: Update/Create/Delete endpoints require admin role (403 for non-admins).
import (
"bytes"
"context"
"encoding/json"
"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)
}
break
}
}
// --- Tests for GetAvailableHours ---
// TestScheduling_GetAvailableHours tests that available appointment
// slots can be calculated for a date range based on working hours and service
// durations.
func TestScheduling_GetAvailableHours(t *testing.T) {
t.Parallel()
_, _ = resetTestData(t)
handler := http.HandlerFunc(GetAvailableHours)
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response []DayAvailableHours
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(response) == 0 {
t.Error("expected available hours in response")
}
// Verify we have slots for open days
for _, day := range response {
if day.IsOpen {
if len(day.Slots) == 0 {
t.Error("expected slots for open days")
}
break
}
}
}
// TestScheduling_GetWorkingHours_OutOfHours_Admin verifies that when an admin
// calls GetWorkingHours with out_of_hours=true, ALL days return isOpen=true
// with startTime=06:00 and endTime=22:00 (including normally-closed days).
func TestScheduling_GetWorkingHours_OutOfHours_Admin(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(GetWorkingHours)
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
req = req.WithContext(ctx)
// Set admin role in context (simulates OptionalAuth setting the role)
req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin"))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response []DayWorkingHours
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(response) == 0 {
t.Fatal("expected working hours in response")
}
for _, day := range response {
if !day.IsOpen {
t.Errorf("expected all days to be open with out_of_hours, got isOpen=false for %s", day.Date)
}
if day.StartTime != "06:00" {
t.Errorf("expected startTime=06:00 for %s, got %s", day.Date, day.StartTime)
}
if day.EndTime != "22:00" {
t.Errorf("expected endTime=22:00 for %s, got %s", day.Date, day.EndTime)
}
}
}
// TestScheduling_GetWorkingHours_OutOfHours_NonAdmin verifies that when a
// non-admin calls GetWorkingHours with out_of_hours=true, the flag is silently
// ignored and normal hours are returned.
func TestScheduling_GetWorkingHours_OutOfHours_NonAdmin(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(GetWorkingHours)
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
req = req.WithContext(ctx)
// Set non-admin role
req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "user"))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response []DayWorkingHours
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(response) == 0 {
t.Fatal("expected working hours in response")
}
for _, day := range response {
if day.StartTime == "06:00" {
t.Errorf("non-admin should not get out_of_hours hours, got startTime=06:00 for %s", day.Date)
break
}
}
}
// TestScheduling_GetWorkingHours_OutOfHours_NoAuth verifies that when no auth
// context is present (unauthenticated user), out_of_hours=true is silently ignored.
func TestScheduling_GetWorkingHours_OutOfHours_NoAuth(t *testing.T) {
t.Parallel()
_, _ = resetTestData(t)
handler := http.HandlerFunc(GetWorkingHours)
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response []DayWorkingHours
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
for _, day := range response {
if day.StartTime == "06:00" {
t.Errorf("unauthenticated user should not get out_of_hours hours, got startTime=06:00 for %s", day.Date)
break
}
}
}
// TestScheduling_GetAvailableHours_OutOfHours_Admin verifies that when an
// admin calls GetAvailableHours with out_of_hours=true, slots are generated
// for ALL days (including normally-closed ones like weekends).
func TestScheduling_GetAvailableHours_OutOfHours_Admin(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(GetAvailableHours)
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
req = req.WithContext(ctx)
req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin"))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response []DayAvailableHours
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(response) == 0 {
t.Fatal("expected available hours in response")
}
for _, day := range response {
if !day.IsOpen {
t.Errorf("expected all days isOpen=true with out_of_hours, got isOpen=false for %s", day.Date)
}
if len(day.Slots) == 0 {
t.Errorf("expected slots for all days with out_of_hours, got none for %s", day.Date)
}
}
}
// TestScheduling_GetAvailableHours_OutOfHours_NonAdmin verifies that when a
// non-admin calls GetAvailableHours with out_of_hours=true, the flag is ignored
// and normal availability is returned (closed days have no slots).
func TestScheduling_GetAvailableHours_OutOfHours_NonAdmin(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(GetAvailableHours)
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
req = req.WithContext(ctx)
req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "user"))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response []DayAvailableHours
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
for _, day := range response {
// Closed days should still be closed for non-admin even with out_of_hours flag
if !day.IsOpen && len(day.Slots) > 0 {
t.Errorf("non-admin should not get slots for closed day %s", day.Date)
}
// All source should be "default" not "out_of_hours"
if day.Source == "out_of_hours" {
t.Errorf("non-admin should not get source=out_of_hours for %s", day.Date)
}
}
}
// TestScheduling_GetAvailableHours_OutOfHours_RespectsBookings verifies that
// out-of-hours mode still subtracts existing bookings from available slots.
// Ensuring the available-hours response is the source of truth for slot data.
func TestScheduling_GetAvailableHours_OutOfHours_RespectsBookings(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create a user + booking starting at 09:00 for 60 min on a weekday
var userID, serviceID string
err := tx.QueryRow(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
err = tx.QueryRow(ctx, `
INSERT INTO services (name, price, duration_minutes)
VALUES ('Test Service', 10, 60)
RETURNING id
`).Scan(&serviceID)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create a booking on Tuesday 2026-02-17 at 09:00, 60min (blocks 09:00-10:00)
bookingTime := time.Date(2026, 2, 17, 9, 0, 0, 0, time.UTC)
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, created_at)
VALUES ($1, $2, 'confirmed', NOW())
`, userID, bookingTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Add booking service so end_time trigger computes correctly
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ((SELECT id FROM bookings WHERE user_id = $1 AND start_time = $2), $3)
`, userID, bookingTime, serviceID)
if err != nil {
t.Fatalf("failed to link booking service: %v", err)
}
// Call GetAvailableHours with out_of_hours=true for a range including Tuesday
handler := http.HandlerFunc(GetAvailableHours)
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
req = req.WithContext(ctx)
req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin"))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response []DayAvailableHours
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
// Find Tuesday 2026-02-17 and verify the 09:00-10:00 slot is excluded
var tuesday DayAvailableHours
for _, day := range response {
if day.Date == "2026-02-17" {
tuesday = day
break
}
}
if tuesday.Date == "" {
t.Fatal("expected Tuesday 2026-02-17 in response")
}
if !tuesday.IsOpen {
t.Fatal("expected Tuesday to be open with out_of_hours")
}
// Verify no slot starts between 09:00 and 10:00 (the booking blocks it)
for _, slot := range tuesday.Slots {
startMin := timeToMinutesForTest(slot.StartTime)
if startMin >= 540 && startMin < 600 { // 09:00-10:00 in minutes
t.Errorf("expected booking at 09:00 to block slots, but found slot at %s", slot.StartTime)
}
}
// Verify we still have slots outside the booking window (e.g. 06:00-09:00)
hasPreBookingSlot := false
for _, slot := range tuesday.Slots {
startMin := timeToMinutesForTest(slot.StartTime)
if startMin < 540 { // before 09:00
hasPreBookingSlot = true
break
}
}
if !hasPreBookingSlot {
t.Error("expected slots before 09:00 (pre-booking) with out_of_hours on Tuesday")
}
}
// TestScheduling_GetAvailableHours_OutOfHours_ExceptionalOpen verifies that
// out-of-hours mode correctly reflects exceptional hours data - the available
// slots come from available-hours API (which includes exceptional hours adjustments),
// not just from the default 06:00-22:00 range.
func TestScheduling_GetAvailableHours_OutOfHours_ExceptionalOpen(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
today := clock.Now()
weekday := int(today.Weekday())
if weekday == 0 {
weekday = 6
} else {
weekday -= 1
}
// Override working hours - today is closed by default
_, err := tx.Exec(ctx, `
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
VALUES ($1, '09:00', '17:00', false)
ON CONFLICT (weekday) DO UPDATE SET start_time = '09:00', end_time = '17:00', is_open = false
`, weekday)
if err != nil {
t.Fatalf("failed to seed working hours: %v", err)
}
// Add exceptional open hours for today (09:00-13:00, open)
daysSinceMonday := int(today.Weekday()) - 1
if daysSinceMonday < 0 {
daysSinceMonday = 6
}
monday := today.AddDate(0, 0, -daysSinceMonday)
mondayStr := monday.Format("2006-01-02")
var groupID int
err = tx.QueryRow(ctx, `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ('Test Holiday', 'Exceptional open day')
RETURNING id
`).Scan(&groupID)
if err != nil {
t.Fatalf("failed to create group: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, $2, '09:00', '13:00', true)
`, groupID, weekday)
if err != nil {
t.Fatalf("failed to seed exceptional hours: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, $2::date)
`, groupID, mondayStr)
if err != nil {
t.Fatalf("failed to seed application: %v", err)
}
// Call normal GetAvailableHours (no out_of_hours) - should return slots based on exceptional hours
handler := http.HandlerFunc(GetAvailableHours)
todayStr := today.Format("2006-01-02")
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start="+todayStr+"&end="+todayStr, nil)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("normal request failed: %d", w.Code)
}
var normalResponse []DayAvailableHours
json.Unmarshal(w.Body.Bytes(), &normalResponse)
// Call with out_of_hours=true - should still generate 06:00-22:00 range
req2 := httptest.NewRequest("GET", "/api/scheduling/available-hours?start="+todayStr+"&end="+todayStr+"&out_of_hours=true", nil)
req2 = req2.WithContext(ctx)
req2 = req2.WithContext(context.WithValue(req2.Context(), mw.UserRoleKey, "admin"))
w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("out_of_hours request failed: %d", w2.Code)
}
var oohResponse []DayAvailableHours
json.Unmarshal(w2.Body.Bytes(), &oohResponse)
// Normal request should have 09:00-13:00 range (exceptional hours) or empty if closed
// Out-of-hours request should have 06:00-22:00 range
if len(oohResponse) > 0 {
day := oohResponse[0]
if !day.IsOpen {
t.Error("expected out_of_hours to make day open")
}
if len(day.Slots) == 0 {
t.Error("expected out_of_hours to generate slots")
}
// Verify we have pre-09:00 slots (6am-9am) which are only available in out_of_hours mode
hasEarlySlot := false
for _, slot := range day.Slots {
if timeToMinutesForTest(slot.StartTime) < 540 { // before 09:00
hasEarlySlot = true
break
}
}
if !hasEarlySlot {
t.Error("expected out_of_hours slots before 09:00 (pre-exceptional-hours)")
}
}
}
// Helper to convert time string to minutes for test assertions
func timeToMinutesForTest(time string) int {
parts := strings.Split(time, ":")
if len(parts) < 2 {
return 0
}
h, _ := strconv.Atoi(parts[0])
m, _ := strconv.Atoi(parts[1])
return h*60 + m
}
// TestScheduling_UpdateExceptionalApplications_Admin verifies that an
// admin can apply an exceptional hours group to specific weeks, activating
// holiday schedules for those periods.
func TestScheduling_UpdateExceptionalApplications_Admin(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
adminToken := jwt.GenerateAdminToken()
// Create a group
var groupID int
err := tx.QueryRow(ctx, `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ('Test Group', 'Test')
RETURNING id
`).Scan(&groupID)
if err != nil {
t.Fatalf("failed to create group: %v", err)
}
handler := http.HandlerFunc(UpdateExceptionalApplications)
reqBody := map[string]interface{}{
"groupId": groupID,
"weekStarts": []string{"2026-03-02", "2026-03-09"},
}
w := makeAuthRequest(handler, "PUT", "/api/scheduling/exceptional-applications", adminToken, reqBody, ctx)
if w.Code != http.StatusNoContent {
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
}
// Verify applications were created
var count int
err = tx.QueryRow(ctx, `
SELECT COUNT(*) FROM exceptional_group_applications WHERE group_id = $1
`, groupID).Scan(&count)
if err != nil {
t.Fatalf("failed to check applications: %v", err)
}
if count != 2 {
t.Errorf("expected 2 applications, got %d", count)
}
}
// TestScheduling_UpdateExceptionalApplications_NonAdmin verifies that
// non-admin users receive HTTP 403 when attempting to apply exceptional hours.
func TestScheduling_UpdateExceptionalApplications_NonAdmin(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
userToken := jwt.GenerateUserToken("user-123")
handler := mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(UpdateExceptionalApplications)))
reqBody := map[string]interface{}{
"groupId": 1,
"weekStarts": []string{"2026-03-02"},
}
w := makeAuthRequest(handler, "PUT", "/api/scheduling/exceptional-applications", userToken, reqBody, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Time Blocker Tests for GetAvailableHours
// =============================================================================
// TestScheduling_GetAvailableHours_WithBlocker_NonAdmin verifies that non-admin
// users do NOT see blocked time slots in their available hours.
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create a time blocker for 2026-03-16 10:00-11:00 (Monday - an open day)
blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC)
_, err := tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Staff Meeting', NULL)
`, blockerTime)
if err != nil {
t.Fatalf("failed to create time blocker: %v", err)
}
// Make request as non-admin user
handler := http.HandlerFunc(GetAvailableHours)
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-16&end=2026-03-16", nil)
// Set non-admin context
reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001")
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response []DayAvailableHours
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(response) == 0 {
t.Fatal("expected at least one day in response")
}
// Find the day with the blocker (2026-03-16)
var targetDay *DayAvailableHours
for i := range response {
if response[i].Date == "2026-03-16" {
targetDay = &response[i]
break
}
}
if targetDay == nil {
t.Fatal("expected day 2026-03-16 in response")
}
// Verify blocker is NOT visible in blockers field for non-admin
if len(targetDay.Blockers) > 0 {
t.Error("expected blockers field to be empty for non-admin users")
}
// Verify 10:00-11:00 slot is NOT available (subtracted due to blocker)
for _, slot := range targetDay.Slots {
if slot.StartTime == "10:00" {
t.Error("expected 10:00 slot to be blocked and not available")
}
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin verifies that admin users
// see blocked time slots subtracted from available slots AND visible in the
// blockers field for warning display.
func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create a time blocker for 2026-03-16 10:00-11:00 (Monday - open day)
blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC)
_, err := tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Staff Meeting', NULL)
`, blockerTime)
if err != nil {
t.Fatalf("failed to create time blocker: %v", err)
}
// Make request as admin user
handler := http.HandlerFunc(GetAvailableHours)
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-16&end=2026-03-16", nil)
// Set admin context
reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001")
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response []DayAvailableHours
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(response) == 0 {
t.Fatal("expected at least one day in response")
}
// Find the day with the blocker (2026-03-16)
var targetDay *DayAvailableHours
for i := range response {
if response[i].Date == "2026-03-16" {
targetDay = &response[i]
break
}
}
if targetDay == nil {
t.Fatal("expected day 2026-03-16 in response")
}
// Verify 10:00-11:00 slot is NOT available in slots (subtracted due to blocker)
for _, slot := range targetDay.Slots {
if slot.StartTime == "10:00" {
t.Error("expected 10:00 slot to be blocked and not available for admin users")
}
}
// Verify blocker IS visible in blockers field for admin (warning display)
if len(targetDay.Blockers) == 0 {
t.Error("expected blockers field to contain the blocker for admin users")
} else {
// Verify the blocker time range
found := false
for _, blocker := range targetDay.Blockers {
if blocker.StartTime == "10:00" && blocker.EndTime == "11:00" {
found = true
break
}
}
if !found {
t.Error("expected blocker 10:00-11:00 in blockers field")
}
}
}
// --- Direct unit tests for isValidTime15Min ---
// TestIsValidTime15Min tests the time validation helper directly for all
// supported formats (HH:MM, HH:MM:SS) and edge cases.
func TestIsValidTime15Min(t *testing.T) {
t.Parallel()
tests := []struct {
name string
time string
valid bool
}{
{"HH:MM 00", "09:00", true},
{"HH:MM 15", "09:15", true},
{"HH:MM 30", "09:30", true},
{"HH:MM 45", "09:45", true},
{"HH:MM :01", "09:01", false},
{"HH:MM :07", "09:07", false},
{"HH:MM :22", "09:22", false},
{"HH:MM :59", "09:59", false},
{"HH:MM:SS 00", "09:00:00", true},
{"HH:MM:SS 15", "09:15:00", true},
{"HH:MM:SS 30", "09:30:00", true},
{"HH:MM:SS 45", "09:45:00", true},
{"HH:MM:SS :01", "09:01:00", false},
{"HH:MM:SS :07", "09:07:00", false},
{"HH:MM:SS :22", "09:22:00", false},
{"HH:MM:SS :59", "09:59:00", false},
{"single-digit hour valid", "9:00", true},
{"single-digit hour invalid", "9:07", false},
{"midnight 00:00", "00:00", true},
{"midnight 00:00:00", "00:00:00", true},
{"empty string", "", false},
{"not a time", "abc", false},
{"single colon only", ":", false},
{"only colon numbers", ":15", false},
{"extra parts", "09:00:00:00", false},
{"hour only", "09", false},
{"garbage after colon", "09:xx", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isValidTime15Min(tt.time)
if got != tt.valid {
if tt.valid {
t.Errorf("isValidTime15Min(%q) = false, want true", tt.time)
} else {
t.Errorf("isValidTime15Min(%q) = true, want false", tt.time)
}
}
})
}
}
// =============================================================================
// Exhaustive Admin Time Blocker Tests
// =============================================================================
//
// These tests verify that time blockers are subtracted from available slots for
// ALL users (including admins), preventing 409 "blocked" errors on reserve.
// Blockers are also surfaced in the blockers field for admin warning display.
// makeAdminAvailableHoursRequest is a helper that sends a GET to
// /api/scheduling/available-hours as an admin user and returns the response.
func makeAdminAvailableHoursRequest(t *testing.T, ctx context.Context, start, end string) []DayAvailableHours {
t.Helper()
handler := http.HandlerFunc(GetAvailableHours)
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start="+start+"&end="+end, nil)
reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001")
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response []DayAvailableHours
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
return response
}
// findDayByDate finds a DayAvailableHours by date string in the response.
func findDayByDate(response []DayAvailableHours, date string) *DayAvailableHours {
for i := range response {
if response[i].Date == date {
return &response[i]
}
}
return nil
}
// slotExists checks if a time falls within any available slot range.
// Slots are continuous ranges (e.g. {StartTime:09:00, EndTime:17:00}) and the
// frontend generates 15-min intervals from them. A time is "available" if it
// falls at or after a slot's StartTime and before its EndTime.
func slotExists(slots []TimeSlot, time string) bool {
for _, s := range slots {
if time >= s.StartTime && time < s.EndTime {
return true
}
}
return false
}
// getWorkingHoursForDate queries the working hours for the given date via the
// GetWorkingHours handler, using the admin context so the returned context works
// with the test transaction. Returns the hours for the specified date, or empty
// strings / false if the day is closed.
func getWorkingHoursForDate(t *testing.T, ctx context.Context, date string) (start, end string, isOpen bool) {
t.Helper()
handler := http.HandlerFunc(GetWorkingHours)
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start="+date+"&end="+date, nil)
reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001")
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GetWorkingHours returned %d: %s", w.Code, w.Body.String())
}
type whDay struct {
Date string `json:"date"`
IsOpen bool `json:"isOpen"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
}
var days []whDay
if err := json.Unmarshal(w.Body.Bytes(), &days); err != nil {
t.Fatalf("failed to unmarshal working hours: %v", err)
}
for _, d := range days {
if d.Date == date {
return d.StartTime, d.EndTime, d.IsOpen
}
}
t.Fatalf("date %s not found in working hours response", date)
return "", "", false
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_MultipleBlockers tests
// that multiple time blockers on the same day are ALL subtracted from admin slots.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultipleBlockers(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create two blockers on Tuesday 2026-03-17 (open 09:00-17:00):
// 10:00-11:00 (Staff Meeting) and 14:00-15:00 (Training)
b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
b2 := time.Date(2026, 3, 17, 14, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff Meeting', NULL)`, b1)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Training', NULL)`, b2)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
targetDay := findDayByDate(response, "2026-03-17")
if targetDay == nil {
t.Fatal("expected day 2026-03-17 in response")
}
// Both blocked slots should be absent from available slots
if slotExists(targetDay.Slots, "10:00") {
t.Error("expected 10:00 slot to be blocked (Staff Meeting)")
}
if slotExists(targetDay.Slots, "14:00") {
t.Error("expected 14:00 slot to be blocked (Training)")
}
// Non-blocked slots should still be available (e.g. 09:00, 11:00, 13:00, 15:00)
if !slotExists(targetDay.Slots, "09:00") {
t.Error("expected 09:00 slot to remain available")
}
if !slotExists(targetDay.Slots, "11:00") {
t.Error("expected 11:00 slot to remain available after 10-11 blocker")
}
if !slotExists(targetDay.Slots, "15:00") {
t.Error("expected 15:00 slot to remain available after 14-15 blocker")
}
// Both blockers visible in blockers field for admin warning display
if len(targetDay.Blockers) != 2 {
t.Errorf("expected 2 blockers, got %d", len(targetDay.Blockers))
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_BlockerAndBooking tests
// that both time blockers AND existing bookings are subtracted from admin slots.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_BlockerAndBooking(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Tuesday 2026-03-17 (open 09:00-17:00)
// Create a booking at 11:00-12:00 and a blocker at 14:00-15:00
bookingStart := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
bookingEnd := bookingStart.Add(60 * time.Minute)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time)
VALUES ($1, $2, 'confirmed', 60, $3)
`, userID, bookingStart, bookingEnd)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
blockerTime := time.Date(2026, 3, 17, 14, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Equipment Maintenance', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
targetDay := findDayByDate(response, "2026-03-17")
if targetDay == nil {
t.Fatal("expected day 2026-03-17 in response")
}
// 11:00 slot removed by booking, 14:00 slot removed by blocker
if slotExists(targetDay.Slots, "11:00") {
t.Error("expected 11:00 slot to be unavailable (booking)")
}
if slotExists(targetDay.Slots, "14:00") {
t.Error("expected 14:00 slot to be blocked (Equipment Maintenance)")
}
// Unaffected slots remain
if !slotExists(targetDay.Slots, "09:00") {
t.Error("expected 09:00 to remain available")
}
if !slotExists(targetDay.Slots, "10:00") {
t.Error("expected 10:00 to remain available")
}
// Only the blocker (not the booking) appears in blockers field
foundBlocker := false
for _, b := range targetDay.Blockers {
if b.StartTime == "14:00" && b.EndTime == "15:00" {
foundBlocker = true
}
}
if !foundBlocker {
t.Error("expected blocker 14:00-15:00 in blockers field")
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_AllDayBlocker tests that
// a blocker covering the entire open period leaves no slots available for admin.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_AllDayBlocker(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Tuesday 2026-03-17 (open 09:00-17:00) — block entire open period
blockerTime := time.Date(2026, 3, 17, 9, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 480, 'All day closure', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
targetDay := findDayByDate(response, "2026-03-17")
if targetDay == nil {
t.Fatal("expected day 2026-03-17 in response")
}
// All slots should be removed
if len(targetDay.Slots) > 0 {
t.Errorf("expected no available slots with all-day blocker, got %d slots: %+v", len(targetDay.Slots), targetDay.Slots)
}
// Blocker is visible in blockers field
if len(targetDay.Blockers) == 0 {
t.Error("expected all-day blocker in blockers field")
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_NonOverlappingBlocker
// tests that a blocker outside open hours does NOT affect admin slot availability.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_NonOverlappingBlocker(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 17:00-18:00 (after close)
blockerTime := time.Date(2026, 3, 17, 17, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'After hours cleaning', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
targetDay := findDayByDate(response, "2026-03-17")
if targetDay == nil {
t.Fatal("expected day 2026-03-17 in response")
}
// All normal slots should still be present
expectedSlots := []string{"09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00", "16:00"}
for _, expected := range expectedSlots {
if !slotExists(targetDay.Slots, expected) {
t.Errorf("expected slot %s to remain available (non-overlapping blocker)", expected)
}
}
// After-hours blocker still visible in blockers field
found := false
for _, b := range targetDay.Blockers {
if b.StartTime == "17:00" && b.EndTime == "18:00" {
found = true
}
}
if !found {
t.Error("expected after-hours blocker in blockers field")
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDay tests that
// blockers on multiple days are independently subtracted for admin users.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDay(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Blockers on Tue 2026-03-17 10:00-11:00 and Wed 2026-03-18 14:00-15:00
b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
b2 := time.Date(2026, 3, 18, 14, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Tue Meeting', NULL)`, b1)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Wed Training', NULL)`, b2)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18")
tue := findDayByDate(response, "2026-03-17")
wed := findDayByDate(response, "2026-03-18")
if tue == nil {
t.Fatal("expected 2026-03-17 in response")
}
if wed == nil {
t.Fatal("expected 2026-03-18 in response")
}
// Tuesday: 10:00 slot blocked
if slotExists(tue.Slots, "10:00") {
t.Error("expected 2026-03-17 10:00 slot to be blocked")
}
// Wednesday: 14:00 slot blocked
if slotExists(wed.Slots, "14:00") {
t.Error("expected 2026-03-18 14:00 slot to be blocked")
}
// Tuesday: 14:00 still available
if !slotExists(tue.Slots, "14:00") {
t.Error("expected 2026-03-17 14:00 to remain available")
}
// Wednesday: 10:00 still available
if !slotExists(wed.Slots, "10:00") {
t.Error("expected 2026-03-18 10:00 to remain available")
}
// Both days have blockers in blockers field
if len(tue.Blockers) != 1 {
t.Errorf("expected 1 blocker on Tuesday, got %d", len(tue.Blockers))
}
if len(wed.Blockers) != 1 {
t.Errorf("expected 1 blocker on Wednesday, got %d", len(wed.Blockers))
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryStart tests that
// a blocker at the exact opening time correctly removes the first slot.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryStart(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 09:00-10:00 (start of day)
blockerTime := time.Date(2026, 3, 17, 9, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning setup', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
targetDay := findDayByDate(response, "2026-03-17")
if targetDay == nil {
t.Fatal("expected day 2026-03-17 in response")
}
// 09:00 slot should be blocked
if slotExists(targetDay.Slots, "09:00") {
t.Error("expected 09:00 slot to be blocked (boundary start)")
}
// 10:00 slot should be available (blocker is 09-10, ends at 10:00)
if !slotExists(targetDay.Slots, "10:00") {
t.Error("expected 10:00 slot to be available (starts after blocker ends)")
}
// Blocker visible in blockers field
found := false
for _, b := range targetDay.Blockers {
if b.StartTime == "09:00" && b.EndTime == "10:00" {
found = true
}
}
if !found {
t.Error("expected blocker 09:00-10:00 in blockers field")
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryEnd tests that
// a blocker at the exact closing time correctly removes the last slot.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryEnd(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 16:00-17:00 (end of day)
blockerTime := time.Date(2026, 3, 17, 16, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'End of day cleanup', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
targetDay := findDayByDate(response, "2026-03-17")
if targetDay == nil {
t.Fatal("expected day 2026-03-17 in response")
}
// 16:00 slot should be blocked
if slotExists(targetDay.Slots, "16:00") {
t.Error("expected 16:00 slot to be blocked (boundary end)")
}
// 15:00 slot should be available
if !slotExists(targetDay.Slots, "15:00") {
t.Error("expected 15:00 slot to be available (ends before blocker starts)")
}
// Blocker visible in blockers field
found := false
for _, b := range targetDay.Blockers {
if b.StartTime == "16:00" && b.EndTime == "17:00" {
found = true
}
}
if !found {
t.Error("expected blocker 16:00-17:00 in blockers field")
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_OutOfHours verifies that
// blockers are still subtracted from admin slots when out_of_hours mode is active.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_OutOfHours(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Tuesday 2026-03-17 — blocker at 10:00-11:00
blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning Meeting', NULL)`, blockerTime)
// Request with out_of_hours=true (extends to 06:00-22:00 for admin)
handler := http.HandlerFunc(GetAvailableHours)
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17&out_of_hours=true", nil)
reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001")
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var response []DayAvailableHours
json.Unmarshal(w.Body.Bytes(), &response)
targetDay := findDayByDate(response, "2026-03-17")
if targetDay == nil {
t.Fatal("expected day 2026-03-17 in response")
}
// 10:00 slot should be blocked even in out_of_hours mode
if slotExists(targetDay.Slots, "10:00") {
t.Error("expected 10:00 slot to be blocked in out_of_hours mode")
}
// 07:00 slot (extended hours) should be available (no blocker there)
if !slotExists(targetDay.Slots, "07:00") {
t.Error("expected 07:00 slot to be available in out_of_hours mode")
}
// Blocker visible in blockers field
if len(targetDay.Blockers) == 0 {
t.Error("expected blocker in blockers field for out_of_hours admin")
}
}
// TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_Regression verifies that
// non-admin users still have blockers correctly subtracted (regression check).
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_Regression(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Tuesday 2026-03-17 — blocker at 10:00-11:00
blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff Meeting', NULL)`, blockerTime)
// Request as non-admin
handler := http.HandlerFunc(GetAvailableHours)
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil)
reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001")
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var response []DayAvailableHours
json.Unmarshal(w.Body.Bytes(), &response)
targetDay := findDayByDate(response, "2026-03-17")
if targetDay == nil {
t.Fatal("expected day 2026-03-17 in response")
}
// 10:00 slot blocked for non-admin
if slotExists(targetDay.Slots, "10:00") {
t.Error("expected 10:00 slot to be blocked for non-admin user")
}
// 09:00 still available
if !slotExists(targetDay.Slots, "09:00") {
t.Error("expected 09:00 slot to remain available for non-admin user")
}
// Non-admin should NOT see blockers in blockers field
if len(targetDay.Blockers) != 0 {
t.Error("expected blockers field to be empty for non-admin user")
}
}
// =============================================================================
// Gap-Filling Tests — Blockers with Recurring, Reservations, Overlaps, etc.
// =============================================================================
// TestScheduling_GetAvailableHours_WithBlocker_Admin_Recurring verifies that a
// recurring (cron-based) time blocker is expanded and subtracted from admin slots.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_Recurring(t *testing.T) {
// No t.Parallel() — GetAvailableHours cleanup operations can deadlock with
// concurrent test transactions on the shared test database.
ctx, tx := resetTestData(t)
// Daily recurring blocker 12:00-13:00 starting Mon 2026-03-16
startTime := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)
cronExpr := "0 12 * * *" // Every day at 12:00
tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by)
VALUES ($1, 60, 'Daily Lunch Blocker', $2, NULL)
`, startTime, cronExpr)
// Query Tue 2026-03-17 (open 09:00-17:00) and Wed 2026-03-18 (open 09:00-17:00)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18")
tue := findDayByDate(response, "2026-03-17")
wed := findDayByDate(response, "2026-03-18")
if tue == nil || wed == nil {
t.Fatal("expected both days in response")
}
// 12:00 slot should be blocked on both days
if slotExists(tue.Slots, "12:00") {
t.Error("expected 12:00 blocked on Tuesday (recurring blocker)")
}
if slotExists(wed.Slots, "12:00") {
t.Error("expected 12:00 blocked on Wednesday (recurring blocker)")
}
// Adjacent slots should be available
if !slotExists(tue.Slots, "11:00") {
t.Error("expected 11:00 available on Tuesday")
}
if !slotExists(wed.Slots, "13:00") {
t.Error("expected 13:00 available on Wednesday")
}
// Both days should show blocker in blockers field
if len(tue.Blockers) == 0 {
t.Error("expected blocker visible in blockers field on Tuesday")
}
if len(wed.Blockers) == 0 {
t.Error("expected blocker visible in blockers field on Wednesday")
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation verifies that
// RESERVATION:admin time_blocker entries are also subtracted from admin slots.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation(t *testing.T) {
ctx, tx := resetTestData(t)
// Create a real admin user to satisfy FK constraint, then simulate a reservation
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
if _, err := tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 30, 'RESERVATION:admin:callin:guest:1712345678', $2)
`, reservationTime, adminID); err != nil {
t.Fatalf("failed to create reservation blocker: %v", err)
}
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
targetDay := findDayByDate(response, "2026-03-17")
if targetDay == nil {
t.Fatal("expected 2026-03-17 in response")
}
// 10:00 slot should be blocked by the reservation
if slotExists(targetDay.Slots, "10:00") {
t.Error("expected 10:00 slot blocked by admin reservation")
}
// 09:00 and 11:00 should still be available
if !slotExists(targetDay.Slots, "09:00") {
t.Error("expected 09:00 to remain available")
}
if !slotExists(targetDay.Slots, "11:00") {
t.Error("expected 11:00 to remain available")
}
// Reservation should be visible in blockers field
if len(targetDay.Blockers) == 0 {
t.Error("expected reservation in blockers field for admin")
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation_NonAdmin verifies
// that RESERVATION entries are also subtracted for non-admin users.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation_NonAdmin(t *testing.T) {
// Not parallel (see above)
ctx, tx := resetTestData(t)
reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 30, 'RESERVATION:admin:callin:guest:1712345678', 'admin001')
`, reservationTime)
handler := http.HandlerFunc(GetAvailableHours)
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil)
reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001")
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var response []DayAvailableHours
json.Unmarshal(w.Body.Bytes(), &response)
targetDay := findDayByDate(response, "2026-03-17")
if targetDay == nil {
t.Fatal("expected 2026-03-17 in response")
}
if slotExists(targetDay.Slots, "10:00") {
t.Error("expected 10:00 slot blocked by reservation for non-admin")
}
if len(targetDay.Blockers) != 0 {
t.Error("expected blockers field empty for non-admin")
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_OverlappingBlockers verifies
// that two time blockers that overlap each other are both correctly subtracted.
// Two gaps applied in sequence are equivalent to their union.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_OverlappingBlockers(t *testing.T) {
ctx, tx := resetTestData(t)
// Two overlapping blockers on Tue 2026-03-17: 10:00-12:00 and 11:00-13:00
b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
b2 := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 120, 'Long Morning Meeting', NULL)`, b1)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 120, 'Extended Training', NULL)`, b2)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
targetDay := findDayByDate(response, "2026-03-17")
if targetDay == nil {
t.Fatal("expected 2026-03-17 in response")
}
// The union of [10-12] and [11-13] is [10-13]. Verify key points.
if slotExists(targetDay.Slots, "10:00") {
t.Error("expected 10:00 blocked (overlapping blockers)")
}
if slotExists(targetDay.Slots, "11:00") {
t.Error("expected 11:00 blocked")
}
if slotExists(targetDay.Slots, "12:00") {
t.Error("expected 12:00 blocked")
}
// 09:00 (before) and 13:00 (after) should remain
if !slotExists(targetDay.Slots, "09:00") {
t.Error("expected 09:00 to remain available")
}
if !slotExists(targetDay.Slots, "13:00") {
t.Error("expected 13:00 to remain available")
}
// Both blockers visible
if len(targetDay.Blockers) != 2 {
t.Errorf("expected 2 blockers in blockers field, got %d", len(targetDay.Blockers))
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_AdjacentBoundaries verifies
// that a booking ending exactly when a blocker starts (and vice versa) does NOT
// cause false overlap — no slot is removed beyond the exact boundaries.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_AdjacentBoundaries(t *testing.T) {
ctx, tx := resetTestData(t)
// Tue 2026-03-17: booking 10:00-11:00, blocker 11:00-12:00 (adjacent)
bookingStart := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
bookingEnd := bookingStart.Add(60 * time.Minute)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time)
VALUES ($1, $2, 'confirmed', 60, $3)
`, userID, bookingStart, bookingEnd)
blockerTime := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Adjacent Blocker', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
targetDay := findDayByDate(response, "2026-03-17")
if targetDay == nil {
t.Fatal("expected 2026-03-17 in response")
}
// Both 10:00 and 11:00 should be unavailable — 10:00 removed by booking,
// 11:00 removed by blocker. But there should be a slot 09:00-10:00 and a
// slot 12:00-17:00.
if slotExists(targetDay.Slots, "10:00") {
t.Error("expected 10:00 unavailable (booking)")
}
if slotExists(targetDay.Slots, "11:00") {
t.Error("expected 11:00 unavailable (blocker)")
}
if !slotExists(targetDay.Slots, "09:00") {
t.Error("expected 09:00 available")
}
if !slotExists(targetDay.Slots, "12:00") {
t.Error("expected 12:00 available")
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_MidnightBlocker verifies that
// a blocker spanning multiple working hours correctly subtracts available slots.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MidnightBlocker(t *testing.T) {
ctx, tx := resetTestData(t)
// Use the first open day found and the following day
tueStart, tueEnd, tueOpen := getWorkingHoursForDate(t, ctx, "2026-03-17")
wedStart, _, wedOpen := getWorkingHoursForDate(t, ctx, "2026-03-18")
if !tueOpen || !wedOpen {
t.Skip("test requires both Tue 2026-03-17 and Wed 2026-03-18 to be open days")
}
// Blocker at 1 hour before close on Tuesday
tueBlockHour := mustParseHour(tueEnd) - 1
tueBlockStart := fmt.Sprintf("%02d:00", tueBlockHour)
tueBlockTime := time.Date(2026, 3, 17, tueBlockHour, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'End-of-day blocker', NULL)`, tueBlockTime)
// Blocker at opening on Wednesday (first 2 hours)
wedBlockStart := wedStart
wedBlockEnd := fmt.Sprintf("%02d:00", mustParseHour(wedStart)+2)
wedBlockDur := 120
wedBlockTime := time.Date(2026, 3, 18, mustParseHour(wedStart), 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, $2, 'Opening blocker', NULL)`, wedBlockTime, wedBlockDur)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18")
tue := findDayByDate(response, "2026-03-17")
wed := findDayByDate(response, "2026-03-18")
if tue == nil || wed == nil {
t.Fatal("expected both days in response")
}
// Tuesday: blocker at end of day removes the last hour
if slotExists(tue.Slots, tueBlockStart) {
t.Errorf("expected %s blocked on Tuesday (end-of-day blocker)", tueBlockStart)
}
// The hour before the blocker should be available
hourBefore := fmt.Sprintf("%02d:00", mustParseHour(tueBlockStart)-1)
if hourBefore >= tueStart && !slotExists(tue.Slots, hourBefore) {
t.Errorf("expected %s available on Tuesday (before block)", hourBefore)
}
// Wednesday: blocker at opening removes first 2 hours
if slotExists(wed.Slots, wedBlockStart) {
t.Errorf("expected %s blocked on Wednesday (opening blocker)", wedBlockStart)
}
// The hour after the blocker should be available
if !slotExists(wed.Slots, wedBlockEnd) {
t.Errorf("expected %s available on Wednesday (after block)", wedBlockEnd)
}
}
// mustParseHour extracts the hour from a "HH:MM" or "HH:MM:SS" time string.
func mustParseHour(t string) int {
if len(t) >= 2 {
var h int
fmt.Sscanf(t, "%d", &h)
return h
}
return 0
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_NoBlockers verifies admin
// sees all normal slots when no time blockers exist.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_NoBlockers(t *testing.T) {
ctx, _ := resetTestData(t)
// Tue 2026-03-17 — no blockers
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
targetDay := findDayByDate(response, "2026-03-17")
if targetDay == nil {
t.Fatal("expected 2026-03-17 in response")
}
// All standard slots should be present
for _, start := range []string{"09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00", "16:00"} {
if !slotExists(targetDay.Slots, start) {
t.Errorf("expected slot %s to be available (no blockers)", start)
}
}
// No blockers in field
if len(targetDay.Blockers) != 0 {
t.Error("expected empty blockers field when no blockers exist")
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_ClosedDayBlocker tests that
// a time blocker on a closed day does NOT create any phantom slots.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_ClosedDayBlocker(t *testing.T) {
ctx, tx := resetTestData(t)
// Sunday 2026-03-22 is closed. Blocker at 10:00-11:00.
blockerTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Sunday Maintenance', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-22", "2026-03-22")
targetDay := findDayByDate(response, "2026-03-22")
if targetDay == nil {
t.Fatal("expected 2026-03-22 in response")
}
if targetDay.IsOpen {
t.Error("expected Sunday to be closed")
}
if len(targetDay.Slots) != 0 {
t.Error("expected no slots on closed day even with blocker")
}
// Blockers on closed days are not populated (the day is closed, so
// the blocker is irrelevant; no slots exist to subtract from).
if len(targetDay.Blockers) != 0 {
t.Error("expected no blockers listed on closed day (day is closed)")
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDayRangePartial verifies
// that querying a multi-day range where only some days have blockers correctly
// leaves unblocked days unaffected.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDayRangePartial(t *testing.T) {
ctx, tx := resetTestData(t)
// Blocker only on Tuesday (2026-03-17) at 10:00-11:00
blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Tue Only Blocker', NULL)`, blockerTime)
// Query Tue-Thu (17th, 18th, 19th)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-19")
tue := findDayByDate(response, "2026-03-17")
wed := findDayByDate(response, "2026-03-18")
thu := findDayByDate(response, "2026-03-19")
if tue == nil || wed == nil || thu == nil {
t.Fatal("expected all three days in response")
}
// Tuesday: 10:00 blocked
if slotExists(tue.Slots, "10:00") {
t.Error("expected 10:00 blocked on Tuesday")
}
if len(tue.Blockers) == 0 {
t.Error("expected blocker visible on Tuesday")
}
// Wednesday: all slots available, no blockers
for _, start := range []string{"09:00", "10:00", "11:00", "14:00"} {
if !slotExists(wed.Slots, start) {
t.Errorf("expected %s available on Wednesday (no blocker)", start)
}
}
if len(wed.Blockers) != 0 {
t.Error("expected no blockers on Wednesday")
}
// Thursday (open 09:00-17:00): all slots available, no blockers
for _, start := range []string{"09:00", "10:00", "11:00", "12:00", "14:00", "16:00"} {
if !slotExists(thu.Slots, start) {
t.Errorf("expected %s available on Thursday (no blocker)", start)
}
}
if len(thu.Blockers) != 0 {
t.Error("expected no blockers on Thursday")
}
}
// TestScheduling_GetAvailableHours_WithBlocker_Admin_ExceptionalHours verifies
// that blockers still subtract from slots on days with exceptional working hours.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_ExceptionalHours(t *testing.T) {
ctx, tx := resetTestData(t)
// Monday 2026-03-16 is normally CLOSED. Add exceptional hours: 10:00-16:00.
// Also add a blocker at 12:00-13:00.
// First create the exceptional group
var groupID int
err := tx.QueryRow(ctx, `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ('Test Exceptional Group', 'Test for blocker on exceptional hours day')
RETURNING id
`).Scan(&groupID)
if err != nil {
t.Fatalf("failed to create exceptional group: %v", err)
}
// Add exceptional hours: Monday (weekday 0) 10:00-16:00
tx.Exec(ctx, `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, 0, '10:00:00', '16:00:00', true)
`, groupID)
// Apply to week containing 2026-03-16
tx.Exec(ctx, `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, '2026-03-16')
`, groupID)
// Blocker on Monday 12:00-13:00
blockerTime := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Lunch Break', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-16", "2026-03-16")
targetDay := findDayByDate(response, "2026-03-16")
if targetDay == nil {
t.Fatal("expected 2026-03-16 in response")
}
if !targetDay.IsOpen {
t.Error("expected Monday to be open (exceptional hours)")
}
// 12:00 blocked by blocker
if slotExists(targetDay.Slots, "12:00") {
t.Error("expected 12:00 blocked on exceptional hours day")
}
// 10:00 and 14:00 should be available
if !slotExists(targetDay.Slots, "10:00") {
t.Error("expected 10:00 available on exceptional hours day")
}
if !slotExists(targetDay.Slots, "14:00") {
t.Error("expected 14:00 available on exceptional hours day")
}
// Blocker visible
if len(targetDay.Blockers) == 0 {
t.Error("expected blocker visible on exceptional hours day")
}
}
// TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_ClosedDay tests that
// a blocker on a closed day has no effect for non-admin users (day stays closed).
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_ClosedDay(t *testing.T) {
ctx, tx := resetTestData(t)
// Sunday 2026-03-22 closed, blocker at 10:00-11:00
blockerTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Weekend Maintenance', NULL)`, blockerTime)
handler := http.HandlerFunc(GetAvailableHours)
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-22&end=2026-03-22", nil)
reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001")
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var response []DayAvailableHours
json.Unmarshal(w.Body.Bytes(), &response)
targetDay := findDayByDate(response, "2026-03-22")
if targetDay == nil {
t.Fatal("expected 2026-03-22 in response")
}
if targetDay.IsOpen {
t.Error("expected Sunday to be closed for non-admin")
}
if len(targetDay.Slots) != 0 {
t.Error("expected no slots on closed day")
}
if len(targetDay.Blockers) != 0 {
t.Error("expected blockers field empty for non-admin")
}
}
// TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_BookingAdjacent verifies
// that a booking and blocker that are adjacent (booking 10-11, blocker 11-12)
// correctly list two separate unavailable ranges for non-admin users.
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_BookingAdjacent(t *testing.T) {
ctx, tx := resetTestData(t)
bookingStart := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
bookingEnd := bookingStart.Add(60 * time.Minute)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time)
VALUES ($1, $2, 'confirmed', 60, $3)
`, userID, bookingStart, bookingEnd)
blockerTime := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Maintenance Window', NULL)`, blockerTime)
handler := http.HandlerFunc(GetAvailableHours)
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil)
reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001")
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var response []DayAvailableHours
json.Unmarshal(w.Body.Bytes(), &response)
targetDay := findDayByDate(response, "2026-03-17")
if targetDay == nil {
t.Fatal("expected 2026-03-17 in response")
}
if slotExists(targetDay.Slots, "10:00") {
t.Error("expected 10:00 unavailable (booking)")
}
if slotExists(targetDay.Slots, "11:00") {
t.Error("expected 11:00 unavailable (blocker)")
}
if !slotExists(targetDay.Slots, "09:00") {
t.Error("expected 09:00 available")
}
if !slotExists(targetDay.Slots, "12:00") {
t.Error("expected 12:00 available")
}
if len(targetDay.Blockers) != 0 {
t.Error("expected blockers field empty for non-admin")
}
}
// normalizeTime unit tests
func TestNormalizeTime_StripsSeconds(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"HH:MM:SS", "09:00:00", "09:00"},
{"already HH:MM", "09:00", "09:00"},
{"empty string", "", ""},
{"single digit hour stripped", "9:00:00", "09:00"},
{"midnight", "00:00:00", "00:00"},
{"23:59:59", "23:59:59", "23:59"},
{"12:30:45", "12:30:45", "12:30"},
{"malformed no colon", "0900", "0900"},
{"single colon", "09:00", "09:00"},
{"extra suffix", "09:00:00:extra", "09:00"},
{"short string", "9:00", "09:00"},
{"minimal HH:MM:SS", "1:2:3", "01:02"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeTime(tt.input)
if got != tt.expected {
t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
func TestNormalizeTime_NoChangeForInvalidFormats(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"purely invalid", "invalid", "invalid"},
// "25:00:00" has ':' at positions 2 and 5 so normalizeTime DOES strip seconds
{"invalid time with pattern", "25:00:00", "25:00"},
// "ab:cd:ef" has ':' at positions 2 and 5 so normalizeTime DOES strip seconds
{"non-numeric with pattern", "ab:cd:ef", "ab:cd"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeTime(tt.input)
if got != tt.expected {
t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
// Late-night lock boundary tests
func TestLateNightLock_After22_BlocksNextMorning(t *testing.T) {
available := []TimeSlot{{StartTime: "06:00", EndTime: "22:00"}}
lateBlock := TimeSlot{StartTime: "00:00", EndTime: "11:00"}
result := subtractTimeSlots(available, []TimeSlot{lateBlock})
expected := []TimeSlot{{StartTime: "11:00", EndTime: "22:00"}}
if len(result) != len(expected) {
t.Fatalf("expected %d slot, got %d: %+v", len(expected), len(result), result)
}
if result[0].StartTime != expected[0].StartTime || result[0].EndTime != expected[0].EndTime {
t.Errorf("expected slot %+v, got %+v", expected[0], result[0])
}
}
func TestLateNightLock_Before22_NoBlock(t *testing.T) {
available := []TimeSlot{{StartTime: "09:00", EndTime: "17:00"}}
result := subtractTimeSlots(available, nil)
if len(result) != 1 {
t.Fatalf("expected 1 slot unchanged, got %d: %+v", len(result), result)
}
}
func TestSubtractTimeSlots_NoOverlap(t *testing.T) {
available := []TimeSlot{
{StartTime: "09:00", EndTime: "12:00"},
{StartTime: "13:00", EndTime: "17:00"},
}
gaps := []TimeSlot{{StartTime: "12:00", EndTime: "13:00"}}
result := subtractTimeSlots(available, gaps)
if len(result) != 2 {
t.Fatalf("expected 2 slots unchanged, got %d: %+v", len(result), result)
}
}
func TestSubtractTimeSlots_MultipleGaps(t *testing.T) {
available := []TimeSlot{{StartTime: "09:00", EndTime: "18:00"}}
gaps := []TimeSlot{
{StartTime: "11:00", EndTime: "12:00"},
{StartTime: "14:00", EndTime: "15:00"},
}
result := subtractTimeSlots(available, gaps)
expected := []TimeSlot{
{StartTime: "09:00", EndTime: "11:00"},
{StartTime: "12:00", EndTime: "14:00"},
{StartTime: "15:00", EndTime: "18:00"},
}
if len(result) != 3 {
t.Fatalf("expected 3 slots, got %d: %+v", len(result), result)
}
for i, exp := range expected {
if result[i].StartTime != exp.StartTime || result[i].EndTime != exp.EndTime {
t.Errorf("slot %d: expected %+v, got %+v", i, exp, result[i])
}
}
}
func TestSubtractTimeSlots_GapEatsWholeSlot(t *testing.T) {
available := []TimeSlot{{StartTime: "09:00", EndTime: "12:00"}}
gaps := []TimeSlot{{StartTime: "08:00", EndTime: "13:00"}}
result := subtractTimeSlots(available, gaps)
if len(result) != 0 {
t.Errorf("expected 0 slots, got %d: %+v", len(result), result)
}
}
func TestSubtractTimeSlots_GapPartialOverlap(t *testing.T) {
available := []TimeSlot{{StartTime: "09:00", EndTime: "17:00"}}
gaps := []TimeSlot{{StartTime: "08:00", EndTime: "10:00"}}
result := subtractTimeSlots(available, gaps)
if len(result) != 1 {
t.Fatalf("expected 1 slot, got %d: %+v", len(result), result)
}
if result[0].StartTime != "10:00" || result[0].EndTime != "17:00" {
t.Errorf("expected slot 10:00-17:00, got %+v", result[0])
}
}
func TestNormalizeTime_SingleDigitHour(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"single digit hour with seconds", "9:00:00", "09:00"},
{"single digit hour no seconds", "9:00", "09:00"},
{"double digit hour with seconds", "09:00:00", "09:00"},
{"double digit hour no seconds", "09:00", "09:00"},
{"single digit min with seconds", "09:5:00", "09:05"},
{"hour only no colon", "0900", "0900"},
{"empty string", "", ""},
{"midnight with seconds", "00:00:00", "00:00"},
{"end of day with seconds", "23:59:59", "23:59"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeTime(tt.input)
if got != tt.expected {
t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
func TestNormalizeTime_NoChangeForEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"no colons", "hello", "hello"},
{"single colon only", ":", ":"},
{"trailing colon", "09:", "09:"},
{"only two chars after colon", "9:0", "09:00"},
{"three colons no trailing pair", "a:b:c:d", "a:b"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeTime(tt.input)
if got != tt.expected {
t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
func TestNormalizeTime_Regression_RealWorldFormats(t *testing.T) {
// These are the actual formats returned by the working hours and blocker
// queries. normaliseTime is called on baseStart and baseEnd in
// GetAvailableHours to ensure string comparison consistency.
realWorld := map[string]string{
"09:00:00": "09:00",
"17:00:00": "17:00",
"09:00": "09:00",
"17:00": "17:00",
"00:00:00": "00:00",
"22:00:00": "22:00",
}
for input, expected := range realWorld {
got := normalizeTime(input)
if got != expected {
t.Errorf("normalizeTime(%q) = %q, want %q", input, got, expected)
}
}
}
// TestScheduling_GetAvailableHours_CrossDayBlocker verifies that a single time
// blocker with a duration spanning multiple calendar days correctly subtracts
// slots from each affected day.
func TestScheduling_GetAvailableHours_CrossDayBlocker(t *testing.T) {
ctx, tx := resetTestData(t)
// Use Tue 2026-03-17 and Wed 2026-03-18 — both open days
_, tueEnd, tueOpen := getWorkingHoursForDate(t, ctx, "2026-03-17")
_, _, wedOpen := getWorkingHoursForDate(t, ctx, "2026-03-18")
if !tueOpen || !wedOpen {
t.Skip("test requires both Tue 2026-03-17 and Wed 2026-03-18 to be open days")
}
// Blocker starts at 15:00 on Tuesday and lasts 20 hours (covers all of
// Wednesday's working hours up to 11:00).
blockerStart := time.Date(2026, 3, 17, 15, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 1200, 'Multi-day blocker', NULL)`, blockerStart)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18")
tue := findDayByDate(response, "2026-03-17")
wed := findDayByDate(response, "2026-03-18")
if tue == nil || wed == nil {
t.Fatal("expected both days in response")
}
// Tuesday: blocker removes 15:00 through end of day (17:00)
if slotExists(tue.Slots, "15:00") {
t.Errorf("expected 15:00 blocked on Tuesday (cross-day blocker starts)")
}
// Slots before 15:00 should remain
if !slotExists(tue.Slots, "09:00") {
t.Error("expected 09:00 available on Tuesday")
}
if !slotExists(tue.Slots, "10:00") {
t.Error("expected 10:00 available on Tuesday")
}
// End-of-day slot after blocker start should be gone
if mustParseHour(tueEnd) > 15 {
lastSlot := fmt.Sprintf("%02d:00", mustParseHour(tueEnd)-2)
if slotExists(tue.Slots, lastSlot) {
t.Errorf("expected end-of-day slot %s blocked on Tuesday", lastSlot)
}
}
// Wednesday: blocker still active until 11:00 — removes morning slots
for _, start := range []string{"09:00", "10:00"} {
if slotExists(wed.Slots, start) {
t.Errorf("expected %s blocked on Wednesday (cross-day blocker spillover)", start)
}
}
// 11:00 onwards should be available
if !slotExists(wed.Slots, "11:00") {
t.Errorf("expected 11:00 available on Wednesday (after cross-day blocker ends)")
}
if !slotExists(wed.Slots, "12:00") {
t.Errorf("expected 12:00 available on Wednesday")
}
}
// =============================================================================
// DST Transition Tests
// =============================================================================
//
// These tests verify that timezone handling is correct during BST (summer)
// when the wall-clock time differs from UTC by +1 hour.
// TestScheduling_DST_BlockerTimeFormatting verifies that blocker times are
// formatted in Europe/London during BST, so the blocker correctly subtracts
// wall-clock slots. A blocker at 15:00 BST (= 14:00 UTC) should block the
// 15:00-16:00 BST slot, not 14:00-15:00 BST.
func TestScheduling_DST_BlockerTimeFormatting(t *testing.T) {
ctx, tx := resetTestData(t)
// Monday 2026-06-15 is in BST (UTC+1). Working hours: 09:00-17:00.
// Insert a blocker at 15:00 BST = 14:00 UTC.
blockerTime := time.Date(2026, 6, 15, 14, 0, 0, 0, time.UTC)
_, err := tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'DST Blocker', NULL)
`, blockerTime)
if err != nil {
t.Fatalf("failed to create DST blocker: %v", err)
}
response := makeAdminAvailableHoursRequest(t, ctx, "2026-06-15", "2026-06-15")
targetDay := findDayByDate(response, "2026-06-15")
if targetDay == nil {
t.Fatal("expected day 2026-06-15 in response")
}
// 15:00 BST slot should be blocked (wall-clock time)
if slotExists(targetDay.Slots, "15:00") {
t.Error("expected 15:00 BST slot to be blocked by DST blocker")
}
// 14:00 BST slot should still be available (blocker starts at 15:00)
if !slotExists(targetDay.Slots, "14:00") {
t.Error("expected 14:00 BST slot to remain available")
}
// 16:00 BST slot should be available (blocker ends at 16:00)
if !slotExists(targetDay.Slots, "16:00") {
t.Error("expected 16:00 BST slot to be available after blocker")
}
// Verify blocker appears in blockers field with correct wall-clock times
found := false
for _, b := range targetDay.Blockers {
if b.StartTime == "15:00" && b.EndTime == "16:00" {
found = true
break
}
}
if !found {
t.Error("expected blocker 15:00-16:00 in blockers field (BST wall-clock time)")
}
}
// TestScheduling_DST_MultipleBlockers verifies multiple blockers during BST
// are all correctly applied to wall-clock time slots.
func TestScheduling_DST_MultipleBlockers(t *testing.T) {
ctx, tx := resetTestData(t)
// Monday 2026-06-15 BST: two blockers at 10:00 BST (= 09:00 UTC)
// and 14:00 BST (= 13:00 UTC).
b1 := time.Date(2026, 6, 15, 9, 0, 0, 0, time.UTC)
b2 := time.Date(2026, 6, 15, 13, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning blocker', NULL)`, b1)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Afternoon blocker', NULL)`, b2)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-06-15", "2026-06-15")
targetDay := findDayByDate(response, "2026-06-15")
if targetDay == nil {
t.Fatal("expected day 2026-06-15 in response")
}
// Morning blocker at 10:00 BST
if slotExists(targetDay.Slots, "10:00") {
t.Error("expected 10:00 BST slot blocked (morning blocker)")
}
// Afternoon blocker at 14:00 BST
if slotExists(targetDay.Slots, "14:00") {
t.Error("expected 14:00 BST slot blocked (afternoon blocker)")
}
// 09:00 should be available (before any blocker)
if !slotExists(targetDay.Slots, "09:00") {
t.Error("expected 09:00 BST to remain available")
}
// 11:00 should be available (between blockers)
if !slotExists(targetDay.Slots, "11:00") {
t.Error("expected 11:00 BST to remain available")
}
// 15:00 should be available (after both blockers)
if !slotExists(targetDay.Slots, "15:00") {
t.Error("expected 15:00 BST to remain available")
}
// Both blockers visible in wall-clock time
found1, found2 := false, false
for _, b := range targetDay.Blockers {
if b.StartTime == "10:00" && b.EndTime == "11:00" {
found1 = true
}
if b.StartTime == "14:00" && b.EndTime == "15:00" {
found2 = true
}
}
if !found1 {
t.Error("expected morning blocker 10:00-11:00 in blockers field")
}
if !found2 {
t.Error("expected afternoon blocker 14:00-15:00 in blockers field")
}
}
// TestScheduling_DST_BlockerOnSpringForward verifies time blocker handling
// during the March BST transition (clocks spring forward 01:00→02:00).
// 2026-03-29 is the spring-forward date. The blocker is placed within
// working hours (10:00-11:00 BST) to verify it correctly blocks wall-clock
// time on the transition day.
func TestScheduling_DST_BlockerOnSpringForward(t *testing.T) {
ctx, tx := resetTestData(t)
// 2026-03-29 is the BST start date (clocks spring forward). Sunday is closed
// by default. Use an exceptional hours override to open 09:00-17:00.
var groupID int
err := tx.QueryRow(ctx, `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ('Spring Forward Test', 'Test BST transition on 2026-03-29')
RETURNING id
`).Scan(&groupID)
if err != nil {
t.Fatalf("failed to create exceptional group: %v", err)
}
tx.Exec(ctx, `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, 6, '09:00:00', '17:00:00', true)
`, groupID)
tx.Exec(ctx, `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, '2026-03-23')
`, groupID)
// Blocker at 10:00 BST on 2026-03-29 = 09:00 UTC (spring-forward day,
// clocks jump 01:00→02:00, so 10:00 BST = 09:00 UTC as usual).
blockerTime := time.Date(2026, 3, 29, 9, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Spring Forward Blocker', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-29", "2026-03-29")
targetDay := findDayByDate(response, "2026-03-29")
if targetDay == nil {
t.Fatal("expected day 2026-03-29 in response")
}
if !targetDay.IsOpen {
t.Fatal("expected 2026-03-29 to be open (exceptional hours)")
}
// 10:00 BST slot should be blocked (wall-clock time within working hours)
if slotExists(targetDay.Slots, "10:00") {
t.Error("expected 10:00 BST slot to be blocked (spring-forward blocker)")
}
// 09:00 BST should be available (before blocker)
if !slotExists(targetDay.Slots, "09:00") {
t.Error("expected 09:00 BST to be available before blocker")
}
// 11:00 BST should be available (after blocker ends)
if !slotExists(targetDay.Slots, "11:00") {
t.Error("expected 11:00 BST to be available after blocker")
}
// Verify blocker in wall-clock time
found := false
for _, b := range targetDay.Blockers {
if b.StartTime == "10:00" && b.EndTime == "11:00" {
found = true
break
}
}
if !found {
t.Error("expected blocker 10:00-11:00 in blockers field (spring-forward wall-clock)")
}
}
// TestNormalizeTime_NonNumericInput verifies that normalizeTime does not
// silently pad non-numeric single-character segments (defensive guard).
func TestNormalizeTime_NonNumericInput(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"non-numeric hour single char", "a:00", "a:00"},
{"non-numeric minute single char", "09:b", "09:b"},
{"both non-numeric single char", "a:b", "a:b"},
{"numeric still works", "9:00", "09:00"},
{"single digit minute", "09:5", "09:05"},
{"both single digit", "9:5", "09:05"},
{"non-numeric multi-char hour passes through", "ab:00", "ab:00"},
{"non-numeric with seconds stripped", "a:b:c", "a:b"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeTime(tt.input)
if got != tt.expected {
t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
// TestScheduling_DST_DateBoundary verifies that GetAvailableHours includes
// BST early-morning bookings (00:00-00:59 BST = 23:00-23:59 UTC previous day)
// in the correct date range. Uses londonLocation for date boundaries.
func TestScheduling_DST_DateBoundary(t *testing.T) {
ctx, tx := resetTestData(t)
// Monday 2026-06-15 is BST. Create a booking at 00:30 BST (= 23:30 UTC June 14).
// This booking should appear in the June 15 results (BST date).
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
defer fixtures.DeleteService(tx, serviceID)
// 00:30 BST on June 15 = 23:30 UTC on June 14
bkStart := time.Date(2026, 6, 15, 0, 30, 0, 0, londonLocation)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bkStart)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
defer fixtures.DeleteBooking(tx, bookingID)
// Query available hours for June 15. The query uses londonLocation for
// date boundaries, so it should see the 00:30 BST booking.
response := makeAdminAvailableHoursRequest(t, ctx, "2026-06-15", "2026-06-15")
targetDay := findDayByDate(response, "2026-06-15")
if targetDay == nil {
t.Fatal("expected day 2026-06-15 in response")
}
if !targetDay.IsOpen {
t.Fatal("expected June 15 to be open")
}
// The booking at 00:30 BST should consume a slot before it.
// If the date boundary was UTC-based, the booking would be invisible
// (23:30 UTC June 14 < 00:00 UTC June 15 query start).
// With londonLocation boundary, 00:30 BST is within the range.
// Verify the 00:00 slot is NOT available (blocked by the 00:30 booking
// because available-hours represents open slots, not individual bookings).
// Actually, available-hours shows slots that ARE available, so a booking
// at 00:30 means the 00:00 slot's 30-min window is partially taken.
// For a 30-min service, 00:00 would be blocked by the 00:30 booking.
if slotExists(targetDay.Slots, "00:00") {
t.Error("expected 00:00 BST slot to be unavailable (booked at 00:30 BST)")
}
}
// TestScheduling_DST_AutumnBack_BookingAt0130BST verifies that a booking at
// 01:30 BST (= 00:30 UTC) on Oct 25, 2026 (BST→GMT transition) is correctly
// handled. Oct 25 is the autumn DST date where clocks go back at 02:00 BST →
// 01:00 GMT, creating a duplicated 01:00-02:00 hour.
func TestScheduling_DST_AutumnBack_BookingAt0130BST(t *testing.T) {
ctx, tx := resetTestData(t)
// 2026-10-25 is the autumn DST date (BST→GMT). Sunday is closed
// by default. Use exceptional hours override to open 09:00-17:00.
var groupID int
err := tx.QueryRow(ctx, `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ('Autumn DST BST Test', 'Test BST→GMT transition on 2026-10-25')
RETURNING id
`).Scan(&groupID)
if err != nil {
t.Fatalf("failed to create exceptional group: %v", err)
}
tx.Exec(ctx, `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, 6, '09:00:00', '17:00:00', true)
`, groupID)
tx.Exec(ctx, `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, '2026-10-19')
`, groupID)
// Create a user and service for the booking
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
defer fixtures.DeleteService(tx, serviceID)
// Booking at 01:30 BST = 00:30 UTC on 2026-10-25 (spans DST transition)
bookingTime := time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, notes)
VALUES ($1, $2, 'pending', 'Autumn DST BST booking at 01:30 BST')
RETURNING id
`, userID, bookingTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service to booking: %v", err)
}
response := makeAdminAvailableHoursRequest(t, ctx, "2026-10-25", "2026-10-25")
targetDay := findDayByDate(response, "2026-10-25")
if targetDay == nil {
t.Fatal("expected day 2026-10-25 in response")
}
if !targetDay.IsOpen {
t.Fatal("expected 2026-10-25 to be open (exceptional hours)")
}
// 01:30 BST slot should be blocked (booking at 01:30 BST = 00:30 UTC)
if slotExists(targetDay.Slots, "01:30") {
t.Error("expected 01:30 BST slot to be blocked (booking at 01:30 BST = 00:30 UTC)")
}
}
// TestScheduling_DST_AutumnBack_BookingAt0130GMT verifies that a booking at
// 01:30 GMT (= 01:30 UTC) on Oct 25, 2026 (BST→GMT transition) is also
// correctly handled. This is the second occurrence of 01:30 during the
// duplicated hour on the autumn DST day.
func TestScheduling_DST_AutumnBack_BookingAt0130GMT(t *testing.T) {
ctx, tx := resetTestData(t)
// Same exceptional hours setup as the BST variant
var groupID int
err := tx.QueryRow(ctx, `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ('Autumn DST GMT Test', 'Test BST→GMT transition on 2026-10-25')
RETURNING id
`).Scan(&groupID)
if err != nil {
t.Fatalf("failed to create exceptional group: %v", err)
}
tx.Exec(ctx, `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, 6, '09:00:00', '17:00:00', true)
`, groupID)
tx.Exec(ctx, `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, '2026-10-19')
`, groupID)
// Create a user and service for the booking
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
defer fixtures.DeleteService(tx, serviceID)
// Booking at 01:30 GMT = 01:30 UTC on 2026-10-25
bookingTime := time.Date(2026, 10, 25, 1, 30, 0, 0, time.UTC)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, notes)
VALUES ($1, $2, 'pending', 'Autumn DST GMT booking at 01:30 GMT')
RETURNING id
`, userID, bookingTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service to booking: %v", err)
}
response := makeAdminAvailableHoursRequest(t, ctx, "2026-10-25", "2026-10-25")
targetDay := findDayByDate(response, "2026-10-25")
if targetDay == nil {
t.Fatal("expected day 2026-10-25 in response")
}
if !targetDay.IsOpen {
t.Fatal("expected 2026-10-25 to be open (exceptional hours)")
}
// 01:30 GMT slot should be blocked (booking at 01:30 GMT = 01:30 UTC)
if slotExists(targetDay.Slots, "01:30") {
t.Error("expected 01:30 GMT slot to be blocked (booking at 01:30 GMT = 01:30 UTC)")
}
}