"""Step definitions for project_cli_commands.feature. Exercises the *actual* CLI command functions in ``cleveragents.cli.commands.project`` (create, list, show, link-resource, unlink-resource, delete) via monkey-patched helpers so that the command bodies are executed under coverage. Each scenario gets a fresh in-memory SQLite database. The DI-container look-up helpers are monkey-patched to return repositories backed by that database, then restored after every step that needs them. """ from __future__ import annotations import json from io import StringIO from typing import Any from behave import given, then, when # type: ignore[import-untyped] from rich.console import Console from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker # --------------------------------------------------------------------------- # Database / service bootstrap # --------------------------------------------------------------------------- class _SharedSession: """Wraps a real SQLAlchemy Session but makes ``close()`` a no-op. This ensures every repository call within the same scenario uses the same session (and therefore sees the same uncommitted data), which is critical for in-memory SQLite where transaction isolation would otherwise hide data between sessions. """ def __init__(self, real: Any) -> None: object.__setattr__(self, "_real", real) def close(self) -> None: """No-op so the shared session stays usable across calls.""" def __getattr__(self, name: str) -> Any: return getattr(object.__getattribute__(self, "_real"), name) def __setattr__(self, name: str, value: Any) -> None: setattr(object.__getattribute__(self, "_real"), name, value) def _init_commands_db(context: Any) -> None: """Create a fresh in-memory database and wire up repos + service.""" from cleveragents.application.services.resource_registry_service import ( ResourceRegistryService, ) from cleveragents.infrastructure.database.models import Base from cleveragents.infrastructure.database.repositories import ( NamespacedProjectRepository, ProjectResourceLinkRepository, ) engine = create_engine( "sqlite:///:memory:", echo=False, connect_args={"check_same_thread": False}, ) Base.metadata.create_all(engine) # Use a single shared session to avoid transaction isolation issues # with :memory: SQLite when multiple repos are involved. real_session = sessionmaker( bind=engine, expire_on_commit=False, autoflush=True, autocommit=False )() wrapper = _SharedSession(real_session) def _shared_factory() -> Any: return wrapper context._cmd_engine = engine context._cmd_factory = _shared_factory context._cmd_project_repo = NamespacedProjectRepository( session_factory=_shared_factory ) context._cmd_link_repo = ProjectResourceLinkRepository( session_factory=_shared_factory ) context._cmd_resource_svc = ResourceRegistryService(session_factory=_shared_factory) context._cmd_resource_svc.bootstrap_builtin_types() context._cmd_output = "" context._cmd_failed = False context._cmd_resource_map = {} context._cmd_last_resource_id = None # --------------------------------------------------------------------------- # Monkey-patching helpers # --------------------------------------------------------------------------- _ORIG_FNS: dict[str, Any] = {} def _patch_project_mod(context: Any) -> None: """Monkey-patch the four DI look-up helpers in project module.""" import cleveragents.cli.commands.project as project_mod _ORIG_FNS["repo"] = project_mod._get_namespaced_project_repo _ORIG_FNS["link"] = project_mod._get_resource_link_repo _ORIG_FNS["svc"] = project_mod._get_resource_registry_service project_mod._get_namespaced_project_repo = lambda: context._cmd_project_repo project_mod._get_resource_link_repo = lambda: context._cmd_link_repo project_mod._get_resource_registry_service = lambda: context._cmd_resource_svc # Patch _store_project_extras to a no-op since it needs a shared # file-based DB (the real helper is tested separately) _ORIG_FNS["store_extras"] = project_mod._store_project_extras project_mod._store_project_extras = lambda *a, **kw: None def _unpatch_project_mod() -> None: """Restore original helpers.""" import cleveragents.cli.commands.project as project_mod project_mod._get_namespaced_project_repo = _ORIG_FNS["repo"] project_mod._get_resource_link_repo = _ORIG_FNS["link"] project_mod._get_resource_registry_service = _ORIG_FNS["svc"] if _ORIG_FNS.get("store_extras"): project_mod._store_project_extras = _ORIG_FNS["store_extras"] def _capture(context: Any, func: Any, *args: Any, **kwargs: Any) -> None: """Call a CLI function, capturing console output and success/failure.""" import contextlib import typer import cleveragents.cli.commands.project as project_mod buf = StringIO() fake_console = Console(file=buf, width=200, no_color=True) orig_console = project_mod.console orig_err = project_mod.err_console project_mod.console = fake_console project_mod.err_console = fake_console _patch_project_mod(context) failed = False try: with contextlib.redirect_stdout(buf): func(*args, **kwargs) except (SystemExit, typer.Exit, typer.Abort): failed = True except Exception: failed = True finally: project_mod.console = orig_console project_mod.err_console = orig_err _unpatch_project_mod() context._cmd_output = buf.getvalue() context._cmd_failed = failed def _load_cmd_json_output(context: Any) -> dict[str, Any]: """Parse a JSON command envelope from captured CLI output.""" output = context._cmd_output.strip() json_start = output.find("{") if json_start > 0: output = output[json_start:] try: envelope = json.loads(output) except json.JSONDecodeError as exc: raise AssertionError( f"Command output is not valid JSON:\n{context._cmd_output}" ) from exc assert isinstance(envelope, dict), ( f"Expected command JSON envelope to be a dict, got " f"{type(envelope).__name__}: {envelope}" ) return envelope # --------------------------------------------------------------------------- # Reusable helpers # --------------------------------------------------------------------------- def _create_project(context: Any, name: str, description: str | None = None) -> Any: from cleveragents.domain.models.core.project import ( NamespacedProject, parse_namespaced_name, ) parsed = parse_namespaced_name(name) proj = NamespacedProject( name=parsed.name, namespace=parsed.namespace, server=parsed.server, description=description, ) context._cmd_project_repo.create(proj) return proj def _register_resource(context: Any, alias: str) -> str: res = context._cmd_resource_svc.register_resource( type_name="git-checkout", name=f"local/{alias}", location="/tmp/test-resource", description=f"Test resource {alias}", ) context._cmd_resource_map[alias] = res.resource_id context._cmd_last_resource_id = res.resource_id return res.resource_id # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("a project CLI commands test database is initialized") def step_init_commands_db(context: Any) -> None: _init_commands_db(context) # --------------------------------------------------------------------------- # Given steps # --------------------------------------------------------------------------- @given('a project "{name}" is created in the commands DB') def step_project_exists_cmd(context: Any, name: str) -> None: _create_project(context, name) @given('a resource "{alias}" is registered in the commands DB') def step_resource_registered_cmd(context: Any, alias: str) -> None: _register_resource(context, alias) @given('a resource is linked to project "{project_name}" in the commands DB') def step_resource_linked_cmd(context: Any, project_name: str) -> None: safe = project_name.replace("/", "-") rid = _register_resource(context, f"linked-{safe}") context._cmd_link_repo.create_link( project_name=project_name, resource_id=rid, ) # --------------------------------------------------------------------------- # Helper function coverage # --------------------------------------------------------------------------- @when("I call _get_namespaced_project_repo via the patched container") def step_call_get_repo(context: Any) -> None: import cleveragents.cli.commands.project as project_mod _patch_project_mod(context) try: context._cmd_helper_result = project_mod._get_namespaced_project_repo() finally: _unpatch_project_mod() @when("I call _get_resource_link_repo via the patched container") def step_call_get_link_repo(context: Any) -> None: import cleveragents.cli.commands.project as project_mod _patch_project_mod(context) try: context._cmd_helper_result = project_mod._get_resource_link_repo() finally: _unpatch_project_mod() @when("I call _get_resource_registry_service via the patched container") def step_call_get_svc(context: Any) -> None: import cleveragents.cli.commands.project as project_mod _patch_project_mod(context) try: context._cmd_helper_result = project_mod._get_resource_registry_service() finally: _unpatch_project_mod() @when('I call _store_project_extras with invariants "{inv}" and actor "{actor}"') def step_call_store_extras(context: Any, inv: str, actor: str) -> None: """Call the real _store_project_extras against a temp file-based DB. ``_store_project_extras`` opens its own engine from ``get_database_url()``, so we create a file-based temp DB, seed a project, and patch the URL. """ import tempfile from pathlib import Path from sqlalchemy import create_engine as _ce from sqlalchemy.orm import sessionmaker as _sm from cleveragents.domain.models.core.project import ( NamespacedProject, parse_namespaced_name, ) from cleveragents.infrastructure.database.models import Base as _Base from cleveragents.infrastructure.database.repositories import ( NamespacedProjectRepository, ) tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) # noqa: SIM115 tmp.close() db_url = f"sqlite:///{tmp.name}" engine = _ce(db_url, echo=False) _Base.metadata.create_all(engine) # Use a single session so we can commit after repo.create() session = _sm(bind=engine, expire_on_commit=False)() def _factory() -> Any: return session repo = NamespacedProjectRepository(session_factory=_factory) parsed = parse_namespaced_name("local/extras-proj") proj = NamespacedProject(name=parsed.name, namespace=parsed.namespace) repo.create(proj) session.commit() import cleveragents.application.container as container_mod import cleveragents.cli.commands.project as project_mod orig = container_mod.get_database_url container_mod.get_database_url = lambda: db_url try: project_mod._store_project_extras( "local/extras-proj", invariant_texts=[inv], inv_actor=actor, ) context._cmd_failed = False except Exception: context._cmd_failed = True finally: container_mod.get_database_url = orig engine.dispose() Path(tmp.name).unlink(missing_ok=True) @then("the helper should return a NamespacedProjectRepository") def step_check_repo(context: Any) -> None: from cleveragents.infrastructure.database.repositories import ( NamespacedProjectRepository, ) assert isinstance(context._cmd_helper_result, NamespacedProjectRepository) @then("the helper should return a ProjectResourceLinkRepository") def step_check_link_repo(context: Any) -> None: from cleveragents.infrastructure.database.repositories import ( ProjectResourceLinkRepository, ) assert isinstance(context._cmd_helper_result, ProjectResourceLinkRepository) @then("the helper should return a ResourceRegistryService") def step_check_svc(context: Any) -> None: from cleveragents.application.services.resource_registry_service import ( ResourceRegistryService, ) assert isinstance(context._cmd_helper_result, ResourceRegistryService) @then("the extras call should complete without error") def step_extras_ok(context: Any) -> None: assert not context._cmd_failed, "Expected _store_project_extras to succeed" # --------------------------------------------------------------------------- # Create command # --------------------------------------------------------------------------- @when("I invoke project-create triggering a database error") def step_invoke_create_db_error(context: Any) -> None: """Exercise the ``except DatabaseError`` branch in the create command.""" from unittest.mock import MagicMock from cleveragents.cli.commands.project import create from cleveragents.core.exceptions import DatabaseError # Temporarily replace the repo with one whose create() raises DatabaseError orig_repo = context._cmd_project_repo mock_repo = MagicMock() mock_repo.create.side_effect = DatabaseError("simulated DB failure") context._cmd_project_repo = mock_repo try: _capture(context, create, name="db-err-proj") finally: context._cmd_project_repo = orig_repo @when('I invoke project-create with bare name "{name}"') def step_invoke_create(context: Any, name: str) -> None: from cleveragents.cli.commands.project import create _capture(context, create, name=name) @when('I invoke project-create with name "{name}" description "{desc}"') def step_invoke_create_desc(context: Any, name: str, desc: str) -> None: from cleveragents.cli.commands.project import create _capture(context, create, name=name, description=desc) @when('I invoke project-create with name "{name}" format "{fmt}"') def step_invoke_create_fmt(context: Any, name: str, fmt: str) -> None: from cleveragents.cli.commands.project import create _capture(context, create, name=name, output_format=fmt) @when('I invoke project-create with name "{name}" invariant "{inv}"') def step_invoke_create_inv(context: Any, name: str, inv: str) -> None: from cleveragents.cli.commands.project import create _capture(context, create, name=name, invariant=[inv]) @when('I invoke project-create with name "{name}" linking resource "{res}"') def step_invoke_create_res(context: Any, name: str, res: str) -> None: from cleveragents.cli.commands.project import create _capture(context, create, name=name, resource=[f"local/{res}"]) # --------------------------------------------------------------------------- # List command # --------------------------------------------------------------------------- @when("I invoke project-list with default options") def step_invoke_list(context: Any) -> None: from cleveragents.cli.commands.project import list_projects _capture(context, list_projects) @when('I invoke project-list with namespace "{ns}"') def step_invoke_list_ns(context: Any, ns: str) -> None: from cleveragents.cli.commands.project import list_projects _capture(context, list_projects, namespace=ns) @when('I invoke project-list with regex "{regex}"') def step_invoke_list_regex(context: Any, regex: str) -> None: from cleveragents.cli.commands.project import list_projects _capture(context, list_projects, regex=regex) @when('I invoke project-list with invalid regex "{regex}"') def step_invoke_list_bad_regex(context: Any, regex: str) -> None: from cleveragents.cli.commands.project import list_projects _capture(context, list_projects, regex=regex) @when('I invoke project-list with format "{fmt}"') def step_invoke_list_fmt(context: Any, fmt: str) -> None: from cleveragents.cli.commands.project import list_projects _capture(context, list_projects, output_format=fmt) # --------------------------------------------------------------------------- # Show command # --------------------------------------------------------------------------- @when('I invoke project-show for "{name}" default format') def step_invoke_show(context: Any, name: str) -> None: from cleveragents.cli.commands.project import show _capture(context, show, project=name) @when('I invoke project-show for "{name}" format "{fmt}"') def step_invoke_show_fmt(context: Any, name: str, fmt: str) -> None: from cleveragents.cli.commands.project import show _capture(context, show, project=name, output_format=fmt) # --------------------------------------------------------------------------- # Link-resource command # --------------------------------------------------------------------------- @when('I invoke project-link for "{proj}" resource "{res}" default format') def step_invoke_link(context: Any, proj: str, res: str) -> None: from cleveragents.cli.commands.project import link_resource resource_name = f"local/{res}" if "/" not in res else res _capture(context, link_resource, project=proj, resource_name=resource_name) @when('I invoke project-link for "{proj}" resource "{res}" read-only') def step_invoke_link_ro(context: Any, proj: str, res: str) -> None: from cleveragents.cli.commands.project import link_resource resource_name = f"local/{res}" if "/" not in res else res _capture( context, link_resource, project=proj, resource_name=resource_name, read_only=True, ) @when('I invoke project-link for "{proj}" resource "{res}" format "{fmt}"') def step_invoke_link_fmt(context: Any, proj: str, res: str, fmt: str) -> None: from cleveragents.cli.commands.project import link_resource resource_name = f"local/{res}" if "/" not in res else res _capture( context, link_resource, project=proj, resource_name=resource_name, output_format=fmt, ) # --------------------------------------------------------------------------- # Unlink-resource command # --------------------------------------------------------------------------- @when('I invoke project-unlink for "{proj}" with yes default format') def step_invoke_unlink(context: Any, proj: str) -> None: from cleveragents.cli.commands.project import unlink_resource links = context._cmd_link_repo.list_links(proj) if links: res_id = str(links[0].resource_id) try: res = context._cmd_resource_svc.show_resource(res_id) res_name = res.name or res_id except Exception: res_name = res_id _capture( context, unlink_resource, project=proj, resource_name=res_name, yes=True, ) else: _capture( context, unlink_resource, project=proj, resource_name="none", yes=True, ) @when('I invoke project-unlink for "{proj}" with yes format "{fmt}"') def step_invoke_unlink_fmt(context: Any, proj: str, fmt: str) -> None: from cleveragents.cli.commands.project import unlink_resource links = context._cmd_link_repo.list_links(proj) if links: res_id = str(links[0].resource_id) try: res = context._cmd_resource_svc.show_resource(res_id) res_name = res.name or res_id except Exception: res_name = res_id _capture( context, unlink_resource, project=proj, resource_name=res_name, yes=True, output_format=fmt, ) else: _capture( context, unlink_resource, project=proj, resource_name="none", yes=True, output_format=fmt, ) @when('I invoke project-unlink for "{proj}" resource "{res}" with yes') def step_invoke_unlink_named(context: Any, proj: str, res: str) -> None: from cleveragents.cli.commands.project import unlink_resource resource_name = f"local/{res}" if "/" not in res else res _capture( context, unlink_resource, project=proj, resource_name=resource_name, yes=True, ) # --------------------------------------------------------------------------- # Delete command # --------------------------------------------------------------------------- @when('I invoke project-delete for "{name}" with yes') def step_invoke_delete(context: Any, name: str) -> None: from cleveragents.cli.commands.project import delete _capture(context, delete, name=name, yes=True) @when('I invoke project-delete for "{name}" with yes format "{fmt}"') def step_invoke_delete_fmt(context: Any, name: str, fmt: str) -> None: from cleveragents.cli.commands.project import delete _capture(context, delete, name=name, yes=True, output_format=fmt) @when('I invoke project-delete for "{name}" with yes no force') def step_invoke_delete_no_force(context: Any, name: str) -> None: from cleveragents.cli.commands.project import delete _capture(context, delete, name=name, yes=True, force=False) @when('I invoke project-delete for "{name}" with yes and force') def step_invoke_delete_force(context: Any, name: str) -> None: from cleveragents.cli.commands.project import delete _capture(context, delete, name=name, yes=True, force=True) @when('I invoke project-delete for "{name}" with yes and short force flag') def step_invoke_delete_short_force(context: Any, name: str) -> None: """Invoke 'project delete' via CliRunner using the ``-f`` short flag. This validates that the CLI argument parser correctly maps ``-f`` to ``--force`` (not ``--format``), matching the specification. """ from typer.testing import CliRunner from cleveragents.cli.commands.project import app as project_app _patch_project_mod(context) try: cli_runner = CliRunner() result = cli_runner.invoke(project_app, ["delete", name, "--yes", "-f"]) context._cmd_output = result.output context._cmd_failed = result.exit_code != 0 finally: _unpatch_project_mod() # --------------------------------------------------------------------------- # Switch command # --------------------------------------------------------------------------- @when('I invoke project-switch for "{name}" with yes default format') def step_invoke_switch(context: Any, name: str) -> None: from cleveragents.cli.commands.project import switch _capture(context, switch, project=name, yes=True) @when('I invoke project-switch for "{name}" with yes format "{fmt}"') def step_invoke_switch_fmt(context: Any, name: str, fmt: str) -> None: from cleveragents.cli.commands.project import switch _capture(context, switch, project=name, yes=True, output_format=fmt) @when('I invoke project-switch for "{name}" without yes') def step_invoke_switch_no_confirm(context: Any, name: str) -> None: """Invoke switch without --yes, denying the confirmation prompt.""" from typer.testing import CliRunner from cleveragents.cli.commands.project import app as project_app _patch_project_mod(context) try: cli_runner = CliRunner() result = cli_runner.invoke(project_app, ["switch", name], input="n\n") context._cmd_output = result.output context._cmd_failed = result.exit_code != 0 finally: _unpatch_project_mod() @when('I invoke project-switch for "{name}" with yes and short flag') def step_invoke_switch_short_flag(context: Any, name: str) -> None: """Invoke 'project switch' via CliRunner using the ``-y`` short flag.""" from typer.testing import CliRunner from cleveragents.cli.commands.project import app as project_app _patch_project_mod(context) try: cli_runner = CliRunner() result = cli_runner.invoke(project_app, ["switch", name, "-y"]) context._cmd_output = result.output context._cmd_failed = result.exit_code != 0 finally: _unpatch_project_mod() # --------------------------------------------------------------------------- # Then assertions # --------------------------------------------------------------------------- @then('the project cmd JSON data should include "previous" key') def step_cmd_json_has_previous(context: Any) -> None: envelope = _load_cmd_json_output(context) data = envelope.get("data") assert isinstance(data, dict), ( f"Expected envelope data to be a dict, got {type(data).__name__}: {data}" ) assert "previous" in data, ( f"Expected 'previous' in command JSON data, got keys {list(data.keys())}" ) @then('the project cmd output should contain "{text}"') def step_cmd_output_contains(context: Any, text: str) -> None: assert text.lower() in context._cmd_output.lower(), ( f"Expected output to contain '{text}', got:\n{context._cmd_output}" ) @then("the project cmd should succeed") def step_cmd_success(context: Any) -> None: assert not context._cmd_failed, ( f"Expected command to succeed but it failed. Output:\n{context._cmd_output}" ) @then("the project cmd should fail") def step_cmd_fail(context: Any) -> None: assert context._cmd_failed, ( f"Expected command to fail but it succeeded. Output:\n{context._cmd_output}" ) @then("the project cmd JSON data should include deleted_at") def step_cmd_json_has_deleted_at(context: Any) -> None: envelope = _load_cmd_json_output(context) data = envelope.get("data") assert isinstance(data, dict), ( f"Expected envelope data to be a dict, got {type(data).__name__}: {data}" ) assert "deleted_at" in data, ( f"Expected 'deleted_at' in command JSON data, got keys {list(data.keys())}" )