test: add tests for staged default hours change handlers

Add tests for ScheduleDefaultHoursChange, GetScheduledDefaultHoursChange, CancelScheduledDefaultHoursChange, GetDefaultHoursConflictingBookings, and GetWorkingHours/GetDefaultHours integration with staged changes.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 2a840bb7e6
commit d0f233ae70
+464 -6
View File
@@ -90,20 +90,24 @@ func TestScheduling_GetDefaultHours(t *testing.T) {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
} }
var response []DefaultHours var response ScheduledHoursChangeResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err) t.Fatalf("failed to unmarshal response: %v", err)
} }
if len(response) != 7 { if len(response.Current) != 7 {
t.Errorf("expected 7 days of hours, got %d", len(response)) t.Errorf("expected 7 days of hours, got %d", len(response.Current))
}
if response.ScheduledChange != nil {
t.Errorf("expected no scheduled change, got effective_date=%s", response.ScheduledChange.EffectiveDate)
} }
// Verify Monday (weekday 0) has our seeded hours // Verify Monday (weekday 0) has our seeded hours
var monday *DefaultHours var monday *DefaultHours
for i := range response { for i := range response.Current {
if response[i].Weekday == 0 { if response.Current[i].Weekday == 0 {
monday = &response[i] monday = &response.Current[i]
break break
} }
} }
@@ -4221,3 +4225,457 @@ func TestPreviewAvailableHours_OutOfHours_Admin(t *testing.T) {
t.Error("expected out_of_hours to be open") t.Error("expected out_of_hours to be open")
} }
} }
// --- Tests for Staged Default Hours Change ---
// TestScheduleDefaultHoursChange_Success creates a pending change.
func TestScheduleDefaultHoursChange_Success(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
futureDate := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
hours := make([]map[string]interface{}, 7)
for i := 0; i < 7; i++ {
hours[i] = map[string]interface{}{
"weekday": i,
"startTime": "10:00",
"endTime": "18:00",
"isOpen": true,
}
}
payload := map[string]interface{}{
"hours": hours,
"effective_date": futureDate,
}
adminCtx := context.WithValue(ctx, mw.UserIDKey, adminID)
handler := http.HandlerFunc(ScheduleDefaultHoursChange)
w := makeRequest(handler, "POST", "/api/admin/scheduling/default-hours/schedule", payload, adminCtx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp["effective_date"] != futureDate {
t.Errorf("expected effective_date %s, got %s", futureDate, resp["effective_date"])
}
}
// TestScheduleDefaultHoursChange_PastDate rejects past dates.
func TestScheduleDefaultHoursChange_PastDate(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
pastDate := "2020-01-01"
hours := make([]map[string]interface{}, 7)
for i := 0; i < 7; i++ {
hours[i] = map[string]interface{}{"weekday": i, "startTime": "10:00", "endTime": "18:00", "isOpen": true}
}
payload := map[string]interface{}{"hours": hours, "effective_date": pastDate}
adminCtx := context.WithValue(ctx, mw.UserIDKey, adminID)
handler := http.HandlerFunc(ScheduleDefaultHoursChange)
w := makeRequest(handler, "POST", "/api/admin/scheduling/default-hours/schedule", payload, adminCtx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for past date, got %d: %s", w.Code, w.Body.String())
}
}
// TestScheduleDefaultHoursChange_Duplicate rejects a second pending change.
func TestScheduleDefaultHoursChange_Duplicate(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
futureDate := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
hours := make([]map[string]interface{}, 7)
for i := 0; i < 7; i++ {
hours[i] = map[string]interface{}{"weekday": i, "startTime": "10:00", "endTime": "18:00", "isOpen": true}
}
payload := map[string]interface{}{"hours": hours, "effective_date": futureDate}
adminCtx := context.WithValue(ctx, mw.UserIDKey, adminID)
handler := http.HandlerFunc(ScheduleDefaultHoursChange)
w1 := makeRequest(handler, "POST", "/api/admin/scheduling/default-hours/schedule", payload, adminCtx)
if w1.Code != http.StatusOK {
t.Fatalf("first schedule should succeed, got %d: %s", w1.Code, w1.Body.String())
}
w2 := makeRequest(handler, "POST", "/api/admin/scheduling/default-hours/schedule", payload, adminCtx)
if w2.Code != http.StatusConflict {
t.Errorf("expected 409 for duplicate, got %d: %s", w2.Code, w2.Body.String())
}
}
// TestGetScheduledDefaultHoursChange returns the pending change.
func TestGetScheduledDefaultHoursChange_Success(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
futureDate := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
hoursJSON := `[{"weekday":0,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":1,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":2,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":3,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":4,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":5,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":6,"startTime":"10:00","endTime":"18:00","isOpen":true}]`
_, err = tx.Exec(ctx, `
INSERT INTO default_hours_scheduled_changes (effective_date, created_by, hours)
VALUES ($1, $2, $3)
`, futureDate, adminID, hoursJSON)
if err != nil {
t.Fatalf("failed to insert scheduled change: %v", err)
}
handler := http.HandlerFunc(GetScheduledDefaultHoursChange)
w := makeRequest(handler, "GET", "/api/admin/scheduling/default-hours/scheduled", nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp ScheduledHoursChange
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp.EffectiveDate != futureDate {
t.Errorf("expected effective_date %s, got %s", futureDate, resp.EffectiveDate)
}
}
// TestCancelScheduledDefaultHoursChange cancels a pending change.
func TestCancelScheduledDefaultHoursChange_Success(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
futureDate := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
hoursJSON := `[{"weekday":0,"startTime":"10:00","endTime":"18:00","isOpen":true}]`
_, err = tx.Exec(ctx, `
INSERT INTO default_hours_scheduled_changes (effective_date, created_by, hours)
VALUES ($1, $2, $3)
`, futureDate, adminID, hoursJSON)
if err != nil {
t.Fatalf("failed to insert scheduled change: %v", err)
}
handler := http.HandlerFunc(CancelScheduledDefaultHoursChange)
w := makeRequest(handler, "DELETE", "/api/admin/scheduling/default-hours/scheduled", nil, ctx)
if w.Code != http.StatusNoContent {
t.Errorf("expected 204, got %d: %s", w.Code, w.Body.String())
}
handler2 := http.HandlerFunc(GetScheduledDefaultHoursChange)
w2 := makeRequest(handler2, "GET", "/api/admin/scheduling/default-hours/scheduled", nil, ctx)
if w2.Code != http.StatusNotFound {
t.Errorf("expected 404 after cancel, got %d", w2.Code)
}
}
// TestCancelScheduledDefaultHoursChange_NoChange returns 404 when nothing to cancel.
func TestCancelScheduledDefaultHoursChange_NoChange(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
handler := http.HandlerFunc(CancelScheduledDefaultHoursChange)
w := makeRequest(handler, "DELETE", "/api/admin/scheduling/default-hours/scheduled", nil, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 when nothing to cancel, got %d: %s", w.Code, w.Body.String())
}
}
// TestScheduleDefaultHoursChange_TodayDate verifies scheduling with today's
// date is rejected — changes can only start from tomorrow.
func TestScheduleDefaultHoursChange_TodayDate(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
today := clock.Now().In(clock.London).Format("2006-01-02")
hours := make([]map[string]interface{}, 7)
for i := 0; i < 7; i++ {
hours[i] = map[string]interface{}{"weekday": i, "startTime": "10:00", "endTime": "18:00", "isOpen": true}
}
payload := map[string]interface{}{"hours": hours, "effective_date": today}
adminCtx := context.WithValue(ctx, mw.UserIDKey, adminID)
handler := http.HandlerFunc(ScheduleDefaultHoursChange)
w := makeRequest(handler, "POST", "/api/admin/scheduling/default-hours/schedule", payload, adminCtx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for today's date, got %d: %s", w.Code, w.Body.String())
}
}
// TestScheduleDefaultHoursChange_TomorrowDate verifies scheduling with
// tomorrow's date succeeds — changes can start from tomorrow onwards.
func TestScheduleDefaultHoursChange_TomorrowDate(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
tomorrow := clock.Now().In(clock.London).AddDate(0, 0, 1).Format("2006-01-02")
hours := make([]map[string]interface{}, 7)
for i := 0; i < 7; i++ {
hours[i] = map[string]interface{}{"weekday": i, "startTime": "10:00", "endTime": "18:00", "isOpen": true}
}
payload := map[string]interface{}{"hours": hours, "effective_date": tomorrow}
adminCtx := context.WithValue(ctx, mw.UserIDKey, adminID)
handler := http.HandlerFunc(ScheduleDefaultHoursChange)
w := makeRequest(handler, "POST", "/api/admin/scheduling/default-hours/schedule", payload, adminCtx)
if w.Code != http.StatusOK {
t.Errorf("expected 200 for tomorrow's date, got %d: %s", w.Code, w.Body.String())
}
}
// TestGetDefaultHoursConflictingBookings_NoConflicts verifies conflict
// detection returns empty when no bookings overlap the proposed hours.
func TestGetDefaultHoursConflictingBookings_NoConflicts(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
tomorrow := clock.Now().In(clock.London).AddDate(0, 0, 1).Format("2006-01-02")
proposedHours := make([]map[string]interface{}, 7)
for i := 0; i < 7; i++ {
proposedHours[i] = map[string]interface{}{"weekday": i, "startTime": "09:00", "endTime": "17:00", "isOpen": true}
}
payload := map[string]interface{}{
"proposedHours": proposedHours,
"effective_date": tomorrow,
}
adminCtx := context.WithValue(ctx, mw.UserIDKey, adminID)
handler := http.HandlerFunc(GetDefaultHoursConflictingBookings)
w := makeRequest(handler, "POST", "/api/admin/scheduling/default-hours/conflicting", payload, adminCtx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Bookings) != 0 {
t.Errorf("expected 0 conflicting bookings (no bookings exist), got %d", len(resp.Bookings))
}
}
// TestGetDefaultHoursConflictingBookings_WithConflicts verifies conflict
// detection finds bookings that fall outside the proposed hours window.
func TestGetDefaultHoursConflictingBookings_WithConflicts(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
// Place a booking at 08:00-09:00 tomorrow (London time). Proposed hours
// are 09:00-17:00 -> this booking should be a conflict (starts before 09:00).
tomorrowLondon := clock.Now().In(clock.London).AddDate(0, 0, 1)
bookingTime := time.Date(tomorrowLondon.Year(), tomorrowLondon.Month(), tomorrowLondon.Day(), 8, 0, 0, 0, clock.London).UTC()
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time)
VALUES ($1, $2, 'confirmed', 60, $3)
`, userID, bookingTime, bookingTime.Add(60*time.Minute))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
tomorrow := tomorrowLondon.Format("2006-01-02")
proposedHours := make([]map[string]interface{}, 7)
for i := 0; i < 7; i++ {
proposedHours[i] = map[string]interface{}{"weekday": i, "startTime": "09:00", "endTime": "17:00", "isOpen": true}
}
payload := map[string]interface{}{
"proposedHours": proposedHours,
"effective_date": tomorrow,
}
adminCtx := context.WithValue(ctx, mw.UserIDKey, adminID)
handler := http.HandlerFunc(GetDefaultHoursConflictingBookings)
w := makeRequest(handler, "POST", "/api/admin/scheduling/default-hours/conflicting", payload, adminCtx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp OverlappingBookingsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Bookings) != 1 {
t.Errorf("expected 1 conflicting booking, got %d", len(resp.Bookings))
}
}
// TestGetWorkingHours_WithStagedChange verifies that GetWorkingHours returns
// the staged hours for dates on/after the effective date.
func TestGetWorkingHours_WithStagedChange(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
londonNow := clock.Now().In(clock.London)
today := londonNow.Format("2006-01-02")
tomorrow := londonNow.AddDate(0, 0, 1).Format("2006-01-02")
endDay := londonNow.AddDate(0, 0, 6).Format("2006-01-02")
// Staged change sets all weekdays to 10:00-18:00 open
hoursJSON := `[{"weekday":0,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":1,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":2,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":3,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":4,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":5,"startTime":"10:00","endTime":"18:00","isOpen":true},{"weekday":6,"startTime":"10:00","endTime":"18:00","isOpen":true}]`
_, err = tx.Exec(ctx, `
INSERT INTO default_hours_scheduled_changes (effective_date, created_by, hours)
VALUES ($1, $2, $3)
`, tomorrow, adminID, hoursJSON)
if err != nil {
t.Fatalf("failed to insert staged change: %v", err)
}
handler := http.HandlerFunc(GetWorkingHours)
// Query today through today+6 — spans before and after the staged effective_date
w := makeRequest(handler, "GET", "/api/scheduling/working-hours?start="+today+"&end="+endDay, nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var response []DayWorkingHours
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(response) != 7 {
t.Fatalf("expected 7 days, got %d", len(response))
}
// Days on/after the effective date (tomorrow) must use staged hours (10:00-18:00).
// Days before must use current working_hours (which vary by weekday).
stagedCount, currentCount := 0, 0
for _, day := range response {
if day.Date >= tomorrow {
stagedCount++
if day.StartTime != "10:00" || day.EndTime != "18:00" {
t.Errorf("date %s should show staged hours (10:00-18:00), got %s-%s (isOpen=%v)",
day.Date, day.StartTime, day.EndTime, day.IsOpen)
}
} else {
currentCount++
}
}
if stagedCount == 0 {
t.Error("expected at least one day with staged hours")
}
if currentCount == 0 {
t.Error("expected at least one day with current hours")
}
}
// TestGetDefaultHours_WithScheduledChange verifies GetDefaultHours includes
// the scheduled_change field when a pending change exists.
func TestGetDefaultHours_WithScheduledChange(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
tomorrow := clock.Now().In(clock.London).AddDate(0, 0, 1).Format("2006-01-02")
hoursJSON := `[{"weekday":0,"startTime":"10:00","endTime":"18:00","isOpen":true}]`
_, err = tx.Exec(ctx, `
INSERT INTO default_hours_scheduled_changes (effective_date, created_by, hours)
VALUES ($1, $2, $3)
`, tomorrow, adminID, hoursJSON)
if err != nil {
t.Fatalf("failed to insert staged change: %v", err)
}
handler := http.HandlerFunc(GetDefaultHours)
w := makeRequest(handler, "GET", "/api/scheduling/default-hours", nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp ScheduledHoursChangeResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp.ScheduledChange == nil {
t.Fatal("expected scheduled_change to be non-nil")
}
if resp.ScheduledChange.EffectiveDate != tomorrow {
t.Errorf("expected effective_date %s, got %s", tomorrow, resp.ScheduledChange.EffectiveDate)
}
if len(resp.Current) != 7 {
t.Errorf("expected 7 current hours, got %d", len(resp.Current))
}
}