feat(scheduling): add out_of_hours override and fix timezone handling
Support out_of_hours query param in GetWorkingHours and GetAvailableHours (admin-only, returns 06:00-22:00 for all days). Replace ukLocation with time.Local for date boundaries. Add OptionalAuth middleware to scheduling routes for admin role detection. Add tests for admin/non-admin/no-auth scenarios, booking respect, and exceptional hours interaction. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -137,21 +137,28 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ukLocation, _ := time.LoadLocation("Europe/London")
|
||||
start, err := time.ParseInLocation("2006-01-02", startStr, ukLocation)
|
||||
start, err := time.Parse("2006-01-02", startStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid start date", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
end, err := time.ParseInLocation("2006-01-02", endStr, ukLocation)
|
||||
end, err := time.Parse("2006-01-02", endStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid end date", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse out_of_hours toggle (admin-only extended hours)
|
||||
outOfHours := r.URL.Query().Get("out_of_hours") == "true"
|
||||
isAdmin := false
|
||||
if userRole, ok := r.Context().Value(mw.UserRoleKey).(string); ok {
|
||||
isAdmin = userRole == "admin"
|
||||
}
|
||||
useOutOfHours := outOfHours && isAdmin
|
||||
|
||||
// Set to local start/end of day
|
||||
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, ukLocation)
|
||||
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, ukLocation)
|
||||
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.Local)
|
||||
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, time.Local)
|
||||
|
||||
// Load default hours
|
||||
defaultMap := map[int]DefaultHours{}
|
||||
@@ -190,7 +197,7 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) {
|
||||
for appRows.Next() {
|
||||
var a appEntry
|
||||
if err := appRows.Scan(&a.GroupID, &a.WeekStart); err == nil {
|
||||
a.WeekStart = time.Date(a.WeekStart.Year(), a.WeekStart.Month(), a.WeekStart.Day(), 0, 0, 0, 0, ukLocation)
|
||||
a.WeekStart = time.Date(a.WeekStart.Year(), a.WeekStart.Month(), a.WeekStart.Day(), 0, 0, 0, 0, time.Local)
|
||||
apps = append(apps, a)
|
||||
groupIDs = append(groupIDs, a.GroupID)
|
||||
}
|
||||
@@ -229,7 +236,7 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) {
|
||||
daysSinceMonday = 6 // Sunday
|
||||
}
|
||||
weekStart := d.AddDate(0, 0, -daysSinceMonday)
|
||||
weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, ukLocation)
|
||||
weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.Local)
|
||||
|
||||
var applied *ExceptionalHours
|
||||
weekStartStr := weekStart.Format("2006-01-02")
|
||||
@@ -264,6 +271,14 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) {
|
||||
day.Source = "default"
|
||||
}
|
||||
|
||||
// Out-of-hours override for admin
|
||||
if useOutOfHours {
|
||||
day.IsOpen = true
|
||||
day.StartTime = "06:00"
|
||||
day.EndTime = "22:00"
|
||||
day.Source = "default"
|
||||
}
|
||||
|
||||
results = append(results, day)
|
||||
}
|
||||
|
||||
@@ -325,14 +340,15 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "start and end query params required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
start, _ := time.Parse("2006-01-02", startStr)
|
||||
end, _ := time.Parse("2006-01-02", endStr)
|
||||
|
||||
ukLocation, _ := time.LoadLocation("Europe/London")
|
||||
start, _ := time.ParseInLocation("2006-01-02", startStr, ukLocation)
|
||||
end, _ := time.ParseInLocation("2006-01-02", endStr, ukLocation)
|
||||
// Parse out_of_hours toggle (admin-only extended hours)
|
||||
outOfHours := r.URL.Query().Get("out_of_hours") == "true"
|
||||
|
||||
// set start/end of day
|
||||
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, ukLocation)
|
||||
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, ukLocation)
|
||||
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.Local)
|
||||
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, time.Local)
|
||||
|
||||
// Clean up old reservations (older than 1 hour)
|
||||
if err := CleanupOldReservations(r.Context()); err != nil {
|
||||
@@ -413,7 +429,7 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
|
||||
for appRows.Next() {
|
||||
var a appEntry
|
||||
if err := appRows.Scan(&a.GroupID, &a.WeekStart); err == nil {
|
||||
a.WeekStart = time.Date(a.WeekStart.Year(), a.WeekStart.Month(), a.WeekStart.Day(), 0, 0, 0, 0, ukLocation)
|
||||
a.WeekStart = time.Date(a.WeekStart.Year(), a.WeekStart.Month(), a.WeekStart.Day(), 0, 0, 0, 0, time.Local)
|
||||
apps = append(apps, a)
|
||||
groupIDs = append(groupIDs, a.GroupID)
|
||||
}
|
||||
@@ -456,7 +472,6 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
|
||||
var t time.Time
|
||||
var dur int
|
||||
if err := bookingRows.Scan(&t, &dur); err == nil {
|
||||
t = t.In(ukLocation)
|
||||
dateStr := t.Format("2006-01-02")
|
||||
endTime := t.Add(time.Duration(dur) * time.Minute)
|
||||
bookings[dateStr] = append(bookings[dateStr], TimeSlot{
|
||||
@@ -513,7 +528,7 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
|
||||
daysSinceMonday = 6 // Sunday
|
||||
}
|
||||
weekStart := d.AddDate(0, 0, -daysSinceMonday)
|
||||
weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, ukLocation)
|
||||
weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.Local)
|
||||
|
||||
var applied *ExceptionalHours
|
||||
weekStartStr := weekStart.Format("2006-01-02")
|
||||
@@ -550,6 +565,14 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
|
||||
day.Source = "default"
|
||||
}
|
||||
|
||||
// Out-of-hours override for admin
|
||||
if outOfHours && isAdmin {
|
||||
baseStart = "06:00"
|
||||
baseEnd = "22:00"
|
||||
isOpen = true
|
||||
day.Source = "out_of_hours"
|
||||
}
|
||||
|
||||
day.IsOpen = isOpen
|
||||
if isOpen {
|
||||
slots := []TimeSlot{{StartTime: baseStart, EndTime: baseEnd}}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -549,7 +550,397 @@ func TestScheduling_GetAvailableHours(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests for UpdateExceptionalApplications ---
|
||||
// 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.Local)
|
||||
_, 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 := time.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
|
||||
|
||||
@@ -226,6 +226,7 @@ func main() {
|
||||
// Scheduling
|
||||
r.Route("/scheduling", func(r chi.Router) {
|
||||
r.Use(mw.RateLimit(120, time.Minute))
|
||||
r.Use(mw.OptionalAuth)
|
||||
r.Get("/default-hours", scheduling.GetDefaultHours)
|
||||
r.Get("/exceptional-groups", scheduling.ListExceptionalGroups)
|
||||
r.Get("/working-hours", scheduling.GetWorkingHours)
|
||||
|
||||
Reference in New Issue
Block a user