Files
temp/features/steps/action_repository_coverage_steps.py
T

539 lines
19 KiB
Python

"""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()
plan_model = LifecyclePlanModel(
plan_id=_next_ulid(),
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",
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