feat: scheduling exceptional hours support and default hours validation
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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{}{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user