diff --git a/backend/handlers/scheduling/scheduling_test.go b/backend/handlers/scheduling/scheduling_test.go index ca51da2..239a4e9 100644 --- a/backend/handlers/scheduling/scheduling_test.go +++ b/backend/handlers/scheduling/scheduling_test.go @@ -1773,11 +1773,19 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation_NonAdmin(t * // Not parallel (see above) 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) + reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) - tx.Exec(ctx, ` + if _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) - VALUES ($1, 30, 'RESERVATION:admin:callin:guest:1712345678', 'admin001') - `, reservationTime) + VALUES ($1, 30, 'RESERVATION:admin:callin:guest:1712345678', $2) + `, reservationTime, adminID); err != nil { + t.Fatalf("failed to create reservation blocker: %v", err) + } handler := http.HandlerFunc(GetAvailableHours) req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil) @@ -3394,3 +3402,822 @@ func TestScheduling_UpdateExceptionalApplications_InvalidDate(t *testing.T) { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } + +// --- Tests for GetConflictingBookingsForExceptionHandler --- + +// makeAllClosedProposedHours returns 7 entries with all days closed (weekday 0-6). +func makeAllClosedProposedHours() []map[string]interface{} { + hours := make([]map[string]interface{}, 7) + for i := 0; i < 7; i++ { + hours[i] = map[string]interface{}{ + "weekday": i, + "startTime": "00:00", + "endTime": "00:00", + "isOpen": false, + } + } + return hours +} + +// makeDefaultOpenProposedHours returns 7 entries with Monday-Saturday open 09:00-17:00, Sunday closed. +func makeDefaultOpenProposedHours() []map[string]interface{} { + hours := make([]map[string]interface{}, 7) + for i := 0; i < 7; i++ { + if i == 6 { + hours[i] = map[string]interface{}{ + "weekday": i, "startTime": "00:00", "endTime": "00:00", "isOpen": false, + } + } else { + hours[i] = map[string]interface{}{ + "weekday": i, "startTime": "09:00", "endTime": "17:00", "isOpen": true, + } + } + } + return hours +} + +func createTestBooking(t *testing.T, ctx context.Context, tx db.Querier, userID string, start time.Time, durationMinutes int) { + t.Helper() + _, err := tx.Exec(ctx, ` + INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time) + VALUES ($1, $2, 'confirmed', $3, $4) + `, userID, start, durationMinutes, start.Add(time.Duration(durationMinutes)*time.Minute)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } +} + +// TestConflictingBookings_ClosedDay_AllConflict verifies that when a day is closed, +// all active bookings on that day are returned as conflicts. +func TestConflictingBookings_ClosedDay_AllConflict(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(tx, userID) + + createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), 60) + + payload := map[string]interface{}{ + "weekStarts": []string{"2026-03-16"}, + "proposedHours": makeAllClosedProposedHours(), + } + + handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler) + w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response OverlappingBookingsResponse + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if len(response.Bookings) != 1 { + t.Errorf("expected 1 conflict, got %d", len(response.Bookings)) + } +} + +// TestConflictingBookings_OpenDay_BookingWithinHours_NoConflict verifies that +// a booking fully within the proposed open window is NOT flagged as conflicting. +func TestConflictingBookings_OpenDay_BookingWithinHours_NoConflict(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(tx, userID) + + createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), 60) + + payload := map[string]interface{}{ + "weekStarts": []string{"2026-03-16"}, + "proposedHours": makeDefaultOpenProposedHours(), + } + + handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler) + w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response OverlappingBookingsResponse + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if len(response.Bookings) != 0 { + t.Errorf("expected 0 conflicts, got %d", len(response.Bookings)) + } +} + +// TestConflictingBookings_StartsBeforeOpening verifies bookings starting before +// the proposed opening time are flagged as conflicts. +func TestConflictingBookings_StartsBeforeOpening(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(tx, userID) + + createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 8, 30, 0, 0, time.UTC), 60) + + payload := map[string]interface{}{ + "weekStarts": []string{"2026-03-16"}, + "proposedHours": makeDefaultOpenProposedHours(), + } + + handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler) + w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response OverlappingBookingsResponse + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if len(response.Bookings) != 1 { + t.Errorf("expected 1 conflict, got %d", len(response.Bookings)) + } +} + +// TestConflictingBookings_EndsAfterClosing verifies bookings ending after the +// proposed closing time are flagged as conflicts. +func TestConflictingBookings_EndsAfterClosing(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(tx, userID) + + createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 16, 30, 0, 0, time.UTC), 60) + + payload := map[string]interface{}{ + "weekStarts": []string{"2026-03-16"}, + "proposedHours": makeDefaultOpenProposedHours(), + } + + handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler) + w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response OverlappingBookingsResponse + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if len(response.Bookings) != 1 { + t.Errorf("expected 1 conflict, got %d", len(response.Bookings)) + } +} + +// TestConflictingBookings_MultipleWeeks verifies that bookings across two +// different weeks are both detected. +func TestConflictingBookings_MultipleWeeks(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(tx, userID) + + createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), 60) + createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 23, 10, 0, 0, 0, time.UTC), 60) + + payload := map[string]interface{}{ + "weekStarts": []string{"2026-03-16", "2026-03-23"}, + "proposedHours": makeAllClosedProposedHours(), + } + + handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler) + w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response OverlappingBookingsResponse + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if len(response.Bookings) != 2 { + t.Errorf("expected 2 conflicts, got %d", len(response.Bookings)) + } +} + +// TestConflictingBookings_ExcludedStatuses verifies that cancelled bookings are +// not returned. +func TestConflictingBookings_ExcludedStatuses(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(tx, userID) + + createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), 60) + cancelledTime := time.Date(2026, 3, 16, 14, 0, 0, 0, time.UTC) + _, err = tx.Exec(ctx, ` + INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time) + VALUES ($1, $2, 'client_cancelled', 60, $3) + `, userID, cancelledTime, cancelledTime.Add(60*time.Minute)) + if err != nil { + t.Fatalf("failed to create cancelled booking: %v", err) + } + + payload := map[string]interface{}{ + "weekStarts": []string{"2026-03-16"}, + "proposedHours": makeAllClosedProposedHours(), + } + + handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler) + w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response OverlappingBookingsResponse + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if len(response.Bookings) != 1 { + t.Errorf("expected 1 conflict (active booking), got %d", len(response.Bookings)) + } +} + +// TestConflictingBookings_InvalidPayload verifies 400 for bad input. +func TestConflictingBookings_InvalidPayload(t *testing.T) { + t.Parallel() + ctx, _ := resetTestData(t) + + handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler) + w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", map[string]interface{}{}, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid payload, got %d", w.Code) + } +} + +// TestConflictingBookings_WrongMethod verifies that GET to a POST-only handler +// fails with a non-200 status (chi enforces method routing; the handler without +// chi reaches JSON decode and returns 400 for missing body). +func TestConflictingBookings_WrongMethod(t *testing.T) { + t.Parallel() + ctx, _ := resetTestData(t) + + handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler) + w := makeRequest(handler, "GET", "/api/admin/bookings/conflicting-for-exception", nil, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for GET without body, got %d", w.Code) + } +} + +// --- Tests for GetPreviewAvailableHours --- + +// TestPreviewAvailableHours_NoProposed verifies preview works without +// proposed hours (should match normal GetAvailableHours behaviour). +func TestPreviewAvailableHours_NoProposed(t *testing.T) { + t.Parallel() + ctx, _ := resetTestData(t) + + handler := http.HandlerFunc(GetPreviewAvailableHours) + req := httptest.NewRequest("GET", "/api/scheduling/preview-available-hours?start=2026-02-16&end=2026-02-22", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req.WithContext(ctx)) + + if w.Code != http.StatusOK { + t.Fatalf("expected 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: %v", err) + } + if len(response) == 0 { + t.Error("expected available hours") + } +} + +// TestPreviewAvailableHours_ProposedOverride verifies proposed hours override +// the normal schedule. +func TestPreviewAvailableHours_ProposedOverride(t *testing.T) { + t.Parallel() + ctx, _ := resetTestData(t) + + proposedJSON := `[{"weekday":0,"startTime":"10:00","endTime":"16:00","isOpen":true}]` + weeksJSON := `["2026-03-16"]` + + req := httptest.NewRequest("GET", + "/api/scheduling/preview-available-hours?start=2026-03-16&end=2026-03-16"+ + "&proposed_hours="+strings.ReplaceAll(strings.ReplaceAll(proposedJSON, "[", "%5B"), "]", "%5D")+ + "&proposed_weeks="+strings.ReplaceAll(strings.ReplaceAll(weeksJSON, "[", "%5B"), "]", "%5D"), + nil) + w := httptest.NewRecorder() + handler := http.HandlerFunc(GetPreviewAvailableHours) + handler.ServeHTTP(w, req.WithContext(ctx)) + + if w.Code != http.StatusOK { + t.Fatalf("expected 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: %v", err) + } + + if len(response) != 1 { + t.Fatalf("expected 1 day, got %d", len(response)) + } + if !response[0].IsOpen { + t.Error("expected Monday to be open (proposed override)") + } + if response[0].Source != "proposed" { + t.Errorf("expected source 'proposed', got '%s'", response[0].Source) + } +} + +// TestPreviewAvailableHours_InvalidProposedHours verifies 400 for bad JSON. +func TestPreviewAvailableHours_InvalidProposedHours(t *testing.T) { + t.Parallel() + ctx, _ := resetTestData(t) + + req := httptest.NewRequest("GET", + "/api/scheduling/preview-available-hours?start=2026-03-16&end=2026-03-16&proposed_hours=not-json", + nil) + w := httptest.NewRecorder() + handler := http.HandlerFunc(GetPreviewAvailableHours) + handler.ServeHTTP(w, req.WithContext(ctx)) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid JSON, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestPreviewAvailableHours_MissingParams verifies 400 when start/end missing. +func TestPreviewAvailableHours_MissingParams(t *testing.T) { + t.Parallel() + ctx, _ := resetTestData(t) + + handler := http.HandlerFunc(GetPreviewAvailableHours) + req := httptest.NewRequest("GET", "/api/scheduling/preview-available-hours", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req.WithContext(ctx)) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +// --- Tests for parseTimeToMinutes --- + +func TestParseTimeToMinutes_Normal(t *testing.T) { + result := parseTimeToMinutes("09:30") + if result != 570 { + t.Errorf("expected 570, got %d", result) + } +} + +func TestParseTimeToMinutes_Midnight(t *testing.T) { + result := parseTimeToMinutes("00:00") + if result != 0 { + t.Errorf("expected 0, got %d", result) + } +} + +func TestParseTimeToMinutes_EndOfDay(t *testing.T) { + result := parseTimeToMinutes("23:59") + if result != 1439 { + t.Errorf("expected 1439, got %d", result) + } +} + +func TestParseTimeToMinutes_SingleDigitHour(t *testing.T) { + result := parseTimeToMinutes("9:05") + if result != 545 { + t.Errorf("expected 545, got %d", result) + } +} + +func TestParseTimeToMinutes_EmptyString(t *testing.T) { + result := parseTimeToMinutes("") + if result != -1 { + t.Errorf("expected -1, got %d", result) + } +} + +func TestParseTimeToMinutes_NoColon(t *testing.T) { + result := parseTimeToMinutes("1230") + if result != -1 { + t.Errorf("expected -1, got %d", result) + } +} + +func TestParseTimeToMinutes_HourOutOfRange(t *testing.T) { + result := parseTimeToMinutes("24:00") + if result != -1 { + t.Errorf("expected -1, got %d", result) + } +} + +func TestParseTimeToMinutes_MinuteOutOfRange(t *testing.T) { + result := parseTimeToMinutes("10:60") + if result != -1 { + t.Errorf("expected -1, got %d", result) + } +} + +func TestParseTimeToMinutes_TextInput(t *testing.T) { + result := parseTimeToMinutes("abc:def") + if result != -1 { + t.Errorf("expected -1, got %d", result) + } +} + +func TestParseTimeToMinutes_NegativeHour(t *testing.T) { + result := parseTimeToMinutes("-1:00") + if result != -1 { + t.Errorf("expected -1, got %d", result) + } +} + +func TestParseTimeToMinutes_JustColon(t *testing.T) { + result := parseTimeToMinutes(":") + if result != -1 { + t.Errorf("expected -1, got %d", result) + } +} + +// --- Tests for sqlIn --- + +func TestSQLIn_MultipleArgs(t *testing.T) { + query, args := sqlIn("SELECT * FROM foo WHERE id IN (%s)", []int{1, 2, 3}) + expected := "SELECT * FROM foo WHERE id IN ($1,$2,$3)" + if query != expected { + t.Errorf("sqlIn query = %q, want %q", query, expected) + } + if len(args) != 3 { + t.Errorf("sqlIn args count = %d, want 3", len(args)) + } + if args[0] != 1 || args[1] != 2 || args[2] != 3 { + t.Errorf("sqlIn args = %v, want [1 2 3]", args) + } +} + +func TestSQLIn_SingleArg(t *testing.T) { + query, args := sqlIn("SELECT * FROM foo WHERE id IN (%s)", []int{42}) + expected := "SELECT * FROM foo WHERE id IN ($1)" + if query != expected { + t.Errorf("sqlIn query = %q, want %q", query, expected) + } + if len(args) != 1 || args[0] != 42 { + t.Errorf("sqlIn args = %v, want [42]", args) + } +} + +func TestSQLIn_EmptyArgs(t *testing.T) { + query, args := sqlIn("SELECT * FROM foo WHERE id IN (%s)", []int{}) + expected := "SELECT * FROM foo WHERE id IN ()" + if query != expected { + t.Errorf("sqlIn query = %q, want %q", query, expected) + } + if len(args) != 0 { + t.Errorf("sqlIn args count = %d, want 0", len(args)) + } +} + +// --- Edge-case tests for GetConflictingBookingsForExceptionHandler --- + +// TestConflictingBookings_BookingExactlyAtOpeningTime verifies a booking +// starting exactly at the proposed opening time does NOT conflict. +func TestConflictingBookings_BookingExactlyAtOpeningTime(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(tx, userID) + + // Monday 09:00 start — proposed open is 09:00 + createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 9, 0, 0, 0, time.UTC), 60) + + payload := map[string]interface{}{ + "weekStarts": []string{"2026-03-16"}, + "proposedHours": makeDefaultOpenProposedHours(), + } + + handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler) + w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response OverlappingBookingsResponse + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if len(response.Bookings) != 0 { + t.Errorf("expected 0 conflicts (booking starts exactly at opening), got %d", len(response.Bookings)) + } +} + +// TestConflictingBookings_BookingExactlyAtClosingTime verifies a booking +// ending exactly at the proposed closing time does NOT conflict. +func TestConflictingBookings_BookingExactlyAtClosingTime(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(tx, userID) + + // Monday 16:00 start (60min → ends 17:00) — proposed closing is 17:00 + createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 16, 0, 0, 0, time.UTC), 60) + + payload := map[string]interface{}{ + "weekStarts": []string{"2026-03-16"}, + "proposedHours": makeDefaultOpenProposedHours(), + } + + handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler) + w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response OverlappingBookingsResponse + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if len(response.Bookings) != 0 { + t.Errorf("expected 0 conflicts (booking ends exactly at closing), got %d", len(response.Bookings)) + } +} + +// TestConflictingBookings_BookingCrossesMidnight verifies that a booking +// spanning midnight is always flagged as a conflict (open hours can't span past midnight). +func TestConflictingBookings_BookingCrossesMidnight(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(tx, userID) + + // Monday 23:00 start, 120min duration → ends 01:00 Tuesday (crosses midnight) + createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 23, 0, 0, 0, time.UTC), 120) + + payload := map[string]interface{}{ + "weekStarts": []string{"2026-03-16"}, + "proposedHours": makeDefaultOpenProposedHours(), + } + + handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler) + w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response OverlappingBookingsResponse + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if len(response.Bookings) != 1 { + t.Errorf("expected 1 conflict (midnight-crossing booking), got %d", len(response.Bookings)) + } +} + +// TestConflictingBookings_InvalidWeekStartFormat verifies 400 for bad week_start. +func TestConflictingBookings_InvalidWeekStartFormat(t *testing.T) { + t.Parallel() + ctx, _ := resetTestData(t) + + payload := map[string]interface{}{ + "weekStarts": []string{"not-a-date"}, + "proposedHours": makeAllClosedProposedHours(), + } + + handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler) + w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid week_start, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestConflictingBookings_BookingOnSundayClosed verifies a conflict on Sunday +// (weekday 6 in our convention) when it's marked closed. +func TestConflictingBookings_BookingOnSundayClosed(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(tx, userID) + + // Sunday March 22, 2026 — 10:00 booking + createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC), 60) + + payload := map[string]interface{}{ + "weekStarts": []string{"2026-03-16"}, + "proposedHours": makeDefaultOpenProposedHours(), // Sunday (idx 6) is closed + } + + handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler) + w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response OverlappingBookingsResponse + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if len(response.Bookings) != 1 { + t.Errorf("expected 1 conflict (Sunday closed), got %d", len(response.Bookings)) + } +} + +// TestConflictingBookings_EmptyResultReturnsEmptyArray verifies that the +// response always has a JSON array (never null) when there are no conflicts. +func TestConflictingBookings_EmptyResultReturnsEmptyArray(t *testing.T) { + t.Parallel() + ctx, _ := resetTestData(t) + + payload := map[string]interface{}{ + "weekStarts": []string{"2026-03-16"}, + "proposedHours": makeDefaultOpenProposedHours(), + } + + handler := http.HandlerFunc(GetConflictingBookingsForExceptionHandler) + w := makeRequest(handler, "POST", "/api/admin/bookings/conflicting-for-exception", payload, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response OverlappingBookingsResponse + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if response.Bookings == nil { + t.Error("expected non-nil bookings array, got nil") + } + if len(response.Bookings) != 0 { + t.Errorf("expected 0 conflicts, got %d", len(response.Bookings)) + } +} + +// --- Edge-case tests for GetPreviewAvailableHours --- + +// TestPreviewAvailableHours_ProposedOverrideWithBookings verifies that when +// proposed hours are used, existing bookings still reduce available slots. +func TestPreviewAvailableHours_ProposedOverrideWithBookings(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + defer fixtures.DeleteUser(tx, userID) + + // Monday 10:00-11:00 booking + createTestBooking(t, ctx, tx, userID, time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), 60) + + // Proposed: Monday open 09:00-17:00 + proposedJSON := `[{"weekday":0,"startTime":"09:00","endTime":"17:00","isOpen":true}]` + weeksJSON := `["2026-03-16"]` + + req := httptest.NewRequest("GET", + "/api/scheduling/preview-available-hours?start=2026-03-16&end=2026-03-16"+ + "&proposed_hours="+strings.ReplaceAll(strings.ReplaceAll(proposedJSON, "[", "%5B"), "]", "%5D")+ + "&proposed_weeks="+strings.ReplaceAll(strings.ReplaceAll(weeksJSON, "[", "%5B"), "]", "%5D"), + nil) + w := httptest.NewRecorder() + handler := http.HandlerFunc(GetPreviewAvailableHours) + handler.ServeHTTP(w, req.WithContext(ctx)) + + if w.Code != http.StatusOK { + t.Fatalf("expected 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: %v", err) + } + + if len(response) != 1 { + t.Fatalf("expected 1 day, got %d", len(response)) + } + if response[0].Source != "proposed" { + t.Errorf("expected source 'proposed', got '%s'", response[0].Source) + } + if !response[0].IsOpen { + t.Error("expected Monday to be open") + } + // With a 10:00-11:00 booking removed from 09:00-17:00, we should see 2 slots: 09:00-10:00 and 11:00-17:00 + if len(response[0].Slots) != 2 { + t.Errorf("expected 2 slots (booking removed), got %d: %+v", len(response[0].Slots), response[0].Slots) + } +} + +// TestPreviewAvailableHours_InvalidProposedWeeks verifies 400 for bad weeks JSON. +func TestPreviewAvailableHours_InvalidProposedWeeks(t *testing.T) { + t.Parallel() + ctx, _ := resetTestData(t) + + req := httptest.NewRequest("GET", + "/api/scheduling/preview-available-hours?start=2026-03-16&end=2026-03-16&proposed_weeks=not-json", + nil) + w := httptest.NewRecorder() + handler := http.HandlerFunc(GetPreviewAvailableHours) + handler.ServeHTTP(w, req.WithContext(ctx)) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid weeks JSON, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestPreviewAvailableHours_OutOfHours_Admin verifies out-of-hours override +// works in preview mode for admin users. +func TestPreviewAvailableHours_OutOfHours_Admin(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) + + proposedJSON := `[{"weekday":0,"startTime":"10:00","endTime":"16:00","isOpen":true}]` + weeksJSON := `["2026-03-16"]` + + req := httptest.NewRequest("GET", + "/api/scheduling/preview-available-hours?start=2026-03-16&end=2026-03-16"+ + "&out_of_hours=true"+ + "&proposed_hours="+strings.ReplaceAll(strings.ReplaceAll(proposedJSON, "[", "%5B"), "]", "%5D")+ + "&proposed_weeks="+strings.ReplaceAll(strings.ReplaceAll(weeksJSON, "[", "%5B"), "]", "%5D"), + nil) + req = req.WithContext(ctx) + // Set admin role in context + req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin")) + + w := httptest.NewRecorder() + handler := http.HandlerFunc(GetPreviewAvailableHours) + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 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: %v", err) + } + + if len(response) != 1 { + t.Fatalf("expected 1 day, got %d", len(response)) + } + // Out-of-hours should override to 06:00-22:00 + if response[0].Source != "out_of_hours" { + t.Errorf("expected source 'out_of_hours', got '%s'", response[0].Source) + } + if !response[0].IsOpen { + t.Error("expected out_of_hours to be open") + } +}