"""Step definitions for project_create_persist.feature (bug #589). Regression tests verifying that ``agents project create`` commits data to the database so that ``agents project list`` shows previously created projects. Design rationale ~~~~~~~~~~~~~~~~ The bug was that ``NamespacedProjectRepository.create()`` called ``session.flush()`` but never ``session.commit()``. Each CLI invocation obtains a **new** session from the factory, so flushed-but-uncommitted rows were invisible to subsequent invocations. To exercise the fix we use a **file-based** SQLite database and patch ``_get_namespaced_project_repo`` so that every CLI invocation receives a *fresh* ``NamespacedProjectRepository`` backed by a **new session factory** pointing at the same file. This mirrors real CLI behaviour where each ``agents project …`` invocation gets its own session. """ from __future__ import annotations import os import shutil import tempfile from typing import Any from unittest.mock import patch from behave import given, then, when from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import NullPool from typer.testing import CliRunner from cleveragents.cli.commands.project import app as project_app from cleveragents.infrastructure.database.models import Base from cleveragents.infrastructure.database.repositories import ( NamespacedProjectRepository, ) # Patch target - the module-level DI helper that returns the project repo. _PATCH_PROJECT_REPO = "cleveragents.cli.commands.project._get_namespaced_project_repo" # The --invariant flag is never passed in these scenarios, so # _store_project_extras is effectively a no-op. We patch it defensively # to avoid side-effects from DI look-ups in the real implementation. _PATCH_STORE_EXTRAS = "cleveragents.cli.commands.project._store_project_extras" runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_fresh_repo(db_path: str) -> NamespacedProjectRepository: """Build a *new* repo with its own engine/session for *db_path*. Each call simulates a separate CLI invocation: a new engine and session factory are created, so uncommitted data from a prior invocation is not visible. ``NullPool`` is used so that SQLite file handles are released immediately when the engine is no longer referenced. Schema is pre-created once during the Background step, so we skip ``Base.metadata.create_all()`` here to avoid redundant DDL. """ engine = create_engine(f"sqlite:///{db_path}", echo=False, poolclass=NullPool) factory = sessionmaker(bind=engine, expire_on_commit=False) return NamespacedProjectRepository(session_factory=factory) def _cleanup_persist_db(context: Any) -> None: """Remove the temporary directory created for the test.""" shutil.rmtree(context.persist_tmpdir, ignore_errors=True) # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("a fresh project-persist database is initialised") def step_init_persist_db(context: Any) -> None: """Create a temp dir with a fresh SQLite file for persistence tests.""" context.persist_tmpdir = tempfile.mkdtemp(prefix="persist_589_") context.persist_db_path = os.path.join(context.persist_tmpdir, "test.db") # Pre-create schema so every fresh repo sees the tables. engine = create_engine( f"sqlite:///{context.persist_db_path}", echo=False, poolclass=NullPool, ) Base.metadata.create_all(engine) engine.dispose() # Initialise result holders. context.persist_create_results = [] # type: ignore[attr-defined] context.persist_list_result = None context.persist_dup_result = None context.add_cleanup(_cleanup_persist_db, context) # --------------------------------------------------------------------------- # When steps - create # --------------------------------------------------------------------------- @when('I create a project named "{name}" via the persist CLI') def step_create_project_persist(context: Any, name: str) -> None: """Invoke ``project create `` with a fresh repo per call.""" repo = _make_fresh_repo(context.persist_db_path) with ( patch(_PATCH_PROJECT_REPO, return_value=repo), patch(_PATCH_STORE_EXTRAS), ): result = runner.invoke(project_app, ["create", name]) assert result.exit_code == 0, ( f"project create '{name}' should exit 0 but got " f"{result.exit_code}. output:\n{result.output}" ) context.persist_create_results.append(result) @when('I attempt to create a duplicate project named "{name}" via the persist CLI') def step_create_duplicate_persist(context: Any, name: str) -> None: """Invoke ``project create`` again for the same name (expect error).""" repo = _make_fresh_repo(context.persist_db_path) with ( patch(_PATCH_PROJECT_REPO, return_value=repo), patch(_PATCH_STORE_EXTRAS), ): result = runner.invoke(project_app, ["create", name]) context.persist_dup_result = result # --------------------------------------------------------------------------- # When steps - list # --------------------------------------------------------------------------- @when("I list projects via the persist CLI") def step_list_projects_persist(context: Any) -> None: """Invoke ``project list`` with a fresh repo pointing at the same DB.""" repo = _make_fresh_repo(context.persist_db_path) with patch(_PATCH_PROJECT_REPO, return_value=repo): result = runner.invoke(project_app, ["list"]) assert result.exit_code == 0, ( f"project list should exit 0 but got {result.exit_code}. " f"output:\n{result.output}" ) context.persist_list_result = result # --------------------------------------------------------------------------- # Then steps # --------------------------------------------------------------------------- @then('the persist project list should contain "{name}"') def step_persist_list_contains(context: Any, name: str) -> None: """Assert that the list output includes the given project name.""" result = context.persist_list_result assert result is not None, "project list was not invoked" output = result.output assert name in output, ( f"Expected '{name}' in project list output but got:\n{output}" ) @then('the persist duplicate creation should fail with "{text}"') def step_persist_dup_fails(context: Any, text: str) -> None: """Assert that the duplicate creation produced an error containing *text*.""" result = context.persist_dup_result assert result is not None, "duplicate create was not invoked" assert result.exit_code != 0, ( f"Duplicate create should fail (exit_code != 0) but got " f"{result.exit_code}. output:\n{result.output}" ) output = result.output assert text.lower() in output.lower(), ( f"Expected '{text}' in duplicate-create output but got:\n{output}" )