Files
cleveragents-core/features/steps/plan_repository_steps.py
T

496 lines
18 KiB
Python

"""Step definitions for LifecyclePlanRepository persistence coverage.
Targets ``LifecyclePlanRepository`` in
``src/cleveragents/infrastructure/database/repositories.py``.
"""
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.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 (
InvariantSource,
NamespacedName,
PlanIdentity,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
from cleveragents.domain.models.core.plan import Plan as V3Plan
from cleveragents.infrastructure.database.models import (
Base,
PlanArgumentModel,
PlanInvariantModel,
PlanProjectModel,
)
from cleveragents.infrastructure.database.repositories import (
ActionRepository,
DuplicatePlanError,
LifecyclePlanRepository,
)
# Crockford base32 alphabet for ULIDs
_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
_PLAN_REPO_ULID_COUNTER = 100
def _next_plan_repo_ulid() -> str:
"""Return a unique, valid ULID string for each call."""
global _PLAN_REPO_ULID_COUNTER
_PLAN_REPO_ULID_COUNTER += 1
n = _PLAN_REPO_ULID_COUNTER
suffix = ""
for _ in range(8):
suffix = _CB32[n % 32] + suffix
n //= 32
return f"01HGZ6FE0AQDYTR4BX{suffix}"
def _ensure_action(ctx: Context, action_name: str = "local/test-action") -> None:
"""Ensure the test action exists in the database."""
try:
actions_created = ctx._plan_repo_actions_created # type: ignore[attr-defined]
except (AttributeError, KeyError):
actions_created: set[str] = set()
ctx._plan_repo_actions_created = actions_created # type: ignore[attr-defined]
if action_name in actions_created:
return
parts = action_name.split("/", 1)
namespace = parts[0] if len(parts) == 2 else "local"
short_name = parts[1] if len(parts) == 2 else parts[0]
action = 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("available"),
created_at=datetime.now(),
updated_at=datetime.now(),
created_by=None,
tags=[],
)
action_repo = ActionRepository(session_factory=ctx.db_session_factory)
action_repo.create(action)
ctx.db_session.commit()
ctx._plan_repo_actions_created.add(action_name)
def _make_plan(
plan_id: str,
phase: str = "action",
processing_state: str = "queued",
action_name: str = "local/test-action",
description: str = "Test plan description",
project_links: list[ProjectLink] | None = None,
invariants: list[PlanInvariant] | None = None,
arguments: dict[str, Any] | None = None,
arguments_order: list[str] | None = None,
) -> V3Plan:
"""Create a minimal valid v3 Plan domain object."""
now = datetime.now()
return V3Plan(
identity=PlanIdentity(plan_id=plan_id, attempt=1),
namespaced_name=NamespacedName(namespace="local", name="test-plan"),
action_name=action_name,
description=description,
definition_of_done="All tests pass",
phase=PlanPhase(phase),
processing_state=ProcessingState(processing_state),
strategy_actor="local/strategist",
execution_actor="local/executor",
project_links=project_links or [],
invariants=invariants or [],
arguments=arguments or {},
arguments_order=arguments_order or list((arguments or {}).keys()),
timestamps=PlanTimestamps(created_at=now, updated_at=now),
created_by="test-user",
tags=[],
reusable=True,
read_only=False,
)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a clean in-memory database for plan repository tests")
def step_clean_db_plan_repo(context: Context) -> None:
"""Create a fresh in-memory SQLite database with all tables."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
context.db_engine = engine
session = sessionmaker(bind=engine)()
context.db_session = session
context.db_session_factory = lambda: session
@given("a lifecycle plan repository backed by the database")
def step_plan_repo(context: Context) -> None:
"""Instantiate a LifecyclePlanRepository using the session factory."""
context.plan_repo = LifecyclePlanRepository(
session_factory=context.db_session_factory,
)
context.plan_error = None
context.plan_result = None
context.retrieved_plan = None
# ---------------------------------------------------------------------------
# Create plans
# ---------------------------------------------------------------------------
@given('a valid plan domain object with id "{plan_id}"')
def step_make_plan(context: Context, plan_id: str) -> None:
"""Build a Plan domain object with the given ULID."""
_ensure_action(context)
context.plan = _make_plan(plan_id=plan_id)
@given("the plan is saved through the plan repository")
def step_save_plan_given(context: Context) -> None:
"""Persist the plan (as a Given step)."""
try:
context.plan_repo.create(context.plan)
context.db_session.commit()
except Exception as exc:
context.plan_error = exc
@when("the plan is saved through the plan repository")
def step_save_plan(context: Context) -> None:
"""Persist the plan and capture any errors."""
try:
context.plan_repo.create(context.plan)
context.db_session.commit()
except Exception as exc:
context.plan_error = exc
@when('a duplicate plan with id "{plan_id}" is saved')
def step_save_duplicate_plan(context: Context, plan_id: str) -> None:
"""Attempt to persist a second plan with the same ID."""
dup = _make_plan(plan_id=plan_id, description="Duplicate plan")
try:
context.plan_repo.create(dup)
context.db_session.commit()
except (DuplicatePlanError, DatabaseError) as exc:
context.plan_error = exc
except Exception as exc:
cause = exc.__cause__ or exc
context.plan_error = cause
# ---------------------------------------------------------------------------
# Retrieve plans
# ---------------------------------------------------------------------------
@then('the plan retrieved by id "{plan_id}" should exist')
def step_get_plan_by_id_exists(context: Context, plan_id: str) -> None:
context.retrieved_plan = context.plan_repo.get(plan_id)
assert context.retrieved_plan is not None, f"Plan {plan_id} not found"
@then('the plan retrieved by id "{plan_id}" should not exist')
def step_get_plan_by_id_not_exists(context: Context, plan_id: str) -> None:
result = context.plan_repo.get(plan_id)
assert result is None, f"Plan {plan_id} should not exist but was found"
@then('the retrieved plan description should be "{desc}"')
def step_check_plan_description(context: Context, desc: str) -> None:
assert context.retrieved_plan.description == desc, (
f"Expected '{desc}', got '{context.retrieved_plan.description}'"
)
@then('the plan retrieved by name "{name}" should exist')
def step_get_plan_by_name(context: Context, name: str) -> None:
result = context.plan_repo.get_by_name(name)
assert result is not None, f"Plan with name {name} not found"
@when('the plan is retrieved by id "{plan_id}"')
def step_when_get_plan_by_id(context: Context, plan_id: str) -> None:
context.plan_result = context.plan_repo.get(plan_id)
@then("the plan retrieve result should be None")
def step_check_plan_none(context: Context) -> None:
assert context.plan_result is None, "Expected None but got a plan"
# ---------------------------------------------------------------------------
# Update plans
# ---------------------------------------------------------------------------
@when('the plan phase is updated to "{phase}" with state "{state}"')
def step_update_plan(context: Context, phase: str, state: str) -> None:
plan = context.plan_repo.get(context.plan.identity.plan_id)
plan.phase = PlanPhase(phase)
plan.processing_state = ProcessingState(state)
plan.timestamps.updated_at = datetime.now()
context.plan_repo.update(plan)
context.db_session.commit()
context.retrieved_plan = context.plan_repo.get(context.plan.identity.plan_id)
@then('the retrieved plan should have phase "{phase}"')
def step_check_plan_phase(context: Context, phase: str) -> None:
# If retrieved_plan is not set yet, fetch it from the repo
if context.retrieved_plan is None:
context.retrieved_plan = context.plan_repo.get(context.plan.identity.plan_id)
actual = (
context.retrieved_plan.phase.value
if hasattr(context.retrieved_plan.phase, "value")
else context.retrieved_plan.phase
)
assert actual == phase, f"Expected phase '{phase}', got '{actual}'"
@then('the retrieved plan should have processing state "{state}"')
def step_check_plan_state(context: Context, state: str) -> None:
# If retrieved_plan is not set yet, fetch it from the repo
if context.retrieved_plan is None:
context.retrieved_plan = context.plan_repo.get(context.plan.identity.plan_id)
actual = (
context.retrieved_plan.processing_state.value
if hasattr(context.retrieved_plan.processing_state, "value")
else context.retrieved_plan.processing_state
)
assert actual == state, f"Expected state '{state}', got '{actual}'"
# ---------------------------------------------------------------------------
# List filters
# ---------------------------------------------------------------------------
@given('{n:d} plans exist in phase "{phase1}" and {m:d} plans in phase "{phase2}"')
def step_create_plans_by_phase(
context: Context, n: int, phase1: str, m: int, phase2: str
) -> None:
_ensure_action(context)
for _ in range(n):
plan = _make_plan(plan_id=_next_plan_repo_ulid(), phase=phase1)
context.plan_repo.create(plan)
for _ in range(m):
plan = _make_plan(plan_id=_next_plan_repo_ulid(), phase=phase2)
context.plan_repo.create(plan)
context.db_session.commit()
@given('{n:d} plans exist in state "{state1}" and {m:d} plan in state "{state2}"')
def step_create_plans_by_state(
context: Context, n: int, state1: str, m: int, state2: str
) -> None:
_ensure_action(context)
for _ in range(n):
plan = _make_plan(plan_id=_next_plan_repo_ulid(), processing_state=state1)
context.plan_repo.create(plan)
for _ in range(m):
plan = _make_plan(plan_id=_next_plan_repo_ulid(), processing_state=state2)
context.plan_repo.create(plan)
context.db_session.commit()
@given(
'{n:d} plans referencing action "{action1}" and {m:d} plan referencing "{action2}"'
)
def step_create_plans_by_action(
context: Context, n: int, action1: str, m: int, action2: str
) -> None:
_ensure_action(context, action1)
_ensure_action(context, action2)
for _ in range(n):
plan = _make_plan(plan_id=_next_plan_repo_ulid(), action_name=action1)
context.plan_repo.create(plan)
for _ in range(m):
plan = _make_plan(plan_id=_next_plan_repo_ulid(), action_name=action2)
context.plan_repo.create(plan)
context.db_session.commit()
@given('a plan linked to project "{project}" and another with no project link')
def step_create_plans_with_project(context: Context, project: str) -> None:
_ensure_action(context)
plan_with = _make_plan(
plan_id=_next_plan_repo_ulid(),
project_links=[ProjectLink(project_name=project)],
)
context.plan_repo.create(plan_with)
plan_without = _make_plan(plan_id=_next_plan_repo_ulid())
context.plan_repo.create(plan_without)
context.db_session.commit()
@when('plans are listed with phase filter "{phase}"')
def step_list_by_phase(context: Context, phase: str) -> None:
context.plan_list_result = context.plan_repo.list_plans(phase=phase)
@when('plans are listed with processing state filter "{state}"')
def step_list_by_state(context: Context, state: str) -> None:
context.plan_list_result = context.plan_repo.list_plans(processing_state=state)
@when('plans are listed with action name filter "{action}"')
def step_list_by_action(context: Context, action: str) -> None:
context.plan_list_result = context.plan_repo.list_plans(action_name=action)
@when('plans are listed with project name filter "{project}"')
def step_list_by_project(context: Context, project: str) -> None:
context.plan_list_result = context.plan_repo.list_plans(project_name=project)
@then("the plan list should contain {n:d} plans")
def step_check_plan_list_count_plural(context: Context, n: int) -> None:
actual = len(context.plan_list_result)
assert actual == n, f"Expected {n} plans, got {actual}"
@then("the plan list should contain {n:d} plan")
def step_check_plan_list_count_singular(context: Context, n: int) -> None:
actual = len(context.plan_list_result)
assert actual == n, f"Expected {n} plan(s), got {actual}"
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
@given('a valid plan domain object with id "{plan_id}" and child rows')
def step_make_plan_with_children(context: Context, plan_id: str) -> None:
_ensure_action(context)
context.plan = _make_plan(
plan_id=plan_id,
project_links=[ProjectLink(project_name="local/child-proj")],
invariants=[PlanInvariant(text="Must pass tests", source=InvariantSource.PLAN)],
arguments={"key": "value"},
arguments_order=["key"],
)
@when('the plan with id "{plan_id}" is deleted')
def step_delete_plan(context: Context, plan_id: str) -> None:
context.plan_repo.delete(plan_id)
context.db_session.commit()
@then('the child rows for plan "{plan_id}" should be gone')
def step_check_children_gone(context: Context, plan_id: str) -> None:
session: Session = context.db_session
projects = session.query(PlanProjectModel).filter_by(plan_id=plan_id).count()
arguments = session.query(PlanArgumentModel).filter_by(plan_id=plan_id).count()
invariants = session.query(PlanInvariantModel).filter_by(plan_id=plan_id).count()
assert projects == 0, f"Expected 0 project links, got {projects}"
assert arguments == 0, f"Expected 0 arguments, got {arguments}"
assert invariants == 0, f"Expected 0 invariants, got {invariants}"
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
@then("the plan repository should not raise an error")
def step_no_plan_error(context: Context) -> None:
assert context.plan_error is None, f"Unexpected error: {context.plan_error}"
@then('a DuplicatePlanError should be raised mentioning "{plan_id}"')
def step_check_duplicate_plan_error(context: Context, plan_id: str) -> None:
assert context.plan_error is not None, "Expected DuplicatePlanError but no error"
assert isinstance(context.plan_error, DuplicatePlanError), (
f"Expected DuplicatePlanError, got {type(context.plan_error).__name__}"
)
assert plan_id in str(context.plan_error), (
f"Error message should mention '{plan_id}': {context.plan_error}"
)
# ---------------------------------------------------------------------------
# Phase and state persistence
# ---------------------------------------------------------------------------
@given(
'a plan domain object in phase "{phase}" with state "{state}" and id "{plan_id}"'
)
def step_make_plan_phase_state(
context: Context, phase: str, state: str, plan_id: str
) -> None:
_ensure_action(context)
context.plan = _make_plan(plan_id=plan_id, phase=phase, processing_state=state)
# ---------------------------------------------------------------------------
# Child row ordering
# ---------------------------------------------------------------------------
@given('a plan with ordered children and id "{plan_id}"')
def step_make_plan_ordered_children(context: Context, plan_id: str) -> None:
_ensure_action(context)
context.plan = _make_plan(
plan_id=plan_id,
project_links=[
ProjectLink(project_name="local/proj-a"),
ProjectLink(project_name="local/proj-b"),
],
invariants=[
PlanInvariant(text="first", source=InvariantSource.PLAN),
PlanInvariant(text="second", source=InvariantSource.ACTION),
PlanInvariant(text="third", source=InvariantSource.PROJECT),
],
arguments={"alpha": "a", "beta": "b", "gamma": "g"},
arguments_order=["alpha", "beta", "gamma"],
)
@then('the retrieved plan should have arguments in order "{expected}"')
def step_check_arguments_order(context: Context, expected: str) -> None:
plan = context.plan_repo.get(context.plan.identity.plan_id)
expected_list = expected.split(",")
assert plan.arguments_order == expected_list, (
f"Expected arguments order {expected_list}, got {plan.arguments_order}"
)
@then('the retrieved plan should have invariants in order "{expected}"')
def step_check_invariants_order(context: Context, expected: str) -> None:
plan = context.plan_repo.get(context.plan.identity.plan_id)
expected_list = expected.split(",")
actual = [inv.text for inv in plan.invariants]
assert actual == expected_list, f"Expected invariants {expected_list}, got {actual}"
@then('the retrieved plan should have project links including "{project}"')
def step_check_project_links(context: Context, project: str) -> None:
plan = context.plan_repo.get(context.plan.identity.plan_id)
names = [pl.project_name for pl in plan.project_links]
assert project in names, f"Expected project '{project}' in links, got {names}"