Add tests for ScheduleDefaultHoursChange, GetScheduledDefaultHoursChange, CancelScheduledDefaultHoursChange, GetDefaultHoursConflictingBookings, and GetWorkingHours/GetDefaultHours integration with staged changes.
4682 lines
165 KiB
Go
4682 lines
165 KiB
Go
//go:build test
|
|
|
|
package scheduling
|
|
|
|
// Package scheduling contains tests for working hours and availability endpoints.
|
|
//
|
|
// Test Coverage:
|
|
// - GetDefaultHours: GET /api/scheduling/default-hours - Get default weekly hours
|
|
// - UpdateDefaultHours: PUT /api/scheduling/default-hours - Update default hours (admin)
|
|
// - ListExceptionalGroups: GET /api/scheduling/exceptional-groups - List holiday hour groups
|
|
// - CreateExceptionalGroup: POST /api/scheduling/exceptional-groups - Create group (admin)
|
|
// - DeleteExceptionalGroup: DELETE /api/scheduling/exceptional-groups?id=X - Delete (admin)
|
|
// - GetWorkingHours: GET /api/scheduling/working-hours?start=X&end=Y - Get hours for date range
|
|
// - GetAvailableHours: GET /api/scheduling/available-hours?start=X&end=Y - Get available slots
|
|
// - UpdateExceptionalApplications: PUT /api/scheduling/exceptional-applications - Apply holidays
|
|
//
|
|
// Authentication: Update/Create/Delete endpoints require admin role (403 for non-admins).
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/mw"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
)
|
|
|
|
func resetTestData(t *testing.T) (context.Context, db.Querier) {
|
|
t.Helper()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
return ctx, tx
|
|
}
|
|
|
|
func makeRequest(handler http.HandlerFunc, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder {
|
|
var req *http.Request
|
|
if body != nil {
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
} else {
|
|
req = httptest.NewRequest(method, path, nil)
|
|
}
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func makeAuthRequest(handler http.Handler, method, path, token string, body interface{}, ctx context.Context) *httptest.ResponseRecorder {
|
|
var req *http.Request
|
|
if body != nil {
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
} else {
|
|
req = httptest.NewRequest(method, path, nil)
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
// --- Tests for GetDefaultHours ---
|
|
|
|
// TestScheduling_GetDefaultHours verifies that the default weekly working hours
|
|
// can be retrieved. The test checks that all 7 days are returned with correct
|
|
// opening times, closing times, and is_open status.
|
|
func TestScheduling_GetDefaultHours(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GetDefaultHours)
|
|
w := makeRequest(handler, "GET", "/api/scheduling/default-hours", nil, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response ScheduledHoursChangeResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if len(response.Current) != 7 {
|
|
t.Errorf("expected 7 days of hours, got %d", len(response.Current))
|
|
}
|
|
|
|
if response.ScheduledChange != nil {
|
|
t.Errorf("expected no scheduled change, got effective_date=%s", response.ScheduledChange.EffectiveDate)
|
|
}
|
|
|
|
// Verify Monday (weekday 0) has our seeded hours
|
|
var monday *DefaultHours
|
|
for i := range response.Current {
|
|
if response.Current[i].Weekday == 0 {
|
|
monday = &response.Current[i]
|
|
break
|
|
}
|
|
}
|
|
if monday == nil {
|
|
t.Fatal("expected Monday hours in response")
|
|
}
|
|
// Database returns HH:MM:SS format
|
|
if monday.StartTime != "09:00:00" {
|
|
t.Errorf("expected Monday start time 09:00:00, got %s", monday.StartTime)
|
|
}
|
|
if monday.EndTime != "17:00:00" {
|
|
t.Errorf("expected Monday end time 17:00:00, got %s", monday.EndTime)
|
|
}
|
|
if !monday.IsOpen {
|
|
t.Error("expected Monday to be open")
|
|
}
|
|
}
|
|
|
|
// --- Tests for UpdateDefaultHours ---
|
|
|
|
// TestScheduling_UpdateDefaultHours_Admin tests that an admin can update
|
|
// the default weekly working hours. The new schedule is persisted to the
|
|
// database and returned on subsequent requests.
|
|
func TestScheduling_UpdateDefaultHours_Admin(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
adminToken := jwt.GenerateAdminToken()
|
|
handler := http.HandlerFunc(UpdateDefaultHours)
|
|
|
|
newHours := []DefaultHours{
|
|
{Weekday: 0, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 1, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 2, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 3, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 4, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false},
|
|
}
|
|
|
|
w := makeAuthRequest(handler, "PUT", "/api/scheduling/default-hours", adminToken, newHours, ctx)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify the update persisted
|
|
var hours []DefaultHours
|
|
rows, err := tx.Query(ctx, `SELECT weekday, start_time::text, end_time::text, is_open FROM working_hours ORDER BY weekday`)
|
|
if err != nil {
|
|
t.Fatalf("failed to query hours: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var h DefaultHours
|
|
if err := rows.Scan(&h.Weekday, &h.StartTime, &h.EndTime, &h.IsOpen); err != nil {
|
|
t.Fatalf("failed to scan hours: %v", err)
|
|
}
|
|
hours = append(hours, h)
|
|
}
|
|
|
|
if hours[0].StartTime != "08:00:00" {
|
|
t.Errorf("expected Monday start time 08:00:00, got %s", hours[0].StartTime)
|
|
}
|
|
}
|
|
|
|
// TestScheduling_UpdateDefaultHours_NonAdmin verifies that non-admin users
|
|
// receive HTTP 403 Forbidden when attempting to update default hours.
|
|
func TestScheduling_UpdateDefaultHours_NonAdmin(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
userToken := jwt.GenerateUserToken("user-123")
|
|
|
|
newHours := []DefaultHours{
|
|
{Weekday: 0, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 1, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 2, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 3, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 4, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false},
|
|
}
|
|
|
|
// Wrap handler with RequireAuth + RequireAdmin middleware (auth first to populate context)
|
|
w := makeAuthRequest(mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(UpdateDefaultHours))), "PUT", "/api/scheduling/default-hours", userToken, newHours, ctx)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// --- Tests for ListExceptionalGroups ---
|
|
|
|
// TestScheduling_ListExceptionalGroups verifies that admins can list all
|
|
// exceptional working hours groups (holidays, special events).
|
|
func TestScheduling_ListExceptionalGroups(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Create an exceptional group
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO exceptional_working_hours_groups (name, description)
|
|
VALUES ('Holiday Hours', 'Christmas holiday schedule')
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("failed to create group: %v", err)
|
|
}
|
|
|
|
handler := http.HandlerFunc(ListExceptionalGroups)
|
|
w := makeRequest(handler, "GET", "/api/scheduling/exceptional-groups", nil, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []ExceptionalGroup
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if len(response) == 0 {
|
|
t.Error("expected at least one group in response")
|
|
}
|
|
|
|
if response[0].Name != "Holiday Hours" {
|
|
t.Errorf("expected group name 'Holiday Hours', got %s", response[0].Name)
|
|
}
|
|
}
|
|
|
|
// --- Tests for CreateExceptionalGroup ---
|
|
|
|
// TestScheduling_CreateExceptionalGroup_Admin tests that an admin can
|
|
// create a new exceptional working hours group with specific hours for each day.
|
|
func TestScheduling_CreateExceptionalGroup_Admin(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
adminToken := jwt.GenerateAdminToken()
|
|
handler := http.HandlerFunc(CreateExceptionalGroup)
|
|
|
|
newGroup := ExceptionalGroup{
|
|
Name: "Summer Hours",
|
|
Description: "Extended summer schedule",
|
|
Hours: []ExceptionalHours{
|
|
{Weekday: 0, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 1, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 2, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 3, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 4, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false},
|
|
},
|
|
WeekStarts: []string{"2026-06-01"},
|
|
}
|
|
|
|
w := makeAuthRequest(handler, "POST", "/api/scheduling/exceptional-groups", adminToken, newGroup, ctx)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response ExceptionalGroup
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if response.Name != "Summer Hours" {
|
|
t.Errorf("expected group name 'Summer Hours', got %s", response.Name)
|
|
}
|
|
if len(response.Hours) != 7 {
|
|
t.Errorf("expected 7 hours, got %d", len(response.Hours))
|
|
}
|
|
}
|
|
|
|
// TestScheduling_CreateExceptionalGroup_NonAdmin verifies that non-admin
|
|
// users receive HTTP 403 when attempting to create exceptional groups.
|
|
func TestScheduling_CreateExceptionalGroup_NonAdmin(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
userToken := jwt.GenerateUserToken("user-123")
|
|
|
|
newGroup := ExceptionalGroup{
|
|
Name: "Summer Hours",
|
|
Description: "Extended summer schedule",
|
|
Hours: []ExceptionalHours{
|
|
{Weekday: 0, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 1, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 2, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 3, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 4, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false},
|
|
},
|
|
WeekStarts: []string{"2026-06-01"},
|
|
}
|
|
|
|
w := makeAuthRequest(mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(CreateExceptionalGroup))), "POST", "/api/scheduling/exceptional-groups", userToken, newGroup, ctx)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// --- Tests for 15-minute interval validation ---
|
|
|
|
// TestScheduling_UpdateDefaultHours_InvalidTimes verifies that UpdateDefaultHours
|
|
// rejects start_time/end_time with minutes not in {00, 15, 30, 45}.
|
|
func TestScheduling_UpdateDefaultHours_InvalidTimes(t *testing.T) {
|
|
handler := http.HandlerFunc(UpdateDefaultHours)
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
tests := []struct {
|
|
name string
|
|
hours []DefaultHours
|
|
wantStatus int
|
|
}{
|
|
{"valid 00 minutes", []DefaultHours{{Weekday: 0, StartTime: "09:00", EndTime: "17:00", IsOpen: true}}, http.StatusNoContent},
|
|
{"valid 15 minutes", []DefaultHours{{Weekday: 0, StartTime: "09:15", EndTime: "17:15", IsOpen: true}}, http.StatusNoContent},
|
|
{"valid 30 minutes", []DefaultHours{{Weekday: 0, StartTime: "09:30", EndTime: "17:30", IsOpen: true}}, http.StatusNoContent},
|
|
{"valid 45 minutes", []DefaultHours{{Weekday: 0, StartTime: "09:45", EndTime: "17:45", IsOpen: true}}, http.StatusNoContent},
|
|
{"valid HH:MM:SS 00", []DefaultHours{{Weekday: 0, StartTime: "09:00:00", EndTime: "17:00:00", IsOpen: true}}, http.StatusNoContent},
|
|
{"valid HH:MM:SS 15", []DefaultHours{{Weekday: 0, StartTime: "09:15:00", EndTime: "17:15:00", IsOpen: true}}, http.StatusNoContent},
|
|
{"valid HH:MM:SS 30", []DefaultHours{{Weekday: 0, StartTime: "09:30:00", EndTime: "17:30:00", IsOpen: true}}, http.StatusNoContent},
|
|
{"valid HH:MM:SS 45", []DefaultHours{{Weekday: 0, StartTime: "09:45:00", EndTime: "17:45:00", IsOpen: true}}, http.StatusNoContent},
|
|
{"invalid start_time :07", []DefaultHours{{Weekday: 0, StartTime: "09:07", EndTime: "17:00", IsOpen: true}}, http.StatusBadRequest},
|
|
{"invalid start_time :22", []DefaultHours{{Weekday: 0, StartTime: "09:22", EndTime: "17:00", IsOpen: true}}, http.StatusBadRequest},
|
|
{"invalid end_time :59", []DefaultHours{{Weekday: 0, StartTime: "09:00", EndTime: "17:59", IsOpen: true}}, http.StatusBadRequest},
|
|
{"invalid HH:MM:SS :07", []DefaultHours{{Weekday: 0, StartTime: "09:07:00", EndTime: "17:00:00", IsOpen: true}}, http.StatusBadRequest},
|
|
{"invalid HH:MM:SS :22", []DefaultHours{{Weekday: 0, StartTime: "09:22:00", EndTime: "17:00:00", IsOpen: true}}, http.StatusBadRequest},
|
|
{"invalid HH:MM:SS :59", []DefaultHours{{Weekday: 0, StartTime: "09:00:00", EndTime: "17:59:00", IsOpen: true}}, http.StatusBadRequest},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
w := makeAuthRequest(handler, "PUT", "/api/scheduling/default-hours", adminToken, tt.hours, ctx)
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("expected status %d, got %d. body: %s", tt.wantStatus, w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestScheduling_CreateExceptionalGroup_InvalidTimes verifies that CreateExceptionalGroup
|
|
// rejects start_time/end_time with minutes not in {00, 15, 30, 45}.
|
|
func TestScheduling_CreateExceptionalGroup_InvalidTimes(t *testing.T) {
|
|
handler := http.HandlerFunc(CreateExceptionalGroup)
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
baseHours := func() []ExceptionalHours {
|
|
return []ExceptionalHours{
|
|
{Weekday: 0, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 1, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 2, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 3, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 4, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false},
|
|
}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
modify func([]ExceptionalHours) []ExceptionalHours
|
|
wantStatus int
|
|
}{
|
|
{"valid times (00,15,30,45)", func(h []ExceptionalHours) []ExceptionalHours { return h }, http.StatusCreated},
|
|
{"valid HH:MM:SS (00,15,30,45)", func(h []ExceptionalHours) []ExceptionalHours {
|
|
h[0].StartTime = "08:00:00"
|
|
h[0].EndTime = "18:00:00"
|
|
h[1].StartTime = "09:15:00"
|
|
h[1].EndTime = "17:30:00"
|
|
return h
|
|
}, http.StatusCreated},
|
|
{"invalid start_time :07", func(h []ExceptionalHours) []ExceptionalHours { h[0].StartTime = "09:07"; return h }, http.StatusBadRequest},
|
|
{"invalid end_time :22", func(h []ExceptionalHours) []ExceptionalHours { h[0].EndTime = "17:22"; return h }, http.StatusBadRequest},
|
|
{"invalid start_time :59", func(h []ExceptionalHours) []ExceptionalHours { h[1].StartTime = "10:59"; return h }, http.StatusBadRequest},
|
|
{"invalid HH:MM:SS :07", func(h []ExceptionalHours) []ExceptionalHours {
|
|
h[0].StartTime = "09:07:00"
|
|
return h
|
|
}, http.StatusBadRequest},
|
|
{"invalid HH:MM:SS :59", func(h []ExceptionalHours) []ExceptionalHours {
|
|
h[1].EndTime = "18:59:00"
|
|
return h
|
|
}, http.StatusBadRequest},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
hours := tt.modify(baseHours())
|
|
group := ExceptionalGroup{
|
|
Name: "Test Group",
|
|
Description: "Test",
|
|
Hours: hours,
|
|
WeekStarts: []string{"2026-06-01"},
|
|
}
|
|
w := makeAuthRequest(handler, "POST", "/api/scheduling/exceptional-groups", adminToken, group, ctx)
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("expected status %d, got %d. body: %s", tt.wantStatus, w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Tests for DeleteExceptionalGroup ---
|
|
|
|
// TestScheduling_DeleteExceptionalGroup_Admin tests that an admin can delete
|
|
// an exceptional working hours group. This removes the group and its associated
|
|
// hours from the system.
|
|
func TestScheduling_DeleteExceptionalGroup_Admin(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
// Create a group to delete
|
|
var groupID int
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO exceptional_working_hours_groups (name, description)
|
|
VALUES ('To Delete', 'Will be deleted')
|
|
RETURNING id
|
|
`).Scan(&groupID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create group: %v", err)
|
|
}
|
|
|
|
handler := http.HandlerFunc(DeleteExceptionalGroup)
|
|
// Use proper URL query with strconv.Itoa
|
|
req := httptest.NewRequest("DELETE", "/api/scheduling/exceptional-groups?id="+strconv.Itoa(groupID), nil)
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify group was deleted
|
|
var count int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM exceptional_working_hours_groups WHERE id = $1`, groupID).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("failed to check group: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Error("expected group to be deleted")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_DeleteExceptionalGroup_NonAdmin verifies that non-admin
|
|
// users receive HTTP 403 when attempting to delete exceptional groups.
|
|
func TestScheduling_DeleteExceptionalGroup_NonAdmin(t *testing.T) {
|
|
t.Parallel()
|
|
_, _ = resetTestData(t)
|
|
|
|
userToken := jwt.GenerateUserToken("user-123")
|
|
|
|
handler := mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(DeleteExceptionalGroup)))
|
|
req := httptest.NewRequest("DELETE", "/api/scheduling/exceptional-groups?id=1", nil)
|
|
req.Header.Set("Authorization", "Bearer "+userToken)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// --- Tests for GetWorkingHours ---
|
|
|
|
// TestScheduling_GetWorkingHours verifies that working hours can be
|
|
// retrieved for a given date range. The response includes whether hours come
|
|
// from default schedule or exceptional groups.
|
|
func TestScheduling_GetWorkingHours(t *testing.T) {
|
|
t.Parallel()
|
|
_, _ = resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GetWorkingHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayWorkingHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if len(response) == 0 {
|
|
t.Error("expected working hours in response")
|
|
}
|
|
|
|
// Verify source is "default" for seeded hours
|
|
for _, day := range response {
|
|
if day.Source != "default" {
|
|
t.Errorf("expected source 'default', got %s", day.Source)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Tests for GetAvailableHours ---
|
|
|
|
// TestScheduling_GetAvailableHours tests that available appointment
|
|
// slots can be calculated for a date range based on working hours and service
|
|
// durations.
|
|
func TestScheduling_GetAvailableHours(t *testing.T) {
|
|
t.Parallel()
|
|
_, _ = resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GetAvailableHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayAvailableHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if len(response) == 0 {
|
|
t.Error("expected available hours in response")
|
|
}
|
|
|
|
// Verify we have slots for open days
|
|
for _, day := range response {
|
|
if day.IsOpen {
|
|
if len(day.Slots) == 0 {
|
|
t.Error("expected slots for open days")
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetWorkingHours_OutOfHours_Admin verifies that when an admin
|
|
// calls GetWorkingHours with out_of_hours=true, ALL days return isOpen=true
|
|
// with startTime=06:00 and endTime=22:00 (including normally-closed days).
|
|
func TestScheduling_GetWorkingHours_OutOfHours_Admin(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GetWorkingHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
|
|
req = req.WithContext(ctx)
|
|
// Set admin role in context (simulates OptionalAuth setting the role)
|
|
req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin"))
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayWorkingHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if len(response) == 0 {
|
|
t.Fatal("expected working hours in response")
|
|
}
|
|
|
|
for _, day := range response {
|
|
if !day.IsOpen {
|
|
t.Errorf("expected all days to be open with out_of_hours, got isOpen=false for %s", day.Date)
|
|
}
|
|
if day.StartTime != "06:00" {
|
|
t.Errorf("expected startTime=06:00 for %s, got %s", day.Date, day.StartTime)
|
|
}
|
|
if day.EndTime != "22:00" {
|
|
t.Errorf("expected endTime=22:00 for %s, got %s", day.Date, day.EndTime)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetWorkingHours_OutOfHours_NonAdmin verifies that when a
|
|
// non-admin calls GetWorkingHours with out_of_hours=true, the flag is silently
|
|
// ignored and normal hours are returned.
|
|
func TestScheduling_GetWorkingHours_OutOfHours_NonAdmin(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GetWorkingHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
|
|
req = req.WithContext(ctx)
|
|
// Set non-admin role
|
|
req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "user"))
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayWorkingHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if len(response) == 0 {
|
|
t.Fatal("expected working hours in response")
|
|
}
|
|
|
|
for _, day := range response {
|
|
if day.StartTime == "06:00" {
|
|
t.Errorf("non-admin should not get out_of_hours hours, got startTime=06:00 for %s", day.Date)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetWorkingHours_OutOfHours_NoAuth verifies that when no auth
|
|
// context is present (unauthenticated user), out_of_hours=true is silently ignored.
|
|
func TestScheduling_GetWorkingHours_OutOfHours_NoAuth(t *testing.T) {
|
|
t.Parallel()
|
|
_, _ = resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GetWorkingHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayWorkingHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
for _, day := range response {
|
|
if day.StartTime == "06:00" {
|
|
t.Errorf("unauthenticated user should not get out_of_hours hours, got startTime=06:00 for %s", day.Date)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_OutOfHours_Admin verifies that when an
|
|
// admin calls GetAvailableHours with out_of_hours=true, slots are generated
|
|
// for ALL days (including normally-closed ones like weekends).
|
|
func TestScheduling_GetAvailableHours_OutOfHours_Admin(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GetAvailableHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
|
|
req = req.WithContext(ctx)
|
|
req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin"))
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayAvailableHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if len(response) == 0 {
|
|
t.Fatal("expected available hours in response")
|
|
}
|
|
|
|
for _, day := range response {
|
|
if !day.IsOpen {
|
|
t.Errorf("expected all days isOpen=true with out_of_hours, got isOpen=false for %s", day.Date)
|
|
}
|
|
if len(day.Slots) == 0 {
|
|
t.Errorf("expected slots for all days with out_of_hours, got none for %s", day.Date)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_OutOfHours_NonAdmin verifies that when a
|
|
// non-admin calls GetAvailableHours with out_of_hours=true, the flag is ignored
|
|
// and normal availability is returned (closed days have no slots).
|
|
func TestScheduling_GetAvailableHours_OutOfHours_NonAdmin(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GetAvailableHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
|
|
req = req.WithContext(ctx)
|
|
req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "user"))
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayAvailableHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
for _, day := range response {
|
|
// Closed days should still be closed for non-admin even with out_of_hours flag
|
|
if !day.IsOpen && len(day.Slots) > 0 {
|
|
t.Errorf("non-admin should not get slots for closed day %s", day.Date)
|
|
}
|
|
// All source should be "default" not "out_of_hours"
|
|
if day.Source == "out_of_hours" {
|
|
t.Errorf("non-admin should not get source=out_of_hours for %s", day.Date)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_OutOfHours_RespectsBookings verifies that
|
|
// out-of-hours mode still subtracts existing bookings from available slots.
|
|
// Ensuring the available-hours response is the source of truth for slot data.
|
|
func TestScheduling_GetAvailableHours_OutOfHours_RespectsBookings(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Create a user + booking starting at 09:00 for 60 min on a weekday
|
|
var userID, serviceID string
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
|
|
VALUES ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
|
|
RETURNING id
|
|
`).Scan(&userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO services (name, price, duration_minutes)
|
|
VALUES ('Test Service', 10, 60)
|
|
RETURNING id
|
|
`).Scan(&serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
// Create a booking on Tuesday 2026-02-17 at 09:00, 60min (blocks 09:00-10:00)
|
|
bookingTime := time.Date(2026, 2, 17, 9, 0, 0, 0, time.UTC)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, created_at)
|
|
VALUES ($1, $2, 'confirmed', NOW())
|
|
`, userID, bookingTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
// Add booking service so end_time trigger computes correctly
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO booking_services (booking_id, service_id)
|
|
VALUES ((SELECT id FROM bookings WHERE user_id = $1 AND start_time = $2), $3)
|
|
`, userID, bookingTime, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to link booking service: %v", err)
|
|
}
|
|
|
|
// Call GetAvailableHours with out_of_hours=true for a range including Tuesday
|
|
handler := http.HandlerFunc(GetAvailableHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
|
|
req = req.WithContext(ctx)
|
|
req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin"))
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayAvailableHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
// Find Tuesday 2026-02-17 and verify the 09:00-10:00 slot is excluded
|
|
var tuesday DayAvailableHours
|
|
for _, day := range response {
|
|
if day.Date == "2026-02-17" {
|
|
tuesday = day
|
|
break
|
|
}
|
|
}
|
|
if tuesday.Date == "" {
|
|
t.Fatal("expected Tuesday 2026-02-17 in response")
|
|
}
|
|
if !tuesday.IsOpen {
|
|
t.Fatal("expected Tuesday to be open with out_of_hours")
|
|
}
|
|
|
|
// Verify no slot starts between 09:00 and 10:00 (the booking blocks it)
|
|
for _, slot := range tuesday.Slots {
|
|
startMin := timeToMinutesForTest(slot.StartTime)
|
|
if startMin >= 540 && startMin < 600 { // 09:00-10:00 in minutes
|
|
t.Errorf("expected booking at 09:00 to block slots, but found slot at %s", slot.StartTime)
|
|
}
|
|
}
|
|
|
|
// Verify we still have slots outside the booking window (e.g. 06:00-09:00)
|
|
hasPreBookingSlot := false
|
|
for _, slot := range tuesday.Slots {
|
|
startMin := timeToMinutesForTest(slot.StartTime)
|
|
if startMin < 540 { // before 09:00
|
|
hasPreBookingSlot = true
|
|
break
|
|
}
|
|
}
|
|
if !hasPreBookingSlot {
|
|
t.Error("expected slots before 09:00 (pre-booking) with out_of_hours on Tuesday")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_OutOfHours_ExceptionalOpen verifies that
|
|
// out-of-hours mode correctly reflects exceptional hours data - the available
|
|
// slots come from available-hours API (which includes exceptional hours adjustments),
|
|
// not just from the default 06:00-22:00 range.
|
|
func TestScheduling_GetAvailableHours_OutOfHours_ExceptionalOpen(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
today := clock.Now()
|
|
weekday := int(today.Weekday())
|
|
if weekday == 0 {
|
|
weekday = 6
|
|
} else {
|
|
weekday -= 1
|
|
}
|
|
|
|
// Override working hours - today is closed by default
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
|
|
VALUES ($1, '09:00', '17:00', false)
|
|
ON CONFLICT (weekday) DO UPDATE SET start_time = '09:00', end_time = '17:00', is_open = false
|
|
`, weekday)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed working hours: %v", err)
|
|
}
|
|
|
|
// Add exceptional open hours for today (09:00-13:00, open)
|
|
daysSinceMonday := int(today.Weekday()) - 1
|
|
if daysSinceMonday < 0 {
|
|
daysSinceMonday = 6
|
|
}
|
|
monday := today.AddDate(0, 0, -daysSinceMonday)
|
|
mondayStr := monday.Format("2006-01-02")
|
|
|
|
var groupID int
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO exceptional_working_hours_groups (name, description)
|
|
VALUES ('Test Holiday', 'Exceptional open day')
|
|
RETURNING id
|
|
`).Scan(&groupID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create group: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
|
VALUES ($1, $2, '09:00', '13:00', true)
|
|
`, groupID, weekday)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed exceptional hours: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO exceptional_group_applications (group_id, week_start)
|
|
VALUES ($1, $2::date)
|
|
`, groupID, mondayStr)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed application: %v", err)
|
|
}
|
|
|
|
// Call normal GetAvailableHours (no out_of_hours) - should return slots based on exceptional hours
|
|
handler := http.HandlerFunc(GetAvailableHours)
|
|
todayStr := today.Format("2006-01-02")
|
|
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start="+todayStr+"&end="+todayStr, nil)
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("normal request failed: %d", w.Code)
|
|
}
|
|
var normalResponse []DayAvailableHours
|
|
json.Unmarshal(w.Body.Bytes(), &normalResponse)
|
|
|
|
// Call with out_of_hours=true - should still generate 06:00-22:00 range
|
|
req2 := httptest.NewRequest("GET", "/api/scheduling/available-hours?start="+todayStr+"&end="+todayStr+"&out_of_hours=true", nil)
|
|
req2 = req2.WithContext(ctx)
|
|
req2 = req2.WithContext(context.WithValue(req2.Context(), mw.UserRoleKey, "admin"))
|
|
w2 := httptest.NewRecorder()
|
|
handler.ServeHTTP(w2, req2)
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("out_of_hours request failed: %d", w2.Code)
|
|
}
|
|
var oohResponse []DayAvailableHours
|
|
json.Unmarshal(w2.Body.Bytes(), &oohResponse)
|
|
|
|
// Normal request should have 09:00-13:00 range (exceptional hours) or empty if closed
|
|
// Out-of-hours request should have 06:00-22:00 range
|
|
if len(oohResponse) > 0 {
|
|
day := oohResponse[0]
|
|
if !day.IsOpen {
|
|
t.Error("expected out_of_hours to make day open")
|
|
}
|
|
if len(day.Slots) == 0 {
|
|
t.Error("expected out_of_hours to generate slots")
|
|
}
|
|
// Verify we have pre-09:00 slots (6am-9am) which are only available in out_of_hours mode
|
|
hasEarlySlot := false
|
|
for _, slot := range day.Slots {
|
|
if timeToMinutesForTest(slot.StartTime) < 540 { // before 09:00
|
|
hasEarlySlot = true
|
|
break
|
|
}
|
|
}
|
|
if !hasEarlySlot {
|
|
t.Error("expected out_of_hours slots before 09:00 (pre-exceptional-hours)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper to convert time string to minutes for test assertions
|
|
func timeToMinutesForTest(time string) int {
|
|
parts := strings.Split(time, ":")
|
|
if len(parts) < 2 {
|
|
return 0
|
|
}
|
|
h, _ := strconv.Atoi(parts[0])
|
|
m, _ := strconv.Atoi(parts[1])
|
|
return h*60 + m
|
|
}
|
|
|
|
// TestScheduling_UpdateExceptionalApplications_Admin verifies that an
|
|
// admin can apply an exceptional hours group to specific weeks, activating
|
|
// holiday schedules for those periods.
|
|
func TestScheduling_UpdateExceptionalApplications_Admin(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
// Create a group
|
|
var groupID int
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO exceptional_working_hours_groups (name, description)
|
|
VALUES ('Test Group', 'Test')
|
|
RETURNING id
|
|
`).Scan(&groupID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create group: %v", err)
|
|
}
|
|
|
|
handler := http.HandlerFunc(UpdateExceptionalApplications)
|
|
|
|
reqBody := map[string]interface{}{
|
|
"groupId": groupID,
|
|
"weekStarts": []string{"2026-03-02", "2026-03-09"},
|
|
}
|
|
|
|
w := makeAuthRequest(handler, "PUT", "/api/scheduling/exceptional-applications", adminToken, reqBody, ctx)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify applications were created
|
|
var count int
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM exceptional_group_applications WHERE group_id = $1
|
|
`, groupID).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("failed to check applications: %v", err)
|
|
}
|
|
if count != 2 {
|
|
t.Errorf("expected 2 applications, got %d", count)
|
|
}
|
|
}
|
|
|
|
// TestScheduling_UpdateExceptionalApplications_NonAdmin verifies that
|
|
// non-admin users receive HTTP 403 when attempting to apply exceptional hours.
|
|
func TestScheduling_UpdateExceptionalApplications_NonAdmin(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
userToken := jwt.GenerateUserToken("user-123")
|
|
handler := mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(UpdateExceptionalApplications)))
|
|
|
|
reqBody := map[string]interface{}{
|
|
"groupId": 1,
|
|
"weekStarts": []string{"2026-03-02"},
|
|
}
|
|
|
|
w := makeAuthRequest(handler, "PUT", "/api/scheduling/exceptional-applications", userToken, reqBody, ctx)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Time Blocker Tests for GetAvailableHours
|
|
// =============================================================================
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_NonAdmin verifies that non-admin
|
|
// users do NOT see blocked time slots in their available hours.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Create a time blocker for 2026-03-16 10:00-11:00 (Monday - an open day)
|
|
blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC)
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'Staff Meeting', NULL)
|
|
`, blockerTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to create time blocker: %v", err)
|
|
}
|
|
|
|
// Make request as non-admin user
|
|
handler := http.HandlerFunc(GetAvailableHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-16&end=2026-03-16", nil)
|
|
|
|
// Set non-admin context
|
|
reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001")
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
|
|
req = req.WithContext(reqCtx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayAvailableHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if len(response) == 0 {
|
|
t.Fatal("expected at least one day in response")
|
|
}
|
|
|
|
// Find the day with the blocker (2026-03-16)
|
|
var targetDay *DayAvailableHours
|
|
for i := range response {
|
|
if response[i].Date == "2026-03-16" {
|
|
targetDay = &response[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-03-16 in response")
|
|
}
|
|
|
|
// Verify blocker is NOT visible in blockers field for non-admin
|
|
if len(targetDay.Blockers) > 0 {
|
|
t.Error("expected blockers field to be empty for non-admin users")
|
|
}
|
|
|
|
// Verify 10:00-11:00 slot is NOT available (subtracted due to blocker)
|
|
for _, slot := range targetDay.Slots {
|
|
if slot.StartTime == "10:00" {
|
|
t.Error("expected 10:00 slot to be blocked and not available")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin verifies that admin users
|
|
// see blocked time slots subtracted from available slots AND visible in the
|
|
// blockers field for warning display.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Create a time blocker for 2026-03-16 10:00-11:00 (Monday - open day)
|
|
blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC)
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'Staff Meeting', NULL)
|
|
`, blockerTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to create time blocker: %v", err)
|
|
}
|
|
|
|
// Make request as admin user
|
|
handler := http.HandlerFunc(GetAvailableHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-16&end=2026-03-16", nil)
|
|
|
|
// Set admin context
|
|
reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001")
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
|
|
req = req.WithContext(reqCtx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayAvailableHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if len(response) == 0 {
|
|
t.Fatal("expected at least one day in response")
|
|
}
|
|
|
|
// Find the day with the blocker (2026-03-16)
|
|
var targetDay *DayAvailableHours
|
|
for i := range response {
|
|
if response[i].Date == "2026-03-16" {
|
|
targetDay = &response[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-03-16 in response")
|
|
}
|
|
|
|
// Verify 10:00-11:00 slot is NOT available in slots (subtracted due to blocker)
|
|
for _, slot := range targetDay.Slots {
|
|
if slot.StartTime == "10:00" {
|
|
t.Error("expected 10:00 slot to be blocked and not available for admin users")
|
|
}
|
|
}
|
|
|
|
// Verify blocker IS visible in blockers field for admin (warning display)
|
|
if len(targetDay.Blockers) == 0 {
|
|
t.Error("expected blockers field to contain the blocker for admin users")
|
|
} else {
|
|
// Verify the blocker time range
|
|
found := false
|
|
for _, blocker := range targetDay.Blockers {
|
|
if blocker.StartTime == "10:00" && blocker.EndTime == "11:00" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected blocker 10:00-11:00 in blockers field")
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Direct unit tests for isValidTime15Min ---
|
|
|
|
// TestIsValidTime15Min tests the time validation helper directly for all
|
|
// supported formats (HH:MM, HH:MM:SS) and edge cases.
|
|
func TestIsValidTime15Min(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
time string
|
|
valid bool
|
|
}{
|
|
{"HH:MM 00", "09:00", true},
|
|
{"HH:MM 15", "09:15", true},
|
|
{"HH:MM 30", "09:30", true},
|
|
{"HH:MM 45", "09:45", true},
|
|
{"HH:MM :01", "09:01", false},
|
|
{"HH:MM :07", "09:07", false},
|
|
{"HH:MM :22", "09:22", false},
|
|
{"HH:MM :59", "09:59", false},
|
|
{"HH:MM:SS 00", "09:00:00", true},
|
|
{"HH:MM:SS 15", "09:15:00", true},
|
|
{"HH:MM:SS 30", "09:30:00", true},
|
|
{"HH:MM:SS 45", "09:45:00", true},
|
|
{"HH:MM:SS :01", "09:01:00", false},
|
|
{"HH:MM:SS :07", "09:07:00", false},
|
|
{"HH:MM:SS :22", "09:22:00", false},
|
|
{"HH:MM:SS :59", "09:59:00", false},
|
|
{"single-digit hour valid", "9:00", true},
|
|
{"single-digit hour invalid", "9:07", false},
|
|
{"midnight 00:00", "00:00", true},
|
|
{"midnight 00:00:00", "00:00:00", true},
|
|
{"empty string", "", false},
|
|
{"not a time", "abc", false},
|
|
{"single colon only", ":", false},
|
|
{"only colon numbers", ":15", false},
|
|
{"extra parts", "09:00:00:00", false},
|
|
{"hour only", "09", false},
|
|
{"garbage after colon", "09:xx", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := isValidTime15Min(tt.time)
|
|
if got != tt.valid {
|
|
if tt.valid {
|
|
t.Errorf("isValidTime15Min(%q) = false, want true", tt.time)
|
|
} else {
|
|
t.Errorf("isValidTime15Min(%q) = true, want false", tt.time)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Exhaustive Admin Time Blocker Tests
|
|
// =============================================================================
|
|
//
|
|
// These tests verify that time blockers are subtracted from available slots for
|
|
// ALL users (including admins), preventing 409 "blocked" errors on reserve.
|
|
// Blockers are also surfaced in the blockers field for admin warning display.
|
|
|
|
// makeAdminAvailableHoursRequest is a helper that sends a GET to
|
|
// /api/scheduling/available-hours as an admin user and returns the response.
|
|
func makeAdminAvailableHoursRequest(t *testing.T, ctx context.Context, start, end string) []DayAvailableHours {
|
|
t.Helper()
|
|
handler := http.HandlerFunc(GetAvailableHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start="+start+"&end="+end, nil)
|
|
reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001")
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
|
|
req = req.WithContext(reqCtx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
var response []DayAvailableHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
return response
|
|
}
|
|
|
|
// findDayByDate finds a DayAvailableHours by date string in the response.
|
|
func findDayByDate(response []DayAvailableHours, date string) *DayAvailableHours {
|
|
for i := range response {
|
|
if response[i].Date == date {
|
|
return &response[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// slotExists checks if a time falls within any available slot range.
|
|
// Slots are continuous ranges (e.g. {StartTime:09:00, EndTime:17:00}) and the
|
|
// frontend generates 15-min intervals from them. A time is "available" if it
|
|
// falls at or after a slot's StartTime and before its EndTime.
|
|
func slotExists(slots []TimeSlot, time string) bool {
|
|
for _, s := range slots {
|
|
if time >= s.StartTime && time < s.EndTime {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getWorkingHoursForDate queries the working hours for the given date via the
|
|
// GetWorkingHours handler, using the admin context so the returned context works
|
|
// with the test transaction. Returns the hours for the specified date, or empty
|
|
// strings / false if the day is closed.
|
|
func getWorkingHoursForDate(t *testing.T, ctx context.Context, date string) (start, end string, isOpen bool) {
|
|
t.Helper()
|
|
handler := http.HandlerFunc(GetWorkingHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start="+date+"&end="+date, nil)
|
|
reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001")
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
|
|
req = req.WithContext(reqCtx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetWorkingHours returned %d: %s", w.Code, w.Body.String())
|
|
}
|
|
type whDay struct {
|
|
Date string `json:"date"`
|
|
IsOpen bool `json:"isOpen"`
|
|
StartTime string `json:"startTime"`
|
|
EndTime string `json:"endTime"`
|
|
}
|
|
var days []whDay
|
|
if err := json.Unmarshal(w.Body.Bytes(), &days); err != nil {
|
|
t.Fatalf("failed to unmarshal working hours: %v", err)
|
|
}
|
|
for _, d := range days {
|
|
if d.Date == date {
|
|
return d.StartTime, d.EndTime, d.IsOpen
|
|
}
|
|
}
|
|
t.Fatalf("date %s not found in working hours response", date)
|
|
return "", "", false
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_MultipleBlockers tests
|
|
// that multiple time blockers on the same day are ALL subtracted from admin slots.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultipleBlockers(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Create two blockers on Tuesday 2026-03-17 (open 09:00-17:00):
|
|
// 10:00-11:00 (Staff Meeting) and 14:00-15:00 (Training)
|
|
b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
|
|
b2 := time.Date(2026, 3, 17, 14, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff Meeting', NULL)`, b1)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Training', NULL)`, b2)
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
|
|
targetDay := findDayByDate(response, "2026-03-17")
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-03-17 in response")
|
|
}
|
|
|
|
// Both blocked slots should be absent from available slots
|
|
if slotExists(targetDay.Slots, "10:00") {
|
|
t.Error("expected 10:00 slot to be blocked (Staff Meeting)")
|
|
}
|
|
if slotExists(targetDay.Slots, "14:00") {
|
|
t.Error("expected 14:00 slot to be blocked (Training)")
|
|
}
|
|
|
|
// Non-blocked slots should still be available (e.g. 09:00, 11:00, 13:00, 15:00)
|
|
if !slotExists(targetDay.Slots, "09:00") {
|
|
t.Error("expected 09:00 slot to remain available")
|
|
}
|
|
if !slotExists(targetDay.Slots, "11:00") {
|
|
t.Error("expected 11:00 slot to remain available after 10-11 blocker")
|
|
}
|
|
if !slotExists(targetDay.Slots, "15:00") {
|
|
t.Error("expected 15:00 slot to remain available after 14-15 blocker")
|
|
}
|
|
|
|
// Both blockers visible in blockers field for admin warning display
|
|
if len(targetDay.Blockers) != 2 {
|
|
t.Errorf("expected 2 blockers, got %d", len(targetDay.Blockers))
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_BlockerAndBooking tests
|
|
// that both time blockers AND existing bookings are subtracted from admin slots.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_BlockerAndBooking(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Tuesday 2026-03-17 (open 09:00-17:00)
|
|
// Create a booking at 11:00-12:00 and a blocker at 14:00-15:00
|
|
bookingStart := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
|
|
bookingEnd := bookingStart.Add(60 * time.Minute)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time)
|
|
VALUES ($1, $2, 'confirmed', 60, $3)
|
|
`, userID, bookingStart, bookingEnd)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
blockerTime := time.Date(2026, 3, 17, 14, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Equipment Maintenance', NULL)`, blockerTime)
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
|
|
targetDay := findDayByDate(response, "2026-03-17")
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-03-17 in response")
|
|
}
|
|
|
|
// 11:00 slot removed by booking, 14:00 slot removed by blocker
|
|
if slotExists(targetDay.Slots, "11:00") {
|
|
t.Error("expected 11:00 slot to be unavailable (booking)")
|
|
}
|
|
if slotExists(targetDay.Slots, "14:00") {
|
|
t.Error("expected 14:00 slot to be blocked (Equipment Maintenance)")
|
|
}
|
|
|
|
// Unaffected slots remain
|
|
if !slotExists(targetDay.Slots, "09:00") {
|
|
t.Error("expected 09:00 to remain available")
|
|
}
|
|
if !slotExists(targetDay.Slots, "10:00") {
|
|
t.Error("expected 10:00 to remain available")
|
|
}
|
|
|
|
// Only the blocker (not the booking) appears in blockers field
|
|
foundBlocker := false
|
|
for _, b := range targetDay.Blockers {
|
|
if b.StartTime == "14:00" && b.EndTime == "15:00" {
|
|
foundBlocker = true
|
|
}
|
|
}
|
|
if !foundBlocker {
|
|
t.Error("expected blocker 14:00-15:00 in blockers field")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_AllDayBlocker tests that
|
|
// a blocker covering the entire open period leaves no slots available for admin.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_AllDayBlocker(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Tuesday 2026-03-17 (open 09:00-17:00) — block entire open period
|
|
blockerTime := time.Date(2026, 3, 17, 9, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 480, 'All day closure', NULL)`, blockerTime)
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
|
|
targetDay := findDayByDate(response, "2026-03-17")
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-03-17 in response")
|
|
}
|
|
|
|
// All slots should be removed
|
|
if len(targetDay.Slots) > 0 {
|
|
t.Errorf("expected no available slots with all-day blocker, got %d slots: %+v", len(targetDay.Slots), targetDay.Slots)
|
|
}
|
|
|
|
// Blocker is visible in blockers field
|
|
if len(targetDay.Blockers) == 0 {
|
|
t.Error("expected all-day blocker in blockers field")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_NonOverlappingBlocker
|
|
// tests that a blocker outside open hours does NOT affect admin slot availability.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_NonOverlappingBlocker(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 17:00-18:00 (after close)
|
|
blockerTime := time.Date(2026, 3, 17, 17, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'After hours cleaning', NULL)`, blockerTime)
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
|
|
targetDay := findDayByDate(response, "2026-03-17")
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-03-17 in response")
|
|
}
|
|
|
|
// All normal slots should still be present
|
|
expectedSlots := []string{"09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00", "16:00"}
|
|
for _, expected := range expectedSlots {
|
|
if !slotExists(targetDay.Slots, expected) {
|
|
t.Errorf("expected slot %s to remain available (non-overlapping blocker)", expected)
|
|
}
|
|
}
|
|
|
|
// After-hours blocker still visible in blockers field
|
|
found := false
|
|
for _, b := range targetDay.Blockers {
|
|
if b.StartTime == "17:00" && b.EndTime == "18:00" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected after-hours blocker in blockers field")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDay tests that
|
|
// blockers on multiple days are independently subtracted for admin users.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDay(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Blockers on Tue 2026-03-17 10:00-11:00 and Wed 2026-03-18 14:00-15:00
|
|
b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
|
|
b2 := time.Date(2026, 3, 18, 14, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Tue Meeting', NULL)`, b1)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Wed Training', NULL)`, b2)
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18")
|
|
tue := findDayByDate(response, "2026-03-17")
|
|
wed := findDayByDate(response, "2026-03-18")
|
|
|
|
if tue == nil {
|
|
t.Fatal("expected 2026-03-17 in response")
|
|
}
|
|
if wed == nil {
|
|
t.Fatal("expected 2026-03-18 in response")
|
|
}
|
|
|
|
// Tuesday: 10:00 slot blocked
|
|
if slotExists(tue.Slots, "10:00") {
|
|
t.Error("expected 2026-03-17 10:00 slot to be blocked")
|
|
}
|
|
// Wednesday: 14:00 slot blocked
|
|
if slotExists(wed.Slots, "14:00") {
|
|
t.Error("expected 2026-03-18 14:00 slot to be blocked")
|
|
}
|
|
// Tuesday: 14:00 still available
|
|
if !slotExists(tue.Slots, "14:00") {
|
|
t.Error("expected 2026-03-17 14:00 to remain available")
|
|
}
|
|
// Wednesday: 10:00 still available
|
|
if !slotExists(wed.Slots, "10:00") {
|
|
t.Error("expected 2026-03-18 10:00 to remain available")
|
|
}
|
|
|
|
// Both days have blockers in blockers field
|
|
if len(tue.Blockers) != 1 {
|
|
t.Errorf("expected 1 blocker on Tuesday, got %d", len(tue.Blockers))
|
|
}
|
|
if len(wed.Blockers) != 1 {
|
|
t.Errorf("expected 1 blocker on Wednesday, got %d", len(wed.Blockers))
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryStart tests that
|
|
// a blocker at the exact opening time correctly removes the first slot.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryStart(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 09:00-10:00 (start of day)
|
|
blockerTime := time.Date(2026, 3, 17, 9, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning setup', NULL)`, blockerTime)
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
|
|
targetDay := findDayByDate(response, "2026-03-17")
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-03-17 in response")
|
|
}
|
|
|
|
// 09:00 slot should be blocked
|
|
if slotExists(targetDay.Slots, "09:00") {
|
|
t.Error("expected 09:00 slot to be blocked (boundary start)")
|
|
}
|
|
// 10:00 slot should be available (blocker is 09-10, ends at 10:00)
|
|
if !slotExists(targetDay.Slots, "10:00") {
|
|
t.Error("expected 10:00 slot to be available (starts after blocker ends)")
|
|
}
|
|
// Blocker visible in blockers field
|
|
found := false
|
|
for _, b := range targetDay.Blockers {
|
|
if b.StartTime == "09:00" && b.EndTime == "10:00" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected blocker 09:00-10:00 in blockers field")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryEnd tests that
|
|
// a blocker at the exact closing time correctly removes the last slot.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryEnd(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 16:00-17:00 (end of day)
|
|
blockerTime := time.Date(2026, 3, 17, 16, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'End of day cleanup', NULL)`, blockerTime)
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
|
|
targetDay := findDayByDate(response, "2026-03-17")
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-03-17 in response")
|
|
}
|
|
|
|
// 16:00 slot should be blocked
|
|
if slotExists(targetDay.Slots, "16:00") {
|
|
t.Error("expected 16:00 slot to be blocked (boundary end)")
|
|
}
|
|
// 15:00 slot should be available
|
|
if !slotExists(targetDay.Slots, "15:00") {
|
|
t.Error("expected 15:00 slot to be available (ends before blocker starts)")
|
|
}
|
|
// Blocker visible in blockers field
|
|
found := false
|
|
for _, b := range targetDay.Blockers {
|
|
if b.StartTime == "16:00" && b.EndTime == "17:00" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected blocker 16:00-17:00 in blockers field")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_OutOfHours verifies that
|
|
// blockers are still subtracted from admin slots when out_of_hours mode is active.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_OutOfHours(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Tuesday 2026-03-17 — blocker at 10:00-11:00
|
|
blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning Meeting', NULL)`, blockerTime)
|
|
|
|
// Request with out_of_hours=true (extends to 06:00-22:00 for admin)
|
|
handler := http.HandlerFunc(GetAvailableHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17&out_of_hours=true", nil)
|
|
reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001")
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
|
|
req = req.WithContext(reqCtx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var response []DayAvailableHours
|
|
json.Unmarshal(w.Body.Bytes(), &response)
|
|
targetDay := findDayByDate(response, "2026-03-17")
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-03-17 in response")
|
|
}
|
|
|
|
// 10:00 slot should be blocked even in out_of_hours mode
|
|
if slotExists(targetDay.Slots, "10:00") {
|
|
t.Error("expected 10:00 slot to be blocked in out_of_hours mode")
|
|
}
|
|
// 07:00 slot (extended hours) should be available (no blocker there)
|
|
if !slotExists(targetDay.Slots, "07:00") {
|
|
t.Error("expected 07:00 slot to be available in out_of_hours mode")
|
|
}
|
|
// Blocker visible in blockers field
|
|
if len(targetDay.Blockers) == 0 {
|
|
t.Error("expected blocker in blockers field for out_of_hours admin")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_Regression verifies that
|
|
// non-admin users still have blockers correctly subtracted (regression check).
|
|
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_Regression(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Tuesday 2026-03-17 — blocker at 10:00-11:00
|
|
blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff Meeting', NULL)`, blockerTime)
|
|
|
|
// Request as non-admin
|
|
handler := http.HandlerFunc(GetAvailableHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil)
|
|
reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001")
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
|
|
req = req.WithContext(reqCtx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var response []DayAvailableHours
|
|
json.Unmarshal(w.Body.Bytes(), &response)
|
|
targetDay := findDayByDate(response, "2026-03-17")
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-03-17 in response")
|
|
}
|
|
|
|
// 10:00 slot blocked for non-admin
|
|
if slotExists(targetDay.Slots, "10:00") {
|
|
t.Error("expected 10:00 slot to be blocked for non-admin user")
|
|
}
|
|
// 09:00 still available
|
|
if !slotExists(targetDay.Slots, "09:00") {
|
|
t.Error("expected 09:00 slot to remain available for non-admin user")
|
|
}
|
|
// Non-admin should NOT see blockers in blockers field
|
|
if len(targetDay.Blockers) != 0 {
|
|
t.Error("expected blockers field to be empty for non-admin user")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Gap-Filling Tests — Blockers with Recurring, Reservations, Overlaps, etc.
|
|
// =============================================================================
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_Recurring verifies that a
|
|
// recurring (cron-based) time blocker is expanded and subtracted from admin slots.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_Recurring(t *testing.T) {
|
|
// No t.Parallel() — GetAvailableHours cleanup operations can deadlock with
|
|
// concurrent test transactions on the shared test database.
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Daily recurring blocker 12:00-13:00 starting Mon 2026-03-16
|
|
startTime := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)
|
|
cronExpr := "0 12 * * *" // Every day at 12:00
|
|
tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by)
|
|
VALUES ($1, 60, 'Daily Lunch Blocker', $2, NULL)
|
|
`, startTime, cronExpr)
|
|
|
|
// Query Tue 2026-03-17 (open 09:00-17:00) and Wed 2026-03-18 (open 09:00-17:00)
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18")
|
|
tue := findDayByDate(response, "2026-03-17")
|
|
wed := findDayByDate(response, "2026-03-18")
|
|
if tue == nil || wed == nil {
|
|
t.Fatal("expected both days in response")
|
|
}
|
|
|
|
// 12:00 slot should be blocked on both days
|
|
if slotExists(tue.Slots, "12:00") {
|
|
t.Error("expected 12:00 blocked on Tuesday (recurring blocker)")
|
|
}
|
|
if slotExists(wed.Slots, "12:00") {
|
|
t.Error("expected 12:00 blocked on Wednesday (recurring blocker)")
|
|
}
|
|
// Adjacent slots should be available
|
|
if !slotExists(tue.Slots, "11:00") {
|
|
t.Error("expected 11:00 available on Tuesday")
|
|
}
|
|
if !slotExists(wed.Slots, "13:00") {
|
|
t.Error("expected 13:00 available on Wednesday")
|
|
}
|
|
// Both days should show blocker in blockers field
|
|
if len(tue.Blockers) == 0 {
|
|
t.Error("expected blocker visible in blockers field on Tuesday")
|
|
}
|
|
if len(wed.Blockers) == 0 {
|
|
t.Error("expected blocker visible in blockers field on Wednesday")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation verifies that
|
|
// RESERVATION:admin time_blocker entries are also subtracted from admin slots.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Create a real admin user to satisfy FK constraint, then simulate a reservation
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, adminID)
|
|
|
|
reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 30, 'RESERVATION:admin:callin:guest:1712345678', $2)
|
|
`, reservationTime, adminID); err != nil {
|
|
t.Fatalf("failed to create reservation blocker: %v", err)
|
|
}
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
|
|
targetDay := findDayByDate(response, "2026-03-17")
|
|
if targetDay == nil {
|
|
t.Fatal("expected 2026-03-17 in response")
|
|
}
|
|
|
|
// 10:00 slot should be blocked by the reservation
|
|
if slotExists(targetDay.Slots, "10:00") {
|
|
t.Error("expected 10:00 slot blocked by admin reservation")
|
|
}
|
|
// 09:00 and 11:00 should still be available
|
|
if !slotExists(targetDay.Slots, "09:00") {
|
|
t.Error("expected 09:00 to remain available")
|
|
}
|
|
if !slotExists(targetDay.Slots, "11:00") {
|
|
t.Error("expected 11:00 to remain available")
|
|
}
|
|
// Reservation should be visible in blockers field
|
|
if len(targetDay.Blockers) == 0 {
|
|
t.Error("expected reservation in blockers field for admin")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation_NonAdmin verifies
|
|
// that RESERVATION entries are also subtracted for non-admin users.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation_NonAdmin(t *testing.T) {
|
|
// Not parallel (see above)
|
|
ctx, tx := resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, adminID)
|
|
|
|
reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 30, 'RESERVATION:admin:callin:guest:1712345678', $2)
|
|
`, reservationTime, adminID); err != nil {
|
|
t.Fatalf("failed to create reservation blocker: %v", err)
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetAvailableHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil)
|
|
reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001")
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
|
|
req = req.WithContext(reqCtx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var response []DayAvailableHours
|
|
json.Unmarshal(w.Body.Bytes(), &response)
|
|
targetDay := findDayByDate(response, "2026-03-17")
|
|
if targetDay == nil {
|
|
t.Fatal("expected 2026-03-17 in response")
|
|
}
|
|
|
|
if slotExists(targetDay.Slots, "10:00") {
|
|
t.Error("expected 10:00 slot blocked by reservation for non-admin")
|
|
}
|
|
if len(targetDay.Blockers) != 0 {
|
|
t.Error("expected blockers field empty for non-admin")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_OverlappingBlockers verifies
|
|
// that two time blockers that overlap each other are both correctly subtracted.
|
|
// Two gaps applied in sequence are equivalent to their union.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_OverlappingBlockers(t *testing.T) {
|
|
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Two overlapping blockers on Tue 2026-03-17: 10:00-12:00 and 11:00-13:00
|
|
b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
|
|
b2 := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 120, 'Long Morning Meeting', NULL)`, b1)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 120, 'Extended Training', NULL)`, b2)
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
|
|
targetDay := findDayByDate(response, "2026-03-17")
|
|
if targetDay == nil {
|
|
t.Fatal("expected 2026-03-17 in response")
|
|
}
|
|
|
|
// The union of [10-12] and [11-13] is [10-13]. Verify key points.
|
|
if slotExists(targetDay.Slots, "10:00") {
|
|
t.Error("expected 10:00 blocked (overlapping blockers)")
|
|
}
|
|
if slotExists(targetDay.Slots, "11:00") {
|
|
t.Error("expected 11:00 blocked")
|
|
}
|
|
if slotExists(targetDay.Slots, "12:00") {
|
|
t.Error("expected 12:00 blocked")
|
|
}
|
|
// 09:00 (before) and 13:00 (after) should remain
|
|
if !slotExists(targetDay.Slots, "09:00") {
|
|
t.Error("expected 09:00 to remain available")
|
|
}
|
|
if !slotExists(targetDay.Slots, "13:00") {
|
|
t.Error("expected 13:00 to remain available")
|
|
}
|
|
// Both blockers visible
|
|
if len(targetDay.Blockers) != 2 {
|
|
t.Errorf("expected 2 blockers in blockers field, got %d", len(targetDay.Blockers))
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_AdjacentBoundaries verifies
|
|
// that a booking ending exactly when a blocker starts (and vice versa) does NOT
|
|
// cause false overlap — no slot is removed beyond the exact boundaries.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_AdjacentBoundaries(t *testing.T) {
|
|
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Tue 2026-03-17: booking 10:00-11:00, blocker 11:00-12:00 (adjacent)
|
|
bookingStart := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
|
|
bookingEnd := bookingStart.Add(60 * time.Minute)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
tx.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time)
|
|
VALUES ($1, $2, 'confirmed', 60, $3)
|
|
`, userID, bookingStart, bookingEnd)
|
|
|
|
blockerTime := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Adjacent Blocker', NULL)`, blockerTime)
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
|
|
targetDay := findDayByDate(response, "2026-03-17")
|
|
if targetDay == nil {
|
|
t.Fatal("expected 2026-03-17 in response")
|
|
}
|
|
|
|
// Both 10:00 and 11:00 should be unavailable — 10:00 removed by booking,
|
|
// 11:00 removed by blocker. But there should be a slot 09:00-10:00 and a
|
|
// slot 12:00-17:00.
|
|
if slotExists(targetDay.Slots, "10:00") {
|
|
t.Error("expected 10:00 unavailable (booking)")
|
|
}
|
|
if slotExists(targetDay.Slots, "11:00") {
|
|
t.Error("expected 11:00 unavailable (blocker)")
|
|
}
|
|
if !slotExists(targetDay.Slots, "09:00") {
|
|
t.Error("expected 09:00 available")
|
|
}
|
|
if !slotExists(targetDay.Slots, "12:00") {
|
|
t.Error("expected 12:00 available")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_MidnightBlocker verifies that
|
|
// a blocker spanning multiple working hours correctly subtracts available slots.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MidnightBlocker(t *testing.T) {
|
|
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Use the first open day found and the following day
|
|
tueStart, tueEnd, tueOpen := getWorkingHoursForDate(t, ctx, "2026-03-17")
|
|
wedStart, _, wedOpen := getWorkingHoursForDate(t, ctx, "2026-03-18")
|
|
if !tueOpen || !wedOpen {
|
|
t.Skip("test requires both Tue 2026-03-17 and Wed 2026-03-18 to be open days")
|
|
}
|
|
|
|
// Blocker at 1 hour before close on Tuesday
|
|
tueBlockHour := mustParseHour(tueEnd) - 1
|
|
tueBlockStart := fmt.Sprintf("%02d:00", tueBlockHour)
|
|
tueBlockTime := time.Date(2026, 3, 17, tueBlockHour, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'End-of-day blocker', NULL)`, tueBlockTime)
|
|
|
|
// Blocker at opening on Wednesday (first 2 hours)
|
|
wedBlockStart := wedStart
|
|
wedBlockEnd := fmt.Sprintf("%02d:00", mustParseHour(wedStart)+2)
|
|
wedBlockDur := 120
|
|
wedBlockTime := time.Date(2026, 3, 18, mustParseHour(wedStart), 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, $2, 'Opening blocker', NULL)`, wedBlockTime, wedBlockDur)
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18")
|
|
tue := findDayByDate(response, "2026-03-17")
|
|
wed := findDayByDate(response, "2026-03-18")
|
|
if tue == nil || wed == nil {
|
|
t.Fatal("expected both days in response")
|
|
}
|
|
|
|
// Tuesday: blocker at end of day removes the last hour
|
|
if slotExists(tue.Slots, tueBlockStart) {
|
|
t.Errorf("expected %s blocked on Tuesday (end-of-day blocker)", tueBlockStart)
|
|
}
|
|
// The hour before the blocker should be available
|
|
hourBefore := fmt.Sprintf("%02d:00", mustParseHour(tueBlockStart)-1)
|
|
if hourBefore >= tueStart && !slotExists(tue.Slots, hourBefore) {
|
|
t.Errorf("expected %s available on Tuesday (before block)", hourBefore)
|
|
}
|
|
|
|
// Wednesday: blocker at opening removes first 2 hours
|
|
if slotExists(wed.Slots, wedBlockStart) {
|
|
t.Errorf("expected %s blocked on Wednesday (opening blocker)", wedBlockStart)
|
|
}
|
|
// The hour after the blocker should be available
|
|
if !slotExists(wed.Slots, wedBlockEnd) {
|
|
t.Errorf("expected %s available on Wednesday (after block)", wedBlockEnd)
|
|
}
|
|
}
|
|
|
|
// mustParseHour extracts the hour from a "HH:MM" or "HH:MM:SS" time string.
|
|
func mustParseHour(t string) int {
|
|
if len(t) >= 2 {
|
|
var h int
|
|
fmt.Sscanf(t, "%d", &h)
|
|
return h
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_NoBlockers verifies admin
|
|
// sees all normal slots when no time blockers exist.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_NoBlockers(t *testing.T) {
|
|
|
|
ctx, _ := resetTestData(t)
|
|
|
|
// Tue 2026-03-17 — no blockers
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
|
|
targetDay := findDayByDate(response, "2026-03-17")
|
|
if targetDay == nil {
|
|
t.Fatal("expected 2026-03-17 in response")
|
|
}
|
|
|
|
// All standard slots should be present
|
|
for _, start := range []string{"09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00", "16:00"} {
|
|
if !slotExists(targetDay.Slots, start) {
|
|
t.Errorf("expected slot %s to be available (no blockers)", start)
|
|
}
|
|
}
|
|
// No blockers in field
|
|
if len(targetDay.Blockers) != 0 {
|
|
t.Error("expected empty blockers field when no blockers exist")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_ClosedDayBlocker tests that
|
|
// a time blocker on a closed day does NOT create any phantom slots.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_ClosedDayBlocker(t *testing.T) {
|
|
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Sunday 2026-03-22 is closed. Blocker at 10:00-11:00.
|
|
blockerTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Sunday Maintenance', NULL)`, blockerTime)
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-22", "2026-03-22")
|
|
targetDay := findDayByDate(response, "2026-03-22")
|
|
if targetDay == nil {
|
|
t.Fatal("expected 2026-03-22 in response")
|
|
}
|
|
|
|
if targetDay.IsOpen {
|
|
t.Error("expected Sunday to be closed")
|
|
}
|
|
if len(targetDay.Slots) != 0 {
|
|
t.Error("expected no slots on closed day even with blocker")
|
|
}
|
|
// Blockers on closed days are not populated (the day is closed, so
|
|
// the blocker is irrelevant; no slots exist to subtract from).
|
|
if len(targetDay.Blockers) != 0 {
|
|
t.Error("expected no blockers listed on closed day (day is closed)")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDayRangePartial verifies
|
|
// that querying a multi-day range where only some days have blockers correctly
|
|
// leaves unblocked days unaffected.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDayRangePartial(t *testing.T) {
|
|
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Blocker only on Tuesday (2026-03-17) at 10:00-11:00
|
|
blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Tue Only Blocker', NULL)`, blockerTime)
|
|
|
|
// Query Tue-Thu (17th, 18th, 19th)
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-19")
|
|
tue := findDayByDate(response, "2026-03-17")
|
|
wed := findDayByDate(response, "2026-03-18")
|
|
thu := findDayByDate(response, "2026-03-19")
|
|
if tue == nil || wed == nil || thu == nil {
|
|
t.Fatal("expected all three days in response")
|
|
}
|
|
|
|
// Tuesday: 10:00 blocked
|
|
if slotExists(tue.Slots, "10:00") {
|
|
t.Error("expected 10:00 blocked on Tuesday")
|
|
}
|
|
if len(tue.Blockers) == 0 {
|
|
t.Error("expected blocker visible on Tuesday")
|
|
}
|
|
|
|
// Wednesday: all slots available, no blockers
|
|
for _, start := range []string{"09:00", "10:00", "11:00", "14:00"} {
|
|
if !slotExists(wed.Slots, start) {
|
|
t.Errorf("expected %s available on Wednesday (no blocker)", start)
|
|
}
|
|
}
|
|
if len(wed.Blockers) != 0 {
|
|
t.Error("expected no blockers on Wednesday")
|
|
}
|
|
|
|
// Thursday (open 09:00-17:00): all slots available, no blockers
|
|
for _, start := range []string{"09:00", "10:00", "11:00", "12:00", "14:00", "16:00"} {
|
|
if !slotExists(thu.Slots, start) {
|
|
t.Errorf("expected %s available on Thursday (no blocker)", start)
|
|
}
|
|
}
|
|
if len(thu.Blockers) != 0 {
|
|
t.Error("expected no blockers on Thursday")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin_ExceptionalHours verifies
|
|
// that blockers still subtract from slots on days with exceptional working hours.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin_ExceptionalHours(t *testing.T) {
|
|
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Monday 2026-03-16 is normally CLOSED. Add exceptional hours: 10:00-16:00.
|
|
// Also add a blocker at 12:00-13:00.
|
|
// First create the exceptional group
|
|
var groupID int
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO exceptional_working_hours_groups (name, description)
|
|
VALUES ('Test Exceptional Group', 'Test for blocker on exceptional hours day')
|
|
RETURNING id
|
|
`).Scan(&groupID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create exceptional group: %v", err)
|
|
}
|
|
// Add exceptional hours: Monday (weekday 0) 10:00-16:00
|
|
tx.Exec(ctx, `
|
|
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
|
VALUES ($1, 0, '10:00:00', '16:00:00', true)
|
|
`, groupID)
|
|
// Apply to week containing 2026-03-16
|
|
tx.Exec(ctx, `
|
|
INSERT INTO exceptional_group_applications (group_id, week_start)
|
|
VALUES ($1, '2026-03-16')
|
|
`, groupID)
|
|
|
|
// Blocker on Monday 12:00-13:00
|
|
blockerTime := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Lunch Break', NULL)`, blockerTime)
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-16", "2026-03-16")
|
|
targetDay := findDayByDate(response, "2026-03-16")
|
|
if targetDay == nil {
|
|
t.Fatal("expected 2026-03-16 in response")
|
|
}
|
|
|
|
if !targetDay.IsOpen {
|
|
t.Error("expected Monday to be open (exceptional hours)")
|
|
}
|
|
// 12:00 blocked by blocker
|
|
if slotExists(targetDay.Slots, "12:00") {
|
|
t.Error("expected 12:00 blocked on exceptional hours day")
|
|
}
|
|
// 10:00 and 14:00 should be available
|
|
if !slotExists(targetDay.Slots, "10:00") {
|
|
t.Error("expected 10:00 available on exceptional hours day")
|
|
}
|
|
if !slotExists(targetDay.Slots, "14:00") {
|
|
t.Error("expected 14:00 available on exceptional hours day")
|
|
}
|
|
// Blocker visible
|
|
if len(targetDay.Blockers) == 0 {
|
|
t.Error("expected blocker visible on exceptional hours day")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_ClosedDay tests that
|
|
// a blocker on a closed day has no effect for non-admin users (day stays closed).
|
|
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_ClosedDay(t *testing.T) {
|
|
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Sunday 2026-03-22 closed, blocker at 10:00-11:00
|
|
blockerTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Weekend Maintenance', NULL)`, blockerTime)
|
|
|
|
handler := http.HandlerFunc(GetAvailableHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-22&end=2026-03-22", nil)
|
|
reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001")
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
|
|
req = req.WithContext(reqCtx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var response []DayAvailableHours
|
|
json.Unmarshal(w.Body.Bytes(), &response)
|
|
targetDay := findDayByDate(response, "2026-03-22")
|
|
if targetDay == nil {
|
|
t.Fatal("expected 2026-03-22 in response")
|
|
}
|
|
|
|
if targetDay.IsOpen {
|
|
t.Error("expected Sunday to be closed for non-admin")
|
|
}
|
|
if len(targetDay.Slots) != 0 {
|
|
t.Error("expected no slots on closed day")
|
|
}
|
|
if len(targetDay.Blockers) != 0 {
|
|
t.Error("expected blockers field empty for non-admin")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_BookingAdjacent verifies
|
|
// that a booking and blocker that are adjacent (booking 10-11, blocker 11-12)
|
|
// correctly list two separate unavailable ranges for non-admin users.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_BookingAdjacent(t *testing.T) {
|
|
|
|
ctx, tx := resetTestData(t)
|
|
|
|
bookingStart := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
|
|
bookingEnd := bookingStart.Add(60 * time.Minute)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
tx.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time)
|
|
VALUES ($1, $2, 'confirmed', 60, $3)
|
|
`, userID, bookingStart, bookingEnd)
|
|
|
|
blockerTime := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Maintenance Window', NULL)`, blockerTime)
|
|
|
|
handler := http.HandlerFunc(GetAvailableHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil)
|
|
reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001")
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
|
|
req = req.WithContext(reqCtx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var response []DayAvailableHours
|
|
json.Unmarshal(w.Body.Bytes(), &response)
|
|
targetDay := findDayByDate(response, "2026-03-17")
|
|
if targetDay == nil {
|
|
t.Fatal("expected 2026-03-17 in response")
|
|
}
|
|
|
|
if slotExists(targetDay.Slots, "10:00") {
|
|
t.Error("expected 10:00 unavailable (booking)")
|
|
}
|
|
if slotExists(targetDay.Slots, "11:00") {
|
|
t.Error("expected 11:00 unavailable (blocker)")
|
|
}
|
|
if !slotExists(targetDay.Slots, "09:00") {
|
|
t.Error("expected 09:00 available")
|
|
}
|
|
if !slotExists(targetDay.Slots, "12:00") {
|
|
t.Error("expected 12:00 available")
|
|
}
|
|
if len(targetDay.Blockers) != 0 {
|
|
t.Error("expected blockers field empty for non-admin")
|
|
}
|
|
}
|
|
|
|
// normalizeTime unit tests
|
|
|
|
func TestNormalizeTime_StripsSeconds(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{"HH:MM:SS", "09:00:00", "09:00"},
|
|
{"already HH:MM", "09:00", "09:00"},
|
|
{"empty string", "", ""},
|
|
{"single digit hour stripped", "9:00:00", "09:00"},
|
|
{"midnight", "00:00:00", "00:00"},
|
|
{"23:59:59", "23:59:59", "23:59"},
|
|
{"12:30:45", "12:30:45", "12:30"},
|
|
{"malformed no colon", "0900", "0900"},
|
|
{"single colon", "09:00", "09:00"},
|
|
{"extra suffix", "09:00:00:extra", "09:00"},
|
|
{"short string", "9:00", "09:00"},
|
|
{"minimal HH:MM:SS", "1:2:3", "01:02"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := normalizeTime(tt.input)
|
|
if got != tt.expected {
|
|
t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTime_NoChangeForInvalidFormats(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{"purely invalid", "invalid", "invalid"},
|
|
// "25:00:00" has ':' at positions 2 and 5 so normalizeTime DOES strip seconds
|
|
{"invalid time with pattern", "25:00:00", "25:00"},
|
|
// "ab:cd:ef" has ':' at positions 2 and 5 so normalizeTime DOES strip seconds
|
|
{"non-numeric with pattern", "ab:cd:ef", "ab:cd"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := normalizeTime(tt.input)
|
|
if got != tt.expected {
|
|
t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Late-night lock boundary tests
|
|
|
|
func TestLateNightLock_After22_BlocksNextMorning(t *testing.T) {
|
|
available := []TimeSlot{{StartTime: "06:00", EndTime: "22:00"}}
|
|
lateBlock := TimeSlot{StartTime: "00:00", EndTime: "11:00"}
|
|
result := subtractTimeSlots(available, []TimeSlot{lateBlock})
|
|
expected := []TimeSlot{{StartTime: "11:00", EndTime: "22:00"}}
|
|
if len(result) != len(expected) {
|
|
t.Fatalf("expected %d slot, got %d: %+v", len(expected), len(result), result)
|
|
}
|
|
if result[0].StartTime != expected[0].StartTime || result[0].EndTime != expected[0].EndTime {
|
|
t.Errorf("expected slot %+v, got %+v", expected[0], result[0])
|
|
}
|
|
}
|
|
|
|
func TestLateNightLock_Before22_NoBlock(t *testing.T) {
|
|
available := []TimeSlot{{StartTime: "09:00", EndTime: "17:00"}}
|
|
result := subtractTimeSlots(available, nil)
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 slot unchanged, got %d: %+v", len(result), result)
|
|
}
|
|
}
|
|
|
|
func TestSubtractTimeSlots_NoOverlap(t *testing.T) {
|
|
available := []TimeSlot{
|
|
{StartTime: "09:00", EndTime: "12:00"},
|
|
{StartTime: "13:00", EndTime: "17:00"},
|
|
}
|
|
gaps := []TimeSlot{{StartTime: "12:00", EndTime: "13:00"}}
|
|
result := subtractTimeSlots(available, gaps)
|
|
if len(result) != 2 {
|
|
t.Fatalf("expected 2 slots unchanged, got %d: %+v", len(result), result)
|
|
}
|
|
}
|
|
|
|
func TestSubtractTimeSlots_MultipleGaps(t *testing.T) {
|
|
available := []TimeSlot{{StartTime: "09:00", EndTime: "18:00"}}
|
|
gaps := []TimeSlot{
|
|
{StartTime: "11:00", EndTime: "12:00"},
|
|
{StartTime: "14:00", EndTime: "15:00"},
|
|
}
|
|
result := subtractTimeSlots(available, gaps)
|
|
expected := []TimeSlot{
|
|
{StartTime: "09:00", EndTime: "11:00"},
|
|
{StartTime: "12:00", EndTime: "14:00"},
|
|
{StartTime: "15:00", EndTime: "18:00"},
|
|
}
|
|
if len(result) != 3 {
|
|
t.Fatalf("expected 3 slots, got %d: %+v", len(result), result)
|
|
}
|
|
for i, exp := range expected {
|
|
if result[i].StartTime != exp.StartTime || result[i].EndTime != exp.EndTime {
|
|
t.Errorf("slot %d: expected %+v, got %+v", i, exp, result[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSubtractTimeSlots_GapEatsWholeSlot(t *testing.T) {
|
|
available := []TimeSlot{{StartTime: "09:00", EndTime: "12:00"}}
|
|
gaps := []TimeSlot{{StartTime: "08:00", EndTime: "13:00"}}
|
|
result := subtractTimeSlots(available, gaps)
|
|
if len(result) != 0 {
|
|
t.Errorf("expected 0 slots, got %d: %+v", len(result), result)
|
|
}
|
|
}
|
|
|
|
func TestSubtractTimeSlots_GapPartialOverlap(t *testing.T) {
|
|
available := []TimeSlot{{StartTime: "09:00", EndTime: "17:00"}}
|
|
gaps := []TimeSlot{{StartTime: "08:00", EndTime: "10:00"}}
|
|
result := subtractTimeSlots(available, gaps)
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 slot, got %d: %+v", len(result), result)
|
|
}
|
|
if result[0].StartTime != "10:00" || result[0].EndTime != "17:00" {
|
|
t.Errorf("expected slot 10:00-17:00, got %+v", result[0])
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTime_SingleDigitHour(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{"single digit hour with seconds", "9:00:00", "09:00"},
|
|
{"single digit hour no seconds", "9:00", "09:00"},
|
|
{"double digit hour with seconds", "09:00:00", "09:00"},
|
|
{"double digit hour no seconds", "09:00", "09:00"},
|
|
{"single digit min with seconds", "09:5:00", "09:05"},
|
|
{"hour only no colon", "0900", "0900"},
|
|
{"empty string", "", ""},
|
|
{"midnight with seconds", "00:00:00", "00:00"},
|
|
{"end of day with seconds", "23:59:59", "23:59"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := normalizeTime(tt.input)
|
|
if got != tt.expected {
|
|
t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTime_NoChangeForEdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{"no colons", "hello", "hello"},
|
|
{"single colon only", ":", ":"},
|
|
{"trailing colon", "09:", "09:"},
|
|
{"only two chars after colon", "9:0", "09:00"},
|
|
{"three colons no trailing pair", "a:b:c:d", "a:b"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := normalizeTime(tt.input)
|
|
if got != tt.expected {
|
|
t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTime_Regression_RealWorldFormats(t *testing.T) {
|
|
// These are the actual formats returned by the working hours and blocker
|
|
// queries. normaliseTime is called on baseStart and baseEnd in
|
|
// GetAvailableHours to ensure string comparison consistency.
|
|
realWorld := map[string]string{
|
|
"09:00:00": "09:00",
|
|
"17:00:00": "17:00",
|
|
"09:00": "09:00",
|
|
"17:00": "17:00",
|
|
"00:00:00": "00:00",
|
|
"22:00:00": "22:00",
|
|
}
|
|
for input, expected := range realWorld {
|
|
got := normalizeTime(input)
|
|
if got != expected {
|
|
t.Errorf("normalizeTime(%q) = %q, want %q", input, got, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_CrossDayBlocker verifies that a single time
|
|
// blocker with a duration spanning multiple calendar days correctly subtracts
|
|
// slots from each affected day.
|
|
func TestScheduling_GetAvailableHours_CrossDayBlocker(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Use Tue 2026-03-17 and Wed 2026-03-18 — both open days
|
|
_, tueEnd, tueOpen := getWorkingHoursForDate(t, ctx, "2026-03-17")
|
|
_, _, wedOpen := getWorkingHoursForDate(t, ctx, "2026-03-18")
|
|
if !tueOpen || !wedOpen {
|
|
t.Skip("test requires both Tue 2026-03-17 and Wed 2026-03-18 to be open days")
|
|
}
|
|
|
|
// Blocker starts at 15:00 on Tuesday and lasts 20 hours (covers all of
|
|
// Wednesday's working hours up to 11:00).
|
|
blockerStart := time.Date(2026, 3, 17, 15, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 1200, 'Multi-day blocker', NULL)`, blockerStart)
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18")
|
|
tue := findDayByDate(response, "2026-03-17")
|
|
wed := findDayByDate(response, "2026-03-18")
|
|
if tue == nil || wed == nil {
|
|
t.Fatal("expected both days in response")
|
|
}
|
|
|
|
// Tuesday: blocker removes 15:00 through end of day (17:00)
|
|
if slotExists(tue.Slots, "15:00") {
|
|
t.Errorf("expected 15:00 blocked on Tuesday (cross-day blocker starts)")
|
|
}
|
|
// Slots before 15:00 should remain
|
|
if !slotExists(tue.Slots, "09:00") {
|
|
t.Error("expected 09:00 available on Tuesday")
|
|
}
|
|
if !slotExists(tue.Slots, "10:00") {
|
|
t.Error("expected 10:00 available on Tuesday")
|
|
}
|
|
// End-of-day slot after blocker start should be gone
|
|
if mustParseHour(tueEnd) > 15 {
|
|
lastSlot := fmt.Sprintf("%02d:00", mustParseHour(tueEnd)-2)
|
|
if slotExists(tue.Slots, lastSlot) {
|
|
t.Errorf("expected end-of-day slot %s blocked on Tuesday", lastSlot)
|
|
}
|
|
}
|
|
|
|
// Wednesday: blocker still active until 11:00 — removes morning slots
|
|
for _, start := range []string{"09:00", "10:00"} {
|
|
if slotExists(wed.Slots, start) {
|
|
t.Errorf("expected %s blocked on Wednesday (cross-day blocker spillover)", start)
|
|
}
|
|
}
|
|
// 11:00 onwards should be available
|
|
if !slotExists(wed.Slots, "11:00") {
|
|
t.Errorf("expected 11:00 available on Wednesday (after cross-day blocker ends)")
|
|
}
|
|
if !slotExists(wed.Slots, "12:00") {
|
|
t.Errorf("expected 12:00 available on Wednesday")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// DST Transition Tests
|
|
// =============================================================================
|
|
//
|
|
// These tests verify that timezone handling is correct during BST (summer)
|
|
// when the wall-clock time differs from UTC by +1 hour.
|
|
|
|
// TestScheduling_DST_BlockerTimeFormatting verifies that blocker times are
|
|
// formatted in Europe/London during BST, so the blocker correctly subtracts
|
|
// wall-clock slots. A blocker at 15:00 BST (= 14:00 UTC) should block the
|
|
// 15:00-16:00 BST slot, not 14:00-15:00 BST.
|
|
func TestScheduling_DST_BlockerTimeFormatting(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Monday 2026-06-15 is in BST (UTC+1). Working hours: 09:00-17:00.
|
|
// Insert a blocker at 15:00 BST = 14:00 UTC.
|
|
blockerTime := time.Date(2026, 6, 15, 14, 0, 0, 0, time.UTC)
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'DST Blocker', NULL)
|
|
`, blockerTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to create DST blocker: %v", err)
|
|
}
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-06-15", "2026-06-15")
|
|
targetDay := findDayByDate(response, "2026-06-15")
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-06-15 in response")
|
|
}
|
|
|
|
// 15:00 BST slot should be blocked (wall-clock time)
|
|
if slotExists(targetDay.Slots, "15:00") {
|
|
t.Error("expected 15:00 BST slot to be blocked by DST blocker")
|
|
}
|
|
// 14:00 BST slot should still be available (blocker starts at 15:00)
|
|
if !slotExists(targetDay.Slots, "14:00") {
|
|
t.Error("expected 14:00 BST slot to remain available")
|
|
}
|
|
// 16:00 BST slot should be available (blocker ends at 16:00)
|
|
if !slotExists(targetDay.Slots, "16:00") {
|
|
t.Error("expected 16:00 BST slot to be available after blocker")
|
|
}
|
|
|
|
// Verify blocker appears in blockers field with correct wall-clock times
|
|
found := false
|
|
for _, b := range targetDay.Blockers {
|
|
if b.StartTime == "15:00" && b.EndTime == "16:00" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected blocker 15:00-16:00 in blockers field (BST wall-clock time)")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_DST_MultipleBlockers verifies multiple blockers during BST
|
|
// are all correctly applied to wall-clock time slots.
|
|
func TestScheduling_DST_MultipleBlockers(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Monday 2026-06-15 BST: two blockers at 10:00 BST (= 09:00 UTC)
|
|
// and 14:00 BST (= 13:00 UTC).
|
|
b1 := time.Date(2026, 6, 15, 9, 0, 0, 0, time.UTC)
|
|
b2 := time.Date(2026, 6, 15, 13, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning blocker', NULL)`, b1)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Afternoon blocker', NULL)`, b2)
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-06-15", "2026-06-15")
|
|
targetDay := findDayByDate(response, "2026-06-15")
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-06-15 in response")
|
|
}
|
|
|
|
// Morning blocker at 10:00 BST
|
|
if slotExists(targetDay.Slots, "10:00") {
|
|
t.Error("expected 10:00 BST slot blocked (morning blocker)")
|
|
}
|
|
// Afternoon blocker at 14:00 BST
|
|
if slotExists(targetDay.Slots, "14:00") {
|
|
t.Error("expected 14:00 BST slot blocked (afternoon blocker)")
|
|
}
|
|
// 09:00 should be available (before any blocker)
|
|
if !slotExists(targetDay.Slots, "09:00") {
|
|
t.Error("expected 09:00 BST to remain available")
|
|
}
|
|
// 11:00 should be available (between blockers)
|
|
if !slotExists(targetDay.Slots, "11:00") {
|
|
t.Error("expected 11:00 BST to remain available")
|
|
}
|
|
// 15:00 should be available (after both blockers)
|
|
if !slotExists(targetDay.Slots, "15:00") {
|
|
t.Error("expected 15:00 BST to remain available")
|
|
}
|
|
|
|
// Both blockers visible in wall-clock time
|
|
found1, found2 := false, false
|
|
for _, b := range targetDay.Blockers {
|
|
if b.StartTime == "10:00" && b.EndTime == "11:00" {
|
|
found1 = true
|
|
}
|
|
if b.StartTime == "14:00" && b.EndTime == "15:00" {
|
|
found2 = true
|
|
}
|
|
}
|
|
if !found1 {
|
|
t.Error("expected morning blocker 10:00-11:00 in blockers field")
|
|
}
|
|
if !found2 {
|
|
t.Error("expected afternoon blocker 14:00-15:00 in blockers field")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_DST_BlockerOnSpringForward verifies time blocker handling
|
|
// during the March BST transition (clocks spring forward 01:00→02:00).
|
|
// 2026-03-29 is the spring-forward date. The blocker is placed within
|
|
// working hours (10:00-11:00 BST) to verify it correctly blocks wall-clock
|
|
// time on the transition day.
|
|
func TestScheduling_DST_BlockerOnSpringForward(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// 2026-03-29 is the BST start date (clocks spring forward). Sunday is closed
|
|
// by default. Use an exceptional hours override to open 09:00-17:00.
|
|
var groupID int
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO exceptional_working_hours_groups (name, description)
|
|
VALUES ('Spring Forward Test', 'Test BST transition on 2026-03-29')
|
|
RETURNING id
|
|
`).Scan(&groupID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create exceptional group: %v", err)
|
|
}
|
|
tx.Exec(ctx, `
|
|
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
|
VALUES ($1, 6, '09:00:00', '17:00:00', true)
|
|
`, groupID)
|
|
tx.Exec(ctx, `
|
|
INSERT INTO exceptional_group_applications (group_id, week_start)
|
|
VALUES ($1, '2026-03-23')
|
|
`, groupID)
|
|
|
|
// Blocker at 10:00 BST on 2026-03-29 = 09:00 UTC (spring-forward day,
|
|
// clocks jump 01:00→02:00, so 10:00 BST = 09:00 UTC as usual).
|
|
blockerTime := time.Date(2026, 3, 29, 9, 0, 0, 0, time.UTC)
|
|
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Spring Forward Blocker', NULL)`, blockerTime)
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-29", "2026-03-29")
|
|
targetDay := findDayByDate(response, "2026-03-29")
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-03-29 in response")
|
|
}
|
|
|
|
if !targetDay.IsOpen {
|
|
t.Fatal("expected 2026-03-29 to be open (exceptional hours)")
|
|
}
|
|
|
|
// 10:00 BST slot should be blocked (wall-clock time within working hours)
|
|
if slotExists(targetDay.Slots, "10:00") {
|
|
t.Error("expected 10:00 BST slot to be blocked (spring-forward blocker)")
|
|
}
|
|
// 09:00 BST should be available (before blocker)
|
|
if !slotExists(targetDay.Slots, "09:00") {
|
|
t.Error("expected 09:00 BST to be available before blocker")
|
|
}
|
|
// 11:00 BST should be available (after blocker ends)
|
|
if !slotExists(targetDay.Slots, "11:00") {
|
|
t.Error("expected 11:00 BST to be available after blocker")
|
|
}
|
|
|
|
// Verify blocker in wall-clock time
|
|
found := false
|
|
for _, b := range targetDay.Blockers {
|
|
if b.StartTime == "10:00" && b.EndTime == "11:00" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected blocker 10:00-11:00 in blockers field (spring-forward wall-clock)")
|
|
}
|
|
}
|
|
|
|
// TestNormalizeTime_NonNumericInput verifies that normalizeTime does not
|
|
// silently pad non-numeric single-character segments (defensive guard).
|
|
func TestNormalizeTime_NonNumericInput(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{"non-numeric hour single char", "a:00", "a:00"},
|
|
{"non-numeric minute single char", "09:b", "09:b"},
|
|
{"both non-numeric single char", "a:b", "a:b"},
|
|
{"numeric still works", "9:00", "09:00"},
|
|
{"single digit minute", "09:5", "09:05"},
|
|
{"both single digit", "9:5", "09:05"},
|
|
{"non-numeric multi-char hour passes through", "ab:00", "ab:00"},
|
|
{"non-numeric with seconds stripped", "a:b:c", "a:b"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := normalizeTime(tt.input)
|
|
if got != tt.expected {
|
|
t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestScheduling_DST_DateBoundary verifies that GetAvailableHours includes
|
|
// BST early-morning bookings (00:00-00:59 BST = 23:00-23:59 UTC previous day)
|
|
// in the correct date range. Uses londonLocation for date boundaries.
|
|
func TestScheduling_DST_DateBoundary(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Monday 2026-06-15 is BST. Create a booking at 00:30 BST (= 23:30 UTC June 14).
|
|
// This booking should appear in the June 15 results (BST date).
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(tx, serviceID)
|
|
|
|
// 00:30 BST on June 15 = 23:30 UTC on June 14
|
|
bkStart := time.Date(2026, 6, 15, 0, 30, 0, 0, londonLocation)
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bkStart)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(tx, bookingID)
|
|
|
|
// Query available hours for June 15. The query uses londonLocation for
|
|
// date boundaries, so it should see the 00:30 BST booking.
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-06-15", "2026-06-15")
|
|
targetDay := findDayByDate(response, "2026-06-15")
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-06-15 in response")
|
|
}
|
|
if !targetDay.IsOpen {
|
|
t.Fatal("expected June 15 to be open")
|
|
}
|
|
|
|
// The booking at 00:30 BST should consume a slot before it.
|
|
// If the date boundary was UTC-based, the booking would be invisible
|
|
// (23:30 UTC June 14 < 00:00 UTC June 15 query start).
|
|
// With londonLocation boundary, 00:30 BST is within the range.
|
|
// Verify the 00:00 slot is NOT available (blocked by the 00:30 booking
|
|
// because available-hours represents open slots, not individual bookings).
|
|
// Actually, available-hours shows slots that ARE available, so a booking
|
|
// at 00:30 means the 00:00 slot's 30-min window is partially taken.
|
|
// For a 30-min service, 00:00 would be blocked by the 00:30 booking.
|
|
if slotExists(targetDay.Slots, "00:00") {
|
|
t.Error("expected 00:00 BST slot to be unavailable (booked at 00:30 BST)")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_DST_AutumnBack_BookingAt0130BST verifies that a booking at
|
|
// 01:30 BST (= 00:30 UTC) on Oct 25, 2026 (BST→GMT transition) is correctly
|
|
// handled. Oct 25 is the autumn DST date where clocks go back at 02:00 BST →
|
|
// 01:00 GMT, creating a duplicated 01:00-02:00 hour.
|
|
func TestScheduling_DST_AutumnBack_BookingAt0130BST(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// 2026-10-25 is the autumn DST date (BST→GMT). Sunday is closed
|
|
// by default. Use exceptional hours override to open 09:00-17:00.
|
|
var groupID int
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO exceptional_working_hours_groups (name, description)
|
|
VALUES ('Autumn DST BST Test', 'Test BST→GMT transition on 2026-10-25')
|
|
RETURNING id
|
|
`).Scan(&groupID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create exceptional group: %v", err)
|
|
}
|
|
tx.Exec(ctx, `
|
|
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
|
VALUES ($1, 6, '09:00:00', '17:00:00', true)
|
|
`, groupID)
|
|
tx.Exec(ctx, `
|
|
INSERT INTO exceptional_group_applications (group_id, week_start)
|
|
VALUES ($1, '2026-10-19')
|
|
`, groupID)
|
|
|
|
// Create a user and service for the booking
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(tx, serviceID)
|
|
|
|
// Booking at 01:30 BST = 00:30 UTC on 2026-10-25 (spans DST transition)
|
|
bookingTime := time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC)
|
|
var bookingID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, notes)
|
|
VALUES ($1, $2, 'pending', 'Autumn DST BST booking at 01:30 BST')
|
|
RETURNING id
|
|
`, userID, bookingTime).Scan(&bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO booking_services (booking_id, service_id)
|
|
VALUES ($1, $2)
|
|
`, bookingID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to link service to booking: %v", err)
|
|
}
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-10-25", "2026-10-25")
|
|
targetDay := findDayByDate(response, "2026-10-25")
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-10-25 in response")
|
|
}
|
|
|
|
if !targetDay.IsOpen {
|
|
t.Fatal("expected 2026-10-25 to be open (exceptional hours)")
|
|
}
|
|
|
|
// 01:30 BST slot should be blocked (booking at 01:30 BST = 00:30 UTC)
|
|
if slotExists(targetDay.Slots, "01:30") {
|
|
t.Error("expected 01:30 BST slot to be blocked (booking at 01:30 BST = 00:30 UTC)")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_DST_AutumnBack_BookingAt0130GMT verifies that a booking at
|
|
// 01:30 GMT (= 01:30 UTC) on Oct 25, 2026 (BST→GMT transition) is also
|
|
// correctly handled. This is the second occurrence of 01:30 during the
|
|
// duplicated hour on the autumn DST day.
|
|
func TestScheduling_DST_AutumnBack_BookingAt0130GMT(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Same exceptional hours setup as the BST variant
|
|
var groupID int
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO exceptional_working_hours_groups (name, description)
|
|
VALUES ('Autumn DST GMT Test', 'Test BST→GMT transition on 2026-10-25')
|
|
RETURNING id
|
|
`).Scan(&groupID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create exceptional group: %v", err)
|
|
}
|
|
tx.Exec(ctx, `
|
|
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
|
VALUES ($1, 6, '09:00:00', '17:00:00', true)
|
|
`, groupID)
|
|
tx.Exec(ctx, `
|
|
INSERT INTO exceptional_group_applications (group_id, week_start)
|
|
VALUES ($1, '2026-10-19')
|
|
`, groupID)
|
|
|
|
// Create a user and service for the booking
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(tx, serviceID)
|
|
|
|
// Booking at 01:30 GMT = 01:30 UTC on 2026-10-25
|
|
bookingTime := time.Date(2026, 10, 25, 1, 30, 0, 0, time.UTC)
|
|
var bookingID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, notes)
|
|
VALUES ($1, $2, 'pending', 'Autumn DST GMT booking at 01:30 GMT')
|
|
RETURNING id
|
|
`, userID, bookingTime).Scan(&bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO booking_services (booking_id, service_id)
|
|
VALUES ($1, $2)
|
|
`, bookingID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to link service to booking: %v", err)
|
|
}
|
|
|
|
response := makeAdminAvailableHoursRequest(t, ctx, "2026-10-25", "2026-10-25")
|
|
targetDay := findDayByDate(response, "2026-10-25")
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-10-25 in response")
|
|
}
|
|
|
|
if !targetDay.IsOpen {
|
|
t.Fatal("expected 2026-10-25 to be open (exceptional hours)")
|
|
}
|
|
|
|
// 01:30 GMT slot should be blocked (booking at 01:30 GMT = 01:30 UTC)
|
|
if slotExists(targetDay.Slots, "01:30") {
|
|
t.Error("expected 01:30 GMT slot to be blocked (booking at 01:30 GMT = 01:30 UTC)")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_ExcludesOwnReservation verifies that an
|
|
// authenticated user's own RESERVATION entry is excluded from time blockers
|
|
// via the excludeUserID parameter in GetTimeBlockersInRange.
|
|
func TestScheduling_GetAvailableHours_ExcludesOwnReservation(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'RESERVATION:user:' || $2 || ':' || EXTRACT(epoch FROM NOW())::bigint::text, $2)
|
|
`, reservationTime, userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create reservation: %v", err)
|
|
}
|
|
|
|
// Request available hours as THIS user — their own reservation should be excluded
|
|
handler := http.HandlerFunc(GetAvailableHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil)
|
|
reqCtx := context.WithValue(ctx, mw.UserIDKey, userID)
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
|
|
req = req.WithContext(reqCtx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var response []DayAvailableHours
|
|
json.Unmarshal(w.Body.Bytes(), &response)
|
|
targetDay := findDayByDate(response, "2026-03-17")
|
|
if targetDay == nil {
|
|
t.Fatal("expected 2026-03-17 in response")
|
|
}
|
|
|
|
// The user's own reservation at 10:00 should NOT block the slot
|
|
if !slotExists(targetDay.Slots, "10:00") {
|
|
t.Error("expected 10:00 to be available (own reservation excluded)")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Gap-Filling Tests for Uncovered Branches
|
|
// =============================================================================
|
|
//
|
|
// These tests exercise error paths and edge cases in the scheduling handlers
|
|
// that were not covered by the original test suite.
|
|
|
|
// TestScheduling_ListExceptionalGroups_WithHoursAndApps verifies that when a
|
|
// group has hours and week applications, they are populated in the response.
|
|
func TestScheduling_ListExceptionalGroups_WithHoursAndApps(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
var groupID int
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO exceptional_working_hours_groups (name, description)
|
|
VALUES ('Holiday Group', 'Christmas schedule')
|
|
RETURNING id
|
|
`).Scan(&groupID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create group: %v", err)
|
|
}
|
|
|
|
// Insert hours for all 7 days
|
|
for _, h := range []struct{ wd int; start, end string; open bool }{
|
|
{0, "09:00", "17:00", true},
|
|
{1, "09:00", "17:00", true},
|
|
{2, "09:00", "17:00", true},
|
|
{3, "09:00", "17:00", true},
|
|
{4, "09:00", "17:00", true},
|
|
{5, "10:00", "16:00", true},
|
|
{6, "00:00", "00:00", false},
|
|
} {
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
`, groupID, h.wd, h.start, h.end, h.open)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert hours: %v", err)
|
|
}
|
|
}
|
|
|
|
// Insert week application
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO exceptional_group_applications (group_id, week_start)
|
|
VALUES ($1, '2026-06-01')
|
|
`, groupID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert application: %v", err)
|
|
}
|
|
|
|
handler := http.HandlerFunc(ListExceptionalGroups)
|
|
w := makeRequest(handler, "GET", "/api/scheduling/exceptional-groups", nil, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []ExceptionalGroup
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if len(response) == 0 {
|
|
t.Fatal("expected at least one group in response")
|
|
}
|
|
|
|
// Find our group and verify hours and weekStarts are populated
|
|
var found bool
|
|
for _, g := range response {
|
|
if g.Name == "Holiday Group" {
|
|
found = true
|
|
if len(g.Hours) != 7 {
|
|
t.Errorf("expected 7 hours, got %d", len(g.Hours))
|
|
}
|
|
if len(g.WeekStarts) != 1 {
|
|
t.Errorf("expected 1 week start, got %d", len(g.WeekStarts))
|
|
}
|
|
if len(g.WeekStarts) > 0 && g.WeekStarts[0] != "2026-06-01" {
|
|
t.Errorf("expected week_start 2026-06-01, got %s", g.WeekStarts[0])
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected to find 'Holiday Group' in response")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetWorkingHours_MissingStart verifies that GetWorkingHours
|
|
// returns 400 when the start query param is missing.
|
|
func TestScheduling_GetWorkingHours_MissingStart(t *testing.T) {
|
|
ctx, _ := resetTestData(t)
|
|
handler := http.HandlerFunc(GetWorkingHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?end=2026-02-22", nil)
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetWorkingHours_MissingEnd verifies that GetWorkingHours
|
|
// returns 400 when the end query param is missing.
|
|
func TestScheduling_GetWorkingHours_MissingEnd(t *testing.T) {
|
|
ctx, _ := resetTestData(t)
|
|
handler := http.HandlerFunc(GetWorkingHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16", nil)
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetWorkingHours_InvalidStart verifies that GetWorkingHours
|
|
// returns 400 for an invalid start date format.
|
|
func TestScheduling_GetWorkingHours_InvalidStart(t *testing.T) {
|
|
ctx, _ := resetTestData(t)
|
|
handler := http.HandlerFunc(GetWorkingHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=invalid&end=2026-02-22", nil)
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetWorkingHours_InvalidEnd verifies that GetWorkingHours
|
|
// returns 400 for an invalid end date format.
|
|
func TestScheduling_GetWorkingHours_InvalidEnd(t *testing.T) {
|
|
ctx, _ := resetTestData(t)
|
|
handler := http.HandlerFunc(GetWorkingHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=invalid", nil)
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetWorkingHours_WithExceptional verifies that GetWorkingHours
|
|
// correctly applies exceptional hours and returns source="exceptional" for
|
|
// affected days.
|
|
func TestScheduling_GetWorkingHours_WithExceptional(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Create an exceptional group that closes Tuesday (weekday 1)
|
|
var groupID int
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO exceptional_working_hours_groups (name, description)
|
|
VALUES ('Test Exception', 'Closed Tuesday')
|
|
RETURNING id
|
|
`).Scan(&groupID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create group: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
|
VALUES ($1, 1, '00:00', '00:00', false)
|
|
`, groupID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert exceptional hours: %v", err)
|
|
}
|
|
|
|
// Apply to week starting 2026-02-16 (Monday)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO exceptional_group_applications (group_id, week_start)
|
|
VALUES ($1, '2026-02-16')
|
|
`, groupID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert application: %v", err)
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetWorkingHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22", nil)
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayWorkingHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
// Find Tuesday (2026-02-17) - should have source="exceptional" and isOpen=false
|
|
var tuesday *DayWorkingHours
|
|
for i := range response {
|
|
if response[i].Date == "2026-02-17" {
|
|
tuesday = &response[i]
|
|
break
|
|
}
|
|
}
|
|
if tuesday == nil {
|
|
t.Fatal("expected Tuesday 2026-02-17 in response")
|
|
}
|
|
if tuesday.Source != "exceptional" {
|
|
t.Errorf("expected source 'exceptional' for Tuesday, got %s", tuesday.Source)
|
|
}
|
|
if tuesday.IsOpen {
|
|
t.Error("expected Tuesday to be closed (exceptional override)")
|
|
}
|
|
}
|
|
|
|
// TestScheduling_UpdateDefaultHours_InvalidJSON verifies that UpdateDefaultHours
|
|
// returns 400 for invalid JSON payload.
|
|
func TestScheduling_UpdateDefaultHours_InvalidJSON(t *testing.T) {
|
|
ctx, _ := resetTestData(t)
|
|
handler := http.HandlerFunc(UpdateDefaultHours)
|
|
req := httptest.NewRequest("PUT", "/api/scheduling/default-hours", strings.NewReader("not json"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduling_UpdateDefaultHours_NullJSON verifies that UpdateDefaultHours
|
|
// returns 400 when the JSON payload decodes to something other than an array.
|
|
func TestScheduling_UpdateDefaultHours_NullJSON(t *testing.T) {
|
|
ctx, _ := resetTestData(t)
|
|
handler := http.HandlerFunc(UpdateDefaultHours)
|
|
req := httptest.NewRequest("PUT", "/api/scheduling/default-hours", strings.NewReader("null"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
// null decodes to nil slice — technically valid but produces 0 items.
|
|
// The handler returns 204 (NoContent) because the loop doesn't run.
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduling_CreateExceptionalGroup_InvalidJSON verifies that
|
|
// CreateExceptionalGroup returns 400 for invalid JSON payload.
|
|
func TestScheduling_CreateExceptionalGroup_InvalidJSON(t *testing.T) {
|
|
ctx, _ := resetTestData(t)
|
|
handler := http.HandlerFunc(CreateExceptionalGroup)
|
|
req := httptest.NewRequest("POST", "/api/scheduling/exceptional-groups", strings.NewReader("not json"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduling_CreateExceptionalGroup_NotEnoughHours verifies that
|
|
// CreateExceptionalGroup returns 400 when fewer than 7 hours are provided.
|
|
func TestScheduling_CreateExceptionalGroup_NotEnoughHours(t *testing.T) {
|
|
ctx, _ := resetTestData(t)
|
|
|
|
group := ExceptionalGroup{
|
|
Name: "Partial Group",
|
|
Description: "Only 3 days",
|
|
Hours: []ExceptionalHours{
|
|
{Weekday: 0, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 1, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 2, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
},
|
|
WeekStarts: []string{"2026-06-01"},
|
|
}
|
|
|
|
handler := http.HandlerFunc(CreateExceptionalGroup)
|
|
w := makeRequest(handler, "POST", "/api/scheduling/exceptional-groups", group, ctx)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduling_CreateExceptionalGroup_DuplicateWeekday verifies that
|
|
// CreateExceptionalGroup returns 400 when duplicate weekdays are provided.
|
|
func TestScheduling_CreateExceptionalGroup_DuplicateWeekday(t *testing.T) {
|
|
ctx, _ := resetTestData(t)
|
|
|
|
group := ExceptionalGroup{
|
|
Name: "Dupe Group",
|
|
Description: "Duplicate weekdays",
|
|
Hours: []ExceptionalHours{
|
|
{Weekday: 0, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 0, StartTime: "10:00", EndTime: "18:00", IsOpen: true},
|
|
{Weekday: 1, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 2, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 3, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 4, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
},
|
|
WeekStarts: []string{"2026-06-01"},
|
|
}
|
|
|
|
handler := http.HandlerFunc(CreateExceptionalGroup)
|
|
w := makeRequest(handler, "POST", "/api/scheduling/exceptional-groups", group, ctx)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduling_CreateExceptionalGroup_WeekdayOutOfRange verifies that
|
|
// CreateExceptionalGroup returns 400 when weekday is < 0 or > 6.
|
|
func TestScheduling_CreateExceptionalGroup_WeekdayOutOfRange(t *testing.T) {
|
|
ctx, _ := resetTestData(t)
|
|
|
|
group := ExceptionalGroup{
|
|
Name: "Bad Weekday",
|
|
Description: "Weekday out of range",
|
|
Hours: []ExceptionalHours{
|
|
{Weekday: 7, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 0, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 1, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 2, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 3, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 4, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
},
|
|
WeekStarts: []string{"2026-06-01"},
|
|
}
|
|
|
|
handler := http.HandlerFunc(CreateExceptionalGroup)
|
|
w := makeRequest(handler, "POST", "/api/scheduling/exceptional-groups", group, ctx)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduling_CreateExceptionalGroup_InvalidWeekStart verifies that
|
|
// CreateExceptionalGroup returns 400 for an invalid week_start date format.
|
|
func TestScheduling_CreateExceptionalGroup_InvalidWeekStart(t *testing.T) {
|
|
ctx, _ := resetTestData(t)
|
|
|
|
group := ExceptionalGroup{
|
|
Name: "Bad Week Start",
|
|
Description: "Invalid format",
|
|
Hours: []ExceptionalHours{
|
|
{Weekday: 0, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 1, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 2, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 3, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 4, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false},
|
|
},
|
|
WeekStarts: []string{"not-a-date"},
|
|
}
|
|
|
|
handler := http.HandlerFunc(CreateExceptionalGroup)
|
|
w := makeRequest(handler, "POST", "/api/scheduling/exceptional-groups", group, ctx)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduling_CreateExceptionalGroup_WeekStartNotMonday verifies that
|
|
// CreateExceptionalGroup returns 400 when week_start is not a Monday.
|
|
func TestScheduling_CreateExceptionalGroup_WeekStartNotMonday(t *testing.T) {
|
|
ctx, _ := resetTestData(t)
|
|
|
|
group := ExceptionalGroup{
|
|
Name: "Bad Week Start",
|
|
Description: "Not Monday",
|
|
Hours: []ExceptionalHours{
|
|
{Weekday: 0, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 1, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 2, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 3, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 4, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
|
|
{Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false},
|
|
},
|
|
WeekStarts: []string{"2026-06-02"}, // Tuesday, not Monday
|
|
}
|
|
|
|
handler := http.HandlerFunc(CreateExceptionalGroup)
|
|
w := makeRequest(handler, "POST", "/api/scheduling/exceptional-groups", group, ctx)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Gap-Filling Tests for DeleteExceptionalGroup
|
|
// =============================================================================
|
|
|
|
// TestScheduling_DeleteExceptionalGroup_MissingID verifies that DELETE
|
|
// without the id parameter returns 400 Bad Request.
|
|
func TestScheduling_DeleteExceptionalGroup_MissingID(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(DeleteExceptionalGroup)
|
|
req := httptest.NewRequest("DELETE", "/api/scheduling/exceptional-groups", nil)
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduling_DeleteExceptionalGroup_InvalidID verifies that DELETE
|
|
// with a non-numeric id parameter returns 400 Bad Request.
|
|
func TestScheduling_DeleteExceptionalGroup_InvalidID(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(DeleteExceptionalGroup)
|
|
req := httptest.NewRequest("DELETE", "/api/scheduling/exceptional-groups?id=abc", nil)
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduling_DeleteExceptionalGroup_NotFound verifies that DELETE
|
|
// with a valid but non-existent id returns 404 Not Found.
|
|
func TestScheduling_DeleteExceptionalGroup_NotFound(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(DeleteExceptionalGroup)
|
|
req := httptest.NewRequest("DELETE", "/api/scheduling/exceptional-groups?id=99999", nil)
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Gap-Filling Tests for UpdateExceptionalApplications
|
|
// =============================================================================
|
|
|
|
// TestScheduling_UpdateExceptionalApplications_InvalidDate verifies that
|
|
// PUT with a non-Monday week_start returns 400 Bad Request.
|
|
func TestScheduling_UpdateExceptionalApplications_InvalidDate(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(UpdateExceptionalApplications)
|
|
|
|
reqBody := map[string]interface{}{
|
|
"groupId": 1,
|
|
"weekStarts": []string{"2026-03-03"}, // Tuesday, not Monday
|
|
}
|
|
|
|
w := makeRequest(handler, "PUT", "/api/scheduling/exceptional-applications", reqBody, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// --- Tests for GetConflictingBookingsForExceptionHandler ---
|
|
|
|
// makeAllClosedProposedHours returns 7 entries with all days closed (weekday 0-6).
|
|
func makeAllClosedProposedHours() []map[string]interface{} {
|
|
hours := make([]map[string]interface{}, 7)
|
|
for i := 0; i < 7; i++ {
|
|
hours[i] = map[string]interface{}{
|
|
"weekday": i,
|
|
"startTime": "00:00",
|
|
"endTime": "00:00",
|
|
"isOpen": false,
|
|
}
|
|
}
|
|
return hours
|
|
}
|
|
|
|
// makeDefaultOpenProposedHours returns 7 entries with Monday-Saturday open 09:00-17:00, Sunday closed.
|
|
func makeDefaultOpenProposedHours() []map[string]interface{} {
|
|
hours := make([]map[string]interface{}, 7)
|
|
for i := 0; i < 7; i++ {
|
|
if i == 6 {
|
|
hours[i] = map[string]interface{}{
|
|
"weekday": i, "startTime": "00:00", "endTime": "00:00", "isOpen": false,
|
|
}
|
|
} else {
|
|
hours[i] = map[string]interface{}{
|
|
"weekday": i, "startTime": "09:00", "endTime": "17:00", "isOpen": true,
|
|
}
|
|
}
|
|
}
|
|
return hours
|
|
}
|
|
|
|
func createTestBooking(t *testing.T, ctx context.Context, tx db.Querier, userID string, start time.Time, durationMinutes int) {
|
|
t.Helper()
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time)
|
|
VALUES ($1, $2, 'confirmed', $3, $4)
|
|
`, userID, start, durationMinutes, start.Add(time.Duration(durationMinutes)*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestConflictingBookings_ClosedDay_AllConflict verifies that when a day is closed,
|
|
// all active bookings on that day are returned as conflicts.
|
|
func TestConflictingBookings_ClosedDay_AllConflict(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), 60)
|
|
|
|
payload := map[string]interface{}{
|
|
"weekStarts": []string{"2026-03-16"},
|
|
"proposedHours": makeAllClosedProposedHours(),
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
|
|
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response OverlappingBookingsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if len(response.Bookings) != 1 {
|
|
t.Errorf("expected 1 conflict, got %d", len(response.Bookings))
|
|
}
|
|
}
|
|
|
|
// TestConflictingBookings_OpenDay_BookingWithinHours_NoConflict verifies that
|
|
// a booking fully within the proposed open window is NOT flagged as conflicting.
|
|
func TestConflictingBookings_OpenDay_BookingWithinHours_NoConflict(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), 60)
|
|
|
|
payload := map[string]interface{}{
|
|
"weekStarts": []string{"2026-03-16"},
|
|
"proposedHours": makeDefaultOpenProposedHours(),
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
|
|
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response OverlappingBookingsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if len(response.Bookings) != 0 {
|
|
t.Errorf("expected 0 conflicts, got %d", len(response.Bookings))
|
|
}
|
|
}
|
|
|
|
// TestConflictingBookings_StartsBeforeOpening verifies bookings starting before
|
|
// the proposed opening time are flagged as conflicts.
|
|
func TestConflictingBookings_StartsBeforeOpening(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 8, 30, 0, 0, time.UTC), 60)
|
|
|
|
payload := map[string]interface{}{
|
|
"weekStarts": []string{"2026-03-16"},
|
|
"proposedHours": makeDefaultOpenProposedHours(),
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
|
|
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response OverlappingBookingsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if len(response.Bookings) != 1 {
|
|
t.Errorf("expected 1 conflict, got %d", len(response.Bookings))
|
|
}
|
|
}
|
|
|
|
// TestConflictingBookings_EndsAfterClosing verifies bookings ending after the
|
|
// proposed closing time are flagged as conflicts.
|
|
func TestConflictingBookings_EndsAfterClosing(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 16, 30, 0, 0, time.UTC), 60)
|
|
|
|
payload := map[string]interface{}{
|
|
"weekStarts": []string{"2026-03-16"},
|
|
"proposedHours": makeDefaultOpenProposedHours(),
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
|
|
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response OverlappingBookingsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if len(response.Bookings) != 1 {
|
|
t.Errorf("expected 1 conflict, got %d", len(response.Bookings))
|
|
}
|
|
}
|
|
|
|
// TestConflictingBookings_MultipleWeeks verifies that bookings across two
|
|
// different weeks are both detected.
|
|
func TestConflictingBookings_MultipleWeeks(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), 60)
|
|
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 23, 10, 0, 0, 0, time.UTC), 60)
|
|
|
|
payload := map[string]interface{}{
|
|
"weekStarts": []string{"2026-03-16", "2026-03-23"},
|
|
"proposedHours": makeAllClosedProposedHours(),
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
|
|
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response OverlappingBookingsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if len(response.Bookings) != 2 {
|
|
t.Errorf("expected 2 conflicts, got %d", len(response.Bookings))
|
|
}
|
|
}
|
|
|
|
// TestConflictingBookings_ExcludedStatuses verifies that cancelled bookings are
|
|
// not returned.
|
|
func TestConflictingBookings_ExcludedStatuses(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), 60)
|
|
cancelledTime := time.Date(2026, 3, 16, 14, 0, 0, 0, time.UTC)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time)
|
|
VALUES ($1, $2, 'client_cancelled', 60, $3)
|
|
`, userID, cancelledTime, cancelledTime.Add(60*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("failed to create cancelled booking: %v", err)
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"weekStarts": []string{"2026-03-16"},
|
|
"proposedHours": makeAllClosedProposedHours(),
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
|
|
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response OverlappingBookingsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if len(response.Bookings) != 1 {
|
|
t.Errorf("expected 1 conflict (active booking), got %d", len(response.Bookings))
|
|
}
|
|
}
|
|
|
|
// TestConflictingBookings_InvalidPayload verifies 400 for bad input.
|
|
func TestConflictingBookings_InvalidPayload(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
|
|
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", map[string]interface{}{}, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for invalid payload, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestConflictingBookings_WrongMethod verifies that GET to a POST-only handler
|
|
// fails with a non-200 status (chi enforces method routing; the handler without
|
|
// chi reaches JSON decode and returns 400 for missing body).
|
|
func TestConflictingBookings_WrongMethod(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
|
|
w := makeRequest(handler, "GET", "/api/admin/bookings/conflicting-for-exception", nil, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for GET without body, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// --- Tests for GetPreviewAvailableHours ---
|
|
|
|
// TestPreviewAvailableHours_NoProposed verifies preview works without
|
|
// proposed hours (should match normal GetAvailableHours behaviour).
|
|
func TestPreviewAvailableHours_NoProposed(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GetPreviewAvailableHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/preview-available-hours?start=2026-02-16&end=2026-02-22", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req.WithContext(ctx))
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayAvailableHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if len(response) == 0 {
|
|
t.Error("expected available hours")
|
|
}
|
|
}
|
|
|
|
// TestPreviewAvailableHours_ProposedOverride verifies proposed hours override
|
|
// the normal schedule.
|
|
func TestPreviewAvailableHours_ProposedOverride(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
proposedJSON := `[{"weekday":0,"startTime":"10:00","endTime":"16:00","isOpen":true}]`
|
|
weeksJSON := `["2026-03-16"]`
|
|
|
|
req := httptest.NewRequest("GET",
|
|
"/api/scheduling/preview-available-hours?start=2026-03-16&end=2026-03-16"+
|
|
"&proposed_hours="+strings.ReplaceAll(strings.ReplaceAll(proposedJSON, "[", "%5B"), "]", "%5D")+
|
|
"&proposed_weeks="+strings.ReplaceAll(strings.ReplaceAll(weeksJSON, "[", "%5B"), "]", "%5D"),
|
|
nil)
|
|
w := httptest.NewRecorder()
|
|
handler := http.HandlerFunc(GetPreviewAvailableHours)
|
|
handler.ServeHTTP(w, req.WithContext(ctx))
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayAvailableHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
|
|
if len(response) != 1 {
|
|
t.Fatalf("expected 1 day, got %d", len(response))
|
|
}
|
|
if !response[0].IsOpen {
|
|
t.Error("expected Monday to be open (proposed override)")
|
|
}
|
|
if response[0].Source != "proposed" {
|
|
t.Errorf("expected source 'proposed', got '%s'", response[0].Source)
|
|
}
|
|
}
|
|
|
|
// TestPreviewAvailableHours_InvalidProposedHours verifies 400 for bad JSON.
|
|
func TestPreviewAvailableHours_InvalidProposedHours(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
req := httptest.NewRequest("GET",
|
|
"/api/scheduling/preview-available-hours?start=2026-03-16&end=2026-03-16&proposed_hours=not-json",
|
|
nil)
|
|
w := httptest.NewRecorder()
|
|
handler := http.HandlerFunc(GetPreviewAvailableHours)
|
|
handler.ServeHTTP(w, req.WithContext(ctx))
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for invalid JSON, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestPreviewAvailableHours_MissingParams verifies 400 when start/end missing.
|
|
func TestPreviewAvailableHours_MissingParams(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GetPreviewAvailableHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/preview-available-hours", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req.WithContext(ctx))
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// --- Tests for parseTimeToMinutes ---
|
|
|
|
func TestParseTimeToMinutes_Normal(t *testing.T) {
|
|
result := parseTimeToMinutes("09:30")
|
|
if result != 570 {
|
|
t.Errorf("expected 570, got %d", result)
|
|
}
|
|
}
|
|
|
|
func TestParseTimeToMinutes_Midnight(t *testing.T) {
|
|
result := parseTimeToMinutes("00:00")
|
|
if result != 0 {
|
|
t.Errorf("expected 0, got %d", result)
|
|
}
|
|
}
|
|
|
|
func TestParseTimeToMinutes_EndOfDay(t *testing.T) {
|
|
result := parseTimeToMinutes("23:59")
|
|
if result != 1439 {
|
|
t.Errorf("expected 1439, got %d", result)
|
|
}
|
|
}
|
|
|
|
func TestParseTimeToMinutes_SingleDigitHour(t *testing.T) {
|
|
result := parseTimeToMinutes("9:05")
|
|
if result != 545 {
|
|
t.Errorf("expected 545, got %d", result)
|
|
}
|
|
}
|
|
|
|
func TestParseTimeToMinutes_EmptyString(t *testing.T) {
|
|
result := parseTimeToMinutes("")
|
|
if result != -1 {
|
|
t.Errorf("expected -1, got %d", result)
|
|
}
|
|
}
|
|
|
|
func TestParseTimeToMinutes_NoColon(t *testing.T) {
|
|
result := parseTimeToMinutes("1230")
|
|
if result != -1 {
|
|
t.Errorf("expected -1, got %d", result)
|
|
}
|
|
}
|
|
|
|
func TestParseTimeToMinutes_HourOutOfRange(t *testing.T) {
|
|
result := parseTimeToMinutes("24:00")
|
|
if result != -1 {
|
|
t.Errorf("expected -1, got %d", result)
|
|
}
|
|
}
|
|
|
|
func TestParseTimeToMinutes_MinuteOutOfRange(t *testing.T) {
|
|
result := parseTimeToMinutes("10:60")
|
|
if result != -1 {
|
|
t.Errorf("expected -1, got %d", result)
|
|
}
|
|
}
|
|
|
|
func TestParseTimeToMinutes_TextInput(t *testing.T) {
|
|
result := parseTimeToMinutes("abc:def")
|
|
if result != -1 {
|
|
t.Errorf("expected -1, got %d", result)
|
|
}
|
|
}
|
|
|
|
func TestParseTimeToMinutes_NegativeHour(t *testing.T) {
|
|
result := parseTimeToMinutes("-1:00")
|
|
if result != -1 {
|
|
t.Errorf("expected -1, got %d", result)
|
|
}
|
|
}
|
|
|
|
func TestParseTimeToMinutes_JustColon(t *testing.T) {
|
|
result := parseTimeToMinutes(":")
|
|
if result != -1 {
|
|
t.Errorf("expected -1, got %d", result)
|
|
}
|
|
}
|
|
|
|
// --- Tests for sqlIn ---
|
|
|
|
func TestSQLIn_MultipleArgs(t *testing.T) {
|
|
query, args := sqlIn("SELECT * FROM foo WHERE id IN (%s)", []int{1, 2, 3})
|
|
expected := "SELECT * FROM foo WHERE id IN ($1,$2,$3)"
|
|
if query != expected {
|
|
t.Errorf("sqlIn query = %q, want %q", query, expected)
|
|
}
|
|
if len(args) != 3 {
|
|
t.Errorf("sqlIn args count = %d, want 3", len(args))
|
|
}
|
|
if args[0] != 1 || args[1] != 2 || args[2] != 3 {
|
|
t.Errorf("sqlIn args = %v, want [1 2 3]", args)
|
|
}
|
|
}
|
|
|
|
func TestSQLIn_SingleArg(t *testing.T) {
|
|
query, args := sqlIn("SELECT * FROM foo WHERE id IN (%s)", []int{42})
|
|
expected := "SELECT * FROM foo WHERE id IN ($1)"
|
|
if query != expected {
|
|
t.Errorf("sqlIn query = %q, want %q", query, expected)
|
|
}
|
|
if len(args) != 1 || args[0] != 42 {
|
|
t.Errorf("sqlIn args = %v, want [42]", args)
|
|
}
|
|
}
|
|
|
|
func TestSQLIn_EmptyArgs(t *testing.T) {
|
|
query, args := sqlIn("SELECT * FROM foo WHERE id IN (%s)", []int{})
|
|
expected := "SELECT * FROM foo WHERE id IN ()"
|
|
if query != expected {
|
|
t.Errorf("sqlIn query = %q, want %q", query, expected)
|
|
}
|
|
if len(args) != 0 {
|
|
t.Errorf("sqlIn args count = %d, want 0", len(args))
|
|
}
|
|
}
|
|
|
|
// --- Edge-case tests for GetConflictingBookingsForExceptionHandler ---
|
|
|
|
// TestConflictingBookings_BookingExactlyAtOpeningTime verifies a booking
|
|
// starting exactly at the proposed opening time does NOT conflict.
|
|
func TestConflictingBookings_BookingExactlyAtOpeningTime(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
// Monday 09:00 start — proposed open is 09:00
|
|
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 9, 0, 0, 0, time.UTC), 60)
|
|
|
|
payload := map[string]interface{}{
|
|
"weekStarts": []string{"2026-03-16"},
|
|
"proposedHours": makeDefaultOpenProposedHours(),
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
|
|
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response OverlappingBookingsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if len(response.Bookings) != 0 {
|
|
t.Errorf("expected 0 conflicts (booking starts exactly at opening), got %d", len(response.Bookings))
|
|
}
|
|
}
|
|
|
|
// TestConflictingBookings_BookingExactlyAtClosingTime verifies a booking
|
|
// ending exactly at the proposed closing time does NOT conflict.
|
|
func TestConflictingBookings_BookingExactlyAtClosingTime(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
// Monday 16:00 start (60min → ends 17:00) — proposed closing is 17:00
|
|
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 16, 0, 0, 0, time.UTC), 60)
|
|
|
|
payload := map[string]interface{}{
|
|
"weekStarts": []string{"2026-03-16"},
|
|
"proposedHours": makeDefaultOpenProposedHours(),
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
|
|
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response OverlappingBookingsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if len(response.Bookings) != 0 {
|
|
t.Errorf("expected 0 conflicts (booking ends exactly at closing), got %d", len(response.Bookings))
|
|
}
|
|
}
|
|
|
|
// TestConflictingBookings_BookingCrossesMidnight verifies that a booking
|
|
// spanning midnight is always flagged as a conflict (open hours can't span past midnight).
|
|
func TestConflictingBookings_BookingCrossesMidnight(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
// Monday 23:00 start, 120min duration → ends 01:00 Tuesday (crosses midnight)
|
|
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 23, 0, 0, 0, time.UTC), 120)
|
|
|
|
payload := map[string]interface{}{
|
|
"weekStarts": []string{"2026-03-16"},
|
|
"proposedHours": makeDefaultOpenProposedHours(),
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
|
|
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response OverlappingBookingsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if len(response.Bookings) != 1 {
|
|
t.Errorf("expected 1 conflict (midnight-crossing booking), got %d", len(response.Bookings))
|
|
}
|
|
}
|
|
|
|
// TestConflictingBookings_InvalidWeekStartFormat verifies 400 for bad week_start.
|
|
func TestConflictingBookings_InvalidWeekStartFormat(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
payload := map[string]interface{}{
|
|
"weekStarts": []string{"not-a-date"},
|
|
"proposedHours": makeAllClosedProposedHours(),
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
|
|
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for invalid week_start, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestConflictingBookings_BookingOnSundayClosed verifies a conflict on Sunday
|
|
// (weekday 6 in our convention) when it's marked closed.
|
|
func TestConflictingBookings_BookingOnSundayClosed(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
// Sunday March 22, 2026 — 10:00 booking
|
|
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC), 60)
|
|
|
|
payload := map[string]interface{}{
|
|
"weekStarts": []string{"2026-03-16"},
|
|
"proposedHours": makeDefaultOpenProposedHours(), // Sunday (idx 6) is closed
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
|
|
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response OverlappingBookingsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if len(response.Bookings) != 1 {
|
|
t.Errorf("expected 1 conflict (Sunday closed), got %d", len(response.Bookings))
|
|
}
|
|
}
|
|
|
|
// TestConflictingBookings_EmptyResultReturnsEmptyArray verifies that the
|
|
// response always has a JSON array (never null) when there are no conflicts.
|
|
func TestConflictingBookings_EmptyResultReturnsEmptyArray(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
payload := map[string]interface{}{
|
|
"weekStarts": []string{"2026-03-16"},
|
|
"proposedHours": makeDefaultOpenProposedHours(),
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler)
|
|
w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response OverlappingBookingsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if response.Bookings == nil {
|
|
t.Error("expected non-nil bookings array, got nil")
|
|
}
|
|
if len(response.Bookings) != 0 {
|
|
t.Errorf("expected 0 conflicts, got %d", len(response.Bookings))
|
|
}
|
|
}
|
|
|
|
// --- Edge-case tests for GetPreviewAvailableHours ---
|
|
|
|
// TestPreviewAvailableHours_ProposedOverrideWithBookings verifies that when
|
|
// proposed hours are used, existing bookings still reduce available slots.
|
|
func TestPreviewAvailableHours_ProposedOverrideWithBookings(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
// Monday 10:00-11:00 booking
|
|
createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), 60)
|
|
|
|
// Proposed: Monday open 09:00-17:00
|
|
proposedJSON := `[{"weekday":0,"startTime":"09:00","endTime":"17:00","isOpen":true}]`
|
|
weeksJSON := `["2026-03-16"]`
|
|
|
|
req := httptest.NewRequest("GET",
|
|
"/api/scheduling/preview-available-hours?start=2026-03-16&end=2026-03-16"+
|
|
"&proposed_hours="+strings.ReplaceAll(strings.ReplaceAll(proposedJSON, "[", "%5B"), "]", "%5D")+
|
|
"&proposed_weeks="+strings.ReplaceAll(strings.ReplaceAll(weeksJSON, "[", "%5B"), "]", "%5D"),
|
|
nil)
|
|
w := httptest.NewRecorder()
|
|
handler := http.HandlerFunc(GetPreviewAvailableHours)
|
|
handler.ServeHTTP(w, req.WithContext(ctx))
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayAvailableHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
|
|
if len(response) != 1 {
|
|
t.Fatalf("expected 1 day, got %d", len(response))
|
|
}
|
|
if response[0].Source != "proposed" {
|
|
t.Errorf("expected source 'proposed', got '%s'", response[0].Source)
|
|
}
|
|
if !response[0].IsOpen {
|
|
t.Error("expected Monday to be open")
|
|
}
|
|
// With a 10:00-11:00 booking removed from 09:00-17:00, we should see 2 slots: 09:00-10:00 and 11:00-17:00
|
|
if len(response[0].Slots) != 2 {
|
|
t.Errorf("expected 2 slots (booking removed), got %d: %+v", len(response[0].Slots), response[0].Slots)
|
|
}
|
|
}
|
|
|
|
// TestPreviewAvailableHours_InvalidProposedWeeks verifies 400 for bad weeks JSON.
|
|
func TestPreviewAvailableHours_InvalidProposedWeeks(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
req := httptest.NewRequest("GET",
|
|
"/api/scheduling/preview-available-hours?start=2026-03-16&end=2026-03-16&proposed_weeks=not-json",
|
|
nil)
|
|
w := httptest.NewRecorder()
|
|
handler := http.HandlerFunc(GetPreviewAvailableHours)
|
|
handler.ServeHTTP(w, req.WithContext(ctx))
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for invalid weeks JSON, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestPreviewAvailableHours_OutOfHours_Admin verifies out-of-hours override
|
|
// works in preview mode for admin users.
|
|
func TestPreviewAvailableHours_OutOfHours_Admin(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, adminID)
|
|
|
|
proposedJSON := `[{"weekday":0,"startTime":"10:00","endTime":"16:00","isOpen":true}]`
|
|
weeksJSON := `["2026-03-16"]`
|
|
|
|
req := httptest.NewRequest("GET",
|
|
"/api/scheduling/preview-available-hours?start=2026-03-16&end=2026-03-16"+
|
|
"&out_of_hours=true"+
|
|
"&proposed_hours="+strings.ReplaceAll(strings.ReplaceAll(proposedJSON, "[", "%5B"), "]", "%5D")+
|
|
"&proposed_weeks="+strings.ReplaceAll(strings.ReplaceAll(weeksJSON, "[", "%5B"), "]", "%5D"),
|
|
nil)
|
|
req = req.WithContext(ctx)
|
|
// Set admin role in context
|
|
req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin"))
|
|
|
|
w := httptest.NewRecorder()
|
|
handler := http.HandlerFunc(GetPreviewAvailableHours)
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayAvailableHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
|
|
if len(response) != 1 {
|
|
t.Fatalf("expected 1 day, got %d", len(response))
|
|
}
|
|
// Out-of-hours should override to 06:00-22:00
|
|
if response[0].Source != "out_of_hours" {
|
|
t.Errorf("expected source 'out_of_hours', got '%s'", response[0].Source)
|
|
}
|
|
if !response[0].IsOpen {
|
|
t.Error("expected out_of_hours to be open")
|
|
}
|
|
}
|
|
|
|
// --- Tests for Staged Default Hours Change ---
|
|
|
|
// TestScheduleDefaultHoursChange_Success creates a pending change.
|
|
func TestScheduleDefaultHoursChange_Success(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, adminID)
|
|
|
|
futureDate := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
|
|
|
|
hours := make([]map[string]interface{}, 7)
|
|
for i := 0; i < 7; i++ {
|
|
hours[i] = map[string]interface{}{
|
|
"weekday": i,
|
|
"startTime": "10:00",
|
|
"endTime": "18:00",
|
|
"isOpen": true,
|
|
}
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"hours": hours,
|
|
"effective_date": futureDate,
|
|
}
|
|
|
|
adminCtx := context.WithValue(ctx, mw.UserIDKey, adminID)
|
|
handler := http.HandlerFunc(ScheduleDefaultHoursChange)
|
|
w := makeRequest(handler, "POST", "/api/admin/scheduling/default-hours/schedule", payload, adminCtx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]string
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if resp["effective_date"] != futureDate {
|
|
t.Errorf("expected effective_date %s, got %s", futureDate, resp["effective_date"])
|
|
}
|
|
}
|
|
|
|
// TestScheduleDefaultHoursChange_PastDate rejects past dates.
|
|
func TestScheduleDefaultHoursChange_PastDate(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, adminID)
|
|
|
|
pastDate := "2020-01-01"
|
|
hours := make([]map[string]interface{}, 7)
|
|
for i := 0; i < 7; i++ {
|
|
hours[i] = map[string]interface{}{"weekday": i, "startTime": "10:00", "endTime": "18:00", "isOpen": true}
|
|
}
|
|
|
|
payload := map[string]interface{}{"hours": hours, "effective_date": pastDate}
|
|
adminCtx := context.WithValue(ctx, mw.UserIDKey, adminID)
|
|
handler := http.HandlerFunc(ScheduleDefaultHoursChange)
|
|
w := makeRequest(handler, "POST", "/api/admin/scheduling/default-hours/schedule", payload, adminCtx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for past date, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduleDefaultHoursChange_Duplicate rejects a second pending change.
|
|
func TestScheduleDefaultHoursChange_Duplicate(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, adminID)
|
|
|
|
futureDate := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
|
|
hours := make([]map[string]interface{}, 7)
|
|
for i := 0; i < 7; i++ {
|
|
hours[i] = map[string]interface{}{"weekday": i, "startTime": "10:00", "endTime": "18:00", "isOpen": true}
|
|
}
|
|
payload := map[string]interface{}{"hours": hours, "effective_date": futureDate}
|
|
|
|
adminCtx := context.WithValue(ctx, mw.UserIDKey, adminID)
|
|
handler := http.HandlerFunc(ScheduleDefaultHoursChange)
|
|
|
|
w1 := makeRequest(handler, "POST", "/api/admin/scheduling/default-hours/schedule", payload, adminCtx)
|
|
if w1.Code != http.StatusOK {
|
|
t.Fatalf("first schedule should succeed, got %d: %s", w1.Code, w1.Body.String())
|
|
}
|
|
|
|
w2 := makeRequest(handler, "POST", "/api/admin/scheduling/default-hours/schedule", payload, adminCtx)
|
|
if w2.Code != http.StatusConflict {
|
|
t.Errorf("expected 409 for duplicate, got %d: %s", w2.Code, w2.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestGetScheduledDefaultHoursChange returns the pending change.
|
|
func TestGetScheduledDefaultHoursChange_Success(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, adminID)
|
|
|
|
futureDate := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
|
|
|
|
hoursJSON := `[{"weekday":0,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":1,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":2,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":3,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":4,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":5,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":6,"startTime":"10:00","endTime":"18:00","isOpen":true}]`
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO default_hours_scheduled_changes (effective_date, created_by, hours)
|
|
VALUES ($1, $2, $3)
|
|
`, futureDate, adminID, hoursJSON)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert scheduled change: %v", err)
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetScheduledDefaultHoursChange)
|
|
w := makeRequest(handler, "GET", "/api/admin/scheduling/default-hours/scheduled", nil, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp ScheduledHoursChange
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if resp.EffectiveDate != futureDate {
|
|
t.Errorf("expected effective_date %s, got %s", futureDate, resp.EffectiveDate)
|
|
}
|
|
}
|
|
|
|
// TestCancelScheduledDefaultHoursChange cancels a pending change.
|
|
func TestCancelScheduledDefaultHoursChange_Success(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, adminID)
|
|
|
|
futureDate := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
|
|
hoursJSON := `[{"weekday":0,"startTime":"10:00","endTime":"18:00","isOpen":true}]`
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO default_hours_scheduled_changes (effective_date, created_by, hours)
|
|
VALUES ($1, $2, $3)
|
|
`, futureDate, adminID, hoursJSON)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert scheduled change: %v", err)
|
|
}
|
|
|
|
handler := http.HandlerFunc(CancelScheduledDefaultHoursChange)
|
|
w := makeRequest(handler, "DELETE", "/api/admin/scheduling/default-hours/scheduled", nil, ctx)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
handler2 := http.HandlerFunc(GetScheduledDefaultHoursChange)
|
|
w2 := makeRequest(handler2, "GET", "/api/admin/scheduling/default-hours/scheduled", nil, ctx)
|
|
if w2.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404 after cancel, got %d", w2.Code)
|
|
}
|
|
}
|
|
|
|
// TestCancelScheduledDefaultHoursChange_NoChange returns 404 when nothing to cancel.
|
|
func TestCancelScheduledDefaultHoursChange_NoChange(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(CancelScheduledDefaultHoursChange)
|
|
w := makeRequest(handler, "DELETE", "/api/admin/scheduling/default-hours/scheduled", nil, ctx)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404 when nothing to cancel, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduleDefaultHoursChange_TodayDate verifies scheduling with today's
|
|
// date is rejected — changes can only start from tomorrow.
|
|
func TestScheduleDefaultHoursChange_TodayDate(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, adminID)
|
|
|
|
today := clock.Now().In(clock.London).Format("2006-01-02")
|
|
hours := make([]map[string]interface{}, 7)
|
|
for i := 0; i < 7; i++ {
|
|
hours[i] = map[string]interface{}{"weekday": i, "startTime": "10:00", "endTime": "18:00", "isOpen": true}
|
|
}
|
|
|
|
payload := map[string]interface{}{"hours": hours, "effective_date": today}
|
|
adminCtx := context.WithValue(ctx, mw.UserIDKey, adminID)
|
|
handler := http.HandlerFunc(ScheduleDefaultHoursChange)
|
|
w := makeRequest(handler, "POST", "/api/admin/scheduling/default-hours/schedule", payload, adminCtx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for today's date, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduleDefaultHoursChange_TomorrowDate verifies scheduling with
|
|
// tomorrow's date succeeds — changes can start from tomorrow onwards.
|
|
func TestScheduleDefaultHoursChange_TomorrowDate(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, adminID)
|
|
|
|
tomorrow := clock.Now().In(clock.London).AddDate(0, 0, 1).Format("2006-01-02")
|
|
hours := make([]map[string]interface{}, 7)
|
|
for i := 0; i < 7; i++ {
|
|
hours[i] = map[string]interface{}{"weekday": i, "startTime": "10:00", "endTime": "18:00", "isOpen": true}
|
|
}
|
|
|
|
payload := map[string]interface{}{"hours": hours, "effective_date": tomorrow}
|
|
adminCtx := context.WithValue(ctx, mw.UserIDKey, adminID)
|
|
handler := http.HandlerFunc(ScheduleDefaultHoursChange)
|
|
w := makeRequest(handler, "POST", "/api/admin/scheduling/default-hours/schedule", payload, adminCtx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200 for tomorrow's date, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestGetDefaultHoursConflictingBookings_NoConflicts verifies conflict
|
|
// detection returns empty when no bookings overlap the proposed hours.
|
|
func TestGetDefaultHoursConflictingBookings_NoConflicts(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, adminID)
|
|
|
|
tomorrow := clock.Now().In(clock.London).AddDate(0, 0, 1).Format("2006-01-02")
|
|
proposedHours := make([]map[string]interface{}, 7)
|
|
for i := 0; i < 7; i++ {
|
|
proposedHours[i] = map[string]interface{}{"weekday": i, "startTime": "09:00", "endTime": "17:00", "isOpen": true}
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"proposedHours": proposedHours,
|
|
"effective_date": tomorrow,
|
|
}
|
|
adminCtx := context.WithValue(ctx, mw.UserIDKey, adminID)
|
|
handler := http.HandlerFunc(GetDefaultHoursConflictingBookings)
|
|
w := makeRequest(handler, "POST", "/api/admin/scheduling/default-hours/conflicting", payload, adminCtx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp OverlappingBookingsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if len(resp.Bookings) != 0 {
|
|
t.Errorf("expected 0 conflicting bookings (no bookings exist), got %d", len(resp.Bookings))
|
|
}
|
|
}
|
|
|
|
// TestGetDefaultHoursConflictingBookings_WithConflicts verifies conflict
|
|
// detection finds bookings that fall outside the proposed hours window.
|
|
func TestGetDefaultHoursConflictingBookings_WithConflicts(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
// Place a booking at 08:00-09:00 tomorrow (London time). Proposed hours
|
|
// are 09:00-17:00 -> this booking should be a conflict (starts before 09:00).
|
|
tomorrowLondon := clock.Now().In(clock.London).AddDate(0, 0, 1)
|
|
bookingTime := time.Date(tomorrowLondon.Year(), tomorrowLondon.Month(), tomorrowLondon.Day(), 8, 0, 0, 0, clock.London).UTC()
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time)
|
|
VALUES ($1, $2, 'confirmed', 60, $3)
|
|
`, userID, bookingTime, bookingTime.Add(60*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
tomorrow := tomorrowLondon.Format("2006-01-02")
|
|
proposedHours := make([]map[string]interface{}, 7)
|
|
for i := 0; i < 7; i++ {
|
|
proposedHours[i] = map[string]interface{}{"weekday": i, "startTime": "09:00", "endTime": "17:00", "isOpen": true}
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"proposedHours": proposedHours,
|
|
"effective_date": tomorrow,
|
|
}
|
|
adminCtx := context.WithValue(ctx, mw.UserIDKey, adminID)
|
|
handler := http.HandlerFunc(GetDefaultHoursConflictingBookings)
|
|
w := makeRequest(handler, "POST", "/api/admin/scheduling/default-hours/conflicting", payload, adminCtx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp OverlappingBookingsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if len(resp.Bookings) != 1 {
|
|
t.Errorf("expected 1 conflicting booking, got %d", len(resp.Bookings))
|
|
}
|
|
}
|
|
|
|
// TestGetWorkingHours_WithStagedChange verifies that GetWorkingHours returns
|
|
// the staged hours for dates on/after the effective date.
|
|
func TestGetWorkingHours_WithStagedChange(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, adminID)
|
|
|
|
londonNow := clock.Now().In(clock.London)
|
|
today := londonNow.Format("2006-01-02")
|
|
tomorrow := londonNow.AddDate(0, 0, 1).Format("2006-01-02")
|
|
endDay := londonNow.AddDate(0, 0, 6).Format("2006-01-02")
|
|
|
|
// Staged change sets all weekdays to 10:00-18:00 open
|
|
hoursJSON := `[{"weekday":0,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":1,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":2,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":3,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":4,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":5,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":6,"startTime":"10:00","endTime":"18:00","isOpen":true}]`
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO default_hours_scheduled_changes (effective_date, created_by, hours)
|
|
VALUES ($1, $2, $3)
|
|
`, tomorrow, adminID, hoursJSON)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert staged change: %v", err)
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetWorkingHours)
|
|
// Query today through today+6 — spans before and after the staged effective_date
|
|
w := makeRequest(handler, "GET", "/api/scheduling/working-hours?start="+today+"&end="+endDay, nil, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayWorkingHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
|
|
if len(response) != 7 {
|
|
t.Fatalf("expected 7 days, got %d", len(response))
|
|
}
|
|
|
|
// Days on/after the effective date (tomorrow) must use staged hours (10:00-18:00).
|
|
// Days before must use current working_hours (which vary by weekday).
|
|
stagedCount, currentCount := 0, 0
|
|
for _, day := range response {
|
|
if day.Date >= tomorrow {
|
|
stagedCount++
|
|
if day.StartTime != "10:00" || day.EndTime != "18:00" {
|
|
t.Errorf("date %s should show staged hours (10:00-18:00), got %s-%s (isOpen=%v)",
|
|
day.Date, day.StartTime, day.EndTime, day.IsOpen)
|
|
}
|
|
} else {
|
|
currentCount++
|
|
}
|
|
}
|
|
if stagedCount == 0 {
|
|
t.Error("expected at least one day with staged hours")
|
|
}
|
|
if currentCount == 0 {
|
|
t.Error("expected at least one day with current hours")
|
|
}
|
|
}
|
|
|
|
// TestGetDefaultHours_WithScheduledChange verifies GetDefaultHours includes
|
|
// the scheduled_change field when a pending change exists.
|
|
func TestGetDefaultHours_WithScheduledChange(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, adminID)
|
|
|
|
tomorrow := clock.Now().In(clock.London).AddDate(0, 0, 1).Format("2006-01-02")
|
|
hoursJSON := `[{"weekday":0,"startTime":"10:00","endTime":"18:00","isOpen":true}]`
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO default_hours_scheduled_changes (effective_date, created_by, hours)
|
|
VALUES ($1, $2, $3)
|
|
`, tomorrow, adminID, hoursJSON)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert staged change: %v", err)
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetDefaultHours)
|
|
w := makeRequest(handler, "GET", "/api/scheduling/default-hours", nil, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp ScheduledHoursChangeResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
if resp.ScheduledChange == nil {
|
|
t.Fatal("expected scheduled_change to be non-nil")
|
|
}
|
|
if resp.ScheduledChange.EffectiveDate != tomorrow {
|
|
t.Errorf("expected effective_date %s, got %s", tomorrow, resp.ScheduledChange.EffectiveDate)
|
|
}
|
|
if len(resp.Current) != 7 {
|
|
t.Errorf("expected 7 current hours, got %d", len(resp.Current))
|
|
}
|
|
}
|