forked from HAL9000/cleveragents-core
539 lines
19 KiB
Python
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
|