Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
832 lines
28 KiB
Go
832 lines
28 KiB
Go
//go:build test
|
|
// +build test
|
|
|
|
package scheduling
|
|
|
|
// Package scheduling contains tests for working hours and availability endpoints.
|
|
//
|
|
// Test Coverage:
|
|
// - GetDefaultHours: GET /api/scheduling/default-hours - Get default weekly hours
|
|
// - UpdateDefaultHours: PUT /api/scheduling/default-hours - Update default hours (admin)
|
|
// - ListExceptionalGroups: GET /api/scheduling/exceptional-groups - List holiday hour groups
|
|
// - CreateExceptionalGroup: POST /api/scheduling/exceptional-groups - Create group (admin)
|
|
// - DeleteExceptionalGroup: DELETE /api/scheduling/exceptional-groups?id=X - Delete (admin)
|
|
// - GetWorkingHours: GET /api/scheduling/working-hours?start=X&end=Y - Get hours for date range
|
|
// - GetAvailableHours: GET /api/scheduling/available-hours?start=X&end=Y - Get available slots
|
|
// - UpdateExceptionalApplications: PUT /api/scheduling/exceptional-applications - Apply holidays
|
|
//
|
|
// Authentication: Update/Create/Delete endpoints require admin role (403 for non-admins).
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/mw"
|
|
"crussell/testutils/jwt"
|
|
"crussell/testutils/testdb"
|
|
)
|
|
|
|
func resetTestData(t *testing.T) {
|
|
t.Helper()
|
|
testdb.TruncateTables(t, db.DB)
|
|
// Also truncate financial_aggregates which is not in the default truncation list
|
|
db.DB.Exec(context.Background(), "TRUNCATE financial_aggregates CASCADE")
|
|
seedDefaultWorkingHours(t)
|
|
}
|
|
|
|
func seedDefaultWorkingHours(t *testing.T) {
|
|
t.Helper()
|
|
|
|
// Seed 7 days of working hours (Monday=0 to Sunday=6)
|
|
hours := []struct {
|
|
weekday int
|
|
startTime string
|
|
endTime string
|
|
isOpen bool
|
|
}{
|
|
{0, "09:00", "17:00", true}, // Monday
|
|
{1, "09:00", "17:00", true}, // Tuesday
|
|
{2, "09:00", "17:00", true}, // Wednesday
|
|
{3, "09:00", "17:00", true}, // Thursday
|
|
{4, "09:00", "17:00", true}, // Friday
|
|
{5, "10:00", "16:00", true}, // Saturday
|
|
{6, "00:00", "00:00", false}, // Sunday
|
|
}
|
|
|
|
for _, h := range hours {
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (weekday) DO UPDATE SET start_time = $2, end_time = $3, is_open = $4
|
|
`, h.weekday, h.startTime, h.endTime, h.isOpen)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed working hours: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func makeRequest(handler http.HandlerFunc, method, path string, body interface{}) *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)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func makeAuthRequest(handler http.Handler, method, path, token string, body interface{}) *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)
|
|
}
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GetDefaultHours)
|
|
w := makeRequest(handler, "GET", "/api/scheduling/default-hours", nil)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DefaultHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if len(response) != 7 {
|
|
t.Errorf("expected 7 days of hours, got %d", len(response))
|
|
}
|
|
|
|
// Verify Monday (weekday 0) has our seeded hours
|
|
var monday *DefaultHours
|
|
for i := range response {
|
|
if response[i].Weekday == 0 {
|
|
monday = &response[i]
|
|
break
|
|
}
|
|
}
|
|
if monday == nil {
|
|
t.Fatal("expected Monday hours in response")
|
|
}
|
|
// Database returns HH:MM:SS format
|
|
if monday.StartTime != "09:00:00" {
|
|
t.Errorf("expected Monday start time 09:00:00, got %s", monday.StartTime)
|
|
}
|
|
if monday.EndTime != "17:00:00" {
|
|
t.Errorf("expected Monday end time 17:00:00, got %s", monday.EndTime)
|
|
}
|
|
if !monday.IsOpen {
|
|
t.Error("expected Monday to be open")
|
|
}
|
|
}
|
|
|
|
// --- Tests for UpdateDefaultHours ---
|
|
|
|
// TestScheduling_UpdateDefaultHours_Admin tests that an admin can update
|
|
// the default weekly working hours. The new schedule is persisted to the
|
|
// database and returned on subsequent requests.
|
|
func TestScheduling_UpdateDefaultHours_Admin(t *testing.T) {
|
|
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)
|
|
|
|
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 := db.DB.Query(context.Background(), `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) {
|
|
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)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
// Create an exceptional group
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
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)
|
|
|
|
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) {
|
|
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)
|
|
|
|
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) {
|
|
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)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
w := makeAuthRequest(handler, "PUT", "/api/scheduling/default-hours", adminToken, tt.hours)
|
|
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) {
|
|
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)
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
// Create a group to delete
|
|
var groupID int
|
|
err := db.DB.QueryRow(context.Background(), `
|
|
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)
|
|
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 = db.DB.QueryRow(context.Background(), `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) {
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GetWorkingHours)
|
|
req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayWorkingHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if len(response) == 0 {
|
|
t.Error("expected working hours in response")
|
|
}
|
|
|
|
// Verify source is "default" for seeded hours
|
|
for _, day := range response {
|
|
if day.Source != "default" {
|
|
t.Errorf("expected source 'default', got %s", day.Source)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// --- Tests for GetAvailableHours ---
|
|
|
|
// TestScheduling_GetAvailableHours tests that available appointment
|
|
// slots can be calculated for a date range based on working hours and service
|
|
// durations.
|
|
func TestScheduling_GetAvailableHours(t *testing.T) {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Tests for UpdateExceptionalApplications ---
|
|
|
|
// 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) {
|
|
resetTestData(t)
|
|
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
// Create a group
|
|
var groupID int
|
|
err := db.DB.QueryRow(context.Background(), `
|
|
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)
|
|
|
|
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 = db.DB.QueryRow(context.Background(), `
|
|
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) {
|
|
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)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
// Create a time blocker for 2026-03-16 10:00-11:00 (Monday - an open day)
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, ukLocation)
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
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
|
|
ctx := context.WithValue(req.Context(), mw.UserIDKey, "user001")
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayAvailableHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if len(response) == 0 {
|
|
t.Fatal("expected at least one day in response")
|
|
}
|
|
|
|
// Find the day with the blocker (2026-03-16)
|
|
var targetDay *DayAvailableHours
|
|
for i := range response {
|
|
if response[i].Date == "2026-03-16" {
|
|
targetDay = &response[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-03-16 in response")
|
|
}
|
|
|
|
// Verify blocker is NOT visible in blockers field for non-admin
|
|
if len(targetDay.Blockers) > 0 {
|
|
t.Error("expected blockers field to be empty for non-admin users")
|
|
}
|
|
|
|
// Verify 10:00-11:00 slot is NOT available (subtracted due to blocker)
|
|
for _, slot := range targetDay.Slots {
|
|
if slot.StartTime == "10:00" {
|
|
t.Error("expected 10:00 slot to be blocked and not available")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestScheduling_GetAvailableHours_WithBlocker_Admin verifies that admin users
|
|
// CAN see blocked time slots in the blockers field.
|
|
func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
// Create a time blocker for 2026-03-16 10:00-11:00 (Monday - open day)
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, ukLocation)
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
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
|
|
ctx := context.WithValue(req.Context(), mw.UserIDKey, "admin001")
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []DayAvailableHours
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if len(response) == 0 {
|
|
t.Fatal("expected at least one day in response")
|
|
}
|
|
|
|
// Find the day with the blocker (2026-03-16)
|
|
var targetDay *DayAvailableHours
|
|
for i := range response {
|
|
if response[i].Date == "2026-03-16" {
|
|
targetDay = &response[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if targetDay == nil {
|
|
t.Fatal("expected day 2026-03-16 in response")
|
|
}
|
|
|
|
// Verify blocker IS visible in blockers field for admin
|
|
if len(targetDay.Blockers) == 0 {
|
|
t.Error("expected blockers field to contain the blocker for admin users")
|
|
} else {
|
|
// Verify the blocker time range
|
|
found := false
|
|
for _, blocker := range targetDay.Blockers {
|
|
if blocker.StartTime == "10:00" && blocker.EndTime == "11:00" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected blocker 10:00-11:00 in blockers field")
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Direct unit tests for isValidTime15Min ---
|
|
|
|
// TestIsValidTime15Min tests the time validation helper directly for all
|
|
// supported formats (HH:MM, HH:MM:SS) and edge cases.
|
|
func TestIsValidTime15Min(t *testing.T) {
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|