//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" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "crussell/db" "crussell/mw" "crussell/testutils/fixtures" "github.com/go-chi/chi/v5" ) 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) { resetTestData(t) ukLocation, _ := time.LoadLocation("Europe/London") blockerTime1 := time.Now().In(ukLocation).Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) blockerTime2 := time.Now().In(ukLocation).Add(8 * 24 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour) _, 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) { resetTestData(t) 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) { resetTestData(t) 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) { resetTestData(t) 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) { resetTestData(t) 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) { resetTestData(t) // 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) { resetTestData(t) 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) { resetTestData(t) 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) { resetTestData(t) 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_IncludesRecurring verifies that recurring blockers // are expanded to actual occurrences within the query range. func TestGetTimeBlockersInRange_IncludesRecurring(t *testing.T) { resetTestData(t) ukLocation, _ := time.LoadLocation("Europe/London") // Create one-off blocker for March 15 oneOffTime := time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation) // Cron: every Monday at 10:00 (0 10 * * 1) cronExpr := "0 10 * * 1" _, 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 Monday', $3, NULL) `, oneOffTime, oneOffTime, cronExpr) if err != nil { t.Fatalf("failed to create blockers: %v", err) } ctx := context.Background() // Query range: March 1-31, 2026 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) } // March 2026 has 5 Mondays: 2nd, 9th, 16th, 23rd, 30th // So we expect: 1 one-off + 5 recurring occurrences = 6 total if len(blockers) != 6 { t.Errorf("expected 6 blockers (1 one-off + 5 recurring), got %d", len(blockers)) for i, b := range blockers { t.Logf("Blocker %d: %s at %v", i, b.Description, b.StartTime) } } // Verify we have the one-off foundOneOff := false foundRecurring := 0 for _, b := range blockers { if b.Description == "One-off" { foundOneOff = true } if b.Description == "Recurring Monday" { foundRecurring++ } } if !foundOneOff { t.Error("expected to find one-off blocker") } if foundRecurring != 5 { t.Errorf("expected 5 recurring Monday occurrences, got %d", foundRecurring) } } // TestCleanupOldReservations verifies that reservation blockers older than 1 hour // are automatically deleted, while recent ones are kept. func TestCleanupOldReservations(t *testing.T) { resetTestData(t) ctx := context.Background() ukLocation, _ := time.LoadLocation("Europe/London") // Create fixture users for the test oldUserID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create old user: %v", err) } recentUserID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create recent user: %v", err) } // Create old reservation (> 1 hour old) oldTime := time.Now().Add(-2 * time.Hour).In(ukLocation) _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, $2, $3)`, oldTime, fmt.Sprintf("RESERVATION:user:%s:%d", oldUserID, time.Now().UnixNano()), oldUserID) if err != nil { t.Fatalf("failed to create old reservation: %v", err) } // Set old created_at to make it eligible for cleanup (> 1 hour old) _, err = db.DB.Exec(ctx, `UPDATE time_blockers SET created_at = $1 WHERE description LIKE 'RESERVATION:user:%' AND start_time = $2`, time.Now().Add(-2*time.Hour), oldTime) if err != nil { t.Fatalf("failed to update old reservation created_at: %v", err) } // Create recent reservation (< 1 hour old) recentTime := time.Now().Add(-30 * time.Minute).In(ukLocation) _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, $2, $3)`, recentTime, fmt.Sprintf("RESERVATION:user:%s:%d", recentUserID, time.Now().UnixNano()), recentUserID) if err != nil { t.Fatalf("failed to create recent reservation: %v", err) } // Set recent created_at to recent (< 1 hour old) so it's NOT deleted _, err = db.DB.Exec(ctx, `UPDATE time_blockers SET created_at = $1 WHERE description LIKE 'RESERVATION:user:%' AND start_time = $2`, time.Now().Add(-30*time.Minute), recentTime) if err != nil { t.Fatalf("failed to update recent reservation created_at: %v", err) } // Create non-reservation blocker (should never be deleted) nonResTime := time.Now().Add(-2 * time.Hour).In(ukLocation) _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, $2, NULL)`, nonResTime, "Admin Blocked Time") if err != nil { t.Fatalf("failed to create non-reservation blocker: %v", err) } // Verify we have 3 blockers before cleanup var countBefore int err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers").Scan(&countBefore) if err != nil { t.Fatalf("failed to count blockers before cleanup: %v", err) } if countBefore != 3 { t.Errorf("expected 3 blockers before cleanup, got %d", countBefore) } // Run cleanup err = CleanupOldReservations(ctx) if err != nil { t.Fatalf("CleanupOldReservations failed: %v", err) } // Verify old reservation was deleted var oldCount int err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%' AND start_time = $1", oldTime).Scan(&oldCount) if err == nil && oldCount > 0 { t.Error("expected old reservation to be deleted") } // Verify recent reservation still exists var recentCount int err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%' AND start_time = $1", recentTime).Scan(&recentCount) if err != nil || recentCount == 0 { t.Error("expected recent reservation to still exist") } // Verify non-reservation blocker still exists var nonResCount int err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'Admin Blocked Time'").Scan(&nonResCount) if err != nil || nonResCount == 0 { t.Error("expected non-reservation blocker to still exist") } // Verify final count (should be 2: recent reservation + non-reservation blocker) var countAfter int err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers").Scan(&countAfter) if err != nil { t.Fatalf("failed to count blockers after cleanup: %v", err) } if countAfter != 2 { t.Errorf("expected 2 blockers after cleanup (1 old deletion), got %d", countAfter) } } // --- Tests for CleanupOldReservations (Admin Walk-In) --- // TestCleanupOldReservations_AdminWalkIn verifies that admin walk-in reservations // older than 15 minutes are deleted, while recent ones are preserved. func TestCleanupOldReservations_AdminWalkIn(t *testing.T) { resetTestData(t) ctx := context.Background() ukLocation, _ := time.LoadLocation("Europe/London") // Create old walk-in reservation (>15 min old) oldTime := time.Now().Add(-16 * time.Minute).In(ukLocation) _, err := db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:123', $2) `, oldTime, time.Now().Add(-16*time.Minute)) if err != nil { t.Fatalf("failed to create old walk-in reservation: %v", err) } // Create recent walk-in reservation (<15 min old) recentTime := time.Now().Add(-14 * time.Minute).In(ukLocation) _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:456', $2) `, recentTime, time.Now().Add(-14*time.Minute)) if err != nil { t.Fatalf("failed to create recent walk-in reservation: %v", err) } // Run cleanup err = CleanupOldReservations(ctx) if err != nil { t.Fatalf("CleanupOldReservations failed: %v", err) } // Verify old reservation was deleted var oldCount int err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:guest:123'").Scan(&oldCount) if err != nil { t.Fatalf("failed to check old reservation: %v", err) } if oldCount != 0 { t.Error("expected old walk-in reservation (16 min) to be deleted") } // Verify recent reservation still exists var recentCount int err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:guest:456'").Scan(&recentCount) if err != nil { t.Fatalf("failed to check recent reservation: %v", err) } if recentCount != 1 { t.Error("expected recent walk-in reservation (14 min) to be preserved") } } // --- Tests for CleanupOldReservations (Admin Call-In) --- // TestCleanupOldReservations_AdminCallIn verifies that admin call-in reservations // older than 15 minutes are deleted, while recent ones are preserved. func TestCleanupOldReservations_AdminCallIn(t *testing.T) { resetTestData(t) ctx := context.Background() ukLocation, _ := time.LoadLocation("Europe/London") // Create old call-in reservation (>15 min old) oldTime := time.Now().Add(-16 * time.Minute).In(ukLocation) _, err := db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:callin:guest:123', $2) `, oldTime, time.Now().Add(-16*time.Minute)) if err != nil { t.Fatalf("failed to create old call-in reservation: %v", err) } // Create recent call-in reservation (<15 min old) recentTime := time.Now().Add(-14 * time.Minute).In(ukLocation) _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:callin:guest:456', $2) `, recentTime, time.Now().Add(-14*time.Minute)) if err != nil { t.Fatalf("failed to create recent call-in reservation: %v", err) } // Run cleanup err = CleanupOldReservations(ctx) if err != nil { t.Fatalf("CleanupOldReservations failed: %v", err) } // Verify old reservation was deleted var oldCount int err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:guest:123'").Scan(&oldCount) if err != nil { t.Fatalf("failed to check old reservation: %v", err) } if oldCount != 0 { t.Error("expected old call-in reservation (16 min) to be deleted") } // Verify recent reservation still exists var recentCount int err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:guest:456'").Scan(&recentCount) if err != nil { t.Fatalf("failed to check recent reservation: %v", err) } if recentCount != 1 { t.Error("expected recent call-in reservation (14 min) to be preserved") } } // --- Tests for CleanupOldReservations (Mixed Types) --- // TestCleanupOldReservations_MixedTypes verifies that cleanup correctly handles // all reservation types with their respective TTLs. func TestCleanupOldReservations_MixedTypes(t *testing.T) { resetTestData(t) ctx := context.Background() ukLocation, _ := time.LoadLocation("Europe/London") // Create old user reservation (>1 hour old) oldUserTime := time.Now().Add(-2 * time.Hour).In(ukLocation) _, err := db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:user:old', $2) `, oldUserTime, time.Now().Add(-2*time.Hour)) if err != nil { t.Fatalf("failed to create old user reservation: %v", err) } // Create recent user reservation (<1 hour old) recentUserTime := time.Now().Add(-30 * time.Minute).In(ukLocation) _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:user:recent', $2) `, recentUserTime, time.Now().Add(-30*time.Minute)) if err != nil { t.Fatalf("failed to create recent user reservation: %v", err) } // Create old anon reservation (>10 min old) oldAnonTime := time.Now().Add(-15 * time.Minute).In(ukLocation) _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:anon:old', $2) `, oldAnonTime, time.Now().Add(-15*time.Minute)) if err != nil { t.Fatalf("failed to create old anon reservation: %v", err) } // Create recent anon reservation (<10 min old) recentAnonTime := time.Now().Add(-5 * time.Minute).In(ukLocation) _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:anon:recent', $2) `, recentAnonTime, time.Now().Add(-5*time.Minute)) if err != nil { t.Fatalf("failed to create recent anon reservation: %v", err) } // Create old admin walk-in reservation (>15 min old) oldWalkinTime := time.Now().Add(-20 * time.Minute).In(ukLocation) _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:walkin:old', $2) `, oldWalkinTime, time.Now().Add(-20*time.Minute)) if err != nil { t.Fatalf("failed to create old walk-in reservation: %v", err) } // Create recent admin walk-in reservation (<15 min old) recentWalkinTime := time.Now().Add(-10 * time.Minute).In(ukLocation) _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:walkin:recent', $2) `, recentWalkinTime, time.Now().Add(-10*time.Minute)) if err != nil { t.Fatalf("failed to create recent walk-in reservation: %v", err) } // Create old admin call-in reservation (>15 min old) oldCallinTime := time.Now().Add(-20 * time.Minute).In(ukLocation) _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:callin:old', $2) `, oldCallinTime, time.Now().Add(-20*time.Minute)) if err != nil { t.Fatalf("failed to create old call-in reservation: %v", err) } // Create recent admin call-in reservation (<15 min old) recentCallinTime := time.Now().Add(-10 * time.Minute).In(ukLocation) _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:callin:recent', $2) `, recentCallinTime, time.Now().Add(-10*time.Minute)) if err != nil { t.Fatalf("failed to create recent call-in reservation: %v", err) } // Verify we have 8 reservations before cleanup var countBefore int err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%'").Scan(&countBefore) if err != nil { t.Fatalf("failed to count reservations before cleanup: %v", err) } if countBefore != 8 { t.Errorf("expected 8 reservations before cleanup, got %d", countBefore) } // Run cleanup err = CleanupOldReservations(ctx) if err != nil { t.Fatalf("CleanupOldReservations failed: %v", err) } // Verify old reservations were deleted (4 old ones) var oldUserCount, oldAnonCount, oldWalkinCount, oldCallinCount int db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:user:old'").Scan(&oldUserCount) db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:anon:old'").Scan(&oldAnonCount) db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:old'").Scan(&oldWalkinCount) db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:old'").Scan(&oldCallinCount) if oldUserCount != 0 { t.Error("expected old user reservation to be deleted") } if oldAnonCount != 0 { t.Error("expected old anon reservation to be deleted") } if oldWalkinCount != 0 { t.Error("expected old walk-in reservation to be deleted") } if oldCallinCount != 0 { t.Error("expected old call-in reservation to be deleted") } // Verify recent reservations still exist (4 recent ones) var recentUserCount, recentAnonCount, recentWalkinCount, recentCallinCount int db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:user:recent'").Scan(&recentUserCount) db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:anon:recent'").Scan(&recentAnonCount) db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:recent'").Scan(&recentWalkinCount) db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:recent'").Scan(&recentCallinCount) if recentUserCount != 1 { t.Error("expected recent user reservation to be preserved") } if recentAnonCount != 1 { t.Error("expected recent anon reservation to be preserved") } if recentWalkinCount != 1 { t.Error("expected recent walk-in reservation to be preserved") } if recentCallinCount != 1 { t.Error("expected recent call-in reservation to be preserved") } } // --- Tests for GetTimeBlockersInRange (Excludes Reservations) --- // TestGetTimeBlockersInRange_ExcludesReservations verifies that reservation // blockers are excluded from the results. func TestGetTimeBlockersInRange_ExcludesReservations(t *testing.T) { resetTestData(t) ctx := context.Background() ukLocation, _ := time.LoadLocation("Europe/London") // Create a regular blocker for tomorrow at 10:00 tomorrow := time.Now().Add(24 * time.Hour).In(ukLocation) blockerTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, ukLocation) _, err := db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff meeting', NULL) `, blockerTime) if err != nil { t.Fatalf("failed to create regular blocker: %v", err) } // Create a reservation for tomorrow at 11:00 reservationTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 11, 0, 0, 0, ukLocation) _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'RESERVATION:user:abc:123', NULL) `, reservationTime) if err != nil { t.Fatalf("failed to create reservation: %v", err) } // Query range covering both times start := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, ukLocation) end := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 23, 59, 59, 0, ukLocation) blockers, err := GetTimeBlockersInRange(ctx, start, end) if err != nil { t.Fatalf("GetTimeBlockersInRange failed: %v", err) } // Should return only 1 blocker (the regular one, not the reservation) if len(blockers) != 1 { t.Errorf("expected 1 blocker, got %d", len(blockers)) } // Verify the blocker is "Staff meeting" if len(blockers) > 0 && blockers[0].Description != "Staff meeting" { t.Errorf("expected blocker 'Staff meeting', got '%s'", blockers[0].Description) } } // --- Tests for AnonymizeStaleGuestAccounts --- // TestAnonymizeStaleGuestAccounts_Exactly6Months verifies that a guest with // a booking exactly 6 months ago is anonymized. func TestAnonymizeStaleGuestAccounts_Exactly6Months(t *testing.T) { resetTestData(t) ctx := context.Background() // Create guest user guestID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create guest user: %v", err) } // Set account_role to guest _, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID) if err != nil { t.Fatalf("failed to set guest role: %v", err) } // Create booking with start_time exactly 6 months ago _, err = db.DB.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, NOW() - INTERVAL '6 months', 'completed', false) `, guestID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Run anonymization err = AnonymizeStaleGuestAccounts(ctx) if err != nil { t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err) } // Verify guest was anonymized var firstName, lastName, email string err = db.DB.QueryRow(ctx, `SELECT n_first_name, n_last_name, email FROM users WHERE id = $1`, guestID).Scan(&firstName, &lastName, &email) if err != nil { t.Fatalf("failed to query anonymized user: %v", err) } if firstName != "Guest" { t.Errorf("expected first_name 'Guest', got '%s'", firstName) } if lastName != "Anonymized" { t.Errorf("expected last_name 'Anonymized', got '%s'", lastName) } if !strings.HasPrefix(email, "anon-") { t.Errorf("expected email to start with 'anon-', got '%s'", email) } } // TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped verifies that a guest // with an active (future) booking is NOT anonymized even if they have a past booking. func TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped(t *testing.T) { resetTestData(t) ctx := context.Background() // Create guest user guestID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create guest user: %v", err) } // Set account_role to guest _, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID) if err != nil { t.Fatalf("failed to set guest role: %v", err) } // Create past booking (7 months ago) _, err = db.DB.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false) `, guestID) if err != nil { t.Fatalf("failed to create past booking: %v", err) } // Create active booking (tomorrow) tomorrow := time.Now().Add(24 * time.Hour) _, err = db.DB.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed', false) `, guestID, tomorrow) if err != nil { t.Fatalf("failed to create active booking: %v", err) } // Run anonymization err = AnonymizeStaleGuestAccounts(ctx) if err != nil { t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err) } // Verify guest was NOT anonymized var firstName string err = db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guestID).Scan(&firstName) if err != nil { t.Fatalf("failed to query user: %v", err) } // The first name should NOT be "Guest" (it should retain original name) if firstName == "Guest" { t.Error("expected guest with active booking to NOT be anonymized") } } // TestAnonymizeStaleGuestAccounts_NoBookings verifies that a guest with // no bookings is NOT anonymized. func TestAnonymizeStaleGuestAccounts_NoBookings(t *testing.T) { resetTestData(t) ctx := context.Background() // Create guest user with no bookings guestID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create guest user: %v", err) } // Set account_role to guest _, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID) if err != nil { t.Fatalf("failed to set guest role: %v", err) } // Run anonymization err = AnonymizeStaleGuestAccounts(ctx) if err != nil { t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err) } // Verify guest was NOT anonymized var firstName string err = db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guestID).Scan(&firstName) if err != nil { t.Fatalf("failed to query user: %v", err) } // The first name should NOT be "Guest" (it should retain original name) if firstName == "Guest" { t.Error("expected guest with no bookings to NOT be anonymized") } } // Ensure bytes is used to avoid unused import error var _ = bytes.Buffer{} func TestAnonymizeStaleGuestAccounts(t *testing.T) { resetTestData(t) ctx := context.Background() // Guest 1: last booking 7 months ago — should be anonymized guest1ID, _ := fixtures.CreateTestUser(db.DB) db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest1ID) db.DB.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false) `, guest1ID) // Guest 2: last booking 3 months ago — should NOT be anonymized guest2ID, _ := fixtures.CreateTestUser(db.DB) db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest2ID) db.DB.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, NOW() - INTERVAL '3 months', 'completed', false) `, guest2ID) // Guest 3: has a pending booking — should NOT be anonymized (regardless of booking age) guest3ID, _ := fixtures.CreateTestUser(db.DB) db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest3ID) db.DB.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, NOW() + INTERVAL '2 days', 'pending', false) `, guest3ID) // Run anonymization err := AnonymizeStaleGuestAccounts(ctx) if err != nil { t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err) } // Guest 1 should be anonymized var g1Name string db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guest1ID).Scan(&g1Name) if g1Name != "Guest" { t.Errorf("expected guest 1 to be anonymized, got first_name='%s'", g1Name) } // Guest 2 should NOT be anonymized var g2Name string db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guest2ID).Scan(&g2Name) if g2Name == "Guest" { t.Error("expected guest 2 to NOT be anonymized (booking too recent)") } // Guest 3 should NOT be anonymized (has pending booking) var g3Name string db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guest3ID).Scan(&g3Name) if g3Name == "Guest" { t.Error("expected guest 3 to NOT be anonymized (has pending booking)") } // Verify guest 1's email was anonymized var g1Email string db.DB.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, guest1ID).Scan(&g1Email) if !strings.HasPrefix(g1Email, "anon-") { t.Errorf("expected guest 1 email to start with 'anon-', got '%s'", g1Email) } } // --- Tests for CleanupOldReservations (Edit Request) --- // TestCleanupOldReservations_EditRequest verifies that edit request reservations // older than 24 hours are deleted, while recent ones are preserved. func TestCleanupOldReservations_EditRequest(t *testing.T) { resetTestData(t) ctx := context.Background() ukLocation, _ := time.LoadLocation("Europe/London") // Create old edit_request reservation (>24 hours old) oldTime := time.Now().Add(-25 * time.Hour).In(ukLocation) _, err := db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:edit_request:bk123', $2) `, oldTime, time.Now().Add(-25*time.Hour)) if err != nil { t.Fatalf("failed to create old edit_request reservation: %v", err) } // Create recent edit_request reservation (<24 hours old) recentTime := time.Now().Add(-12 * time.Hour).In(ukLocation) _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:edit_request:bk456', $2) `, recentTime, time.Now().Add(-12*time.Hour)) if err != nil { t.Fatalf("failed to create recent edit_request reservation: %v", err) } // Run cleanup err = CleanupOldReservations(ctx) if err != nil { t.Fatalf("CleanupOldReservations failed: %v", err) } // Verify old reservation was deleted var oldCount int err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:edit_request:bk123'").Scan(&oldCount) if err != nil { t.Fatalf("failed to check old reservation: %v", err) } if oldCount != 0 { t.Error("expected old edit_request reservation (25h) to be deleted") } // Verify recent reservation still exists var recentCount int err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:edit_request:bk456'").Scan(&recentCount) if err != nil { t.Fatalf("failed to check recent reservation: %v", err) } if recentCount != 1 { t.Error("expected recent edit_request reservation (12h) to be preserved") } }