"""Step definitions for repositories_coverage.feature. Targets uncovered lines and branches in ActionRepository and LifecyclePlanRepository in ``repositories.py``: Lines: 924, 943-946, 968, 1077-1078, 1124-1127, 1142-1143, 1160, 1162-1163, 1188, 1267, 1283-1284, 1296-1298, 1303, 1315-1319, 1338, 1342-1344, 1383, 1393-1394, 1411-1422 Branches at: 923, 942, 944, 967, 1122, 1159, 1187, 1266, 1282, 1283, 1295, 1337, 1382, 1414, 1416 """ from __future__ import annotations from datetime import datetime from typing import Any from behave import given, then, when from behave.runner import Context from sqlalchemy import create_engine from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session, sessionmaker from cleveragents.core.exceptions import DatabaseError from cleveragents.domain.models.core.action import ( Action, ActionArgument, ActionState, ArgumentRequirement, ArgumentType, ) from cleveragents.domain.models.core.plan import ( InvariantSource, NamespacedName, Plan, PlanIdentity, PlanInvariant, PlanPhase, PlanTimestamps, ProcessingState, ProjectLink, ) from cleveragents.infrastructure.database.models import ( Base, LifecyclePlanModel, ) from cleveragents.infrastructure.database.repositories import ( ActionInUseError, ActionRepository, DuplicatePlanError, LifecyclePlanRepository, ) # Valid ULIDs for deterministic tests (Crockford base32, 26 chars) _CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" _ULID_COUNTER = 0 def _next_ulid() -> str: """Return a unique, valid ULID string for each call.""" global _ULID_COUNTER _ULID_COUNTER += 1 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 _make_plan( action_name: str, plan_name: str = "local/test-plan", ) -> Plan: """Create a minimal valid Plan domain object.""" parts = plan_name.split("/", 1) namespace = parts[0] if len(parts) == 2 else "local" short_name = parts[1] if len(parts) == 2 else parts[0] now = datetime.now() return Plan( identity=PlanIdentity(plan_id=_next_ulid(), attempt=1), namespaced_name=NamespacedName(namespace=namespace, name=short_name), action_name=action_name, description=f"Test plan {short_name}", definition_of_done="Verify plan completes", phase=PlanPhase.STRATEGIZE, processing_state=ProcessingState.QUEUED, strategy_actor="local/strategist", execution_actor="local/executor", timestamps=PlanTimestamps(created_at=now, updated_at=now), error_message=None, error_details=None, created_by=None, tags=[], reusable=True, read_only=False, ) # ----------------------------------------------------------------------- # Background # ----------------------------------------------------------------------- @given("a repos-cov in-memory database with lifecycle schema") def step_repos_cov_clean_db(context: Context) -> None: """Create a fresh in-memory SQLite database with all tables.""" engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) context.rc_engine = engine session = sessionmaker(bind=engine)() context.rc_session = session context.rc_session_factory = lambda: session context.rc_error = None context.rc_result = None context.rc_delete_result = None context.rc_result_list = None @given("a repos-cov action repository") def step_repos_cov_action_repo(context: Context) -> None: """Instantiate an ActionRepository using the session factory.""" context.rc_action_repo = ActionRepository( session_factory=context.rc_session_factory, ) @given("a repos-cov lifecycle plan repository") def step_repos_cov_plan_repo(context: Context) -> None: """Instantiate a LifecyclePlanRepository using the session factory.""" context.rc_plan_repo = LifecyclePlanRepository( session_factory=context.rc_session_factory, ) # ----------------------------------------------------------------------- # Helpers for error-producing sessions # ----------------------------------------------------------------------- class _QueryRaisingSession: """Stub session that raises OperationalError on query().""" def query(self, *args: Any, **kwargs: Any) -> None: """Raise OperationalError to simulate a transient DB failure.""" raise OperationalError("simulated", {}, Exception("db down")) def rollback(self) -> None: """No-op rollback.""" def close(self) -> None: """No-op close.""" @given( "a repos-cov action repository with a session that raises OperationalError on query" ) def step_repos_cov_action_repo_error(context: Context) -> None: """Create an ActionRepository whose session raises on query.""" context.rc_action_repo = ActionRepository( session_factory=lambda: _QueryRaisingSession(), # type: ignore[arg-type] ) @given( "a repos-cov plan repository with a session that raises OperationalError on query" ) def step_repos_cov_plan_repo_error(context: Context) -> None: """Create a LifecyclePlanRepository whose session raises on query.""" context.rc_plan_repo = LifecyclePlanRepository( session_factory=lambda: _QueryRaisingSession(), # type: ignore[arg-type] ) # ----------------------------------------------------------------------- # ActionRepository.get_by_name # ----------------------------------------------------------------------- @when('repos-cov get_by_name is called with "{name}"') def step_repos_cov_get_by_name(context: Context, name: str) -> None: """Call ActionRepository.get_by_name and capture result or error.""" try: context.rc_result = context.rc_action_repo.get_by_name(name) except Exception as exc: context.rc_error = exc @then('repos-cov a DatabaseError should be raised containing "{text}"') def step_repos_cov_verify_db_error(context: Context, text: str) -> None: """Assert that a DatabaseError was raised with expected text.""" assert context.rc_error is not None, ( "Expected DatabaseError but no error was raised" ) assert isinstance(context.rc_error, DatabaseError), ( f"Expected DatabaseError, got {type(context.rc_error).__name__}: {context.rc_error}" ) assert text in str(context.rc_error), ( f"Expected '{text}' in error message: {context.rc_error}" ) @then('repos-cov the returned action should have name "{name}"') def step_repos_cov_verify_action_name(context: Context, name: str) -> None: """Assert the returned action has the expected namespaced name.""" assert context.rc_result is not None, "Expected an action, got None" assert str(context.rc_result.namespaced_name) == name, ( f"Expected '{name}', got '{context.rc_result.namespaced_name}'" ) # ----------------------------------------------------------------------- # ActionRepository - persisting actions # ----------------------------------------------------------------------- @given('repos-cov an available action "{name}" exists') def step_repos_cov_persist_action(context: Context, name: str) -> None: """Create and persist an action with default state 'available'.""" action = _make_action(name=name, state="available") context.rc_action_repo.create(action) context.rc_session.commit() context.rc_current_action = action @given('repos-cov an action "{name}" exists with state "{state}"') def step_repos_cov_persist_action_state( context: Context, name: str, state: str ) -> None: """Create and persist an action with the given state.""" action = _make_action(name=name, state=state) context.rc_action_repo.create(action) context.rc_session.commit() context.rc_current_action = action # ----------------------------------------------------------------------- # ActionRepository.get_by_namespace # ----------------------------------------------------------------------- @when('repos-cov get_by_namespace is called with namespace "{ns}" and state "{state}"') def step_repos_cov_get_by_namespace_state( context: Context, ns: str, state: str ) -> None: """Call get_by_namespace with namespace and state filter.""" try: context.rc_result_list = context.rc_action_repo.get_by_namespace( ns, state=state ) except Exception as exc: context.rc_error = exc @when('repos-cov get_by_namespace is called with namespace "{ns}" expecting error') def step_repos_cov_get_by_namespace_error(context: Context, ns: str) -> None: """Call get_by_namespace expecting a DatabaseError.""" try: context.rc_action_repo.get_by_namespace(ns) except Exception as exc: context.rc_error = exc @then("repos-cov exactly {count:d} action should be in the result list") def step_repos_cov_verify_count_singular(context: Context, count: int) -> None: """Assert the result list has exactly count items (singular).""" assert context.rc_result_list is not None, "Result list is None" assert len(context.rc_result_list) == count, ( f"Expected {count} action(s), got {len(context.rc_result_list)}" ) @then("repos-cov exactly {count:d} actions should be in the result list") def step_repos_cov_verify_count_plural(context: Context, count: int) -> None: """Assert the result list has exactly count items (plural).""" assert context.rc_result_list is not None, "Result list is None" assert len(context.rc_result_list) == count, ( f"Expected {count} action(s), got {len(context.rc_result_list)}" ) @then('repos-cov the result list should contain action named "{name}"') def step_repos_cov_verify_list_contains(context: Context, name: str) -> None: """Assert the result list contains an action with the given name.""" names = [str(a.namespaced_name) for a in context.rc_result_list] assert name in names, f"Expected '{name}' in {names}" # ----------------------------------------------------------------------- # ActionRepository.list_available # ----------------------------------------------------------------------- @when("repos-cov list_available is called without a namespace filter") def step_repos_cov_list_available_all(context: Context) -> None: """Call list_available with no namespace.""" try: context.rc_result_list = context.rc_action_repo.list_available() except Exception as exc: context.rc_error = exc @when('repos-cov list_available is called with namespace "{ns}"') def step_repos_cov_list_available_ns(context: Context, ns: str) -> None: """Call list_available with a namespace filter.""" try: context.rc_result_list = context.rc_action_repo.list_available(namespace=ns) except Exception as exc: context.rc_error = exc @when("repos-cov list_available is called and an error is expected") def step_repos_cov_list_available_error(context: Context) -> None: """Call list_available expecting a DatabaseError.""" try: context.rc_action_repo.list_available() except Exception as exc: context.rc_error = exc # ----------------------------------------------------------------------- # ActionRepository.update - with min_value/max_value arguments # ----------------------------------------------------------------------- @given("repos-cov the action has arguments with min_value and max_value set") def step_repos_cov_add_minmax_args(context: Context) -> None: """Add arguments with min_value and max_value to the current action.""" context.rc_current_action.arguments = [ ActionArgument( name="count", arg_type=ArgumentType.INTEGER, requirement=ArgumentRequirement.REQUIRED, description="Number of items", default_value=5, min_value=1.0, max_value=100.0, validation_pattern=None, ), ] @when("repos-cov the action is updated via the repository") def step_repos_cov_update_action(context: Context) -> None: """Update the current action via the repository.""" try: context.rc_result = context.rc_action_repo.update(context.rc_current_action) context.rc_session.commit() except Exception as exc: context.rc_error = exc @then("repos-cov the update should succeed") def step_repos_cov_update_success(context: Context) -> None: """Assert no error occurred during update.""" assert context.rc_error is None, f"Unexpected error: {context.rc_error}" @then("repos-cov the retrieved action arguments should have min_value and max_value") def step_repos_cov_verify_minmax_args(context: Context) -> None: """Retrieve the action and verify min_value/max_value on arguments.""" name = str(context.rc_current_action.namespaced_name) retrieved = context.rc_action_repo.get_by_id(name) assert retrieved is not None, f"Action '{name}' not found after update" assert len(retrieved.arguments) >= 1, "Expected at least 1 argument" arg = retrieved.arguments[0] assert arg.min_value == 1.0, f"Expected min_value=1.0, got {arg.min_value}" assert arg.max_value == 100.0, f"Expected max_value=100.0, got {arg.max_value}" # ----------------------------------------------------------------------- # ActionRepository.delete # ----------------------------------------------------------------------- @given('a repos-cov lifecycle plan references action "{action_name}"') def step_repos_cov_create_referencing_plan(context: Context, action_name: str) -> None: """Insert a LifecyclePlanModel row referencing the specified action.""" session: Session = context.rc_session now_iso = datetime.now().isoformat() pid = _next_ulid() plan_model = LifecyclePlanModel( plan_id=pid, root_plan_id=pid, action_name=action_name, phase="strategize", processing_state="queued", attempt=1, namespaced_name="local/ref-plan", namespace="local", description="Plan referencing 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('repos-cov delete is called for action "{name}"') def step_repos_cov_delete_action(context: Context, name: str) -> None: """Call ActionRepository.delete and capture result or error.""" try: context.rc_delete_result = context.rc_action_repo.delete(name) context.rc_session.commit() except Exception as exc: context.rc_error = exc @then('repos-cov an ActionInUseError should be raised for "{name}"') def step_repos_cov_verify_action_in_use(context: Context, name: str) -> None: """Assert that an ActionInUseError was raised.""" assert context.rc_error is not None, ( "Expected ActionInUseError but no error was raised" ) assert isinstance(context.rc_error, ActionInUseError), ( f"Expected ActionInUseError, got {type(context.rc_error).__name__}: {context.rc_error}" ) assert name in str(context.rc_error), ( f"Expected '{name}' in error message: {context.rc_error}" ) @then("repos-cov the delete result should be True") def step_repos_cov_delete_true(context: Context) -> None: """Assert delete returned True.""" assert context.rc_delete_result is True, ( f"Expected True, got {context.rc_delete_result}" ) @then("repos-cov the delete result should be False") def step_repos_cov_delete_false(context: Context) -> None: """Assert delete returned False.""" assert context.rc_delete_result is False, ( f"Expected False, got {context.rc_delete_result}" ) @then('repos-cov get_by_id for "{name}" should return None') def step_repos_cov_verify_deleted(context: Context, name: str) -> None: """Assert the action no longer exists.""" found = context.rc_action_repo.get_by_id(name) assert found is None, f"Expected None, got {found}" # ----------------------------------------------------------------------- # DuplicatePlanError # ----------------------------------------------------------------------- @when('repos-cov a DuplicatePlanError is created for plan_id "{plan_id}"') def step_repos_cov_create_dup_plan_error(context: Context, plan_id: str) -> None: """Instantiate a DuplicatePlanError.""" context.rc_dup_plan_error = DuplicatePlanError(plan_id) @then('repos-cov the error message should contain "{text}"') def step_repos_cov_verify_error_contains(context: Context, text: str) -> None: """Assert the error message contains expected text.""" assert text in str(context.rc_dup_plan_error), ( f"Expected '{text}' in '{context.rc_dup_plan_error}'" ) @then('repos-cov the error plan_id attribute should be "{plan_id}"') def step_repos_cov_verify_plan_id_attr(context: Context, plan_id: str) -> None: """Assert the plan_id attribute is correct.""" assert context.rc_dup_plan_error.plan_id == plan_id, ( f"Expected plan_id='{plan_id}', got '{context.rc_dup_plan_error.plan_id}'" ) # ----------------------------------------------------------------------- # LifecyclePlanRepository.get_by_name # ----------------------------------------------------------------------- @given( 'a repos-cov lifecycle plan linked to "{action_name}" with name "{plan_name}" has been persisted' ) def step_repos_cov_persist_plan( context: Context, action_name: str, plan_name: str ) -> None: """Create and persist a lifecycle plan.""" plan = _make_plan(action_name=action_name, plan_name=plan_name) context.rc_plan_repo.create(plan) context.rc_session.commit() context.rc_current_plan = plan @when('repos-cov plan get_by_name is called with "{name}"') def step_repos_cov_plan_get_by_name(context: Context, name: str) -> None: """Call LifecyclePlanRepository.get_by_name and capture result or error.""" try: context.rc_result = context.rc_plan_repo.get_by_name(name) except Exception as exc: context.rc_error = exc @when('repos-cov plan get_by_name is called with "{name}" expecting error') def step_repos_cov_plan_get_by_name_error(context: Context, name: str) -> None: """Call LifecyclePlanRepository.get_by_name expecting error.""" try: context.rc_plan_repo.get_by_name(name) except Exception as exc: context.rc_error = exc @then("repos-cov the returned plan should not be None") def step_repos_cov_plan_not_none(context: Context) -> None: """Assert the returned plan is not None.""" assert context.rc_result is not None, "Expected a plan, got None" @then("repos-cov the returned plan should be None") def step_repos_cov_plan_none(context: Context) -> None: """Assert the returned plan is None.""" assert context.rc_result is None, f"Expected None, got {context.rc_result}" # ----------------------------------------------------------------------- # LifecyclePlanRepository.update - with project_links, arguments, invariants # ----------------------------------------------------------------------- class _StubInvariantWithText: """Stub invariant with .text and .source attributes (source has .value).""" def __init__(self, text: str, source: InvariantSource) -> None: """Initialise with text and InvariantSource enum.""" self.text = text self.source = source class _StubInvariantPlainString: """Stub invariant that is a plain string (no .text, no .source).""" def __init__(self, text: str) -> None: """Initialise with just a string value.""" self._text = text def __str__(self) -> str: """Return the text representation.""" return self._text @given( "repos-cov the plan has project_links, arguments, and invariants with text attribute" ) def step_repos_cov_plan_add_children(context: Context) -> None: """Add project_links, arguments, and invariants (with .text) to the plan.""" plan = context.rc_current_plan plan.project_links = [ ProjectLink(project_name="local/my-project", alias=None, read_only=False), ] plan.arguments = {"greeting": "hello", "count": 42} plan.arguments_order = ["greeting", "count"] plan.invariants = [ PlanInvariant(text="Must not break prod", source=InvariantSource.PLAN), ] plan.error_message = None plan.error_details = None plan.changeset_id = None @when("repos-cov the plan is updated via the repository") def step_repos_cov_update_plan(context: Context) -> None: """Update the current plan via the repository.""" try: context.rc_result = context.rc_plan_repo.update(context.rc_current_plan) context.rc_session.commit() except Exception as exc: context.rc_error = exc @then("repos-cov the plan update should succeed") def step_repos_cov_plan_update_success(context: Context) -> None: """Assert no error occurred during plan update.""" assert context.rc_error is None, f"Unexpected error: {context.rc_error}" @then("repos-cov the retrieved plan should have project links") def step_repos_cov_verify_project_links(context: Context) -> None: """Retrieve the plan and verify project links.""" plan_id = context.rc_current_plan.identity.plan_id retrieved = context.rc_plan_repo.get(plan_id) assert retrieved is not None, f"Plan '{plan_id}' not found after update" assert len(retrieved.project_links) >= 1, ( f"Expected at least 1 project link, got {len(retrieved.project_links)}" ) assert retrieved.project_links[0].project_name == "local/my-project", ( f"Expected project_name='local/my-project', got '{retrieved.project_links[0].project_name}'" ) @then("repos-cov the retrieved plan should have arguments") def step_repos_cov_verify_plan_args(context: Context) -> None: """Retrieve the plan and verify arguments.""" plan_id = context.rc_current_plan.identity.plan_id retrieved = context.rc_plan_repo.get(plan_id) assert retrieved is not None, f"Plan '{plan_id}' not found after update" assert len(retrieved.arguments) >= 1, ( f"Expected at least 1 argument, got {len(retrieved.arguments)}" ) @then("repos-cov the retrieved plan should have invariants") def step_repos_cov_verify_plan_invariants(context: Context) -> None: """Retrieve the plan and verify invariants.""" plan_id = context.rc_current_plan.identity.plan_id retrieved = context.rc_plan_repo.get(plan_id) assert retrieved is not None, f"Plan '{plan_id}' not found after update" assert len(retrieved.invariants) >= 1, ( f"Expected at least 1 invariant, got {len(retrieved.invariants)}" ) # ----------------------------------------------------------------------- # LifecyclePlanRepository.update - invariants as plain strings # ----------------------------------------------------------------------- @given("repos-cov the plan has invariants as plain strings without text attribute") def step_repos_cov_plan_plain_string_invariants(context: Context) -> None: """Set invariants on the plan as objects without .text attribute (falls back to str).""" plan = context.rc_current_plan plan.project_links = [] plan.arguments = {} plan.arguments_order = [] # Use object.__setattr__ to bypass pydantic validation — the code path does: # inv_text = inv.text if hasattr(inv, "text") else str(inv) object.__setattr__( plan, "invariants", [_StubInvariantPlainString("No side effects allowed")], ) # ----------------------------------------------------------------------- # LifecyclePlanRepository.update - invariant source with .value enum # ----------------------------------------------------------------------- @given("repos-cov the plan has invariants with InvariantSource enum values") def step_repos_cov_plan_enum_invariants(context: Context) -> None: """Set invariants using stub objects with InvariantSource enum source.""" plan = context.rc_current_plan plan.project_links = [] plan.arguments = {} plan.arguments_order = [] # Use object.__setattr__ to bypass pydantic validation # _StubInvariantWithText has .text and .source (an InvariantSource enum with .value) object.__setattr__( plan, "invariants", [ _StubInvariantWithText( "Keep API backward compatible", InvariantSource.ACTION ) ], ) # ----------------------------------------------------------------------- # LifecyclePlanRepository.update - processing_state as plain string # ----------------------------------------------------------------------- @given('repos-cov the plan processing_state is set to a plain string "{state}"') def step_repos_cov_plan_plain_state(context: Context, state: str) -> None: """Set the processing_state to a plain string (no .value attribute).""" plan = context.rc_current_plan plan.project_links = [] plan.arguments = {} plan.arguments_order = [] plan.invariants = [] # Use object.__setattr__ to bypass pydantic validation and set a raw string # that doesn't have a .value attribute — exercises the else branch at line 1317 object.__setattr__(plan, "processing_state", state) # ----------------------------------------------------------------------- # LifecyclePlanRepository.update - error_details and changeset_id # ----------------------------------------------------------------------- @given("repos-cov the plan has error_message, error_details, and changeset_id set") def step_repos_cov_plan_error_details(context: Context) -> None: """Set error_message, error_details, and changeset_id on the plan.""" plan = context.rc_current_plan plan.project_links = [] plan.arguments = {} plan.arguments_order = [] plan.invariants = [] plan.error_message = "Something went wrong" plan.error_details = {"step": "execute", "reason": "timeout"} plan.changeset_id = "01HGZ6FE0AQDYTR4BX00000001" @then("repos-cov the retrieved plan should have the error_message set") def step_repos_cov_verify_error_message(context: Context) -> None: """Retrieve the plan and verify error_message is set.""" plan_id = context.rc_current_plan.identity.plan_id retrieved = context.rc_plan_repo.get(plan_id) assert retrieved is not None, f"Plan '{plan_id}' not found after update" assert retrieved.error_message == "Something went wrong", ( f"Expected error_message='Something went wrong', got '{retrieved.error_message}'" )