Files
temp/features/steps/action_repository_coverage_steps.py
CoreRasurae a4a6b061a6 fix(db): align v3_plans schema with specification DDL
Aligned v3_plans table with specification DDL:

1. Added effective_profile_snapshot column (TEXT NOT NULL) for
   storing frozen JSON snapshot of automation profile at plan
   creation time.  Added Pydantic field_validator ensuring the
   value is well-formed JSON.  Validator catches RecursionError
   for deeply nested JSON, consistent with automation_profile
   deserialization hardening.  Validator error message uses
   length-only to avoid potential information disclosure.
   Documented that the default "{}" exists for backward
   compatibility; new plans should explicitly set the snapshot.

2. Made root_plan_id NOT NULL — root plans self-reference their
   own plan_id, child plans reference the root ancestor.  Added
   explicit ondelete="RESTRICT" FK policy for consistency with
   other FKs in the model.  Documented known FK policy drift
   between ORM model (RESTRICT) and migrated databases (retained
   SET NULL) in the migration; data integrity is preserved by
   the NOT NULL constraint regardless.  Moved root_plan_id
   self-reference resolution into a PlanIdentity model_validator
   so the domain model is consistent with the DB NOT NULL
   constraint before and after persistence (previously the
   resolution only happened in from_domain(), creating an
   asymmetry where root_plan_id was None in-memory but non-null
   after round-tripping through the database).

3. Made automation_profile NOT NULL with default "balanced".

4. Documented intentional deviation: phase default is "action"
   (code) vs "strategize" (spec) because the Action phase was
   added as a pre-Strategize setup step.

5. Created Alembic migration with backfill logic for existing
   rows.  Root-ancestor backfill uses level-by-level propagation
   with a parent-readiness guard to correctly resolve plans at
   arbitrary hierarchy depth (3+ levels).  Added safety bound
   (max 100 iterations) with logged error on exhaustion to guard
   against cycles in parent_plan_id.  Merged batch_alter_table
   operations to avoid redundant full-table copies in SQLite
   batch mode.  Migration backfill also handles empty-string
   automation_profile values.  Documented downgrade limitation
   (backfill is not reversible).  Orphan-row fallback now logs
   affected row count at WARNING level.  Migration cycle-detection
   now logs affected plan_id values before the orphan fallback
   overwrites them.  All migration SQL uses sa.text() for
   consistency with SQLAlchemy best practices.

6. Hardened automation_profile deserialization in to_domain() to
   catch ValueError (invalid StrEnum provenance), Pydantic
   ValidationError, and RecursionError (deeply nested JSON) in
   addition to JSONDecodeError and KeyError, preventing
   unreadable plans from corrupted DB rows.  Applied the same
   defensive deserialization pattern to effective_profile_snapshot
   in to_domain(): corrupted JSON falls back to '{}' with a
   WARNING log instead of crashing the read path.  Added TypeError
   to the effective_profile_snapshot exception list in to_domain()
   for consistency with the Pydantic validator.  Logging of
   unparseable values uses length only to avoid potential
   information disclosure.

7. Used explicit None check (is not None) instead of truthiness
   for root_plan_id resolution in from_domain(), for
   effective_profile_snapshot in to_domain(), and in
   _serialize_automation_profile() for consistency.

8. Documented intentional column naming conventions vs spec DDL
   (e.g. automation_profile vs automation_profile_name, *_actor
   vs *_actor_name, processing_state vs state, v3_plans vs
   plans).  Documented the semantic difference: automation_profile
   stores either a bare name or structured JSON with provenance,
   whereas the spec automation_profile_name stores a plain name.

9. Fixed benchmark plan constructors
   (plan_phase_migration_bench.py) that were missing the now-
   required root_plan_id and effective_profile_snapshot fields.

10. Replaced defensive getattr() with direct attribute access for
    effective_profile_snapshot in from_domain() and update(),
    since the field is now defined on the Plan domain model.

11. Fixed Any type annotation in test helper _make_plan() to use
    AutomationProfileRef | None for proper type safety.

12. Added BDD scenarios for PlanIdentity self-reference
    resolution, NULL effective_profile_snapshot constraint
    enforcement, valid-JSON-missing-profile_name-key
    deserialization, invalid-JSON and empty-string snapshot
    rejection by Pydantic validator, and corrupted
    effective_profile_snapshot DB fallback in to_domain().

13. Extracted default automation profile name to a module-level
    constant (DEFAULT_AUTOMATION_PROFILE) to reduce sentinel
    duplication across models.py and repositories.py.

14. Centralised automation-profile serialisation into
    LifecyclePlanModel._serialize_automation_profile() to
    eliminate duplication between from_domain() and
    LifecyclePlanRepository.update().

15. Fixed to_domain() root_plan_id type cast from str | None
    to str, reflecting the NOT NULL column constraint.

16. Added PlanIdentity model_validator that resolves None
    root_plan_id to plan_id at domain construction time, ensuring
    the domain model honours the spec DDL NOT NULL constraint
    regardless of persistence state.  Simplified from_domain()
    root resolution accordingly.

ISSUES CLOSED: #921
2026-03-30 23:40:36 +01:00

542 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()
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