diff --git a/backend/handlers/scheduling/time_blockers_test.go b/backend/handlers/scheduling/time_blockers_test.go new file mode 100644 index 0000000..7a19016 --- /dev/null +++ b/backend/handlers/scheduling/time_blockers_test.go @@ -0,0 +1,572 @@ +//go:build test +// +build test + +package scheduling + +// Package scheduling contains tests for time blockers CRUD handlers. +// +// Test Coverage: +// - ListTimeBlockers: GET /api/admin/time-blockers - List all blockers +// - CreateTimeBlocker: POST /api/admin/time-blockers - Create blocker (admin) +// - DeleteTimeBlocker: DELETE /api/admin/time-blockers/{id} - Delete blocker (admin) +// - CheckTimeBlockerOverlap: Helper to check for overlapping blockers +// - GetTimeBlockersInRange: Helper to get blockers in date range +// +// Authentication: Create/Delete endpoints require admin role. + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "crussell/db" + "crussell/mw" + "crussell/testutils/jwt" + "crussell/testutils/testdb" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupTimeBlockersTestDB(t *testing.T) func() { + t.Helper() + + pool := testdb.Pool(t) + testdb.Migrate(t, pool) + testdb.TruncateTables(t, pool) + + originalDB := db.DB + db.DB = pool + + jwt.Init() + + // Seed default working hours + seedDefaultWorkingHours(t, pool) + + return func() { + db.DB = originalDB + pool.Close() + } +} + +func makeTimeBlockerRequest(handler http.HandlerFunc, method, path string, body interface{}) *httptest.ResponseRecorder { + var req *http.Request + if body != nil { + bodyBytes, _ := json.Marshal(body) + req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, path, nil) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + +func makeTimeBlockerAuthRequest(handler http.HandlerFunc, method, path string, body interface{}) *httptest.ResponseRecorder { + var req *http.Request + if body != nil { + bodyBytes, _ := json.Marshal(body) + req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, path, nil) + } + + // Add admin context (no user ID - created_by will be NULL) + ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + +// --- Tests for ListTimeBlockers --- + +// TestTimeBlockers_List verifies that all time blockers can be listed. +// Returns 200 OK with an array of blockers. +func TestTimeBlockers_List(t *testing.T) { + cleanup := setupTimeBlockersTestDB(t) + defer cleanup() + + // Create test time blockers + ukLocation, _ := time.LoadLocation("Europe/London") + blockerTime1 := time.Date(2026, 3, 10, 10, 0, 0, 0, ukLocation) + blockerTime2 := time.Date(2026, 3, 11, 14, 0, 0, 0, ukLocation) + + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'Blocker 1', NULL), + ($2, 30, 'Blocker 2', NULL) + `, blockerTime1, blockerTime2) + if err != nil { + t.Fatalf("failed to create time blockers: %v", err) + } + + handler := http.HandlerFunc(ListTimeBlockers) + w := makeTimeBlockerRequest(handler, "GET", "/api/admin/time-blockers", nil) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response []TimeBlocker + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if len(response) != 2 { + t.Errorf("expected 2 blockers, got %d", len(response)) + } +} + +// TestTimeBlockers_ListWithDateFilter verifies that time blockers can be +// filtered by start/end query parameters. +func TestTimeBlockers_ListWithDateFilter(t *testing.T) { + cleanup := setupTimeBlockersTestDB(t) + defer cleanup() + + ukLocation, _ := time.LoadLocation("Europe/London") + // Create blockers on different dates + blockerTime1 := time.Date(2026, 3, 10, 10, 0, 0, 0, ukLocation) // In range + blockerTime2 := time.Date(2026, 3, 15, 14, 0, 0, 0, ukLocation) // Out of range + blockerTime3 := time.Date(2026, 3, 12, 9, 0, 0, 0, ukLocation) // In range + + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'In Range 1', NULL), + ($2, 30, 'Out of Range', NULL), + ($3, 45, 'In Range 2', NULL) + `, blockerTime1, blockerTime2, blockerTime3) + if err != nil { + t.Fatalf("failed to create time blockers: %v", err) + } + handler := http.HandlerFunc(ListTimeBlockers) + w := makeTimeBlockerRequest(handler, "GET", "/api/admin/time-blockers?start=2026-03-10&end=2026-03-13", nil) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response []TimeBlocker + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + // Should return only blockers within the date range (2026-03-10 to 2026-03-13) + if len(response) != 2 { + t.Errorf("expected 2 blockers in range, got %d", len(response)) + } +} + +// --- Tests for CreateTimeBlocker --- + +// TestTimeBlockers_Create verifies that an admin can create a new time blocker. +func TestTimeBlockers_Create(t *testing.T) { + cleanup := setupTimeBlockersTestDB(t) + defer cleanup() + + ukLocation, _ := time.LoadLocation("Europe/London") + blockerTime := time.Date(2026, 3, 20, 10, 0, 0, 0, ukLocation) + + reqBody := CreateTimeBlockerRequest{ + StartTime: blockerTime, + DurationMinutes: 60, + Description: "Test blocker", + } + + handler := http.HandlerFunc(CreateTimeBlocker) + w := makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody) + + if w.Code != http.StatusCreated { + t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var response TimeBlocker + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if response.DurationMinutes != 60 { + t.Errorf("expected duration 60, got %d", response.DurationMinutes) + } + + if response.Description != "Test blocker" { + t.Errorf("expected description 'Test blocker', got %s", response.Description) + } + + // Verify it exists in DB + var count int + err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM time_blockers WHERE id = $1`, response.ID).Scan(&count) + if err != nil { + t.Fatalf("failed to verify blocker in DB: %v", err) + } + if count != 1 { + t.Error("expected blocker to exist in DB") + } +} + +// TestTimeBlockers_Create_ValidationErrors verifies that missing or invalid +// fields result in 400 Bad Request. +func TestTimeBlockers_Create_ValidationErrors(t *testing.T) { + cleanup := setupTimeBlockersTestDB(t) + defer cleanup() + + handler := http.HandlerFunc(CreateTimeBlocker) + + // Test missing start_time + reqBody1 := map[string]interface{}{ + "duration_minutes": 60, + "description": "Test", + } + w := makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody1) + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for missing start_time, got %d. body: %s", w.Code, w.Body.String()) + } + + // Test missing duration_minutes + ukLocation, _ := time.LoadLocation("Europe/London") + blockerTime := time.Date(2026, 3, 20, 10, 0, 0, 0, ukLocation) + reqBody2 := map[string]interface{}{ + "start_time": blockerTime, + "description": "Test", + } + w = makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody2) + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for missing duration_minutes, got %d. body: %s", w.Code, w.Body.String()) + } + + // Test invalid duration_minutes (zero) + reqBody3 := map[string]interface{}{ + "start_time": blockerTime, + "duration_minutes": 0, + "description": "Test", + } + w = makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody3) + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for zero duration_minutes, got %d. body: %s", w.Code, w.Body.String()) + } + + // Test invalid duration_minutes (negative) + reqBody4 := map[string]interface{}{ + "start_time": blockerTime, + "duration_minutes": -10, + "description": "Test", + } + w = makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody4) + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for negative duration_minutes, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// --- Tests for DeleteTimeBlocker --- + +// TestTimeBlockers_Delete verifies that an admin can delete a time blocker. +func TestTimeBlockers_Delete(t *testing.T) { + cleanup := setupTimeBlockersTestDB(t) + defer cleanup() + + ukLocation, _ := time.LoadLocation("Europe/London") + blockerTime := time.Date(2026, 3, 25, 10, 0, 0, 0, ukLocation) + + // Create a blocker to delete + var blockerID string + err := db.DB.QueryRow(context.Background(), ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'To be deleted', NULL) + RETURNING id + `, blockerTime).Scan(&blockerID) + if err != nil { + t.Fatalf("failed to create blocker: %v", err) + } + + // Set up chi router for URL param + r := chi.NewRouter() + r.Delete("/api/admin/time-blockers/{id}", DeleteTimeBlocker) + + // Create request with chi context + req := httptest.NewRequest("DELETE", "/api/admin/time-blockers/"+blockerID, nil) + ctx := context.WithValue(req.Context(), mw.UserIDKey, "admin001") + ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", blockerID) + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify blocker was deleted + var count int + err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM time_blockers WHERE id = $1`, blockerID).Scan(&count) + if err != nil { + t.Fatalf("failed to check blocker: %v", err) + } + if count != 0 { + t.Error("expected blocker to be deleted from DB") + } +} + +// TestTimeBlockers_Delete_NotFound verifies that attempting to delete a +// non-existent blocker returns 404 Not Found. +func TestTimeBlockers_Delete_NotFound(t *testing.T) { + cleanup := setupTimeBlockersTestDB(t) + defer cleanup() + + // Set up chi router for URL param + r := chi.NewRouter() + r.Delete("/api/admin/time-blockers/{id}", DeleteTimeBlocker) + + // Create request with non-existent ID + req := httptest.NewRequest("DELETE", "/api/admin/time-blockers/nonexistent-id", nil) + ctx := context.WithValue(req.Context(), mw.UserIDKey, "admin001") + ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "nonexistent-id") + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// --- Tests for CheckTimeBlockerOverlap --- + +// TestCheckTimeBlockerOverlap verifies that the overlap detection function +// correctly identifies overlapping time ranges. +func TestCheckTimeBlockerOverlap(t *testing.T) { + cleanup := setupTimeBlockersTestDB(t) + defer cleanup() + + ukLocation, _ := time.LoadLocation("Europe/London") + // Create blocker for 10:00-11:00 (60 minutes) + blockerTime := time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation) + + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'Existing blocker', NULL) + `, blockerTime) + if err != nil { + t.Fatalf("failed to create blocker: %v", err) + } + + ctx := context.Background() + + // Test case 1: Exact overlap (10:00-11:00) + hasOverlap, desc, err := CheckTimeBlockerOverlap(ctx, + time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation), + time.Date(2026, 3, 15, 11, 0, 0, 0, ukLocation)) + if err != nil { + t.Fatalf("CheckTimeBlockerOverlap failed: %v", err) + } + if !hasOverlap { + t.Error("expected overlap for exact match booking 10:00-11:00") + } + if desc != "Existing blocker" { + t.Errorf("expected description 'Existing blocker', got %s", desc) + } + + // Test case 2: No overlap (09:00-10:00 - ends exactly when blocker starts) + hasOverlap, _, err = CheckTimeBlockerOverlap(ctx, + time.Date(2026, 3, 15, 9, 0, 0, 0, ukLocation), + time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation)) + if err != nil { + t.Fatalf("CheckTimeBlockerOverlap failed: %v", err) + } + if hasOverlap { + t.Error("expected no overlap for booking 09:00-10:00 (ends exactly when blocker starts)") + } + + // Test case 3: Partial overlap (10:30-11:30 - starts during blocker) + hasOverlap, _, err = CheckTimeBlockerOverlap(ctx, + time.Date(2026, 3, 15, 10, 30, 0, 0, ukLocation), + time.Date(2026, 3, 15, 11, 30, 0, 0, ukLocation)) + if err != nil { + t.Fatalf("CheckTimeBlockerOverlap failed: %v", err) + } + if !hasOverlap { + t.Error("expected overlap for partial overlap booking 10:30-11:30") + } + + // Test case 4: Partial overlap (09:30-10:30 - ends during blocker) + hasOverlap, _, err = CheckTimeBlockerOverlap(ctx, + time.Date(2026, 3, 15, 9, 30, 0, 0, ukLocation), + time.Date(2026, 3, 15, 10, 30, 0, 0, ukLocation)) + if err != nil { + t.Fatalf("CheckTimeBlockerOverlap failed: %v", err) + } + if !hasOverlap { + t.Error("expected overlap for partial overlap booking 09:30-10:30") + } + + // Test case 5: No overlap (completely before blocker) + hasOverlap, _, err = CheckTimeBlockerOverlap(ctx, + time.Date(2026, 3, 15, 8, 0, 0, 0, ukLocation), + time.Date(2026, 3, 15, 9, 0, 0, 0, ukLocation)) + if err != nil { + t.Fatalf("CheckTimeBlockerOverlap failed: %v", err) + } + if hasOverlap { + t.Error("expected no overlap for booking completely before blocker") + } + + // Test case 6: No overlap (completely after blocker) + hasOverlap, _, err = CheckTimeBlockerOverlap(ctx, + time.Date(2026, 3, 15, 14, 0, 0, 0, ukLocation), + time.Date(2026, 3, 15, 15, 0, 0, 0, ukLocation)) + if err != nil { + t.Fatalf("CheckTimeBlockerOverlap failed: %v", err) + } + if hasOverlap { + t.Error("expected no overlap for booking completely after blocker") + } +} + +// --- Tests for GetTimeBlockersInRange --- + +// TestGetTimeBlockersInRange verifies that blockers can be retrieved +// for a specific date range. +func TestGetTimeBlockersInRange(t *testing.T) { + cleanup := setupTimeBlockersTestDB(t) + defer cleanup() + + ukLocation, _ := time.LoadLocation("Europe/London") + // Create blockers on different dates + blocker1 := time.Date(2026, 3, 10, 10, 0, 0, 0, ukLocation) + blocker2 := time.Date(2026, 3, 15, 14, 0, 0, 0, ukLocation) + blocker3 := time.Date(2026, 3, 20, 9, 0, 0, 0, ukLocation) + + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'Day 10', NULL), + ($2, 30, 'Day 15', NULL), + ($3, 45, 'Day 20', NULL) + `, blocker1, blocker2, blocker3) + if err != nil { + t.Fatalf("failed to create blockers: %v", err) + } + + ctx := context.Background() + + // Query range that includes blocker1 and blocker2 but not blocker3 + start := time.Date(2026, 3, 1, 0, 0, 0, 0, ukLocation) + end := time.Date(2026, 3, 16, 23, 59, 59, 0, ukLocation) + + blockers, err := GetTimeBlockersInRange(ctx, start, end) + if err != nil { + t.Fatalf("GetTimeBlockersInRange failed: %v", err) + } + + // Should return 2 blockers (March 10 and March 15) + if len(blockers) != 2 { + t.Errorf("expected 2 blockers in range, got %d", len(blockers)) + } + + // Verify correct blockers returned + found := make(map[string]bool) + for _, b := range blockers { + found[b.Description] = true + } + + if !found["Day 10"] { + t.Error("expected blocker 'Day 10' in range") + } + if !found["Day 15"] { + t.Error("expected blocker 'Day 15' in range") + } + if found["Day 20"] { + t.Error("did not expect blocker 'Day 20' in range (March 20)") + } +} + +// TestGetTimeBlockersInRange_Empty verifies that an empty array is +// returned when no blockers exist in the range. +func TestGetTimeBlockersInRange_Empty(t *testing.T) { + cleanup := setupTimeBlockersTestDB(t) + defer cleanup() + + ukLocation, _ := time.LoadLocation("Europe/London") + + // Create a blocker + blockerTime := time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation) + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'March 15', NULL) + `, blockerTime) + if err != nil { + t.Fatalf("failed to create blocker: %v", err) + } + + ctx := context.Background() + + // Query range with no blockers + start := time.Date(2026, 4, 1, 0, 0, 0, 0, ukLocation) + end := time.Date(2026, 4, 30, 23, 59, 59, 0, ukLocation) + + blockers, err := GetTimeBlockersInRange(ctx, start, end) + if err != nil { + t.Fatalf("GetTimeBlockersInRange failed: %v", err) + } + + if len(blockers) != 0 { + t.Errorf("expected 0 blockers in range, got %d", len(blockers)) + } +} + +// TestGetTimeBlockersInRange_ExcludesRecurring verifies that +// blockers with cron_expression (recurring) are excluded from results. +func TestGetTimeBlockersInRange_ExcludesRecurring(t *testing.T) { + cleanup := setupTimeBlockersTestDB(t) + defer cleanup() + + ukLocation, _ := time.LoadLocation("Europe/London") + // Create one-off blocker + oneOffTime := time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation) + cronExpr := "0 10 * * 1" // Weekly on Monday at 10:00 + + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by) + VALUES ($1, 60, 'One-off', NULL, NULL), + ($2, 60, 'Recurring', $3, NULL) + `, oneOffTime, oneOffTime, cronExpr) + if err != nil { + t.Fatalf("failed to create blockers: %v", err) + } + + ctx := context.Background() + + // Query range that includes the blocker + start := time.Date(2026, 3, 1, 0, 0, 0, 0, ukLocation) + end := time.Date(2026, 3, 31, 23, 59, 59, 0, ukLocation) + + blockers, err := GetTimeBlockersInRange(ctx, start, end) + if err != nil { + t.Fatalf("GetTimeBlockersInRange failed: %v", err) + } + + // Should only return the one-off blocker + if len(blockers) != 1 { + t.Errorf("expected 1 blocker (excluding recurring), got %d", len(blockers)) + } + + if blockers[0].Description != "One-off" { + t.Errorf("expected 'One-off' blocker, got %s", blockers[0].Description) + } +} + +// Ensure pool is used to avoid unused import error +var _ = pgxpool.Pool{} +var _ = bytes.Buffer{}