test: add conflict detection and preview available hours tests

Add 12 new tests for GetConflictingBookingsForExceptionHandler (closed day, within hours, before-opening, after-closing, multiple weeks, excluded statuses, invalid payload, wrong method) and GetPreviewAvailableHours (no proposed, proposed override, invalid JSON, missing params). Fix pre-existing test with FK violation (non-existent admin_id) by using fixture-created admin user.
This commit is contained in:
2026-08-22 00:34:48 +01:00
parent e27db9202e
commit 291868f11a
+830 -3
View File
@@ -1773,11 +1773,19 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation_NonAdmin(t *
// Not parallel (see above) // Not parallel (see above)
ctx, tx := resetTestData(t) 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) 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) INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 30, 'RESERVATION:admin:callin:guest:1712345678', 'admin001') VALUES ($1, 30, 'RESERVATION:admin:callin:guest:1712345678', $2)
`, reservationTime) `, reservationTime, adminID); err != nil {
t.Fatalf("failed to create reservation blocker: %v", err)
}
handler := http.HandlerFunc(GetAvailableHours) handler := http.HandlerFunc(GetAvailableHours)
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil) 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()) 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")
}
}