//go: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" "database/sql" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "crussell/clock" "crussell/mw" "crussell/testutils/fixtures" "github.com/go-chi/chi/v5" ) func makeTimeBlockerRequest(handler http.HandlerFunc, method, path string, body interface{}, ctx context.Context) *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) } req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } func makeTimeBlockerAuthRequest(handler http.HandlerFunc, method, path string, body interface{}, ctx context.Context) *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) } // Layer admin context on top of test transaction context chiCtx := context.WithValue(ctx, mw.UserRoleKey, "admin") req = req.WithContext(chiCtx) 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) { t.Parallel() ctx, tx := resetTestData(t) blockerTime1 := clock.Now().Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) blockerTime2 := clock.Now().Add(8 * 24 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour) _, err := tx.Exec(ctx, ` 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, ctx) 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) { t.Parallel() ctx, tx := resetTestData(t) // Create blockers on different dates blockerTime1 := time.Date(2026, 3, 10, 10, 0, 0, 0, time.UTC) // In range blockerTime2 := time.Date(2026, 3, 15, 14, 0, 0, 0, time.UTC) // Out of range blockerTime3 := time.Date(2026, 3, 12, 9, 0, 0, 0, time.UTC) // In range _, err := tx.Exec(ctx, ` 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, ctx) 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) { t.Parallel() ctx, tx := resetTestData(t) blockerTime := time.Date(2026, 3, 20, 10, 0, 0, 0, time.UTC) reqBody := CreateTimeBlockerRequest{ StartTime: blockerTime, DurationMinutes: 60, Description: "Test blocker", } handler := http.HandlerFunc(CreateTimeBlocker) w := makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody, ctx) 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 := tx.QueryRow(ctx, `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) { t.Parallel() ctx, _ := 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, ctx) 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 blockerTime := time.Date(2026, 3, 20, 10, 0, 0, 0, time.UTC) reqBody2 := map[string]interface{}{ "start_time": blockerTime, "description": "Test", } w = makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody2, ctx) 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, ctx) 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, ctx) 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) { t.Parallel() ctx, tx := resetTestData(t) blockerTime := time.Date(2026, 3, 25, 10, 0, 0, 0, time.UTC) // Create a blocker to delete var blockerID string err := tx.QueryRow(ctx, ` 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) reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001") reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") rctx := chi.NewRouteContext() rctx.URLParams.Add("id", blockerID) reqCtx = context.WithValue(reqCtx, chi.RouteCtxKey, rctx) req = req.WithContext(reqCtx) 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 = tx.QueryRow(ctx, `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) { t.Parallel() ctx, _ := 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) reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001") reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "nonexistent-id") reqCtx = context.WithValue(reqCtx, chi.RouteCtxKey, rctx) req = req.WithContext(reqCtx) 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) { t.Parallel() ctx, tx := resetTestData(t) // Create blocker for 10:00-11:00 (60 minutes) blockerTime := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) _, err := tx.Exec(ctx, ` 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) } // Test case 1: Exact overlap (10:00-11:00) hasOverlap, desc, err := CheckTimeBlockerOverlap(ctx, time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC), nil) 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, time.UTC), time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), nil) 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, time.UTC), time.Date(2026, 3, 15, 11, 30, 0, 0, time.UTC), nil) 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, time.UTC), time.Date(2026, 3, 15, 10, 30, 0, 0, time.UTC), nil) 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, time.UTC), time.Date(2026, 3, 15, 9, 0, 0, 0, time.UTC), nil) 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, time.UTC), time.Date(2026, 3, 15, 15, 0, 0, 0, time.UTC), nil) 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) { t.Parallel() ctx, tx := resetTestData(t) // Create blockers on different dates blocker1 := time.Date(2026, 3, 10, 10, 0, 0, 0, time.UTC) blocker2 := time.Date(2026, 3, 15, 14, 0, 0, 0, time.UTC) blocker3 := time.Date(2026, 3, 20, 9, 0, 0, 0, time.UTC) _, err := tx.Exec(ctx, ` 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) } // Query range that includes blocker1 and blocker2 but not blocker3 start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) end := time.Date(2026, 3, 16, 23, 59, 59, 0, time.UTC) blockers, err := GetTimeBlockersInRange(ctx, start, end, nil) 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) { t.Parallel() ctx, tx := resetTestData(t) // Create a blocker blockerTime := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) _, err := tx.Exec(ctx, ` 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) } // Query range with no blockers start := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) end := time.Date(2026, 4, 30, 23, 59, 59, 0, time.UTC) blockers, err := GetTimeBlockersInRange(ctx, start, end, nil) 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) { t.Parallel() ctx, tx := resetTestData(t) // Create one-off blocker for March 15 oneOffTime := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) // Cron: every Monday at 10:00 (0 10 * * 1) cronExpr := "0 10 * * 1" _, err := tx.Exec(ctx, ` 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) } // Query range: March 1-31, 2026 start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) end := time.Date(2026, 3, 31, 23, 59, 59, 0, time.UTC) blockers, err := GetTimeBlockersInRange(ctx, start, end, nil) 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) { t.Parallel() ctx, tx := resetTestData(t) // Create fixture users for the test oldUserID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create old user: %v", err) } recentUserID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create recent user: %v", err) } // Create old reservation (> 1 hour old) oldTime := clock.Now().Add(-2 * time.Hour) _, err = tx.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, clock.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 = tx.Exec(ctx, `UPDATE time_blockers SET created_at = $1 WHERE description LIKE 'RESERVATION:user:%' AND start_time = $2`, clock.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 := clock.Now().Add(-30 * time.Minute) _, err = tx.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, clock.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 = tx.Exec(ctx, `UPDATE time_blockers SET created_at = $1 WHERE description LIKE 'RESERVATION:user:%' AND start_time = $2`, clock.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 := clock.Now().Add(-2 * time.Hour) _, err = tx.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 = tx.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 = tx.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 = tx.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 = tx.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 = tx.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) { t.Parallel() ctx, tx := resetTestData(t) // Create old walk-in reservation (>15 min old) oldTime := clock.Now().Add(-16 * time.Minute) _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:123', $2) `, oldTime, clock.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 := clock.Now().Add(-14 * time.Minute) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:456', $2) `, recentTime, clock.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 = tx.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 = tx.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) { t.Parallel() ctx, tx := resetTestData(t) // Create old call-in reservation (>15 min old) oldTime := clock.Now().Add(-16 * time.Minute) _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:callin:guest:123', $2) `, oldTime, clock.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 := clock.Now().Add(-14 * time.Minute) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:callin:guest:456', $2) `, recentTime, clock.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 = tx.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 = tx.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) { t.Parallel() ctx, tx := resetTestData(t) // Create old user reservation (>1 hour old) oldUserTime := clock.Now().Add(-2 * time.Hour) _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:user:old', $2) `, oldUserTime, clock.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 := clock.Now().Add(-30 * time.Minute) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:user:recent', $2) `, recentUserTime, clock.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 := clock.Now().Add(-15 * time.Minute) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:anon:old', $2) `, oldAnonTime, clock.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 := clock.Now().Add(-5 * time.Minute) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:anon:recent', $2) `, recentAnonTime, clock.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 := clock.Now().Add(-20 * time.Minute) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:walkin:old', $2) `, oldWalkinTime, clock.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 := clock.Now().Add(-10 * time.Minute) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:walkin:recent', $2) `, recentWalkinTime, clock.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 := clock.Now().Add(-20 * time.Minute) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:callin:old', $2) `, oldCallinTime, clock.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 := clock.Now().Add(-10 * time.Minute) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:admin:callin:recent', $2) `, recentCallinTime, clock.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 = tx.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 tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:user:old'").Scan(&oldUserCount) tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:anon:old'").Scan(&oldAnonCount) tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:old'").Scan(&oldWalkinCount) tx.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 tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:user:recent'").Scan(&recentUserCount) tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:anon:recent'").Scan(&recentAnonCount) tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:recent'").Scan(&recentWalkinCount) tx.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_IncludesReservations verifies that reservation // blockers ARE included in the results (needed for available-hours to correctly // exclude reserved slots). func TestGetTimeBlockersInRange_IncludesReservations(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Create a regular blocker for tomorrow at 10:00 tomorrow := clock.Now().Add(24 * time.Hour) blockerTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, time.UTC) _, err := tx.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, time.UTC) _, err = tx.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, time.UTC) end := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 23, 59, 59, 0, time.UTC) blockers, err := GetTimeBlockersInRange(ctx, start, end, nil) if err != nil { t.Fatalf("GetTimeBlockersInRange failed: %v", err) } // Should return both blockers (regular + reservation) if len(blockers) != 2 { t.Errorf("expected 2 blockers (including reservation), got %d", len(blockers)) } } // --- Tests for GetTimeBlockersInRange (Exclude User Reservations) --- // TestGetTimeBlockersInRange_ExcludeOwnReservation verifies that when // excludeUserID is set, RESERVATION entries owned by that user are excluded // from results, while other blockers (including other users' reservations) remain. func TestGetTimeBlockersInRange_ExcludeOwnReservation(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) userAID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user A: %v", err) } userBID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user B: %v", err) } tomorrow := clock.Now().Add(24 * time.Hour) dayStart := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, time.UTC) dayEnd := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 23, 59, 59, 0, time.UTC) // Create a regular blocker (always returned) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff meeting', NULL) `, dayStart.Add(9*time.Hour)) if err != nil { t.Fatalf("failed to create regular blocker: %v", err) } // Create a RESERVATION for userA _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'RESERVATION:user:' || $2 || ':123', $2) `, dayStart.Add(10*time.Hour), userAID) if err != nil { t.Fatalf("failed to create userA reservation: %v", err) } // Create a RESERVATION for userB _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'RESERVATION:user:' || $2 || ':456', $2) `, dayStart.Add(11*time.Hour), userBID) if err != nil { t.Fatalf("failed to create userB reservation: %v", err) } // Test 1: excludeUserID = userA — should see regular + userB, but NOT userA's reservation blockers, err := GetTimeBlockersInRange(ctx, dayStart, dayEnd, &userAID) if err != nil { t.Fatalf("GetTimeBlockersInRange failed: %v", err) } if len(blockers) != 2 { t.Errorf("expected 2 blockers (regular + userB) when excluding userA, got %d", len(blockers)) for _, b := range blockers { t.Logf(" blocker: %s created_by=%v", b.Description, b.CreatedBy) } } // Test 2: excludeUserID = nil — should see all 3 (regular + userA + userB) blockers, err = GetTimeBlockersInRange(ctx, dayStart, dayEnd, nil) if err != nil { t.Fatalf("GetTimeBlockersInRange failed: %v", err) } if len(blockers) != 3 { t.Errorf("expected 3 blockers (all) when excludeUserID=nil, got %d", len(blockers)) } // Test 3: excludeUserID = userB — should see regular + userA, but NOT userB's reservation blockers, err = GetTimeBlockersInRange(ctx, dayStart, dayEnd, &userBID) if err != nil { t.Fatalf("GetTimeBlockersInRange failed: %v", err) } if len(blockers) != 2 { t.Errorf("expected 2 blockers (regular + userA) when excluding userB, got %d", len(blockers)) } } // --- Tests for CheckTimeBlockerOverlap (Exclude User Reservations) --- // TestCheckTimeBlockerOverlap_ExcludeOwnReservation verifies that RESERVATION // entries are not considered overlapping when excludeUserID matches the owner. func TestCheckTimeBlockerOverlap_ExcludeOwnReservation(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) } tomorrow := clock.Now().Add(24 * time.Hour) slotStart := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, time.UTC) slotEnd := slotStart.Add(1 * time.Hour) // Create a regular blocker (always detected) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Existing blocker', NULL) `, slotStart) if err != nil { t.Fatalf("failed to create regular blocker: %v", err) } // Create a RESERVATION for this user at a different time reservationStart := slotStart.Add(2 * time.Hour) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'RESERVATION:user:' || $2 || ':123', $2) `, reservationStart, userID) if err != nil { t.Fatalf("failed to create user reservation: %v", err) } // Test 1: excludeUserID = userID — the regular blocker at slotStart should // still be detected, but the user's own RESERVATION at reservationStart // should be ignored. hasOverlap, desc, err := CheckTimeBlockerOverlap(ctx, slotStart, slotEnd, &userID) if err != nil { t.Fatalf("CheckTimeBlockerOverlap failed: %v", err) } if !hasOverlap { t.Error("expected overlap with regular blocker even when excluding own reservation") } if desc != "Existing blocker" { t.Errorf("expected description 'Existing blocker', got %s", desc) } // Test 2: excludeUserID = nil — the regular blocker should still be detected hasOverlap, _, err = CheckTimeBlockerOverlap(ctx, slotStart, slotEnd, nil) if err != nil { t.Fatalf("CheckTimeBlockerOverlap failed: %v", err) } if !hasOverlap { t.Error("expected overlap with regular blocker when excludeUserID=nil") } // Test 3: Overlap with own RESERVATION at reservationStart — with excludeUserID // set, this should NOT be detected as overlap resEnd := reservationStart.Add(1 * time.Hour) hasOverlap, _, err = CheckTimeBlockerOverlap(ctx, reservationStart, resEnd, &userID) if err != nil { t.Fatalf("CheckTimeBlockerOverlap failed: %v", err) } if hasOverlap { t.Error("expected NO overlap with own RESERVATION when excludeUserID matches") } // Test 4: Without excludeUserID, the own RESERVATION SHOULD be detected hasOverlap, _, err = CheckTimeBlockerOverlap(ctx, reservationStart, resEnd, nil) if err != nil { t.Fatalf("CheckTimeBlockerOverlap failed: %v", err) } if !hasOverlap { t.Error("expected overlap with own RESERVATION when excludeUserID=nil") } } // --- Tests for AnonymizeStaleGuestAccounts --- // TestAnonymizeStaleGuestAccounts_Exactly6Months verifies that a guest with // a booking exactly 6 months ago is anonymized. func TestAnonymizeStaleGuestAccounts_Exactly6Months(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Create guest user guestID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } // Set account_role to guest _, err = tx.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 more than 6 months ago // (strictly less than NOW() - INTERVAL '6 months' per the SQL condition) _, err = tx.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 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 = tx.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) { t.Parallel() ctx, tx := resetTestData(t) // Create guest user guestID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } // Set account_role to guest _, err = tx.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 = tx.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 := clock.Now().Add(24 * time.Hour) _, err = tx.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 = tx.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) { t.Parallel() ctx, tx := resetTestData(t) // Create guest user with no bookings guestID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } // Set account_role to guest _, err = tx.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 = tx.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") } } // --- Tests for CleanupExpiredFinancialRecords --- // TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years verifies that a // payment older than 7 years is aggregated and deleted. func TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } eightYearsAgo := clock.Now().AddDate(-8, 0, 0) _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at) VALUES ($1, 'full', 'cash', 'completed', 50.00, $2) `, bookingID, eightYearsAgo) if err != nil { t.Fatalf("failed to create payment: %v", err) } // Anonymize user so they qualify for deletion (>1 year ago, guest role, anon email) _, err = tx.Exec(ctx, ` UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '2 years' WHERE id = $1 `, userID) if err != nil { t.Fatalf("failed to anonymize user: %v", err) } // Run cleanup _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err) } // Verify payment was deleted var paymentCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } if paymentCount != 0 { t.Errorf("expected payment to be deleted, got %d payment(s)", paymentCount) } // Verify financial_aggregates has 1 row var aggCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount) if err != nil { t.Fatalf("failed to count aggregates: %v", err) } if aggCount != 1 { t.Errorf("expected 1 financial_aggregates row, got %d", aggCount) } // Verify aggregate has correct month and total expectedMonth := time.Date(eightYearsAgo.Year(), eightYearsAgo.Month(), 1, 0, 0, 0, 0, time.UTC) var aggMonth time.Time var totalPayments float64 err = tx.QueryRow(ctx, "SELECT month, total_payments FROM financial_aggregates").Scan(&aggMonth, &totalPayments) if err != nil { t.Fatalf("failed to query aggregate: %v", err) } if !aggMonth.Equal(expectedMonth) { t.Errorf("expected month %s, got %s", expectedMonth.Format("2006-01-02"), aggMonth.Format("2006-01-02")) } if totalPayments != 50.00 { t.Errorf("expected total_payments 50.00, got %.2f", totalPayments) } } // TestCleanupExpiredFinancialRecords_AnonUserWithin1YearBuffer verifies that // an anonymized user's payment is NOT deleted when the 1-year buffer hasn't // elapsed, even if the payment is older than 7 years. func TestCleanupExpiredFinancialRecords_AnonUserWithin1YearBuffer(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) guestID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = tx.Exec(ctx, ` UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '6 months' WHERE id = $1 `, guestID) if err != nil { t.Fatalf("failed to anonymize user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, guestID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } fourYearsAgo := clock.Now().AddDate(-4, 0, 0) var paymentID string err = tx.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at) VALUES ($1, 'full', 'cash', 'completed', 75.00, $2) RETURNING id `, bookingID, fourYearsAgo).Scan(&paymentID) if err != nil { t.Fatalf("failed to create payment: %v", err) } // Run cleanup _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err) } // Verify payment still exists (neither 7yr nor 1yr conditions satisfied) var paymentCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } if paymentCount != 1 { t.Error("expected payment to still exist") } // Verify no aggregate was created var aggCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount) if err != nil { t.Fatalf("failed to count aggregates: %v", err) } if aggCount != 0 { t.Errorf("expected no aggregates, got %d", aggCount) } } // TestCleanupExpiredFinancialRecords_PaymentOlderThan9Years verifies that a // payment older than 9 years is always deleted regardless of user status. func TestCleanupExpiredFinancialRecords_PaymentOlderThan9Years(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Payment created 9 years ago nineYearsAgo := clock.Now().AddDate(-9, 0, 0) _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at) VALUES ($1, 'full', 'cash', 'completed', 60.00, $2) `, bookingID, nineYearsAgo) if err != nil { t.Fatalf("failed to create payment: %v", err) } // Anonymize user so record qualifies for deletion _, err = tx.Exec(ctx, ` UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '2 years' WHERE id = $1 `, userID) if err != nil { t.Fatalf("failed to anonymize user: %v", err) } // Run cleanup _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err) } // Verify payment was deleted var paymentCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } if paymentCount != 0 { t.Errorf("expected payment to be deleted, got %d payment(s)", paymentCount) } // Verify financial_aggregates has 1 row var aggCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount) if err != nil { t.Fatalf("failed to count aggregates: %v", err) } if aggCount != 1 { t.Errorf("expected 1 financial_aggregates row, got %d", aggCount) } } // TestCleanupExpiredFinancialRecords_AggregationCorrectTotals verifies that // multiple payments in the same month are correctly aggregated by method and type. func TestCleanupExpiredFinancialRecords_AggregationCorrectTotals(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // 3 payments 8 years ago, all in the same month sameMonth := clock.Now().AddDate(-8, 0, 0) _ = sameMonth // used for all payments // Payment 1: £50 cash _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, fees, created_at) VALUES ($1, 'full', 'cash', 'completed', 50.00, 0, $2) `, bookingID, sameMonth) if err != nil { t.Fatalf("failed to create cash payment: %v", err) } // Payment 2: £30 online_square with £2.50 fees _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, fees, created_at) VALUES ($1, 'full', 'online_square', 'completed', 30.00, 2.50, $2) `, bookingID, sameMonth) if err != nil { t.Fatalf("failed to create online payment: %v", err) } // Payment 3: £20 in_person_card _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, fees, created_at) VALUES ($1, 'full', 'in_person_card', 'completed', 20.00, 0, $2) `, bookingID, sameMonth) if err != nil { t.Fatalf("failed to create card payment: %v", err) } // Anonymize user so records qualify for deletion _, err = tx.Exec(ctx, ` UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '2 years' WHERE id = $1 `, userID) if err != nil { t.Fatalf("failed to anonymize user: %v", err) } // Run cleanup _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err) } // Verify aggregate totals var totalPayments, totalCash, totalOnline, totalInPerson, totalSquareFees float64 var bookingCount int err = tx.QueryRow(ctx, ` SELECT total_payments, total_cash, total_online, total_in_person, total_square_fees, booking_count FROM financial_aggregates `).Scan(&totalPayments, &totalCash, &totalOnline, &totalInPerson, &totalSquareFees, &bookingCount) if err != nil { t.Fatalf("failed to query aggregate: %v", err) } if totalPayments != 100.00 { t.Errorf("expected total_payments 100.00, got %.2f", totalPayments) } if totalCash != 50.00 { t.Errorf("expected total_cash 50.00, got %.2f", totalCash) } if totalOnline != 30.00 { t.Errorf("expected total_online 30.00, got %.2f", totalOnline) } if totalInPerson != 20.00 { t.Errorf("expected total_in_person 20.00, got %.2f", totalInPerson) } if totalSquareFees != 2.50 { t.Errorf("expected total_square_fees 2.50, got %.2f", totalSquareFees) } if bookingCount != 1 { t.Errorf("expected booking_count 1, got %d", bookingCount) } } // TestCleanupExpiredFinancialRecords_Idempotent verifies that running the // cleanup function twice produces the same result (no double-counting). func TestCleanupExpiredFinancialRecords_Idempotent(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Payment 8 years ago eightYearsAgo := clock.Now().AddDate(-8, 0, 0) _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at) VALUES ($1, 'full', 'cash', 'completed', 100.00, $2) `, bookingID, eightYearsAgo) if err != nil { t.Fatalf("failed to create payment: %v", err) } // Anonymize user so record qualifies for deletion _, err = tx.Exec(ctx, ` UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '2 years' WHERE id = $1 `, userID) if err != nil { t.Fatalf("failed to anonymize user: %v", err) } // First run _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("first cleanup failed: %v", err) } // Capture aggregate values after first run var aggCount1 int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount1) if err != nil { t.Fatalf("failed to count aggregates: %v", err) } var totalPayments1 float64 var aggMonth1 time.Time err = tx.QueryRow(ctx, "SELECT month, total_payments FROM financial_aggregates").Scan(&aggMonth1, &totalPayments1) if err != nil { t.Fatalf("failed to query aggregate after first run: %v", err) } // Second run _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("second cleanup failed: %v", err) } // Verify same aggregate count and values var aggCount2 int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount2) if err != nil { t.Fatalf("failed to count aggregates: %v", err) } if aggCount2 != aggCount1 { t.Errorf("expected %d aggregates after second run, got %d", aggCount1, aggCount2) } var totalPayments2 float64 var aggMonth2 time.Time err = tx.QueryRow(ctx, "SELECT month, total_payments FROM financial_aggregates").Scan(&aggMonth2, &totalPayments2) if err != nil { t.Fatalf("failed to query aggregate after second run: %v", err) } if !aggMonth2.Equal(aggMonth1) { t.Errorf("expected month %s, got %s", aggMonth1.Format("2006-01-02"), aggMonth2.Format("2006-01-02")) } if totalPayments2 != totalPayments1 { t.Errorf("expected total_payments %.2f after second run, got %.2f", totalPayments1, totalPayments2) } // Verify payments are still deleted var paymentCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } if paymentCount != 0 { t.Errorf("expected payments to be deleted, got %d", paymentCount) } } // TestCleanupExpiredFinancialRecords_ActiveUserWithin7Years verifies that // a payment newer than 7 years for an active user is NOT deleted. func TestCleanupExpiredFinancialRecords_ActiveUserWithin7Years(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Payment created 3 years ago (< 7 years) threeYearsAgo := clock.Now().AddDate(-3, 0, 0) var paymentID string err = tx.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at) VALUES ($1, 'full', 'cash', 'completed', 40.00, $2) RETURNING id `, bookingID, threeYearsAgo).Scan(&paymentID) if err != nil { t.Fatalf("failed to create payment: %v", err) } // Run cleanup _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err) } // Verify payment still exists var paymentCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } if paymentCount != 1 { t.Error("expected payment to still exist") } // Verify no aggregate was created var aggCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount) if err != nil { t.Fatalf("failed to count aggregates: %v", err) } if aggCount != 0 { t.Errorf("expected no aggregates, got %d", aggCount) } } // TestCleanupExpiredFinancialRecords_AnonUserBothThresholdsElapsed verifies that // an anonymized user's payment IS deleted when BOTH the 7-year rule AND the // 1-year post-anonymization buffer have elapsed. func TestCleanupExpiredFinancialRecords_AnonUserBothThresholdsElapsed(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) guestID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Anonymize user 2 years ago _, err = tx.Exec(ctx, ` UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '2 years' WHERE id = $1 `, guestID) if err != nil { t.Fatalf("failed to anonymize user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, guestID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Payment created 8 years ago (past 7yr rule) // User anonymized 2 years ago (past 1yr buffer) // Both conditions met → should be deleted eightYearsAgo := clock.Now().AddDate(-8, 0, 0) var paymentID string err = tx.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at) VALUES ($1, 'full', 'cash', 'completed', 75.00, $2) RETURNING id `, bookingID, eightYearsAgo).Scan(&paymentID) if err != nil { t.Fatalf("failed to create payment: %v", err) } // Run cleanup _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err) } // Verify payment was deleted var paymentCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } if paymentCount != 0 { t.Error("expected payment to be deleted (both 7yr and 1yr+ thresholds elapsed)") } // Verify aggregate was created var aggCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount) if err != nil { t.Fatalf("failed to count aggregates: %v", err) } if aggCount != 1 { t.Errorf("expected 1 financial_aggregates row, got %d", aggCount) } } // TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted verifies that // refunds are aggregated and deleted alongside their parent payment when // retention expires. func TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Payment 8 years ago eightYearsAgo := clock.Now().AddDate(-8, 0, 0) var paymentID string err = tx.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at) VALUES ($1, 'full', 'cash', 'completed', 100.00, $2) RETURNING id `, bookingID, eightYearsAgo).Scan(&paymentID) if err != nil { t.Fatalf("failed to create payment: %v", err) } // Refund 8 years ago (same month as payment) var refundID string err = tx.QueryRow(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at) VALUES ($1, $2, 30.00, 'completed', 'test refund', $3) RETURNING id `, paymentID, bookingID, eightYearsAgo).Scan(&refundID) if err != nil { t.Fatalf("failed to create refund: %v", err) } // Anonymize user so records qualify for deletion _, err = tx.Exec(ctx, ` UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '2 years' WHERE id = $1 `, userID) if err != nil { t.Fatalf("failed to anonymize user: %v", err) } // Run cleanup _, err = CleanupExpiredFinancialRecords(ctx) if err != nil { t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err) } // Verify payment was deleted var paymentCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount) if err != nil { t.Fatalf("failed to count payments: %v", err) } if paymentCount != 0 { t.Error("expected payment to be deleted") } // Verify refund was deleted var refundCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE id = $1", refundID).Scan(&refundCount) if err != nil { t.Fatalf("failed to count refunds: %v", err) } if refundCount != 0 { t.Error("expected refund to be deleted") } // Verify aggregate has both payment and refund totals var totalPayments, totalRefunds float64 err = tx.QueryRow(ctx, "SELECT total_payments, total_refunds FROM financial_aggregates").Scan(&totalPayments, &totalRefunds) if err != nil { t.Fatalf("failed to query aggregate: %v", err) } if totalPayments != 100.00 { t.Errorf("expected total_payments 100.00, got %.2f", totalPayments) } if totalRefunds != 30.00 { t.Errorf("expected total_refunds 30.00, got %.2f", totalRefunds) } } // Ensure bytes is used to avoid unused import error var _ = bytes.Buffer{} func TestAnonymizeStaleGuestAccounts(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Guest 1: last booking 7 months ago — should be anonymized guest1ID, _ := fixtures.CreateTestUser(tx) tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest1ID) tx.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(tx) tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest2ID) tx.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(tx) tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest3ID) tx.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 tx.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 tx.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 tx.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 tx.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) { t.Parallel() ctx, tx := resetTestData(t) // Create old edit_request reservation (>24 hours old) oldTime := clock.Now().Add(-25 * time.Hour) _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:edit_request:bk123', $2) `, oldTime, clock.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 := clock.Now().Add(-12 * time.Hour) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:edit_request:bk456', $2) `, recentTime, clock.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 = tx.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 = tx.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") } } // --- Tests for CleanupOldReservations (Placeholder) --- // TestCleanupOldReservations_Placeholder verifies that placeholder reservations // older than 24 hours are deleted, while recent ones are preserved. func TestCleanupOldReservations_Placeholder(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Create old placeholder reservation (>24 hours old) oldTime := clock.Now().Add(-25 * time.Hour) _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:placeholder:bk123', $2) `, oldTime, clock.Now().Add(-25*time.Hour)) if err != nil { t.Fatalf("failed to create old placeholder reservation: %v", err) } // Create recent placeholder reservation (<24 hours old) recentTime := clock.Now().Add(-12 * time.Hour) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:placeholder:bk456', $2) `, recentTime, clock.Now().Add(-12*time.Hour)) if err != nil { t.Fatalf("failed to create recent placeholder reservation: %v", err) } // Run cleanup _, err = CleanupOldReservations(ctx) if err != nil { t.Fatalf("CleanupOldReservations failed: %v", err) } // Verify old placeholder was deleted var oldCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:placeholder:bk123'").Scan(&oldCount) if err != nil { t.Fatalf("failed to check old placeholder: %v", err) } if oldCount != 0 { t.Error("expected old placeholder reservation (25h) to be deleted") } // Verify recent placeholder still exists var recentCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:placeholder:bk456'").Scan(&recentCount) if err != nil { t.Fatalf("failed to check recent placeholder: %v", err) } if recentCount != 1 { t.Error("expected recent placeholder reservation (12h) to be preserved") } } // TestCleanupOldReservations_HolidayPlaceholder verifies that holiday placeholder // reservations older than 24 hours are deleted, while recent ones are preserved. func TestCleanupOldReservations_HolidayPlaceholder(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Create old holiday_placeholder reservation (>24 hours old) oldTime := clock.Now().Add(-25 * time.Hour) _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:holiday_placeholder:hld123', $2) `, oldTime, clock.Now().Add(-25*time.Hour)) if err != nil { t.Fatalf("failed to create old holiday_placeholder reservation: %v", err) } // Create recent holiday_placeholder reservation (<24 hours old) recentTime := clock.Now().Add(-12 * time.Hour) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:holiday_placeholder:hld456', $2) `, recentTime, clock.Now().Add(-12*time.Hour)) if err != nil { t.Fatalf("failed to create recent holiday_placeholder reservation: %v", err) } // Run cleanup _, err = CleanupOldReservations(ctx) if err != nil { t.Fatalf("CleanupOldReservations failed: %v", err) } // Verify old placeholder was deleted var oldCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:holiday_placeholder:hld123'").Scan(&oldCount) if err != nil { t.Fatalf("failed to check old holiday_placeholder: %v", err) } if oldCount != 0 { t.Error("expected old holiday_placeholder reservation (25h) to be deleted") } // Verify recent placeholder still exists var recentCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:holiday_placeholder:hld456'").Scan(&recentCount) if err != nil { t.Fatalf("failed to check recent holiday_placeholder: %v", err) } if recentCount != 1 { t.Error("expected recent holiday_placeholder reservation (12h) to be preserved") } } // TestCleanupOldReservations_MixedPlaceholders verifies that both placeholder // and holiday_placeholder patterns are cleaned up together alongside other types. func TestCleanupOldReservations_MixedPlaceholders(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Old placeholder (>24 hours) _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:placeholder:bkOld', $2) `, clock.Now().Add(-25*time.Hour), clock.Now().Add(-25*time.Hour)) if err != nil { t.Fatalf("failed to create old placeholder: %v", err) } // Old holiday_placeholder (>24 hours) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) VALUES ($1, 60, 'RESERVATION:holiday_placeholder:hldOld', $2) `, clock.Now().Add(-25*time.Hour), clock.Now().Add(-25*time.Hour)) if err != nil { t.Fatalf("failed to create old holiday_placeholder: %v", err) } // Old user reservation (>1 hour) — should also be deleted oldUserTime := clock.Now().Add(-2 * time.Hour) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by, created_at) VALUES ($1, 60, 'RESERVATION:user:test', $2, $3) `, oldUserTime, userID, clock.Now().Add(-2*time.Hour)) if err != nil { t.Fatalf("failed to create old user reservation: %v", err) } // Count before cleanup var countBefore int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers").Scan(&countBefore) if err != nil { t.Fatalf("failed to count before: %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) } // All 3 should be deleted var countAfter int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers").Scan(&countAfter) if err != nil { t.Fatalf("failed to count after: %v", err) } if countAfter != 0 { t.Errorf("expected 0 blockers after cleanup (all 3 expired), got %d", countAfter) } } // --- Tests for CleanupExpiredDeposits --- func TestCleanupExpiredDeposits_ExpiredConfirmed(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) } startTime := clock.Now().Add(12 * time.Hour) var bookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed', true) RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description) VALUES ($1, 60, $2) `, startTime, "RESERVATION:user:"+userID+":bk123") if err != nil { t.Fatalf("failed to create reservation time blocker: %v", err) } _, err = CleanupExpiredDeposits(ctx) if err != nil { t.Fatalf("CleanupExpiredDeposits failed: %v", err) } var status string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking: %v", err) } if status != "pending_release" { t.Errorf("expected status 'pending_release', got '%s'", status) } var notifCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'deposit_not_paid_by_deadline'", bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query admin notifications: %v", err) } if notifCount != 1 { t.Errorf("expected 1 deposit_not_paid_by_deadline notification, got %d", notifCount) } var tbCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount) if err != nil { t.Fatalf("failed to query time blockers: %v", err) } if tbCount != 0 { t.Error("expected reservation time blocker to be deleted") } } func TestCleanupExpiredDeposits_ExpiredPending(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) } startTime := clock.Now().Add(12 * time.Hour) var bookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'pending', true) RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description) VALUES ($1, 60, $2) `, startTime, "RESERVATION:user:"+userID+":bk123") if err != nil { t.Fatalf("failed to create reservation time blocker: %v", err) } _, err = CleanupExpiredDeposits(ctx) if err != nil { t.Fatalf("CleanupExpiredDeposits failed: %v", err) } var status string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking: %v", err) } if status != "pending_release" { t.Errorf("expected status 'pending_release', got '%s'", status) } var notifCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'deposit_not_paid_by_deadline'", bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query admin notifications: %v", err) } if notifCount != 1 { t.Errorf("expected 1 deposit_not_paid_by_deadline notification, got %d", notifCount) } var tbCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount) if err != nil { t.Fatalf("failed to query time blockers: %v", err) } if tbCount != 0 { t.Error("expected reservation time blocker to be deleted") } } // TestCleanupExpiredDeposits_PaidDepositPreserved verifies that a booking // past its deposit deadline with completed payment is NOT cancelled. func TestCleanupExpiredDeposits_PaidDepositPreserved(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) } startTime := clock.Now().Add(12 * time.Hour) var bookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed', true) RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Add completed payment _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at) VALUES ($1, 'deposit', 'online_square', 'completed', 20.00, NOW() - INTERVAL '1 hour') `, bookingID) if err != nil { t.Fatalf("failed to create payment: %v", err) } // Create reservation time blocker _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description) VALUES ($1, 60, $2) `, startTime, "RESERVATION:user:"+userID+":bk123") if err != nil { t.Fatalf("failed to create reservation time blocker: %v", err) } // Run cleanup _, err = CleanupExpiredDeposits(ctx) if err != nil { t.Fatalf("CleanupExpiredDeposits failed: %v", err) } // Verify status remains 'confirmed' var status string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking: %v", err) } if status != "confirmed" { t.Errorf("expected status 'confirmed', got '%s'", status) } // Verify reservation time blocker remains var tbCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount) if err != nil { t.Fatalf("failed to query time blockers: %v", err) } if tbCount != 1 { t.Error("expected reservation time blocker to still exist") } } // TestCleanupExpiredDeposits_FutureDeadlinePreserved verifies that a booking // with deposit deadline in the future (e.g. starting 48h from now, deadline is 24h from now) // is NOT cancelled. func TestCleanupExpiredDeposits_FutureDeadlinePreserved(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) } startTime := clock.Now().Add(48 * time.Hour) var bookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed', true) RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Create reservation time blocker _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description) VALUES ($1, 60, $2) `, startTime, "RESERVATION:user:"+userID+":bk123") if err != nil { t.Fatalf("failed to create reservation time blocker: %v", err) } // Run cleanup _, err = CleanupExpiredDeposits(ctx) if err != nil { t.Fatalf("CleanupExpiredDeposits failed: %v", err) } // Verify status remains 'confirmed' var status string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking: %v", err) } if status != "confirmed" { t.Errorf("expected status 'confirmed', got '%s'", status) } // Verify reservation time blocker remains var tbCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount) if err != nil { t.Fatalf("failed to query time blockers: %v", err) } if tbCount != 1 { t.Error("expected reservation time blocker to still exist") } } // --- Tests for CleanupExpiredGiftCards --- // TestCleanupExpiredGiftCards verifies that gift cards unused for 24+ months // are expired: amount_remaining set to 0, moved to gift_card_expired_balances, // and an 'expire' transaction is recorded. func TestCleanupExpiredGiftCards(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL // for unredeemed gift cards (no user account to reference). _, err := tx.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`) if err != nil { t.Fatalf("failed to alter gift_card_expired_balances: %v", err) } // Create expired gift card (unused for 25 months) var expiredCardID string err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, last_used_at) VALUES (100.00, 50.00, NOW() - INTERVAL '25 months') RETURNING id `).Scan(&expiredCardID) if err != nil { t.Fatalf("failed to create expired gift card: %v", err) } // Run cleanup _, err = CleanupExpiredGiftCards(ctx) if err != nil { t.Fatalf("CleanupExpiredGiftCards failed: %v", err) } // Verify expired card's amount_remaining is 0 var amountRemaining float64 err = tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, expiredCardID).Scan(&amountRemaining) if err != nil { t.Fatalf("failed to query gift card: %v", err) } if amountRemaining != 0 { t.Errorf("expected amount_remaining 0 for expired card, got %.2f", amountRemaining) } // Verify gift_card_expired_balances has a record (account_id IS NULL for gift cards) var ebCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_expired_balances WHERE account_id IS NULL`).Scan(&ebCount) if err != nil { t.Fatalf("failed to count expired balances: %v", err) } if ebCount != 1 { t.Errorf("expected 1 expired balance record, got %d", ebCount) } // Verify the expired balance amount var originalBalance float64 err = tx.QueryRow(ctx, `SELECT original_balance FROM gift_card_expired_balances WHERE account_id IS NULL`).Scan(&originalBalance) if err != nil { t.Fatalf("failed to query expired balance: %v", err) } if originalBalance != 50.00 { t.Errorf("expected original_balance 50.00, got %.2f", originalBalance) } // Verify gift_card_transactions has an 'expire' transaction var txCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'expire'`, expiredCardID).Scan(&txCount) if err != nil { t.Fatalf("failed to count transactions: %v", err) } if txCount != 1 { t.Errorf("expected 1 expire transaction, got %d", txCount) } } // TestCleanupExpiredGiftCards_SkipRecentlyUsed verifies that gift cards used // recently (last_used_at = NOW()) are NOT expired. func TestCleanupExpiredGiftCards_SkipRecentlyUsed(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL // for unredeemed gift cards (no user account to reference). _, err := tx.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`) if err != nil { t.Fatalf("failed to alter gift_card_expired_balances: %v", err) } // Create recently used gift card var recentCardID string err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, last_used_at) VALUES (100.00, 75.00, NOW()) RETURNING id `).Scan(&recentCardID) if err != nil { t.Fatalf("failed to create recent gift card: %v", err) } // Run cleanup _, err = CleanupExpiredGiftCards(ctx) if err != nil { t.Fatalf("CleanupExpiredGiftCards failed: %v", err) } // Verify recent card's amount_remaining unchanged var amountRemaining float64 err = tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, recentCardID).Scan(&amountRemaining) if err != nil { t.Fatalf("failed to query gift card: %v", err) } if amountRemaining != 75.00 { t.Errorf("expected amount_remaining 75.00 for recent card, got %.2f", amountRemaining) } // Verify no expired balances created var ebCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_expired_balances`).Scan(&ebCount) if err != nil { t.Fatalf("failed to count expired balances: %v", err) } if ebCount != 0 { t.Errorf("expected 0 expired balance records, got %d", ebCount) } } // TestCleanupExpiredGiftCards_SkipRedeemed verifies that gift cards already // redeemed to an account are NOT expired (they're already claimed). func TestCleanupExpiredGiftCards_SkipRedeemed(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL // for unredeemed gift cards (no user account to reference). Even though this // card is redeemed, the function may also match other cards; ensure schema allows it. _, err := tx.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`) if err != nil { t.Fatalf("failed to alter gift_card_expired_balances: %v", err) } // Create a user to be the redeemer userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Create redeemed gift card (redeemed_by IS NOT NULL) that is otherwise expired var redeemedCardID string err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, redeemed_by, redeemed_at, last_used_at) VALUES (100.00, 25.00, $1, NOW() - INTERVAL '25 months', NOW() - INTERVAL '25 months') RETURNING id `, userID).Scan(&redeemedCardID) if err != nil { t.Fatalf("failed to create redeemed gift card: %v", err) } // Run cleanup _, err = CleanupExpiredGiftCards(ctx) if err != nil { t.Fatalf("CleanupExpiredGiftCards failed: %v", err) } // Verify redeemed card's amount_remaining unchanged var amountRemaining float64 err = tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, redeemedCardID).Scan(&amountRemaining) if err != nil { t.Fatalf("failed to query gift card: %v", err) } if amountRemaining != 25.00 { t.Errorf("expected amount_remaining 25.00 for redeemed card, got %.2f", amountRemaining) } // Verify no expired balances created (redeemed cards are skipped) var ebCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_expired_balances`).Scan(&ebCount) if err != nil { t.Fatalf("failed to count expired balances: %v", err) } if ebCount != 0 { t.Errorf("expected 0 expired balance records, got %d", ebCount) } } // --- Tests for CleanupIdleAccounts --- // TestCleanupIdleAccounts_WithBalance verifies that an account idle for 5+ years // with a balance is anonymized and the balance is moved to gift_card_expired_balances. func TestCleanupIdleAccounts_WithBalance(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Create a user userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Set last_login_at to 6 years ago (past the 5yr threshold) _, err = tx.Exec(ctx, `UPDATE users SET last_login_at = NOW() - INTERVAL '6 years' WHERE id = $1`, userID) if err != nil { t.Fatalf("failed to set last_login_at: %v", err) } // Create a gift card balance for this user _, err = tx.Exec(ctx, ` INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 150.00) `, userID) if err != nil { t.Fatalf("failed to create user giftcard balance: %v", err) } // Run cleanup _, err = CleanupIdleAccounts(ctx) if err != nil { t.Fatalf("CleanupIdleAccounts failed: %v", err) } // Verify balance was zeroed var balance float64 err = tx.QueryRow(ctx, `SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balance) if err != nil { t.Fatalf("failed to query balance: %v", err) } if balance != 0 { t.Errorf("expected balance 0, got %.2f", balance) } // Verify gift_card_expired_balances has a record with the correct amount var originalBalance float64 var ebCount int err = tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(SUM(original_balance), 0) FROM gift_card_expired_balances WHERE account_id = $1`, userID).Scan(&ebCount, &originalBalance) if err != nil { t.Fatalf("failed to query expired balances: %v", err) } if ebCount != 1 { t.Errorf("expected 1 expired balance record, got %d", ebCount) } if originalBalance != 150.00 { t.Errorf("expected original_balance 150.00, got %.2f", originalBalance) } // Verify user was anonymized var email string err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&email) if err != nil { t.Fatalf("failed to query user email: %v", err) } if !strings.Contains(email, "deleted+") || !strings.HasSuffix(email, "@deleted.invalid") { t.Errorf("expected anonymized email 'deleted+%s@deleted.invalid', got '%s'", userID, email) } } // TestCleanupIdleAccounts_NoBalance verifies that an account idle for 2+ years // with no balance is anonymized. func TestCleanupIdleAccounts_NoBalance(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Create a user userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Set last_login_at to 3 years ago (past the 2yr threshold) _, err = tx.Exec(ctx, `UPDATE users SET last_login_at = NOW() - INTERVAL '3 years' WHERE id = $1`, userID) if err != nil { t.Fatalf("failed to set last_login_at: %v", err) } // Run cleanup _, err = CleanupIdleAccounts(ctx) if err != nil { t.Fatalf("CleanupIdleAccounts failed: %v", err) } // Verify user was anonymized var email string err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&email) if err != nil { t.Fatalf("failed to query user email: %v", err) } if !strings.Contains(email, "deleted+") || !strings.HasSuffix(email, "@deleted.invalid") { t.Errorf("expected anonymized email 'deleted+%s@deleted.invalid', got '%s'", userID, email) } } // TestCleanupIdleAccounts_SkipActive verifies that recently active accounts // are NOT anonymized. func TestCleanupIdleAccounts_SkipActive(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Create a user with recent last_login userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } // Set last_login_at to NOW() (active account) _, err = tx.Exec(ctx, `UPDATE users SET last_login_at = NOW() WHERE id = $1`, userID) if err != nil { t.Fatalf("failed to set last_login_at: %v", err) } // Run cleanup _, err = CleanupIdleAccounts(ctx) if err != nil { t.Fatalf("CleanupIdleAccounts failed: %v", err) } // Verify user was NOT anonymized var email string err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&email) if err != nil { t.Fatalf("failed to query user email: %v", err) } if strings.Contains(email, "deleted+") { t.Errorf("expected active user to NOT be anonymized, got email '%s'", email) } } // TestCleanupIdleAccounts_SkipAdminGuest verifies that admin and guest accounts // are NOT anonymized regardless of inactivity. func TestCleanupIdleAccounts_SkipAdminGuest(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Create admin user with old last_login adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } _, err = tx.Exec(ctx, `UPDATE users SET account_role = 'admin', last_login_at = NOW() - INTERVAL '10 years' WHERE id = $1`, adminID) if err != nil { t.Fatalf("failed to set admin role and last_login: %v", err) } // Create guest user with old last_login guestID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } _, err = tx.Exec(ctx, `UPDATE users SET account_role = 'guest', last_login_at = NOW() - INTERVAL '10 years' WHERE id = $1`, guestID) if err != nil { t.Fatalf("failed to set guest role and last_login: %v", err) } // Run cleanup _, err = CleanupIdleAccounts(ctx) if err != nil { t.Fatalf("CleanupIdleAccounts failed: %v", err) } // Verify admin was NOT anonymized var adminEmail string err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, adminID).Scan(&adminEmail) if err != nil { t.Fatalf("failed to query admin email: %v", err) } if strings.Contains(adminEmail, "deleted+") { t.Errorf("expected admin to NOT be anonymized, got email '%s'", adminEmail) } // Verify guest was NOT anonymized var guestEmail string err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, guestID).Scan(&guestEmail) if err != nil { t.Fatalf("failed to query guest email: %v", err) } if strings.Contains(guestEmail, "deleted+") { t.Errorf("expected guest to NOT be anonymized, got email '%s'", guestEmail) } } // --- Tests for CleanupOldIdempotencyKeys --- func TestCleanupOldIdempotencyKeys_ClearsOldBookings(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Create an old booking (created 48h ago, status = 'completed') with idempotency_key userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } var oldBookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, idempotency_key, created_at) VALUES ($1, NOW() - INTERVAL '1 hour', 'completed', 'old-key-001', NOW() - INTERVAL '48 hours') RETURNING id `, userID).Scan(&oldBookingID) if err != nil { t.Fatalf("failed to create old booking: %v", err) } // Create a recent booking (< 24h) with idempotency_key var recentBookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, idempotency_key, created_at) VALUES ($1, NOW(), 'completed', 'recent-key-002', NOW() - INTERVAL '2 hours') RETURNING id `, userID).Scan(&recentBookingID) if err != nil { t.Fatalf("failed to create recent booking: %v", err) } // Create a pending old booking (should NOT be cleared) var pendingBookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, idempotency_key, created_at) VALUES ($1, NOW() - INTERVAL '1 hour', 'pending', 'pending-key-003', NOW() - INTERVAL '48 hours') RETURNING id `, userID).Scan(&pendingBookingID) if err != nil { t.Fatalf("failed to create pending booking: %v", err) } // Run cleanup _, err = CleanupOldIdempotencyKeys(ctx) if err != nil { t.Fatalf("CleanupOldIdempotencyKeys failed: %v", err) } // Verify old booking's key was cleared var oldKey sql.NullString err = tx.QueryRow(ctx, `SELECT idempotency_key FROM bookings WHERE id = $1`, oldBookingID).Scan(&oldKey) if err != nil { t.Fatalf("failed to query old booking: %v", err) } if oldKey.Valid { t.Error("expected old booking's idempotency_key to be cleared") } // Verify recent booking's key was preserved var recentKey sql.NullString err = tx.QueryRow(ctx, `SELECT idempotency_key FROM bookings WHERE id = $1`, recentBookingID).Scan(&recentKey) if err != nil { t.Fatalf("failed to query recent booking: %v", err) } if !recentKey.Valid || recentKey.String != "recent-key-002" { t.Errorf("expected recent booking's idempotency_key to be preserved, got %v", recentKey) } // Verify pending old booking's key was preserved var pendingKey sql.NullString err = tx.QueryRow(ctx, `SELECT idempotency_key FROM bookings WHERE id = $1`, pendingBookingID).Scan(&pendingKey) if err != nil { t.Fatalf("failed to query pending booking: %v", err) } if !pendingKey.Valid || pendingKey.String != "pending-key-003" { t.Errorf("expected pending booking's idempotency_key to be preserved, got %v", pendingKey) } } func TestCleanupOldIdempotencyKeys_ClearsOldPayments(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) } // Create an old completed payment with idempotency_key _, err = tx.Exec(ctx, ` INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_by, created_at) VALUES ('full', 'online_square', 'completed', 50.00, 'old-pay-key', $1, NOW() - INTERVAL '48 hours') `, userID) if err != nil { t.Fatalf("failed to create old payment: %v", err) } _, err = CleanupOldIdempotencyKeys(ctx) if err != nil { t.Fatalf("CleanupOldIdempotencyKeys failed: %v", err) } var key sql.NullString err = tx.QueryRow(ctx, `SELECT idempotency_key FROM payments WHERE idempotency_key = 'old-pay-key'`).Scan(&key) if err == nil { t.Error("expected old payment's idempotency_key to be cleared") } } func TestCleanupOldIdempotencyKeys_ClearsOldTillSales(t *testing.T) { ctx, tx := resetTestData(t) // Create an admin user for till_sales.created_by adminID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } // Create an old till_sale with idempotency_key _, err = tx.Exec(ctx, ` INSERT INTO till_sales (item_type, total_amount, unit_price, status, payment_method, idempotency_key, created_by, created_at) VALUES ('gift_card', 25.00, 25.00, 'completed', 'cash', 'old-till-key', $1, NOW() - INTERVAL '48 hours') `, adminID) if err != nil { t.Fatalf("failed to create old till_sale: %v", err) } _, err = CleanupOldIdempotencyKeys(ctx) if err != nil { t.Fatalf("CleanupOldIdempotencyKeys failed: %v", err) } var key sql.NullString err = tx.QueryRow(ctx, `SELECT idempotency_key FROM till_sales WHERE idempotency_key = 'old-till-key'`).Scan(&key) if err == nil { t.Error("expected old till_sale's idempotency_key to be cleared") } } // ============================================================================= // CleanupExpiredDeposits - pending_release status // ============================================================================= func TestCleanupExpiredDeposits_SetsPendingRelease(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) } t.Cleanup(func() { fixtures.DeleteUser(tx, userID) }) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } t.Cleanup(func() { fixtures.DeleteService(tx, serviceID) }) soon := clock.Now().Add(1 * time.Hour) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon) if err != nil { t.Fatalf("failed to create booking: %v", err) } t.Cleanup(func() { fixtures.DeleteBooking(tx, bookingID) }) _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed', deposit_required = true WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set booking: %v", err) } if _, err := CleanupExpiredDeposits(ctx); err != nil { t.Fatalf("CleanupExpiredDeposits failed: %v", err) } var status string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking status: %v", err) } if status != "pending_release" { t.Errorf("expected status 'pending_release', got %q", status) } } func TestCleanupExpiredDeposits_DoesNotAffectPaidBookings(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) } t.Cleanup(func() { fixtures.DeleteUser(tx, userID) }) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } t.Cleanup(func() { fixtures.DeleteService(tx, serviceID) }) soon := clock.Now().Add(1 * time.Hour) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon) if err != nil { t.Fatalf("failed to create booking: %v", err) } t.Cleanup(func() { fixtures.DeleteBooking(tx, bookingID) }) _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed', deposit_required = true WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set booking: %v", err) } paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50, "online_square", "deposit", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } t.Cleanup(func() { fixtures.DeletePayment(tx, paymentID) }) if _, err := CleanupExpiredDeposits(ctx); err != nil { t.Fatalf("CleanupExpiredDeposits failed: %v", err) } var status string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking status: %v", err) } if status != "confirmed" { t.Errorf("expected status 'confirmed' (deposit was paid), got %q", status) } } // ============================================================================= // CleanupExpiredLoyaltyRedemptions Tests // ============================================================================= func TestCleanupExpiredLoyaltyRedemptions_DeletesExpiredPending(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) } t.Cleanup(func() { fixtures.DeleteUser(tx, userID) }) // Insert expired pending redemption _, err = tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, status, expires_at) VALUES ($1, 'pending', NOW() - INTERVAL '1 day') `, userID) if err != nil { t.Fatalf("failed to insert expired redemption: %v", err) } // Insert non-expired pending redemption (should be preserved) _, err = tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, status, expires_at) VALUES ($1, 'pending', NOW() + INTERVAL '7 days') `, userID) if err != nil { t.Fatalf("failed to insert active redemption: %v", err) } // Insert applied redemption (different status, should be preserved) _, err = tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, status, expires_at) VALUES ($1, 'applied', NOW() - INTERVAL '1 day') `, userID) if err != nil { t.Fatalf("failed to insert applied redemption: %v", err) } _, err = CleanupExpiredLoyaltyRedemptions(ctx) if err != nil { t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err) } var remaining int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM loyalty_redemptions").Scan(&remaining) if err != nil { t.Fatalf("failed to count redemptions: %v", err) } if remaining != 2 { t.Errorf("expected 2 remaining redemptions (active + applied), got %d", remaining) } } func TestCleanupExpiredLoyaltyRedemptions_NoExpired(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) } t.Cleanup(func() { fixtures.DeleteUser(tx, userID) }) // Only active redemptions _, err = tx.Exec(ctx, ` INSERT INTO loyalty_redemptions (user_id, status, expires_at) VALUES ($1, 'pending', NOW() + INTERVAL '7 days') `, userID) if err != nil { t.Fatalf("failed to insert active redemption: %v", err) } _, err = CleanupExpiredLoyaltyRedemptions(ctx) if err != nil { t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err) } var count int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM loyalty_redemptions").Scan(&count) if err != nil { t.Fatalf("failed to count: %v", err) } if count != 1 { t.Errorf("expected 1 remaining (no expired), got %d", count) } } func TestCleanupExpiredLoyaltyRedemptions_Empty(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) _, err := CleanupExpiredLoyaltyRedemptions(ctx) if err != nil { t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err) } } // ============================================================================= // CleanupOldNameHistory Tests // ============================================================================= func TestCleanupOldNameHistory_DeletesOldEntries(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) } // Insert an old name_history entry (7 months ago) _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name, changed_at) VALUES ($1, 'Old', 'Name', NOW() - INTERVAL '7 months') `, userID) if err != nil { t.Fatalf("failed to insert old name_history: %v", err) } // Insert a recent name_history entry (1 month ago — should be preserved) _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name, changed_at) VALUES ($1, 'Recent', 'Name', NOW() - INTERVAL '1 month') `, userID) if err != nil { t.Fatalf("failed to insert recent name_history: %v", err) } _, err = CleanupOldNameHistory(ctx) if err != nil { t.Fatalf("CleanupOldNameHistory failed: %v", err) } // Verify old entry was deleted var oldCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM name_history WHERE previous_first_name = 'Old'`).Scan(&oldCount) if err != nil { t.Fatalf("failed to count old entries: %v", err) } if oldCount != 0 { t.Errorf("expected old name_history entry to be deleted, got %d entries", oldCount) } // Verify recent entry was preserved var recentCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM name_history WHERE previous_first_name = 'Recent'`).Scan(&recentCount) if err != nil { t.Fatalf("failed to count recent entries: %v", err) } if recentCount != 1 { t.Errorf("expected recent name_history entry to be preserved, got %d entries", recentCount) } // Verify total entries: 1 preserved (recent), old was deleted var totalCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM name_history`).Scan(&totalCount) if err != nil { t.Fatalf("failed to count total entries: %v", err) } if totalCount != 1 { t.Errorf("expected 1 total entry (only recent preserved), got %d", totalCount) } } func TestCleanupOldNameHistory_EmptyTable(t *testing.T) { t.Parallel() ctx, _ := resetTestData(t) _, err := CleanupOldNameHistory(ctx) if err != nil { t.Fatalf("CleanupOldNameHistory failed: %v", err) } } func TestCleanupOldNameHistory_Idempotent(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) } // Insert an old entry _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name, changed_at) VALUES ($1, 'Old', 'Name', NOW() - INTERVAL '7 months') `, userID) if err != nil { t.Fatalf("failed to insert name_history: %v", err) } // Run cleanup twice _, err = CleanupOldNameHistory(ctx) if err != nil { t.Fatalf("first CleanupOldNameHistory failed: %v", err) } _, err = CleanupOldNameHistory(ctx) if err != nil { t.Fatalf("second CleanupOldNameHistory failed: %v", err) } // Verify no errors and still clean var count int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM name_history`).Scan(&count) if err != nil { t.Fatalf("failed to count: %v", err) } if count != 0 { t.Errorf("expected 0 entries after idempotent cleanup, got %d", count) } } // ============================================================================= // Gap-Filling Tests for Uncovered Branches // ============================================================================= // TestTimeBlockers_Create_InvalidJSON verifies that CreateTimeBlocker returns // 400 for invalid JSON payload. func TestTimeBlockers_Create_InvalidJSON(t *testing.T) { ctx, _ := resetTestData(t) handler := http.HandlerFunc(CreateTimeBlocker) req := httptest.NewRequest("POST", "/api/admin/time-blockers", strings.NewReader("not json")) req.Header.Set("Content-Type", "application/json") req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } // TestTimeBlockers_Create_WithCron verifies that an admin can create a recurring // time blocker with a cron expression and the created_by field is populated. func TestTimeBlockers_Create_WithCron(t *testing.T) { ctx, tx := resetTestData(t) // Create a real admin user so the created_by FK constraint is satisfied adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(tx, adminID) blockerTime := time.Date(2026, 3, 20, 10, 0, 0, 0, time.UTC) cronExpr := "0 10 * * 1" // Every Monday at 10:00 reqBody := CreateTimeBlockerRequest{ StartTime: blockerTime, DurationMinutes: 60, Description: "Recurring Monday Blocker", CronExpression: &cronExpr, } // Use context with both role and real user ID to exercise createdBy branch handler := http.HandlerFunc(CreateTimeBlocker) reqBodyBytes, _ := json.Marshal(reqBody) req := httptest.NewRequest("POST", "/api/admin/time-blockers", bytes.NewReader(reqBodyBytes)) req.Header.Set("Content-Type", "application/json") reqCtx := context.WithValue(ctx, mw.UserRoleKey, "admin") reqCtx = context.WithValue(reqCtx, mw.UserIDKey, adminID) req = req.WithContext(reqCtx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) 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 != "Recurring Monday Blocker" { t.Errorf("expected description 'Recurring Monday Blocker', got %s", response.Description) } if response.CronExpression == nil || *response.CronExpression != cronExpr { t.Errorf("expected cron expression %s, got %v", cronExpr, response.CronExpression) } if response.CreatedBy == nil || *response.CreatedBy != adminID { t.Errorf("expected created_by %s, got %v", adminID, response.CreatedBy) } // Verify it exists in DB var count int err2 := tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE id = $1`, response.ID).Scan(&count) if err2 != nil { t.Fatalf("failed to verify blocker in DB: %v", err2) } if count != 1 { t.Error("expected blocker to exist in DB") } } // TestTimeBlockers_List_InvalidDateFilter verifies that ListTimeBlockers // returns 400 for invalid date filter parameters. func TestTimeBlockers_List_InvalidDateFilter(t *testing.T) { ctx, _ := resetTestData(t) handler := http.HandlerFunc(ListTimeBlockers) w := makeTimeBlockerRequest(handler, "GET", "/api/admin/time-blockers?start=invalid&end=2026-03-13", nil, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } // TestTimeBlockers_List_InvalidEndDate verifies that ListTimeBlockers // returns 400 when end date is invalid but start is valid. func TestTimeBlockers_List_InvalidEndDate(t *testing.T) { ctx, _ := resetTestData(t) handler := http.HandlerFunc(ListTimeBlockers) w := makeTimeBlockerRequest(handler, "GET", "/api/admin/time-blockers?start=2026-03-10&end=invalid", nil, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } // TestTimeBlockers_List_Empty verifies that ListTimeBlockers returns an empty // array when no time blockers exist. func TestTimeBlockers_List_Empty(t *testing.T) { ctx, _ := resetTestData(t) handler := http.HandlerFunc(ListTimeBlockers) w := makeTimeBlockerRequest(handler, "GET", "/api/admin/time-blockers", nil, ctx) 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) != 0 { t.Errorf("expected empty array, got %d blockers", len(response)) } } // TestTimeBlockers_List_WithRecurring verifies that ListTimeBlockers includes // both one-off and recurring blockers in the default (no date filter) query. func TestTimeBlockers_List_WithRecurring(t *testing.T) { ctx, tx := resetTestData(t) // Create a one-off blocker in the future futureTime := clock.Now().Add(30 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) cronExpr := "0 14 * * 3" // Every Wednesday at 14:00 _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by) VALUES ($1, 60, 'One-off Blocker', NULL, NULL), ($2, 30, 'Recurring Blocker', $3, NULL) `, futureTime, futureTime, cronExpr) if err != nil { t.Fatalf("failed to create blockers: %v", err) } handler := http.HandlerFunc(ListTimeBlockers) w := makeTimeBlockerRequest(handler, "GET", "/api/admin/time-blockers", nil, ctx) 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)) } foundOneOff := false foundRecurring := false for _, b := range response { if b.Description == "One-off Blocker" { foundOneOff = true } if b.Description == "Recurring Blocker" { foundRecurring = true } } if !foundOneOff { t.Error("expected to find 'One-off Blocker'") } if !foundRecurring { t.Error("expected to find 'Recurring Blocker'") } } // ============================================================================= // Gap-Filling Tests for CheckTimeBlockerOverlap // ============================================================================= // TestCheckTimeBlockerOverlap_EmptyDescription verifies that a blocker with // an empty description returns "Time blocked" as the fallback description // when overlapping. func TestCheckTimeBlockerOverlap_EmptyDescription(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Create blocker with empty description blockerTime := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, '', NULL) `, blockerTime) if err != nil { t.Fatalf("failed to create blocker with empty description: %v", err) } // Overlap with the empty-description blocker at 10:00-11:00 hasOverlap, desc, err := CheckTimeBlockerOverlap(ctx, time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC), time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC), nil) if err != nil { t.Fatalf("CheckTimeBlockerOverlap failed: %v", err) } if !hasOverlap { t.Error("expected overlap for empty-description blocker") } if desc != "Time blocked" { t.Errorf("expected description 'Time blocked', got %q", desc) } } // TestCheckTimeBlockerOverlap_Recurring verifies that overlap detection works // correctly for recurring (cron-based) blockers and includes the cron expression // in the returned description. func TestCheckTimeBlockerOverlap_Recurring(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) // Create recurring blocker every Monday at 10:00 starting 2026-03-02 startTime := time.Date(2026, 3, 2, 10, 0, 0, 0, time.UTC) cronExpr := "0 10 * * 1" _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by) VALUES ($1, 60, 'Recurring Monday Meeting', $2, NULL) `, startTime, cronExpr) if err != nil { t.Fatalf("failed to create recurring blocker: %v", err) } // Check overlap on Monday 2026-03-09 at 10:00-11:00 (recurring occurrence) hasOverlap, desc, err := CheckTimeBlockerOverlap(ctx, time.Date(2026, 3, 9, 10, 0, 0, 0, time.UTC), time.Date(2026, 3, 9, 11, 0, 0, 0, time.UTC), nil) if err != nil { t.Fatalf("CheckTimeBlockerOverlap failed: %v", err) } if !hasOverlap { t.Error("expected overlap with recurring blocker") } expectedDesc := "Recurring Monday Meeting (recurring: 0 10 * * 1)" if desc != expectedDesc { t.Errorf("expected description %q, got %q", expectedDesc, desc) } } // TestTimeBlockers_List_WithDateFilter_RecurringOnly verifies that when a date // range filter is applied, recurring blockers are always included even if their // start time falls outside the range. func TestTimeBlockers_List_WithDateFilter_RecurringOnly(t *testing.T) { ctx, tx := resetTestData(t) // Create only a recurring blocker with start time outside the query range futureTime := clock.Now().Add(60 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) cronExpr := "0 10 * * 1" // Every Monday at 10:00 _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by) VALUES ($1, 60, 'Only Recurring', $2, NULL) `, futureTime, cronExpr) if err != nil { t.Fatalf("failed to create recurring blocker: %v", err) } // Query a past date range — the recurring blocker should still appear handler := http.HandlerFunc(ListTimeBlockers) w := makeTimeBlockerRequest(handler, "GET", "/api/admin/time-blockers?start=2022-01-01&end=2022-01-31", nil, ctx) 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) != 1 { t.Errorf("expected 1 recurring blocker in filtered results, got %d", len(response)) } if len(response) > 0 && response[0].Description != "Only Recurring" { t.Errorf("expected 'Only Recurring', got %s", response[0].Description) } }