Files
cleveragents-core/features/steps/project_cli_commands_steps.py
T
CoreRasurae ef905baff6
CI / lint (pull_request) Failing after 16s
CI / quality (pull_request) Successful in 17s
CI / typecheck (pull_request) Successful in 30s
CI / coverage (pull_request) Has been skipped
CI / security (pull_request) Successful in 30s
CI / build (pull_request) Successful in 14s
CI / integration_tests (pull_request) Successful in 5m13s
CI / unit_tests (pull_request) Successful in 17m8s
CI / docker (pull_request) Has been skipped
test(cov): Fix coverage test failures
2026-02-17 16:40:18 +00:00

654 lines
22 KiB
Python

"""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
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 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:
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
# ---------------------------------------------------------------------------
# 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)
# ---------------------------------------------------------------------------
# Then assertions
# ---------------------------------------------------------------------------
@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}"
)