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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user