"""Step definitions for session_create_error.feature (bug #570). TDD regression tests for ``agents session create`` after ``agents init``. These scenarios assert the correct expected behaviour and will fail until the DI container fix is applied. Design rationale ~~~~~~~~~~~~~~~~ ``_get_session_service()`` calls ``container.db()`` but the DI ``Container`` class has no ``db`` provider, raising ``AttributeError``. Same root cause as bug #554. We reset ``_service`` to ``None`` so the real ``_get_session_service()`` is exercised. A file-based SQLite database and ``CLEVERAGENTS_DATABASE_URL`` override ensure the commands can reach the database once the fix lands. All CLI invocations use ``--format plain`` so output goes through ``typer.echo`` (captured by ``CliRunner``) rather than ``rich.console``. Private API access (``session_mod._service``, ``session_mod._reset_session_service``) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ These step definitions access private attributes of the ``session`` CLI module because the DI integration tests *must* force the module to re-run its service resolution logic. The module caches a singleton ``_service`` instance; resetting it to ``None`` is the only way to make the CLI re-exercise ``_get_session_service()`` (the buggy code path). If the module's internal caching mechanism changes, these tests will need to be updated accordingly. """ from __future__ import annotations import os import shutil import tempfile from typing import Any from behave import given, then, when from sqlalchemy import create_engine from typer.testing import CliRunner from cleveragents.application.container import reset_container from cleveragents.cli.commands import session as session_mod from cleveragents.cli.commands.session import app as session_app from cleveragents.infrastructure.database.models import Base runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _setup_real_di_path(context: Any) -> None: """Prepare a temp dir with a fresh SQLite DB and override container. Registers cleanup immediately after capturing original state so that env vars and module state are restored even if later setup lines raise. """ # Store original _service so cleanup can restore it. context.sce_original_service = session_mod._service context.sce_tmpdir = tempfile.mkdtemp(prefix="session_create_err_570_") # Register cleanup early so env/state is always restored (SEC-3). context.add_cleanup(_cleanup_sce, context) context.sce_db_path = os.path.join(context.sce_tmpdir, "test.db") db_url = f"sqlite:///{context.sce_db_path}" # Create schema so the DB file exists with all tables. engine = create_engine(db_url, echo=False) Base.metadata.create_all(engine) engine.dispose() # Override the container's database_url so real DI can find the DB. os.environ["CLEVERAGENTS_DATABASE_URL"] = db_url # Reset global DI container so post-fix DI reads the fresh env var. reset_container() # Reset the module-level _service so _get_session_service() is used. # This direct attribute mutation is fragile — if the module's internal # caching mechanism changes (e.g. lazy singleton via descriptor), this # line will need to be updated. See module docstring for rationale. session_mod._service = None context.sce_result = None context.sce_list_result = None def _cleanup_sce(context: Any) -> None: """Remove temp dir, restore env and original _service. Also resets the module-level ``_service`` cache via the public API so that any DI-created engine is released before the temp dir is removed. """ # Ensure module-level cache is cleared; GC will release any engine when # the container is reset (done separately via reset_container()). session_mod._reset_session_service() # Then restore the original _service value. session_mod._service = context.sce_original_service # Release any DI-created connections before removing temp dir. reset_container() os.environ.pop("CLEVERAGENTS_DATABASE_URL", None) shutil.rmtree(context.sce_tmpdir, ignore_errors=True) # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("a session-create-error CLI runner using the real DI path") def step_session_create_error_runner(context: Any) -> None: _setup_real_di_path(context) # --------------------------------------------------------------------------- # When - create # --------------------------------------------------------------------------- @when("I invoke session-create-error create with no arguments") def step_invoke_create_no_args(context: Any) -> None: context.sce_result = runner.invoke(session_app, ["create", "--format", "plain"]) @when('I invoke session-create-error create with actor "{actor}"') def step_invoke_create_with_actor(context: Any, actor: str) -> None: context.sce_result = runner.invoke( session_app, ["create", "--actor", actor, "--format", "plain"] ) # --------------------------------------------------------------------------- # When - list (for persistence verification) # --------------------------------------------------------------------------- @when("I invoke session-create-error list to verify persistence") def step_invoke_list_after_create(context: Any) -> None: context.sce_list_result = runner.invoke(session_app, ["list", "--format", "plain"]) # --------------------------------------------------------------------------- # Then - assertions # --------------------------------------------------------------------------- @then("the session-create-error command should exit successfully") def step_exit_success(context: Any) -> None: result = context.sce_result assert result is not None, "No command was invoked" assert result.exit_code == 0, ( f"Expected exit code 0, got {result.exit_code}.\n" f"Output: {result.output}\n" f"Exception: {result.exception!r}" ) @then('the session-create-error output should contain "{text}"') def step_output_contains(context: Any, text: str) -> None: result = context.sce_result assert result is not None, "No command was invoked" assert text in result.output, ( f"Expected '{text}' in output but got:\n{result.output}" ) @then("the session-create-error list should show at least one session") def step_list_shows_sessions(context: Any) -> None: result = context.sce_list_result assert result is not None, "List was not invoked" assert result.exit_code == 0, ( f"Expected list exit code 0, got {result.exit_code}.\n" f"Output: {result.output}\n" f"Exception: {result.exception!r}" ) # In plain format the output should contain "total:" with a count > 0. assert "total:" in result.output, ( f"Expected 'total:' in plain list output but got:\n{result.output}" ) assert "total: 0" not in result.output, ( f"Expected at least one session but got:\n{result.output}" )