"""Step definitions for CONC3 cleanup CLI commands and edge cases. Exercises the Typer CLI commands (scan, purge, status) and service-level edge cases for coverage. """ from __future__ import annotations import os import tempfile import time from pathlib import Path from behave import given, then, when from behave.runner import Context from cleveragents.application.services.cleanup_service import ( CleanupReport, CleanupService, ) from cleveragents.config.settings import Settings # ── Helpers ─────────────────────────────────────────────────────── def _make_settings(**overrides: object) -> Settings: """Create a Settings instance with optional field overrides. Uses ``model_copy(update=...)`` so that Pydantic validators (including ``ge=`` bounds on retention fields) are enforced. """ Settings._instance = None base = Settings() if overrides: return base.model_copy(update=overrides) return base def _create_sandbox_dir( prefix: str = "ca-sandbox-testplan-", age_hours: float = 72, ) -> Path: """Create a fake sandbox temp directory with a given age.""" d = Path(tempfile.mkdtemp(prefix=prefix)) old_time = time.time() - (age_hours * 3600) os.utime(d, (old_time, old_time)) return d # ── CLI command tests ───────────────────────────────────────────── @given("a cleanup CLI runner") def step_cli_runner(context: Context) -> None: from typer.testing import CliRunner context.cli_runner = CliRunner() @given("a stale sandbox for CLI testing") def step_stale_sandbox_cli(context: Context) -> None: import shutil context.cli_sandbox_dir = _create_sandbox_dir(age_hours=72) if not hasattr(context, "_temp_paths"): context._temp_paths = [] context._temp_paths.append(context.cli_sandbox_dir) def _cleanup() -> None: for p in getattr(context, "_temp_paths", []): if p.exists(): if p.is_dir(): shutil.rmtree(p, ignore_errors=True) else: p.unlink(missing_ok=True) if _cleanup not in context._cleanup_handlers: context._cleanup_handlers.append(_cleanup) @when("I invoke the cleanup scan command") def step_invoke_scan(context: Context) -> None: from cleveragents.cli.commands.cleanup import app as cleanup_app context.cli_result = context.cli_runner.invoke(cleanup_app, ["scan"]) @when("I invoke the cleanup purge command with dry-run") def step_invoke_purge_dry(context: Context) -> None: from cleveragents.cli.commands.cleanup import app as cleanup_app context.cli_result = context.cli_runner.invoke(cleanup_app, ["purge", "--dry-run"]) @when("I invoke the cleanup purge command with yes flag") def step_invoke_purge_yes(context: Context) -> None: from cleveragents.cli.commands.cleanup import app as cleanup_app context.cli_result = context.cli_runner.invoke(cleanup_app, ["purge", "--yes"]) @when("I invoke the cleanup purge command with all and yes") def step_invoke_purge_all_yes(context: Context) -> None: from cleveragents.cli.commands.cleanup import app as cleanup_app context.cli_result = context.cli_runner.invoke( cleanup_app, ["purge", "--all", "--yes"] ) @when("I invoke the cleanup status command") def step_invoke_status(context: Context) -> None: from cleveragents.cli.commands.cleanup import app as cleanup_app context.cli_result = context.cli_runner.invoke(cleanup_app, ["status"]) @when("I invoke the cleanup purge command without confirmation") def step_invoke_purge_no_confirm(context: Context) -> None: from cleveragents.cli.commands.cleanup import app as cleanup_app context.cli_result = context.cli_runner.invoke(cleanup_app, ["purge"], input="n\n") @then("the CLI exit code should be {code:d}") def step_cli_exit_code(context: Context, code: int) -> None: assert context.cli_result.exit_code == code, ( f"Expected exit code {code}, got {context.cli_result.exit_code}. " f"Output: {context.cli_result.output}" ) @then('the cleanup CLI output should contain "{text}"') def step_cleanup_cli_output_contains(context: Context, text: str) -> None: assert text in context.cli_result.output, ( f"Expected '{text}' in output, got: {context.cli_result.output}" ) # ── CleanupReport and CleanupService edge case tests ───────────── @when("I create a cleanup report with known values") def step_create_report(context: Context) -> None: report = CleanupReport(dry_run=True) report.sandboxes.scanned = 5 report.sandboxes.removed = 2 report.checkpoints.scanned = 100 context.report_dict = report.as_dict() @then('the report dict should contain key "{key}"') def step_report_dict_key(context: Context, key: str) -> None: assert key in context.report_dict, ( f"Expected key '{key}' in report dict, got: {list(context.report_dict.keys())}" ) @when("I create a cleanup service with invalid settings") def step_invalid_settings(context: Context) -> None: try: CleanupService(settings="not-a-settings") # type: ignore[arg-type] context.type_error_raised = False except TypeError: context.type_error_raised = True @then("a TypeError should be raised") def step_type_error(context: Context) -> None: assert context.type_error_raised, "Expected TypeError to be raised" @given("a cleanup service for age description testing") def step_age_service(context: Context) -> None: context.settings = _make_settings() context.service = CleanupService( settings=context.settings, active_plan_ids=frozenset(), ) @when("I check the age description for a {hours:d} hour old file") def step_age_hours(context: Context, hours: int) -> None: tmp = Path(tempfile.mktemp(prefix="ca-age-test-")) tmp.write_text("test") old_time = time.time() - (hours * 3600) os.utime(tmp, (old_time, old_time)) context.age_desc = CleanupService._age_description(tmp) tmp.unlink() @when("I check the age description for a {minutes:d} minute old file") def step_age_minutes(context: Context, minutes: int) -> None: tmp = Path(tempfile.mktemp(prefix="ca-age-test-")) tmp.write_text("test") old_time = time.time() - (minutes * 60) os.utime(tmp, (old_time, old_time)) context.age_desc = CleanupService._age_description(tmp) tmp.unlink() @when("I check the age description for a {days:d} day old file") def step_age_days(context: Context, days: int) -> None: tmp = Path(tempfile.mktemp(prefix="ca-age-test-")) tmp.write_text("test") old_time = time.time() - (days * 86400) os.utime(tmp, (old_time, old_time)) context.age_desc = CleanupService._age_description(tmp) tmp.unlink() @then('the age description should contain "{text}"') def step_age_contains(context: Context, text: str) -> None: assert text in context.age_desc, ( f"Expected '{text}' in age description, got: {context.age_desc}" ) # ── Full scan/purge integration via service ────────────────────── @when("I run a full scan") def step_full_scan(context: Context) -> None: context.report = context.service.scan() @then("the report should contain log stale items") def step_log_stale_items(context: Context) -> None: log_items = [i for i in context.report.stale_items if i.resource_type == "log"] assert len(log_items) >= 1, f"Expected >= 1 log stale item, found {len(log_items)}" @then("the report should contain backup stale items") def step_backup_stale_items(context: Context) -> None: backup_items = [ i for i in context.report.stale_items if i.resource_type == "backup" ] assert len(backup_items) >= 1, ( f"Expected >= 1 backup stale item, found {len(backup_items)}" ) @when("I run purge all") def step_run_purge_all(context: Context) -> None: context.report = context.service.purge(purge_all=True) @then("the backup file should be removed") def step_backup_removed(context: Context) -> None: assert not context.backup_file.exists(), ( f"Backup file {context.backup_file} still exists" ) @when("I run a full checkpoint scan") def step_full_checkpoint_scan(context: Context) -> None: context.service._get_checkpoint_base_dir = lambda: context.checkpoint_dir.parent plan_dir = context.checkpoint_dir.parent / "test-plan" if not plan_dir.exists(): context.checkpoint_dir.rename(plan_dir) context.checkpoint_dir = plan_dir context.service._get_checkpoint_base_dir = lambda: plan_dir.parent context.report = context.service.scan() @then("the report should contain checkpoint stale items") def step_checkpoint_stale_items(context: Context) -> None: cp_items = [ i for i in context.report.stale_items if i.resource_type == "checkpoint" ] assert len(cp_items) >= 1, ( f"Expected >= 1 checkpoint stale item, found {len(cp_items)}" ) @when("I run purge for checkpoints") def step_purge_checkpoints_full(context: Context) -> None: plan_dir = context.checkpoint_dir context.service._get_checkpoint_base_dir = lambda: plan_dir.parent target = plan_dir.parent / "test-plan-purge" if not target.exists(): plan_dir.rename(target) context.checkpoint_dir = target context.service._get_checkpoint_base_dir = lambda: target.parent context.report = context.service.purge() @then("the report should show checkpoints removed") def step_checkpoints_removed_report(context: Context) -> None: assert context.report.checkpoints.removed >= 1, ( f"Expected >= 1 checkpoint removed, got {context.report.checkpoints.removed}" )