"""Step definitions for ActionRepository persistence coverage. Targets uncovered lines 711-998 and partial branches at lines 145 and 215 in ``src/cleveragents/infrastructure/database/repositories.py``. """ from __future__ import annotations from datetime import datetime from behave import given, then, when from behave.runner import Context from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from cleveragents.core.exceptions import DatabaseError from cleveragents.domain.models.core.action import Action, ActionState from cleveragents.domain.models.core.plan import NamespacedName from cleveragents.infrastructure.database.models import ( Base, LifecyclePlanModel, ) from cleveragents.infrastructure.database.repositories import ( ActionInUseError, ActionRepository, DuplicateActionError, PlanRepository, ProjectRepository, ) # Valid ULIDs for deterministic tests (Crockford base32, 26 chars) # Crockford base32 alphabet: 0123456789ABCDEFGHJKMNPQRSTVWXYZ _ULID_COUNTER = 0 _CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" def _next_ulid() -> str: """Return a unique, valid ULID string for each call.""" global _ULID_COUNTER _ULID_COUNTER += 1 # Encode the counter into the last 8 Crockford base32 digits n = _ULID_COUNTER suffix = "" for _ in range(8): suffix = _CB32[n % 32] + suffix n //= 32 return f"01HGZ6FE0AQDYTR4BX{suffix}" def _make_action( name: str = "local/test-action", state: str = "available", ) -> Action: """Create a minimal valid Action domain object.""" parts = name.split("/", 1) namespace = parts[0] if len(parts) == 2 else "local" short_name = parts[1] if len(parts) == 2 else parts[0] return Action( namespaced_name=NamespacedName( namespace=namespace, name=short_name, ), description=f"Test action {short_name}", long_description=None, definition_of_done=f"Verify {short_name} completes", strategy_actor="local/strategist", execution_actor="local/executor", estimation_actor=None, review_actor=None, arguments=[], reusable=True, read_only=False, state=ActionState(state), created_at=datetime.now(), updated_at=datetime.now(), created_by=None, tags=[], ) def _get_session(context: Context) -> Session: """Return the shared session stored on the behave context.""" return context.db_session # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("a clean in-memory database with the lifecycle schema") def step_clean_db(context: Context) -> None: """Create a fresh in-memory SQLite database with all tables. The ``ActionRepository`` uses a session-factory pattern: each public method calls ``self._session()`` to obtain a session. For the tests to exercise commit / rollback semantics correctly the factory must return the **same** session instance so that ``session.flush()`` inside the repository and ``session.commit()`` in the test step operate on the same transaction. """ engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) context.db_engine = engine # Canonical session shared by all callers within one scenario session = sessionmaker(bind=engine)() context.db_session = session context.db_session_factory = lambda: session @given("an action repository backed by the database") def step_action_repo(context: Context) -> None: """Instantiate an ActionRepository using the session factory.""" context.action_repo = ActionRepository( session_factory=context.db_session_factory, ) context.saved_action = None context.result_action = None context.error = None context.delete_result = None # --------------------------------------------------------------------------- # Creating actions # --------------------------------------------------------------------------- @given('a valid action domain object named "{name}"') def step_make_action(context: Context, name: str) -> None: """Build an Action domain object with the given namespaced name.""" context.action = _make_action(name=name) @given("the action has already been saved once") def step_save_action_once(context: Context) -> None: """Persist the current action so a subsequent save will conflict.""" context.action_repo.create(context.action) context.db_session.commit() @when("the action is saved through the repository") def step_save_action(context: Context) -> None: """Persist the action and capture any errors.""" try: context.saved_action = context.action_repo.create(context.action) context.db_session.commit() except Exception as exc: context.error = exc @when('a second action with the same name "{name}" is saved') def step_save_duplicate(context: Context, name: str) -> None: """Attempt to persist a second action with the same namespaced name. The ``create`` method is wrapped in a retry decorator that may re-attempt before surfacing the error. We catch the final exception after all retries are exhausted. """ # Build a new Action that shares the same namespaced name but has a # different action_id (the unique constraint is on the ``name`` column). dup = _make_action(name=name) try: context.action_repo.create(dup) context.db_session.commit() except (DuplicateActionError, DatabaseError) as exc: # The retry decorator may wrap the DuplicateActionError inside a # RetryError. Unwrap if necessary. context.error = exc except Exception as exc: # tenacity.RetryError wraps the last attempt's exception. cause = exc.__cause__ or exc context.error = cause @then("the repository should not raise an error") def step_no_error(context: Context) -> None: assert context.error is None, f"Unexpected error: {context.error}" @then('the persisted action should retain the name "{name}"') def step_verify_name(context: Context, name: str) -> None: assert context.saved_action is not None assert str(context.saved_action.namespaced_name) == name, ( f"Expected '{name}', got '{context.saved_action.namespaced_name}'" ) @then('a DuplicateActionError should be raised mentioning "{name}"') def step_verify_dup_error(context: Context, name: str) -> None: assert context.error is not None, "Expected DuplicateActionError" assert isinstance(context.error, DuplicateActionError), ( f"Expected DuplicateActionError, got {type(context.error).__name__}" ) assert name in str(context.error), ( f"Error message should mention '{name}': {context.error}" ) # --------------------------------------------------------------------------- # Retrieving actions by identifier # --------------------------------------------------------------------------- @given("the action has been saved through the repository") def step_save_for_lookup(context: Context) -> None: """Persist the action for subsequent retrieval tests.""" context.saved_action = context.action_repo.create(context.action) context.db_session.commit() @when("the action is looked up by its identifier") def step_get_by_id(context: Context) -> None: context.result_action = context.action_repo.get_by_id( str(context.action.namespaced_name), ) @when('an action is looked up by the identifier "{action_id}"') def step_get_by_id_direct(context: Context, action_id: str) -> None: context.result_action = context.action_repo.get_by_id(action_id) @then('the returned action should have the name "{name}"') def step_verify_returned_name(context: Context, name: str) -> None: assert context.result_action is not None, "Expected an action, got None" assert str(context.result_action.namespaced_name) == name @then("no action should be returned") def step_verify_none(context: Context) -> None: assert context.result_action is None, f"Expected None, got {context.result_action}" # --------------------------------------------------------------------------- # Retrieving actions by name # --------------------------------------------------------------------------- @when('the action is looked up by name "{name}"') def step_get_by_name(context: Context, name: str) -> None: context.result_action = context.action_repo.get_by_name(name) # --------------------------------------------------------------------------- # Listing actions by namespace # --------------------------------------------------------------------------- @given("the following actions have been saved:") def step_save_multiple(context: Context) -> None: """Persist several actions described in a Behave table.""" assert context.table is not None, "Step requires a data table" for row in context.table: name = row["name"] state = row.get("state", "available") action = _make_action(name=name, state=state) context.action_repo.create(action) context.db_session.commit() @when('actions in the "{namespace}" namespace are listed') def step_list_namespace(context: Context, namespace: str) -> None: context.result_list = context.action_repo.get_by_namespace(namespace) @when('actions in the "{namespace}" namespace are listed with state "{state}"') def step_list_namespace_state(context: Context, namespace: str, state: str) -> None: context.result_list = context.action_repo.get_by_namespace( namespace, state=state, ) @then("{count:d} action should be returned") def step_verify_count_singular(context: Context, count: int) -> None: assert len(context.result_list) == count, ( f"Expected {count}, got {len(context.result_list)}" ) @then("{count:d} actions should be returned") def step_verify_count_plural(context: Context, count: int) -> None: assert len(context.result_list) == count, ( f"Expected {count}, got {len(context.result_list)}" ) @then('the returned action names should include "{name}"') def step_verify_includes_name(context: Context, name: str) -> None: names = [str(a.namespaced_name) for a in context.result_list] assert name in names, f"Expected '{name}' in {names}" # --------------------------------------------------------------------------- # Listing actions by state # --------------------------------------------------------------------------- @when('actions in the "{state}" state are listed') def step_list_by_state(context: Context, state: str) -> None: context.result_list = context.action_repo.get_by_state(state) # --------------------------------------------------------------------------- # Listing available actions # --------------------------------------------------------------------------- @when("all available actions are listed") def step_list_available(context: Context) -> None: context.result_list = context.action_repo.list_available() @when('available actions in the "{namespace}" namespace are listed') def step_list_available_ns(context: Context, namespace: str) -> None: context.result_list = context.action_repo.list_available( namespace=namespace, ) # --------------------------------------------------------------------------- # Updating actions # --------------------------------------------------------------------------- @when('the action description is changed to "{new_dod}"') def step_change_dod(context: Context, new_dod: str) -> None: context.action.definition_of_done = new_dod @when("the action is updated through the repository") def step_update_action(context: Context) -> None: try: context.result_action = context.action_repo.update(context.action) context.db_session.commit() except Exception as exc: context.error = exc @when("the phantom action is updated without being saved first") def step_update_unsaved(context: Context) -> None: try: context.action_repo.update(context.action) except Exception as exc: context.error = exc @then("a DatabaseError should be raised about the missing action") def step_verify_db_error(context: Context) -> None: assert context.error is not None, "Expected DatabaseError" assert isinstance(context.error, DatabaseError), ( f"Expected DatabaseError, got {type(context.error).__name__}" ) # --------------------------------------------------------------------------- # Deleting actions # --------------------------------------------------------------------------- @when("the action is deleted by its identifier") def step_delete_action(context: Context) -> None: context.delete_result = context.action_repo.delete( str(context.action.namespaced_name), ) context.db_session.commit() @when('an action with identifier "{action_id}" is deleted') def step_delete_by_id(context: Context, action_id: str) -> None: context.delete_result = context.action_repo.delete(action_id) @then("the delete operation should return true") def step_verify_deleted(context: Context) -> None: assert context.delete_result is True, f"Expected True, got {context.delete_result}" @then("the delete operation should return false") def step_verify_not_deleted(context: Context) -> None: assert context.delete_result is False, ( f"Expected False, got {context.delete_result}" ) @then("looking up the deleted action by identifier should return nothing") def step_verify_gone(context: Context) -> None: found = context.action_repo.get_by_id(str(context.action.namespaced_name)) assert found is None, f"Expected None, got {found}" @given("a lifecycle plan references that action") def step_create_referencing_plan(context: Context) -> None: """Insert a LifecyclePlanModel row that references the saved action.""" session = _get_session(context) now_iso = datetime.now().isoformat() pid = _next_ulid() plan_model = LifecyclePlanModel( plan_id=pid, root_plan_id=pid, action_name=str(context.action.namespaced_name), phase="strategize", processing_state="queued", attempt=1, namespaced_name="local/test-plan", namespace="local", description="Plan referencing the action under test", effective_profile_snapshot="{}", created_at=now_iso, updated_at=now_iso, tags_json="[]", ) session.add(plan_model) session.flush() session.commit() @when("the referenced action is deleted") def step_delete_referenced(context: Context) -> None: try: context.action_repo.delete(str(context.action.namespaced_name)) except Exception as exc: context.error = exc @then("an ActionInUseError should be raised") def step_verify_in_use(context: Context) -> None: assert context.error is not None, "Expected ActionInUseError" assert isinstance(context.error, ActionInUseError), ( f"Expected ActionInUseError, got {type(context.error).__name__}" ) # --------------------------------------------------------------------------- # Error classes # --------------------------------------------------------------------------- @when('a DuplicateActionError is created for name "{name}"') def step_create_dup_error(context: Context, name: str) -> None: context.error_instance = DuplicateActionError(name) @then('the error should contain the message "{text}"') def step_verify_error_msg(context: Context, text: str) -> None: assert text in str(context.error_instance), ( f"Expected '{text}' in '{context.error_instance}'" ) @then('the error should expose the action name "{name}"') def step_verify_error_attr(context: Context, name: str) -> None: assert context.error_instance.action_name == name @when( 'an ActionInUseError is created for action "{aid}" with {count:d} referencing plans' ) def step_create_in_use_error(context: Context, aid: str, count: int) -> None: context.error_instance = ActionInUseError(aid, count) @then('the error should mention "{aid}" and "{plans}"') def step_verify_in_use_msg(context: Context, aid: str, plans: str) -> None: msg = str(context.error_instance) assert aid in msg, f"Expected '{aid}' in '{msg}'" assert plans in msg, f"Expected '{plans}' in '{msg}'" @then('the error should expose action identifier "{aid}" and plan count {count:d}') def step_verify_in_use_attrs(context: Context, aid: str, count: int) -> None: assert context.error_instance.action_name == aid assert context.error_instance.plan_count == count # --------------------------------------------------------------------------- # Partial branch - ProjectRepository.delete (line 145) # --------------------------------------------------------------------------- @given("a project repository backed by the database") def step_project_repo(context: Context) -> None: context.project_repo = ProjectRepository( session=_get_session(context), ) context.error = None @when("a project with identifier {pid:d} is deleted") def step_delete_project(context: Context, pid: int) -> None: try: context.project_repo.delete(pid) context.db_session.commit() except Exception as exc: context.error = exc @then("the delete operation should complete without error") def step_verify_no_delete_error(context: Context) -> None: assert context.error is None, f"Unexpected error: {context.error}" # --------------------------------------------------------------------------- # Partial branch - PlanRepository.update (line 215) # --------------------------------------------------------------------------- @given("a plan repository backed by the database") def step_plan_repo(context: Context) -> None: from cleveragents.domain.models.core import PlanStatus context.plan_repo = PlanRepository(session=_get_session(context)) context.error = None context.plan_status_cls = PlanStatus @given("a plan domain object with identifier {pid:d}") def step_plan_with_id(context: Context, pid: int) -> None: from cleveragents.domain.models.core import Plan as LegacyPlan context.legacy_plan = LegacyPlan( id=pid, project_id=1, name="nonexistent-plan", prompt="test prompt", status=context.plan_status_cls.PENDING, current=False, ) @when("the non-existent plan is updated through the repository") def step_update_missing_plan(context: Context) -> None: context.result_plan = context.plan_repo.update(context.legacy_plan) @then("the plan object should be returned unchanged") def step_verify_plan_unchanged(context: Context) -> None: assert context.result_plan is not None assert context.result_plan.id == context.legacy_plan.id assert context.result_plan.name == context.legacy_plan.name