Files
Crussell/backend/handlers/scheduling/scheduling_test.go
T
2026-03-02 11:37:31 +00:00

552 lines
17 KiB
Go

//go:build test
// +build test
// 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).
package scheduling
//go:build test
// +build test
package scheduling
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"crussell/db"
"crussell/mw"
"crussell/testutils/jwt"
"crussell/testutils/testdb"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupTestDB(t *testing.T) func() {
t.Helper()
pool := testdb.Pool(t)
testdb.Migrate(t, pool)
testdb.TruncateTables(t, pool)
originalDB := db.DB
db.DB = pool
jwt.Init()
// Seed default working hours
seedDefaultWorkingHours(t, pool)
return func() {
db.DB = originalDB
pool.Close()
}
}
func seedDefaultWorkingHours(t *testing.T, pool *pgxpool.Pool) {
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 := pool.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.
cleanup := setupTestDB(t)
defer cleanup()
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.
cleanup := setupTestDB(t)
defer cleanup()
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.
cleanup := setupTestDB(t)
defer cleanup()
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).
cleanup := setupTestDB(t)
defer cleanup()
// 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.
cleanup := setupTestDB(t)
defer cleanup()
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.
cleanup := setupTestDB(t)
defer cleanup()
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 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.
cleanup := setupTestDB(t)
defer cleanup()
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.
cleanup := setupTestDB(t)
defer cleanup()
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.
cleanup := setupTestDB(t)
defer cleanup()
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.
cleanup := setupTestDB(t)
defer cleanup()
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.
cleanup := setupTestDB(t)
defer cleanup()
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.
cleanup := setupTestDB(t)
defer cleanup()
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())
}
}