From d0f233ae7012caadc28e64c2f5912be55dbe45cb Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 30 Jul 2026 08:58:54 +0100 Subject: [PATCH] test: add tests for staged default hours change handlers Add tests for ScheduleDefaultHoursChange, GetScheduledDefaultHoursChange, CancelScheduledDefaultHoursChange, GetDefaultHoursConflictingBookings, and GetWorkingHours/GetDefaultHours integration with staged changes. --- .../handlers/scheduling/scheduling_test.go | 470 +++++++++++++++++- 1 file changed, 464 insertions(+), 6 deletions(-) diff --git a/backend/handlers/scheduling/scheduling_test.go b/backend/handlers/scheduling/scheduling_test.go index 239a4e9..122ea9f 100644 --- a/backend/handlers/scheduling/scheduling_test.go +++ b/backend/handlers/scheduling/scheduling_test.go @@ -90,20 +90,24 @@ func TestScheduling_GetDefaultHours(t *testing.T) { 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 { t.Fatalf("failed to unmarshal response: %v", err) } - if len(response) != 7 { - t.Errorf("expected 7 days of hours, got %d", len(response)) + if len(response.Current) != 7 { + 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 var monday *DefaultHours - for i := range response { - if response[i].Weekday == 0 { - monday = &response[i] + for i := range response.Current { + if response.Current[i].Weekday == 0 { + monday = &response.Current[i] break } } @@ -4221,3 +4225,457 @@ func TestPreviewAvailableHours_OutOfHours_Admin(t *testing.T) { 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)) + } +}