a4a6b061a6
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 18s
CI / lint (pull_request) Successful in 20s
CI / helm (pull_request) Successful in 22s
CI / typecheck (pull_request) Successful in 51s
CI / quality (pull_request) Successful in 3m45s
CI / integration_tests (pull_request) Successful in 3m59s
CI / security (pull_request) Successful in 4m8s
CI / unit_tests (pull_request) Successful in 5m44s
CI / docker (pull_request) Successful in 1m32s
CI / coverage (pull_request) Successful in 11m50s
CI / e2e_tests (pull_request) Successful in 20m28s
CI / status-check (pull_request) Successful in 1s
CI / lint (push) Successful in 17s
CI / build (push) Successful in 18s
CI / helm (push) Successful in 22s
CI / quality (push) Successful in 31s
CI / typecheck (push) Successful in 1m1s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 3m57s
CI / security (push) Successful in 4m3s
CI / docker (push) Successful in 1m21s
CI / integration_tests (push) Successful in 7m11s
CI / coverage (push) Successful in 11m56s
CI / e2e_tests (push) Successful in 20m56s
CI / status-check (push) Successful in 1s
CI / benchmark-publish (push) Successful in 28m22s
CI / benchmark-regression (pull_request) Successful in 55m20s
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
741 lines
27 KiB
Python
741 lines
27 KiB
Python
"""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}'"
|
|
)
|