forked from HAL9000/cleveragents-core
06130212ed
## Summary E2E test for Workflow Example 5 — database schema migration with safety nets using the **review** automation profile. Exercises the full spec-aligned workflow: - **Custom resource type registration** via `resource type add --config` (postgres-db type with `transaction_rollback` sandbox strategy, `--host`, `--port`, `--database`, `--schema` CLI args with flat `type`/`default` fields per `ResourceTypeArgument` schema) - **Custom resource instantiation** — attempts `resource add` with the custom type to exercise mixed resource types, followed by `project link-resource` to link DB resource to the project - **Custom skill creation** with spec-aligned database tools: `local/query_db` (read-only), `local/execute_migration` (writes, checkpointable), `local/backfill_column` (writes, checkpointable) — registered via `skill add --config`, with namespaced tool reference names per `SkillToolRefSchema` validation - **Action creation** with `automation_profile: review`, `reusable: true`, `state: available`, spec invariants, and typed `arguments` section (`table_name`, `column_name`, `column_type`, `backfill_source` — all required per spec, using `arguments` field per `ActionConfigSchema`) - **Plan use** with `--arg` flags exercising parameterized action invocation including `backfill_source=audit_log`, plus **explicit `--automation-profile review`** flag (action-to-plan profile propagation is not yet wired in `PlanLifecycleService.use_action`) - **Phased child plan verification** via `plan tree --format json` with `decision_count >= 2` hard assertion on framework decisions plus WARN tiers for LLM decomposition quality (`< 3`, `< 5`) - **Plan phase assertion** — hard assertion that phase is populated after execute - **Checkpoint-based rollback** with hard assertions: `rc=0` on rollback success, `rc!=0` on fake checkpoint, None guard for JSON null checkpoint IDs, re-execute with Traceback/INTERNAL checks on success and explanatory comment on failure path - **Plan diff** with hard `rc=0` assertion and content-signal verification - **Migration content verification** — baseline SHA saved before apply, diff against baseline (not `HEAD~1`), WARN-level check on migration keywords (`last_login`, `schema`, `migration`, `column`, `alter`) — flexible per LLM non-determinism - **Commit count** assertion `>= 2` (fixture baseline: Create Temp Git Repo + DB fixture commit), WARN if no additional commits from lifecycle-apply - **Backfill evidence** WARN-level check in plan tree/execution output (`backfill`, `batch`, `populate`, `last_login`) with explanatory comment noting tree covers decomposition plan - **Combined AC #6 gate** — if *both* migration content *and* backfill evidence are absent, explicit WARN visibility for CI debugging - **Terminal state assertion** after `lifecycle-apply` — `plan status` call verifies phase/processing_state reflects terminal or apply-progress outcome - **Automation profile fallback verification** — if `plan use` output omits `automation_profile`, falls back to `plan status` for secondary verification (hard assertion always runs) - **Traceback and INTERNAL checks** on all CLI commands (resource add, project create, resource type add, skill add, action create, plan use, strategize, execute, plan tree, plan status, plan diff, plan rollback, re-execute after rollback, lifecycle-apply) including custom resource error paths - **Dynamic actor selection** — detects available API keys (Anthropic/OpenAI) at suite setup - **Skip If No LLM Keys** guard for graceful CI degradation - **Test-level teardown** with diagnostic logging for both plan status and plan tree on failure - **30-minute timeout** covering worst-case rollback+re-execute path - **Force Tags** for consistency with `m6_acceptance.robot` - **Timeout parameters** (`timeout=60s on_timeout=kill`) on all local `Run Process` git commands - **Sequential section numbering** (1 through 15) for readability Closes #751 ISSUES CLOSED: #751 ## Approach Follows the patterns established by `m6_acceptance.robot` and `m2_acceptance.robot`: - `WF05 Suite Setup` initialises the workspace, generates a unique run suffix, and detects available LLM API keys - `Safe Parse Json Field` from `common_e2e.resource` for JSON field extraction with None guards for JSON null values - All CLI commands use `--format json` for predictable, parseable output - `expected_rc=None` with explicit `Should Be Equal As Integers` for detailed failure messages - Hard assertions on infrastructure/framework behavior (CLI commands, phase transitions, tool registration) - WARN-level assertions on LLM-dependent output (decision decomposition, migration content, backfill evidence, commit count) — per ticket requirement "output validation is flexible" - Traceback and INTERNAL checks on all CLI commands following `m2_acceptance.robot` pattern - Baseline SHA approach for post-apply diff verification eliminates false positives from fixture commits ## Bug Fix: LifecyclePlanRepository.update() UNIQUE Constraint Violation **Root cause**: `LifecyclePlanRepository.update()` called `clear()` on child relationship collections (project_links, arguments, invariants) followed by `append()` with new items, but only flushed at the end. SQLAlchemy's default operation ordering can emit INSERTs before DELETEs within the same flush, causing `UNIQUE constraint failed: plan_arguments.plan_id, plan_arguments.name` when plans have arguments. **Fix**: Group all three `clear()` calls together and flush them before appending new rows. This ensures the DELETEs are committed before any INSERTs, preventing the UNIQUE constraint violation. **Impact**: This was a latent bug affecting ALL plans with arguments when `update()` is called. Previously undetected because existing E2E tests (M1, M2, M5, M6) create plans without `--arg` flags. ## Review Fixes (addressing medium findings from @CoreRasurae review) | # | Finding | Fix | |---|---------|-----| | **BUG-1** | No regression test for UNIQUE constraint fix | Added targeted BDD scenario in `repositories_coverage_boost.feature` — creates plan with argument `x=v1`, updates to `x=v2`, asserts no `IntegrityError` | | **TEST-1** | AC #4 weakened — fragile string counting | Replaced raw `count('"decision_id"')` with proper JSON parsing via `json.loads()`, recursive tree walking for decision counting, structural `children_key_count` and `child_link_count` verification | | **TEST-2** | AC #5 conditionally tested | Added explicit WARN log when no checkpoint_id is present ("AC #5 visibility"); fake checkpoint test now runs unconditionally (moved outside IF/ELSE) with Traceback/INTERNAL checks | | **TEST-3** | No terminal state assertion after lifecycle-apply | Added `plan status` call after apply with phase/processing_state extraction; hard assertion on terminal state or apply-phase progress | | **TEST-4** | AC #6 migration/backfill WARN-only | Added combined gate (`has_ac6_evidence`): if *both* migration and backfill evidence are absent, explicit WARN for CI visibility. WARN-only is intentional per ticket AC "output validation is flexible" | | **TEST-5** | Automation profile silently skipped | Added fallback to `plan status --format json` when `plan use` output omits `automation_profile`; hard assertion (`Should Be Equal As Strings review`) now always executes | | **TEST-8** | Missing Traceback/INTERNAL on custom resource error paths | Added Traceback/INTERNAL checks inside both `resource add` and `project link-resource` ELSE branches with `NoSuchOption` guard | ## Quality Gates - `nox -e lint` ✅ - `nox -e typecheck` ✅ (0 errors) - `nox -e unit_tests` ✅ (471 features, 12,422 scenarios, 0 failures) - `nox -e integration_tests` ✅ (1,727 tests, 0 failures) - `nox -e e2e_tests` ✅ (42 tests, 42 passed, 0 failed) - `nox -e coverage_report` ✅ (98%, meets threshold) ## Manual Verification ### Prerequisites - `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` environment variable set ### Commands ```bash nox -e e2e_tests # Or run just this suite: python -m robot --outputdir build/reports/robot --include E2E robot/e2e/wf05_db_migration.robot ``` Reviewed-on: cleveragents/cleveragents-core#816 Reviewed-by: Luis Mendes <luis.mendes@cleverthis.com> Co-authored-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me> Co-committed-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
771 lines
26 KiB
Python
771 lines
26 KiB
Python
"""Step definitions for repository coverage boost.
|
|
|
|
Targets uncovered lines in ``repositories.py``:
|
|
- ActionRepository.update: row-is-None branch, arguments loop with default_value,
|
|
invariants loop
|
|
- LifecyclePlanRepository: get_by_name, update PlanNotFoundError,
|
|
update with project_links/arguments/invariants, list_plans by phase
|
|
- ResourceTypeRepository: create, get, duplicate handling
|
|
- ResourceRepository: create, get, get_by_name, list_resources by type
|
|
"""
|
|
|
|
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 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
|
|
from cleveragents.infrastructure.database.repositories import (
|
|
ActionRepository,
|
|
LifecyclePlanRepository,
|
|
PlanNotFoundError,
|
|
)
|
|
|
|
# Crockford base32 alphabet for ULID generation
|
|
_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
_ULID_COUNTER = 100 # start high to avoid collisions with other step files
|
|
|
|
|
|
def _next_ulid() -> str:
|
|
"""Return a unique, valid ULID string for each call."""
|
|
from ulid import ULID
|
|
|
|
return str(ULID())
|
|
|
|
|
|
def _make_action(
|
|
name: str = "local/test-action",
|
|
state: str = "available",
|
|
arguments: list[ActionArgument] | None = None,
|
|
invariants: list[str] | None = None,
|
|
) -> 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=arguments or [],
|
|
invariants=invariants or [],
|
|
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_id: str | None = None,
|
|
phase: PlanPhase = PlanPhase.STRATEGIZE,
|
|
ns_name: str = "local/test-plan",
|
|
) -> Plan:
|
|
"""Create a minimal valid Plan domain object."""
|
|
pid = plan_id or _next_ulid()
|
|
parts = ns_name.split("/", 1)
|
|
namespace = parts[0] if len(parts) == 2 else "local"
|
|
short_name = parts[1] if len(parts) == 2 else parts[0]
|
|
|
|
return Plan(
|
|
identity=PlanIdentity(
|
|
plan_id=pid,
|
|
parent_plan_id=None,
|
|
root_plan_id=None,
|
|
attempt=1,
|
|
),
|
|
namespaced_name=NamespacedName(
|
|
namespace=namespace,
|
|
name=short_name,
|
|
),
|
|
action_name=action_name,
|
|
description=f"Test plan for {action_name}",
|
|
definition_of_done="Tests pass",
|
|
phase=phase,
|
|
processing_state=ProcessingState.QUEUED,
|
|
strategy_actor="local/strategist",
|
|
execution_actor="local/executor",
|
|
timestamps=PlanTimestamps(
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
),
|
|
created_by=None,
|
|
tags=[],
|
|
reusable=True,
|
|
read_only=False,
|
|
)
|
|
|
|
|
|
# ========================================================================
|
|
# Background
|
|
# ========================================================================
|
|
|
|
|
|
@given("a fresh in-memory database with full lifecycle schema")
|
|
def step_fresh_db(context: Context) -> None:
|
|
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
|
|
context.error = None
|
|
|
|
|
|
@given("an action repository using the session factory")
|
|
def step_action_repo(context: Context) -> None:
|
|
context.action_repo = ActionRepository(
|
|
session_factory=context.db_session_factory,
|
|
)
|
|
context.error = None
|
|
|
|
|
|
@given("a lifecycle plan repository using the session factory")
|
|
def step_plan_repo(context: Context) -> None:
|
|
context.plan_repo = LifecyclePlanRepository(
|
|
session_factory=context.db_session_factory,
|
|
)
|
|
context.error = None
|
|
|
|
|
|
# ========================================================================
|
|
# ActionRepository.update - non-existent action (line 921)
|
|
# ========================================================================
|
|
|
|
|
|
@given('a valid action object named "{name}"')
|
|
def step_make_action_obj(context: Context, name: str) -> None:
|
|
context.action = _make_action(name=name)
|
|
|
|
|
|
@when("the action is updated without being persisted first")
|
|
def step_update_non_existent_action(context: Context) -> None:
|
|
try:
|
|
context.action_repo.update(context.action)
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then('a DatabaseError mentioning "{text}" should be raised')
|
|
def step_verify_database_error_text(context: Context, text: str) -> None:
|
|
assert context.error is not None, "Expected DatabaseError, got no error"
|
|
assert isinstance(context.error, DatabaseError), (
|
|
f"Expected DatabaseError, got {type(context.error).__name__}: {context.error}"
|
|
)
|
|
assert text in str(context.error), (
|
|
f"Expected '{text}' in error message: {context.error}"
|
|
)
|
|
|
|
|
|
# ========================================================================
|
|
# ActionRepository.update - with arguments and invariants (lines 940-965)
|
|
# ========================================================================
|
|
|
|
|
|
@given("the action has been persisted in the database")
|
|
def step_persist_action(context: Context) -> None:
|
|
context.action_repo.create(context.action)
|
|
context.db_session.commit()
|
|
|
|
|
|
@when("the action arguments are replaced with new arguments including defaults")
|
|
def step_replace_arguments(context: Context) -> None:
|
|
new_args = [
|
|
ActionArgument(
|
|
name="target_coverage",
|
|
arg_type=ArgumentType.INTEGER,
|
|
requirement=ArgumentRequirement.REQUIRED,
|
|
description="Target coverage percentage",
|
|
default_value=80,
|
|
min_value=0.0,
|
|
max_value=100.0,
|
|
),
|
|
ActionArgument(
|
|
name="framework",
|
|
arg_type=ArgumentType.STRING,
|
|
requirement=ArgumentRequirement.OPTIONAL,
|
|
description="Test framework to use",
|
|
default_value="pytest",
|
|
),
|
|
ActionArgument(
|
|
name="verbose",
|
|
arg_type=ArgumentType.BOOLEAN,
|
|
requirement=ArgumentRequirement.OPTIONAL,
|
|
description="Enable verbose output",
|
|
default_value=None,
|
|
),
|
|
]
|
|
context.action = context.action.model_copy(update={"arguments": new_args})
|
|
|
|
|
|
@when("the action invariants are set to new invariant texts")
|
|
def step_set_invariants(context: Context) -> None:
|
|
context.action = context.action.model_copy(
|
|
update={
|
|
"invariants": [
|
|
"All tests must pass before deployment",
|
|
"Code coverage must not decrease",
|
|
],
|
|
}
|
|
)
|
|
|
|
|
|
@when("the action is updated via the repository")
|
|
def step_update_action(context: Context) -> None:
|
|
try:
|
|
context.action = context.action.model_copy(
|
|
update={"updated_at": datetime.now()}
|
|
)
|
|
context.result_action = context.action_repo.update(context.action)
|
|
context.db_session.commit()
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then("the update should succeed without error")
|
|
def step_verify_no_update_error(context: Context) -> None:
|
|
assert context.error is None, f"Unexpected error: {context.error}"
|
|
|
|
|
|
@then("retrieving the action should show the new arguments")
|
|
def step_verify_action_arguments(context: Context) -> None:
|
|
fetched = context.action_repo.get_by_name(str(context.action.namespaced_name))
|
|
assert fetched is not None, "Action not found after update"
|
|
assert len(fetched.arguments) == 3, (
|
|
f"Expected 3 arguments, got {len(fetched.arguments)}"
|
|
)
|
|
arg_names = [a.name for a in fetched.arguments]
|
|
assert "target_coverage" in arg_names
|
|
assert "framework" in arg_names
|
|
assert "verbose" in arg_names
|
|
|
|
# Verify default_value round-trip for the argument that has one
|
|
framework_arg = next(a for a in fetched.arguments if a.name == "framework")
|
|
assert framework_arg.default_value == "pytest", (
|
|
f"Expected default_value 'pytest', got {framework_arg.default_value!r}"
|
|
)
|
|
|
|
coverage_arg = next(a for a in fetched.arguments if a.name == "target_coverage")
|
|
assert coverage_arg.default_value == 80, (
|
|
f"Expected default_value 80, got {coverage_arg.default_value!r}"
|
|
)
|
|
|
|
|
|
@then("retrieving the action should show the new invariants")
|
|
def step_verify_action_invariants(context: Context) -> None:
|
|
fetched = context.action_repo.get_by_name(str(context.action.namespaced_name))
|
|
assert fetched is not None, "Action not found after update"
|
|
assert len(fetched.invariants) == 2, (
|
|
f"Expected 2 invariants, got {len(fetched.invariants)}"
|
|
)
|
|
assert "All tests must pass before deployment" in fetched.invariants
|
|
assert "Code coverage must not decrease" in fetched.invariants
|
|
|
|
|
|
# ========================================================================
|
|
# LifecyclePlanRepository.get_by_name (lines 1162-1163)
|
|
# ========================================================================
|
|
|
|
|
|
@given('a lifecycle plan domain object linked to "{action_name}"')
|
|
def step_make_plan(context: Context, action_name: str) -> None:
|
|
context.plan = _make_plan(
|
|
action_name=action_name,
|
|
ns_name=f"local/plan-{action_name.split('/')[-1]}",
|
|
)
|
|
|
|
|
|
@given("the lifecycle plan has been persisted in the database")
|
|
def step_persist_plan(context: Context) -> None:
|
|
context.plan_repo.create(context.plan)
|
|
context.db_session.commit()
|
|
|
|
|
|
@when("the lifecycle plan is looked up by namespaced name")
|
|
def step_get_plan_by_name(context: Context) -> None:
|
|
ns_name = str(context.plan.namespaced_name)
|
|
context.result_plan = context.plan_repo.get_by_name(ns_name)
|
|
|
|
|
|
@then("the returned plan should match the original plan identity")
|
|
def step_verify_plan_identity(context: Context) -> None:
|
|
assert context.result_plan is not None, "Expected a plan, got None"
|
|
assert context.result_plan.identity.plan_id == context.plan.identity.plan_id, (
|
|
f"Expected plan_id {context.plan.identity.plan_id}, "
|
|
f"got {context.result_plan.identity.plan_id}"
|
|
)
|
|
|
|
|
|
@when('a lifecycle plan is looked up by name "{name}"')
|
|
def step_get_plan_by_name_direct(context: Context, name: str) -> None:
|
|
context.result_plan = context.plan_repo.get_by_name(name)
|
|
|
|
|
|
@then("no lifecycle plan should be returned")
|
|
def step_verify_no_plan(context: Context) -> None:
|
|
assert context.result_plan is None, f"Expected None, got {context.result_plan}"
|
|
|
|
|
|
# ========================================================================
|
|
# LifecyclePlanRepository.update - PlanNotFoundError (lines 1190-1191)
|
|
# ========================================================================
|
|
|
|
|
|
@when("the lifecycle plan is updated without being persisted first")
|
|
def step_update_non_existent_plan(context: Context) -> None:
|
|
try:
|
|
context.plan_repo.update(context.plan)
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then("a PlanNotFoundError should be raised for the repository boost")
|
|
def step_verify_plan_not_found(context: Context) -> None:
|
|
assert context.error is not None, "Expected PlanNotFoundError, got no error"
|
|
assert isinstance(context.error, PlanNotFoundError), (
|
|
f"Expected PlanNotFoundError, got {type(context.error).__name__}: {context.error}"
|
|
)
|
|
|
|
|
|
# ========================================================================
|
|
# LifecyclePlanRepository.update - with project_links, arguments, invariants
|
|
# (lines 1225-1314)
|
|
# ========================================================================
|
|
|
|
|
|
@when("the plan is updated with project links, arguments, and invariants")
|
|
def step_update_plan_with_children(context: Context) -> None:
|
|
try:
|
|
updated = context.plan.model_copy(
|
|
update={
|
|
"project_links": [
|
|
ProjectLink(
|
|
project_name="local/api-service",
|
|
alias="api",
|
|
read_only=False,
|
|
),
|
|
ProjectLink(
|
|
project_name="local/web-frontend",
|
|
alias="web",
|
|
read_only=True,
|
|
),
|
|
],
|
|
"arguments": {
|
|
"env": "production",
|
|
"replicas": 3,
|
|
},
|
|
"arguments_order": ["env", "replicas"],
|
|
"invariants": [
|
|
PlanInvariant(
|
|
text="Must not break backward compatibility",
|
|
source=InvariantSource.PLAN,
|
|
),
|
|
PlanInvariant(
|
|
text="All integration tests must pass",
|
|
source=InvariantSource.ACTION,
|
|
),
|
|
],
|
|
"timestamps": PlanTimestamps(
|
|
created_at=context.plan.timestamps.created_at,
|
|
updated_at=datetime.now(),
|
|
),
|
|
}
|
|
)
|
|
context.plan = updated
|
|
context.result_plan = context.plan_repo.update(updated)
|
|
context.db_session.commit()
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then("the plan update should succeed without error")
|
|
def step_verify_plan_update_ok(context: Context) -> None:
|
|
assert context.error is None, f"Unexpected error: {context.error}"
|
|
|
|
|
|
@then("retrieving the plan should show the new project links")
|
|
def step_verify_plan_project_links(context: Context) -> None:
|
|
fetched = context.plan_repo.get(context.plan.identity.plan_id)
|
|
assert fetched is not None, "Plan not found after update"
|
|
assert len(fetched.project_links) == 2, (
|
|
f"Expected 2 project links, got {len(fetched.project_links)}"
|
|
)
|
|
names = [pl.project_name for pl in fetched.project_links]
|
|
assert "local/api-service" in names
|
|
assert "local/web-frontend" in names
|
|
|
|
|
|
@then("retrieving the plan should show the new plan arguments")
|
|
def step_verify_plan_arguments(context: Context) -> None:
|
|
fetched = context.plan_repo.get(context.plan.identity.plan_id)
|
|
assert fetched is not None, "Plan not found after update"
|
|
assert "env" in fetched.arguments, (
|
|
f"Expected 'env' in arguments, got {fetched.arguments}"
|
|
)
|
|
assert fetched.arguments["env"] == "production"
|
|
assert fetched.arguments["replicas"] == 3
|
|
|
|
|
|
@then("retrieving the plan should show the new plan invariants")
|
|
def step_verify_plan_invariants(context: Context) -> None:
|
|
fetched = context.plan_repo.get(context.plan.identity.plan_id)
|
|
assert fetched is not None, "Plan not found after update"
|
|
assert len(fetched.invariants) == 2, (
|
|
f"Expected 2 invariants, got {len(fetched.invariants)}"
|
|
)
|
|
texts = [inv.text for inv in fetched.invariants]
|
|
assert "Must not break backward compatibility" in texts
|
|
assert "All integration tests must pass" in texts
|
|
|
|
|
|
@given('the lifecycle plan has argument "{arg_name}" set to "{arg_value}"')
|
|
def step_set_initial_plan_argument(
|
|
context: Context, arg_name: str, arg_value: str
|
|
) -> None:
|
|
"""Set an initial argument on the in-memory plan before persisting.
|
|
|
|
This enables a regression scenario where update() replaces a child
|
|
argument row with the same composite key (plan_id, name).
|
|
"""
|
|
context.plan = context.plan.model_copy(
|
|
update={
|
|
"arguments": {arg_name: arg_value},
|
|
"arguments_order": [arg_name],
|
|
"timestamps": PlanTimestamps(
|
|
created_at=context.plan.timestamps.created_at,
|
|
updated_at=datetime.now(),
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
@when('the lifecycle plan argument "{arg_name}" is updated to "{new_value}"')
|
|
def step_update_plan_argument_same_name(
|
|
context: Context, arg_name: str, new_value: str
|
|
) -> None:
|
|
"""Update an existing argument name to a new value via repository.update()."""
|
|
updated = context.plan.model_copy(
|
|
update={
|
|
"arguments": {arg_name: new_value},
|
|
"arguments_order": [arg_name],
|
|
"timestamps": PlanTimestamps(
|
|
created_at=context.plan.timestamps.created_at,
|
|
updated_at=datetime.now(),
|
|
),
|
|
}
|
|
)
|
|
context.plan = updated
|
|
try:
|
|
context.result_plan = context.plan_repo.update(updated)
|
|
context.db_session.commit()
|
|
context.error = None
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then('retrieving the plan should show argument "{arg_name}" value "{expected_value}"')
|
|
def step_verify_plan_argument_value(
|
|
context: Context, arg_name: str, expected_value: str
|
|
) -> None:
|
|
"""Verify the replacement argument row persisted with new value."""
|
|
fetched = context.plan_repo.get(context.plan.identity.plan_id)
|
|
assert fetched is not None, "Plan not found after update"
|
|
assert arg_name in fetched.arguments, (
|
|
f"Expected argument '{arg_name}' in plan args, got {fetched.arguments}"
|
|
)
|
|
assert fetched.arguments[arg_name] == expected_value, (
|
|
f"Expected argument '{arg_name}' value '{expected_value}', "
|
|
f"got '{fetched.arguments[arg_name]}'"
|
|
)
|
|
|
|
|
|
# ========================================================================
|
|
# LifecyclePlanRepository.list_plans filtered by phase (lines 1269-1271)
|
|
# ========================================================================
|
|
|
|
|
|
@given('lifecycle plans in different phases linked to "{action_name}"')
|
|
def step_create_plans_in_phases(context: Context, action_name: str) -> None:
|
|
context.strategize_plan_ids = []
|
|
context.execute_plan_ids = []
|
|
|
|
for i in range(2):
|
|
plan = _make_plan(
|
|
action_name=action_name,
|
|
phase=PlanPhase.STRATEGIZE,
|
|
ns_name=f"local/strat-plan-{i}",
|
|
)
|
|
context.plan_repo.create(plan)
|
|
context.strategize_plan_ids.append(plan.identity.plan_id)
|
|
|
|
for i in range(1):
|
|
plan = _make_plan(
|
|
action_name=action_name,
|
|
phase=PlanPhase.EXECUTE,
|
|
ns_name=f"local/exec-plan-{i}",
|
|
)
|
|
context.plan_repo.create(plan)
|
|
context.execute_plan_ids.append(plan.identity.plan_id)
|
|
|
|
context.db_session.commit()
|
|
|
|
|
|
@when('plans are listed filtered by phase "{phase}"')
|
|
def step_list_by_phase(context: Context, phase: str) -> None:
|
|
context.result_plans = context.plan_repo.list_plans(phase=phase)
|
|
|
|
|
|
@then("only the strategize-phase plans should be returned")
|
|
def step_verify_phase_filter(context: Context) -> None:
|
|
assert len(context.result_plans) == 2, (
|
|
f"Expected 2 strategize plans, got {len(context.result_plans)}"
|
|
)
|
|
for plan in context.result_plans:
|
|
assert plan.phase == PlanPhase.STRATEGIZE, (
|
|
f"Expected STRATEGIZE phase, got {plan.phase}"
|
|
)
|
|
|
|
|
|
# ========================================================================
|
|
# ResourceTypeRepository (lines 1126-1135)
|
|
# ========================================================================
|
|
|
|
|
|
@given("a resource type repository using the session factory")
|
|
def step_resource_type_repo(context: Context) -> None:
|
|
from cleveragents.infrastructure.database.repositories import (
|
|
ResourceTypeRepository,
|
|
)
|
|
|
|
context.resource_type_repo = ResourceTypeRepository(
|
|
session_factory=context.db_session_factory,
|
|
)
|
|
context.error = None
|
|
|
|
|
|
@given('a valid resource type domain object named "{name}"')
|
|
def step_make_resource_type(context: Context, name: str) -> None:
|
|
from cleveragents.domain.models.core.resource_type import (
|
|
ResourceKind,
|
|
ResourceTypeSpec,
|
|
SandboxStrategy,
|
|
)
|
|
|
|
context.resource_type = ResourceTypeSpec(
|
|
name=name,
|
|
description="Test resource type",
|
|
resource_kind=ResourceKind.PHYSICAL,
|
|
sandbox_strategy=SandboxStrategy.COPY_ON_WRITE,
|
|
user_addable=True,
|
|
cli_args=[],
|
|
parent_types=[],
|
|
child_types=[],
|
|
auto_discovery=None,
|
|
equivalence=None,
|
|
handler=None,
|
|
capabilities={
|
|
"read": True,
|
|
"write": True,
|
|
"sandbox": True,
|
|
"checkpoint": False,
|
|
},
|
|
built_in=False,
|
|
)
|
|
|
|
|
|
@given("the resource type has been persisted in the database")
|
|
def step_persist_resource_type(context: Context) -> None:
|
|
context.resource_type_repo.create(context.resource_type)
|
|
context.db_session.commit()
|
|
|
|
|
|
@when("the resource type is created through the repository")
|
|
def step_create_resource_type(context: Context) -> None:
|
|
try:
|
|
context.resource_type_repo.create(context.resource_type)
|
|
context.db_session.commit()
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then('the resource type should be retrievable by name "{name}"')
|
|
def step_verify_resource_type_by_name(context: Context, name: str) -> None:
|
|
fetched = context.resource_type_repo.get(name)
|
|
assert fetched is not None, f"Resource type '{name}' not found"
|
|
assert fetched.name == name
|
|
|
|
|
|
@when("the same resource type is created again")
|
|
def step_create_duplicate_resource_type(context: Context) -> None:
|
|
try:
|
|
context.resource_type_repo.create(context.resource_type)
|
|
context.db_session.commit()
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then("a DuplicateResourceTypeError should be raised")
|
|
def step_verify_dup_resource_type(context: Context) -> None:
|
|
from cleveragents.infrastructure.database.repositories import (
|
|
DuplicateResourceTypeError,
|
|
)
|
|
|
|
assert context.error is not None, "Expected DuplicateResourceTypeError"
|
|
assert isinstance(context.error, DuplicateResourceTypeError), (
|
|
f"Expected DuplicateResourceTypeError, "
|
|
f"got {type(context.error).__name__}: {context.error}"
|
|
)
|
|
|
|
|
|
# ========================================================================
|
|
# ResourceRepository (lines 1346-1422)
|
|
# ========================================================================
|
|
|
|
|
|
@given("a resource repository using the session factory")
|
|
def step_resource_repo(context: Context) -> None:
|
|
from cleveragents.infrastructure.database.repositories import ResourceRepository
|
|
|
|
context.resource_repo = ResourceRepository(
|
|
session_factory=context.db_session_factory,
|
|
)
|
|
context.error = None
|
|
|
|
|
|
@given('a valid resource domain object of type "{type_name}"')
|
|
def step_make_resource(context: Context, type_name: str) -> None:
|
|
from cleveragents.domain.models.core.resource import (
|
|
PhysVirt,
|
|
Resource,
|
|
ResourceCapabilities,
|
|
)
|
|
|
|
resource_id = _next_ulid()
|
|
context.resource = Resource(
|
|
resource_id=resource_id,
|
|
name=f"local/test-resource-{resource_id[-6:].lower()}",
|
|
resource_type_name=type_name,
|
|
classification=PhysVirt.PHYSICAL,
|
|
description="A test resource",
|
|
properties={"key": "value"},
|
|
location="/tmp/test-location",
|
|
content_hash=None,
|
|
sandbox_strategy=None,
|
|
capabilities=ResourceCapabilities(
|
|
readable=True,
|
|
writable=True,
|
|
sandboxable=True,
|
|
checkpointable=False,
|
|
),
|
|
)
|
|
|
|
|
|
@when("the resource is created through the repository")
|
|
def step_create_resource(context: Context) -> None:
|
|
try:
|
|
context.resource_repo.create(context.resource)
|
|
context.db_session.commit()
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then("the resource should be retrievable by its ID")
|
|
def step_verify_resource_by_id(context: Context) -> None:
|
|
assert context.error is None, f"Unexpected error: {context.error}"
|
|
fetched = context.resource_repo.get(context.resource.resource_id)
|
|
assert fetched is not None, "Resource not found by ID"
|
|
assert fetched.resource_id == context.resource.resource_id
|
|
|
|
|
|
@then("the resource should be retrievable by its namespaced name")
|
|
def step_verify_resource_by_name(context: Context) -> None:
|
|
fetched = context.resource_repo.get_by_name(context.resource.name)
|
|
assert fetched is not None, "Resource not found by namespaced name"
|
|
assert fetched.name == context.resource.name
|
|
|
|
|
|
@given('multiple resources of type "{type_name}" have been created')
|
|
def step_create_multiple_resources(context: Context, type_name: str) -> None:
|
|
from cleveragents.domain.models.core.resource import (
|
|
PhysVirt,
|
|
Resource,
|
|
ResourceCapabilities,
|
|
)
|
|
|
|
context.created_resource_ids = []
|
|
for i in range(3):
|
|
resource_id = _next_ulid()
|
|
resource = Resource(
|
|
resource_id=resource_id,
|
|
name=f"local/multi-res-{resource_id[-6:].lower()}",
|
|
resource_type_name=type_name,
|
|
classification=PhysVirt.PHYSICAL,
|
|
description=f"Test resource {i}",
|
|
properties={},
|
|
location=f"/tmp/test-{i}",
|
|
content_hash=None,
|
|
sandbox_strategy=None,
|
|
capabilities=ResourceCapabilities(
|
|
readable=True,
|
|
writable=True,
|
|
sandboxable=True,
|
|
checkpointable=False,
|
|
),
|
|
)
|
|
context.resource_repo.create(resource)
|
|
context.created_resource_ids.append(resource_id)
|
|
context.db_session.commit()
|
|
|
|
|
|
@when('resources are listed by type "{type_name}"')
|
|
def step_list_resources_by_type(context: Context, type_name: str) -> None:
|
|
context.result_resources = context.resource_repo.list_resources(
|
|
type_name=type_name,
|
|
)
|
|
|
|
|
|
@then("all resources of that type should be returned")
|
|
def step_verify_resources_listed(context: Context) -> None:
|
|
assert len(context.result_resources) == 3, (
|
|
f"Expected 3 resources, got {len(context.result_resources)}"
|
|
)
|
|
returned_ids = {r.resource_id for r in context.result_resources}
|
|
for rid in context.created_resource_ids:
|
|
assert rid in returned_ids, f"Resource {rid} not in result list"
|