Files
placeholder/features/steps/database_models_coverage_boost_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

952 lines
33 KiB
Python

"""Step definitions for database model coverage boost tests.
Targets uncovered branches in LifecycleActionModel, LifecyclePlanModel,
and NamespacedProjectModel to_domain/from_domain methods.
"""
import json
from datetime import datetime
from behave import given, then, when
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from ulid import ULID
from cleveragents.infrastructure.database.models import (
ActionArgumentModel,
Base,
LifecycleActionModel,
LifecyclePlanModel,
NamespacedProjectModel,
PlanArgumentModel,
ProjectResourceLinkModel,
ResourceModel,
ResourceTypeModel,
)
# ---------------------------------------------------------------------------
# Valid ULIDs for tests (generated via python-ulid)
# ---------------------------------------------------------------------------
ULID_PLAN_1 = str(ULID())
ULID_PLAN_2 = str(ULID())
ULID_RESOURCE_1 = str(ULID())
ULID_RESOURCE_2 = str(ULID())
ULID_LINK_1 = str(ULID())
ULID_LINK_2 = str(ULID())
NOW_ISO = "2025-06-01T12:00:00"
LATER_ISO = "2025-06-01T13:00:00"
# ---------------------------------------------------------------------------
# Background steps
# ---------------------------------------------------------------------------
@given("the coverage boost database is ready")
def step_coverage_boost_db_ready(context):
"""Set up an in-memory database with all lifecycle tables."""
context.cb_engine = create_engine("sqlite:///:memory:")
context.CbSessionLocal = sessionmaker(
bind=context.cb_engine, autoflush=False, autocommit=False
)
Base.metadata.create_all(context.cb_engine)
@given("a coverage boost database session is open")
def step_coverage_boost_session_open(context):
"""Open a database session for coverage boost tests."""
context.cb_session = context.CbSessionLocal()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ensure_action_exists(session, name="local/cov-action"):
"""Ensure the FK-target action row exists."""
existing = (
session.query(LifecycleActionModel).filter_by(namespaced_name=name).first()
)
if existing:
return
session.add(
LifecycleActionModel(
namespaced_name=name,
namespace="local",
name="cov-action",
description="Coverage action",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
execution_actor="local/executor",
state="available",
reusable=True,
read_only=False,
created_at=NOW_ISO,
updated_at=LATER_ISO,
tags_json="[]",
)
)
session.commit()
def _ensure_resource_type_exists(session, type_name="builtin/git-checkout"):
"""Ensure the FK-target resource_type row exists."""
existing = session.query(ResourceTypeModel).filter_by(name=type_name).first()
if existing:
return
session.add(
ResourceTypeModel(
name=type_name,
namespace="builtin",
resource_kind="physical",
user_addable=False,
created_at=NOW_ISO,
updated_at=LATER_ISO,
)
)
session.commit()
def _ensure_resource_exists(session, resource_id, type_name="builtin/git-checkout"):
"""Ensure the FK-target resource row exists."""
_ensure_resource_type_exists(session, type_name)
existing = session.query(ResourceModel).filter_by(resource_id=resource_id).first()
if existing:
return
session.add(
ResourceModel(
resource_id=resource_id,
type_name=type_name,
resource_kind="physical",
read_only=False,
auto_discovered=False,
created_at=NOW_ISO,
updated_at=LATER_ISO,
)
)
session.commit()
# ===================================================================
# LifecycleActionModel.to_domain: default_value_json branch
# ===================================================================
@given("an action model exists with arguments that have default values set")
def step_action_model_with_default_values(context):
"""Create an action model with arguments having default_value_json set."""
model = LifecycleActionModel(
namespaced_name="local/default-val-action",
namespace="local",
name="default-val-action",
description="Action with arg defaults",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
execution_actor="local/executor",
state="available",
reusable=True,
read_only=False,
created_at=NOW_ISO,
updated_at=LATER_ISO,
tags_json="[]",
)
# Argument WITH a default value
model.arguments_rel.append(
ActionArgumentModel(
name="threshold",
arg_type="integer",
requirement="optional",
description="Coverage threshold",
default_value_json=json.dumps(80),
position=0,
)
)
# Argument WITHOUT a default value
model.arguments_rel.append(
ActionArgumentModel(
name="target",
arg_type="string",
requirement="required",
description="Target module",
default_value_json=None,
position=1,
)
)
context.cb_session.add(model)
context.cb_session.commit()
context.cb_action_model = model
@when("the action model is converted to a domain object")
def step_action_model_to_domain(context):
"""Call to_domain on the action model."""
context.cb_action_domain = context.cb_action_model.to_domain()
@then("the domain action argument should have the correct default value")
def step_verify_arg_default_value(context):
"""Verify the argument with default_value_json has its value deserialized."""
args = context.cb_action_domain.arguments
threshold_arg = next(a for a in args if a.name == "threshold")
assert threshold_arg.default_value == 80
@then("the domain action argument without a default should have None")
def step_verify_arg_no_default(context):
"""Verify the argument without default_value_json has None."""
args = context.cb_action_domain.arguments
target_arg = next(a for a in args if a.name == "target")
assert target_arg.default_value is None
# ===================================================================
# LifecycleActionModel.to_domain: inputs_schema_json branch
# ===================================================================
@given("an action model exists with inputs_schema_json populated")
def step_action_model_with_inputs_schema(context):
"""Create an action model with a non-null inputs_schema_json."""
schema = {"type": "object", "properties": {"name": {"type": "string"}}}
model = LifecycleActionModel(
namespaced_name="local/schema-action",
namespace="local",
name="schema-action",
description="Action with inputs schema",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
execution_actor="local/executor",
state="available",
reusable=True,
read_only=False,
inputs_schema_json=json.dumps(schema),
created_at=NOW_ISO,
updated_at=LATER_ISO,
tags_json="[]",
)
context.cb_session.add(model)
context.cb_session.commit()
context.cb_action_model = model
@then("the domain action should have the parsed inputs_schema dict")
def step_verify_inputs_schema_parsed(context):
"""Verify inputs_schema is a dict parsed from JSON."""
schema = context.cb_action_domain.inputs_schema
assert isinstance(schema, dict)
assert schema["type"] == "object"
assert "properties" in schema
# ===================================================================
# LifecycleActionModel.from_domain: inputs_schema not None
# ===================================================================
@given("an action domain object with inputs_schema set")
def step_action_domain_with_inputs_schema(context):
"""Prepare an Action domain object that has inputs_schema."""
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import NamespacedName
context.cb_action_domain_input = Action(
namespaced_name=NamespacedName(namespace="local", name="schema-from"),
description="Action with schema",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
execution_actor="local/executor",
inputs_schema={"type": "object", "required": ["name"]},
state=ActionState.AVAILABLE,
created_at=datetime(2025, 6, 1, 12, 0, 0),
updated_at=datetime(2025, 6, 1, 13, 0, 0),
)
@when("the action domain object is converted to a database model")
def step_action_domain_to_model(context):
"""Call from_domain on the action domain object."""
context.cb_action_model_result = LifecycleActionModel.from_domain(
context.cb_action_domain_input
)
@then("the database model should have inputs_schema_json as a JSON string")
def step_verify_model_inputs_schema_json(context):
"""Verify inputs_schema_json is a non-null JSON string."""
raw = context.cb_action_model_result.inputs_schema_json
assert raw is not None
parsed = json.loads(raw)
assert parsed["type"] == "object"
assert "required" in parsed
# ===================================================================
# LifecycleActionModel.from_domain: default_value not None
# ===================================================================
@given("an action domain object with arguments that have default values")
def step_action_domain_with_arg_defaults(context):
"""Prepare an Action with arguments, some having default_value."""
from cleveragents.domain.models.core.action import (
Action,
ActionArgument,
ActionState,
ArgumentRequirement,
ArgumentType,
)
from cleveragents.domain.models.core.plan import NamespacedName
context.cb_action_domain_input = Action(
namespaced_name=NamespacedName(namespace="local", name="defaults-from"),
description="Action with arg defaults",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
execution_actor="local/executor",
arguments=[
ActionArgument(
name="threshold",
arg_type=ArgumentType.INTEGER,
requirement=ArgumentRequirement.OPTIONAL,
description="Coverage threshold",
default_value=80,
),
ActionArgument(
name="target",
arg_type=ArgumentType.STRING,
requirement=ArgumentRequirement.REQUIRED,
description="Target module",
default_value=None,
),
],
state=ActionState.AVAILABLE,
created_at=datetime(2025, 6, 1, 12, 0, 0),
updated_at=datetime(2025, 6, 1, 13, 0, 0),
)
@then("the database model arguments should have default_value_json set")
def step_verify_model_arg_default_json(context):
"""Verify argument with default_value has default_value_json serialized."""
args = context.cb_action_model_result.arguments_rel
threshold_arg = next(a for a in args if a.name == "threshold")
assert threshold_arg.default_value_json is not None
assert json.loads(threshold_arg.default_value_json) == 80
@then("the argument without a default should have null default_value_json")
def step_verify_model_arg_no_default_json(context):
"""Verify argument without default_value has null default_value_json."""
args = context.cb_action_model_result.arguments_rel
target_arg = next(a for a in args if a.name == "target")
assert target_arg.default_value_json is None
# ===================================================================
# LifecycleActionModel.from_domain: invariants loop body
# ===================================================================
@given("an action domain object with invariants defined")
def step_action_domain_with_invariants(context):
"""Prepare an Action with non-empty invariants list."""
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import NamespacedName
context.cb_action_domain_input = Action(
namespaced_name=NamespacedName(namespace="local", name="inv-action"),
description="Action with invariants",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
execution_actor="local/executor",
invariants=["No breaking changes", "Maintain backward compatibility"],
state=ActionState.AVAILABLE,
created_at=datetime(2025, 6, 1, 12, 0, 0),
updated_at=datetime(2025, 6, 1, 13, 0, 0),
)
@then("the database model should have invariant child records")
def step_verify_model_has_invariants(context):
"""Verify the model has invariant child records."""
invs = context.cb_action_model_result.invariants_rel
assert len(invs) == 2
@then("each invariant child record should have correct text and position")
def step_verify_invariant_child_records(context):
"""Verify invariant child records have correct text and position."""
invs = context.cb_action_model_result.invariants_rel
assert invs[0].invariant_text == "No breaking changes"
assert invs[0].position == 0
assert invs[1].invariant_text == "Maintain backward compatibility"
assert invs[1].position == 1
# ===================================================================
# LifecyclePlanModel.to_domain: automation_profile not None
# ===================================================================
@given("a plan model exists with automation_profile JSON set")
def step_plan_model_with_automation_profile(context):
"""Create a plan model with automation_profile as JSON string."""
_ensure_action_exists(context.cb_session)
profile_json = json.dumps(
{
"profile_name": "trusted",
"provenance": "action",
}
)
model = LifecyclePlanModel(
plan_id=ULID_PLAN_1,
root_plan_id=ULID_PLAN_1,
action_name="local/cov-action",
namespaced_name="local/profile-plan",
namespace="local",
phase="strategize",
processing_state="queued",
attempt=1,
description="Plan with automation profile",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
execution_actor="local/executor",
automation_profile=profile_json,
effective_profile_snapshot="{}",
reusable=True,
read_only=False,
created_at=NOW_ISO,
updated_at=LATER_ISO,
tags_json="[]",
)
context.cb_session.add(model)
context.cb_session.commit()
context.cb_plan_model = model
@when("the plan model is converted to a domain object")
def step_plan_model_to_domain(context):
"""Call to_domain on the plan model."""
context.cb_plan_domain = context.cb_plan_model.to_domain()
@then("the domain plan should have an automation profile with the correct name")
def step_verify_plan_automation_profile_name(context):
"""Verify automation_profile is parsed with correct profile_name."""
ap = context.cb_plan_domain.automation_profile
assert ap is not None
assert ap.profile_name == "trusted"
@then("the domain plan automation profile should have the correct provenance")
def step_verify_plan_automation_profile_provenance(context):
"""Verify automation_profile has correct provenance."""
from cleveragents.domain.models.core.plan import AutomationProfileProvenance
ap = context.cb_plan_domain.automation_profile
assert ap.provenance == AutomationProfileProvenance.ACTION
# ===================================================================
# LifecyclePlanModel.to_domain: sandbox_refs_json
# ===================================================================
@given("a plan model exists with sandbox_refs_json populated")
def step_plan_model_with_sandbox_refs(context):
"""Create a plan model with sandbox_refs_json as a JSON list."""
_ensure_action_exists(context.cb_session)
model = LifecyclePlanModel(
plan_id=ULID_PLAN_2,
root_plan_id=ULID_PLAN_2,
action_name="local/cov-action",
namespaced_name="local/sandbox-plan",
namespace="local",
phase="execute",
processing_state="processing",
attempt=1,
description="Plan with sandbox refs",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
execution_actor="local/executor",
sandbox_refs_json=json.dumps(["sandbox-ref-1", "sandbox-ref-2"]),
effective_profile_snapshot="{}",
reusable=True,
read_only=False,
created_at=NOW_ISO,
updated_at=LATER_ISO,
tags_json="[]",
)
context.cb_session.add(model)
context.cb_session.commit()
context.cb_plan_model = model
@then("the domain plan should have the parsed sandbox refs list")
def step_verify_plan_sandbox_refs(context):
"""Verify sandbox_refs is a list parsed from JSON."""
refs = context.cb_plan_domain.sandbox_refs
assert refs == ["sandbox-ref-1", "sandbox-ref-2"]
# ===================================================================
# LifecyclePlanModel.to_domain: validation_summary_json
# ===================================================================
@given("a plan model exists with validation_summary_json populated")
def step_plan_model_with_validation_summary(context):
"""Create a plan model with validation_summary_json set."""
_ensure_action_exists(context.cb_session)
summary = {"tests_passed": 42, "tests_failed": 0, "coverage": 95.5}
# Use a fresh ULID to avoid PK conflict
plan_id = str(ULID())
model = LifecyclePlanModel(
plan_id=plan_id,
root_plan_id=plan_id,
action_name="local/cov-action",
namespaced_name="local/validation-plan",
namespace="local",
phase="apply",
processing_state="queued",
attempt=1,
description="Plan with validation summary",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
execution_actor="local/executor",
validation_summary_json=json.dumps(summary),
effective_profile_snapshot="{}",
reusable=True,
read_only=False,
created_at=NOW_ISO,
updated_at=LATER_ISO,
tags_json="[]",
)
context.cb_session.add(model)
context.cb_session.commit()
context.cb_plan_model = model
@then("the domain plan should have the parsed validation summary dict")
def step_verify_plan_validation_summary(context):
"""Verify validation_summary is a dict parsed from JSON."""
vs = context.cb_plan_domain.validation_summary
assert isinstance(vs, dict)
assert vs["tests_passed"] == 42
assert vs["coverage"] == 95.5
# ===================================================================
# LifecyclePlanModel.to_domain: error_details_json
# ===================================================================
@given("a plan model exists with error_details_json populated")
def step_plan_model_with_error_details(context):
"""Create a plan model with error_details_json set."""
_ensure_action_exists(context.cb_session)
error_details = {"error_type": "ValidationError", "trace": "line 42"}
plan_id = str(ULID())
model = LifecyclePlanModel(
plan_id=plan_id,
root_plan_id=plan_id,
action_name="local/cov-action",
namespaced_name="local/error-plan",
namespace="local",
phase="apply",
processing_state="errored",
attempt=1,
description="Plan with error details",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
execution_actor="local/executor",
error_message="Something went wrong",
error_details_json=json.dumps(error_details),
effective_profile_snapshot="{}",
reusable=True,
read_only=False,
created_at=NOW_ISO,
updated_at=LATER_ISO,
tags_json="[]",
)
context.cb_session.add(model)
context.cb_session.commit()
context.cb_plan_model = model
@then("the domain plan should have the parsed error details dict")
def step_verify_plan_error_details(context):
"""Verify error_details is a dict parsed from JSON."""
ed = context.cb_plan_domain.error_details
assert isinstance(ed, dict)
assert ed["error_type"] == "ValidationError"
assert ed["trace"] == "line 42"
# ===================================================================
# LifecyclePlanModel.to_domain: arguments from child table
# ===================================================================
@given("a plan model exists with argument child records")
def step_plan_model_with_arguments(context):
"""Create a plan model with PlanArgumentModel child records."""
_ensure_action_exists(context.cb_session)
plan_id = str(ULID())
model = LifecyclePlanModel(
plan_id=plan_id,
root_plan_id=plan_id,
action_name="local/cov-action",
namespaced_name="local/args-plan",
namespace="local",
phase="strategize",
processing_state="queued",
attempt=1,
description="Plan with arguments",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
execution_actor="local/executor",
effective_profile_snapshot="{}",
reusable=True,
read_only=False,
created_at=NOW_ISO,
updated_at=LATER_ISO,
tags_json="[]",
)
model.arguments_rel.append(
PlanArgumentModel(
name="coverage",
value_json=json.dumps(80),
value_type="integer",
position=0,
)
)
model.arguments_rel.append(
PlanArgumentModel(
name="framework",
value_json=json.dumps("pytest"),
value_type="string",
position=1,
)
)
model.arguments_rel.append(
PlanArgumentModel(
name="verbose",
value_json=None,
value_type="boolean",
position=2,
)
)
context.cb_session.add(model)
context.cb_session.commit()
context.cb_plan_model = model
@then("the domain plan should have the arguments dict populated")
def step_verify_plan_arguments_dict(context):
"""Verify the arguments dict has entries from child records."""
args = context.cb_plan_domain.arguments
assert args["coverage"] == 80
assert args["framework"] == "pytest"
assert args["verbose"] is None
@then("the domain plan should have arguments_order populated")
def step_verify_plan_arguments_order(context):
"""Verify arguments_order contains the argument names in order."""
order = context.cb_plan_domain.arguments_order
assert order == ["coverage", "framework", "verbose"]
# ===================================================================
# LifecyclePlanModel.from_domain: project_links loop
# ===================================================================
@given("a plan domain object with project links defined")
def step_plan_domain_with_project_links(context):
"""Prepare a Plan domain object with project_links populated."""
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
context.cb_plan_domain_input = Plan(
identity=PlanIdentity(plan_id=str(ULID())),
namespaced_name=NamespacedName(namespace="local", name="pl-plan"),
action_name="local/cov-action",
description="Plan with project links",
definition_of_done="Tests pass",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
strategy_actor="local/strategy",
execution_actor="local/executor",
project_links=[
ProjectLink(project_name="local/api-service", alias="api", read_only=False),
ProjectLink(project_name="local/web-app", alias="web", read_only=True),
],
timestamps=PlanTimestamps(
created_at=datetime(2025, 6, 1, 12, 0, 0),
updated_at=datetime(2025, 6, 1, 13, 0, 0),
),
tags=["test"],
)
@when("the plan domain object is converted to a database model via from_domain")
def step_plan_domain_to_model(context):
"""Call from_domain on the plan domain object."""
context.cb_plan_model_result = LifecyclePlanModel.from_domain(
context.cb_plan_domain_input
)
@then("the database plan model should have project link child records")
def step_verify_plan_model_project_links(context):
"""Verify the model has project link child records."""
links = context.cb_plan_model_result.project_links_rel
assert len(links) == 2
@then("each project link child record should have the correct project name")
def step_verify_project_link_names(context):
"""Verify each project link child record has the correct project name."""
links = context.cb_plan_model_result.project_links_rel
names = [pl.project_name for pl in links]
assert "local/api-service" in names
assert "local/web-app" in names
# ===================================================================
# LifecyclePlanModel.from_domain: invariants loop
# ===================================================================
@given("a plan domain object with plan invariants defined")
def step_plan_domain_with_invariants(context):
"""Prepare a Plan domain object with invariants list."""
from cleveragents.domain.models.core.plan import (
InvariantSource,
NamespacedName,
Plan,
PlanIdentity,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
)
context.cb_plan_domain_input = Plan(
identity=PlanIdentity(plan_id=str(ULID())),
namespaced_name=NamespacedName(namespace="local", name="inv-plan"),
action_name="local/cov-action",
description="Plan with invariants",
definition_of_done="Tests pass",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
strategy_actor="local/strategy",
execution_actor="local/executor",
invariants=[
PlanInvariant(text="No breaking changes", source=InvariantSource.ACTION),
PlanInvariant(text="Keep API stable", source=InvariantSource.PROJECT),
],
timestamps=PlanTimestamps(
created_at=datetime(2025, 6, 1, 12, 0, 0),
updated_at=datetime(2025, 6, 1, 13, 0, 0),
),
tags=["test"],
)
@then("the database plan model should have invariant child records with source")
def step_verify_plan_model_invariants(context):
"""Verify the model has invariant child records."""
invs = context.cb_plan_model_result.invariants_rel
assert len(invs) == 2
@then("each plan invariant child record should have correct text and source")
def step_verify_plan_invariant_details(context):
"""Verify invariant child records have correct text and source_scope."""
invs = context.cb_plan_model_result.invariants_rel
assert invs[0].invariant_text == "No breaking changes"
assert invs[0].source_scope == "action"
assert invs[0].position == 0
assert invs[1].invariant_text == "Keep API stable"
assert invs[1].source_scope == "project"
assert invs[1].position == 1
# ===================================================================
# LifecyclePlanModel.from_domain: arguments loop
# ===================================================================
@given("a plan domain object with arguments and arguments_order defined")
def step_plan_domain_with_arguments(context):
"""Prepare a Plan domain object with arguments dict and arguments_order."""
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
)
context.cb_plan_domain_input = Plan(
identity=PlanIdentity(plan_id=str(ULID())),
namespaced_name=NamespacedName(namespace="local", name="args-plan"),
action_name="local/cov-action",
description="Plan with arguments",
definition_of_done="Tests pass",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
strategy_actor="local/strategy",
execution_actor="local/executor",
arguments={"coverage": 80, "framework": "pytest"},
arguments_order=["coverage", "framework"],
timestamps=PlanTimestamps(
created_at=datetime(2025, 6, 1, 12, 0, 0),
updated_at=datetime(2025, 6, 1, 13, 0, 0),
),
tags=["test"],
)
@then("the database plan model should have argument child records")
def step_verify_plan_model_arguments(context):
"""Verify the model has argument child records."""
args = context.cb_plan_model_result.arguments_rel
assert len(args) == 2
@then("each plan argument child record should have correct name and value")
def step_verify_plan_argument_details(context):
"""Verify argument child records have correct name and value_json."""
args = context.cb_plan_model_result.arguments_rel
coverage_arg = next(a for a in args if a.name == "coverage")
framework_arg = next(a for a in args if a.name == "framework")
assert json.loads(coverage_arg.value_json) == 80
assert json.loads(framework_arg.value_json) == "pytest"
assert coverage_arg.position == 0
assert framework_arg.position == 1
# ===================================================================
# NamespacedProjectModel.to_domain: resource_links loop
# ===================================================================
@given("a project model exists with resource link child records")
def step_project_model_with_resource_links(context):
"""Create a project model with ProjectResourceLinkModel children."""
_ensure_resource_exists(context.cb_session, ULID_RESOURCE_1)
_ensure_resource_exists(context.cb_session, ULID_RESOURCE_2)
model = NamespacedProjectModel(
namespaced_name="local/linked-project",
namespace="local",
description="Project with resource links",
created_at=NOW_ISO,
updated_at=LATER_ISO,
tags_json="[]",
)
context.cb_session.add(model)
context.cb_session.flush()
link1 = ProjectResourceLinkModel(
link_id=ULID_LINK_1,
project_name="local/linked-project",
resource_id=ULID_RESOURCE_1,
alias="main-repo",
read_only=False,
created_at=NOW_ISO,
)
link2 = ProjectResourceLinkModel(
link_id=ULID_LINK_2,
project_name="local/linked-project",
resource_id=ULID_RESOURCE_2,
alias="docs-repo",
read_only=True,
created_at=NOW_ISO,
)
context.cb_session.add_all([link1, link2])
context.cb_session.commit()
# Re-fetch to ensure relationships are loaded
context.cb_project_model = (
context.cb_session.query(NamespacedProjectModel)
.filter_by(namespaced_name="local/linked-project")
.first()
)
@when("the project model is converted to a domain object")
def step_project_model_to_domain(context):
"""Call to_domain on the project model."""
context.cb_project_domain = context.cb_project_model.to_domain()
@then("the domain project should have linked resources populated")
def step_verify_project_linked_resources(context):
"""Verify the project has linked_resources populated."""
lr = context.cb_project_domain.linked_resources
assert len(lr) == 2
@then("each linked resource should have the correct resource id and alias")
def step_verify_linked_resource_details(context):
"""Verify each linked resource has correct resource_id and alias."""
lr = context.cb_project_domain.linked_resources
ids = {r.resource_id for r in lr}
assert ULID_RESOURCE_1 in ids
assert ULID_RESOURCE_2 in ids
aliases = {r.alias for r in lr}
assert "main-repo" in aliases
assert "docs-repo" in aliases
# ===================================================================
# NamespacedProjectModel.to_domain: context_policy_json parsing
# ===================================================================
@given("a project model exists with context_policy_json populated")
def step_project_model_with_context_policy(context):
"""Create a project model with non-null context_policy_json."""
policy = {
"max_file_size": 500000,
"indexing_strategy": "semantic",
"summarize": False,
}
model = NamespacedProjectModel(
namespaced_name="local/ctx-project",
namespace="local",
description="Project with context policy",
context_policy_json=json.dumps(policy),
created_at=NOW_ISO,
updated_at=LATER_ISO,
tags_json="[]",
)
context.cb_session.add(model)
context.cb_session.commit()
context.cb_project_model = model
@then("the domain project should have a context config with custom values")
def step_verify_project_context_config(context):
"""Verify context_config is parsed from JSON with custom values."""
cc = context.cb_project_domain.context_config
assert cc.max_file_size == 500000
assert cc.indexing_strategy == "semantic"
assert cc.summarize is False