From c442c150c0982ab7de317854f25934c593c7366e Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 28 May 2026 16:29:17 +0100 Subject: [PATCH] feat: scheduling exceptional hours support and default hours validation Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/scheduling/default-hours.go | 29 ++++ .../handlers/scheduling/exceptional-hours.go | 8 + .../handlers/scheduling/scheduling_test.go | 155 ++++++++++++++++++ backend/main.go | 4 + 4 files changed, 196 insertions(+) diff --git a/backend/handlers/scheduling/default-hours.go b/backend/handlers/scheduling/default-hours.go index 5a4a24c..2434295 100644 --- a/backend/handlers/scheduling/default-hours.go +++ b/backend/handlers/scheduling/default-hours.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "net/http" + "strconv" + "strings" "time" "crussell/db" @@ -61,6 +63,17 @@ func UpdateDefaultHours(w http.ResponseWriter, r *http.Request) { return } + for _, h := range hours { + if !isValidTime15Min(h.StartTime) { + http.Error(w, "start_time must be in 15-minute intervals (00, 15, 30, 45)", http.StatusBadRequest) + return + } + if !isValidTime15Min(h.EndTime) { + http.Error(w, "end_time must be in 15-minute intervals (00, 15, 30, 45)", http.StatusBadRequest) + return + } + } + tx, err := db.DB.Begin(r.Context()) if err != nil { http.Error(w, "failed to start tx", http.StatusInternalServerError) @@ -223,6 +236,22 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(results) } +// isValidTime15Min checks that a time string (HH:MM or HH:MM:SS) has minutes in {00, 15, 30, 45}. +func isValidTime15Min(t string) bool { + parts := strings.Split(t, ":") + if len(parts) < 2 || len(parts) > 3 { + return false + } + if parts[0] == "" { + return false + } + mins, err := strconv.Atoi(parts[1]) + if err != nil { + return false + } + return mins == 0 || mins == 15 || mins == 30 || mins == 45 +} + // --- helper: sqlIn generates IN queries dynamically for Postgres --- func sqlIn(query string, args []int) (string, []interface{}, error) { inArgs := []interface{}{} diff --git a/backend/handlers/scheduling/exceptional-hours.go b/backend/handlers/scheduling/exceptional-hours.go index e8efb91..60ed734 100644 --- a/backend/handlers/scheduling/exceptional-hours.go +++ b/backend/handlers/scheduling/exceptional-hours.go @@ -139,6 +139,14 @@ func CreateExceptionalGroup(w http.ResponseWriter, r *http.Request) { return } weekdaysSeen[h.Weekday] = true + if !isValidTime15Min(h.StartTime) { + http.Error(w, "start_time must be in 15-minute intervals (00, 15, 30, 45)", http.StatusBadRequest) + return + } + if !isValidTime15Min(h.EndTime) { + http.Error(w, "end_time must be in 15-minute intervals (00, 15, 30, 45)", http.StatusBadRequest) + return + } } var parsedWeeks []time.Time diff --git a/backend/handlers/scheduling/scheduling_test.go b/backend/handlers/scheduling/scheduling_test.go index 7c61bdc..d09afba 100644 --- a/backend/handlers/scheduling/scheduling_test.go +++ b/backend/handlers/scheduling/scheduling_test.go @@ -330,6 +330,108 @@ func TestScheduling_CreateExceptionalGroup_NonAdmin(t *testing.T) { } } +// --- 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 @@ -673,3 +775,56 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) { } } } + +// --- 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) + } + } + }) + } +} diff --git a/backend/main.go b/backend/main.go index 2e4402d..f1c7e42 100644 --- a/backend/main.go +++ b/backend/main.go @@ -259,6 +259,10 @@ func main() { r.Get("/{id}", bookings.GetAdminBookingHandler) r.Put("/{id}", bookings.UpdateBookingServicesHandler) r.Get("/{id}/overlapping", bookings.GetOverlappingBookingsHandler) + r.Get("/overlapping", bookings.GetOverlappingBookingsByTimeHandler) + r.Get("/by-date-range", bookings.GetBookingsByDateRangeHandler) + r.Get("/by-created-range", bookings.GetBookingsByCreatedRangeHandler) + r.Put("/{id}/reschedule", bookings.AdminRescheduleBookingHandler) r.Put("/{id}/progress", bookings.ProgressBookingHandler) r.Post("/{id}/confirm", bookings.ConfirmBookingHandler) r.Post("/{id}/cancel", bookings.AdminCancelBookingHandler)