813faa4853
CI / helm (pull_request) Successful in 31s
CI / lint (pull_request) Successful in 3m57s
CI / build (pull_request) Successful in 3m50s
CI / quality (pull_request) Successful in 4m16s
CI / typecheck (pull_request) Successful in 4m37s
CI / security (pull_request) Successful in 4m43s
CI / push-validation (pull_request) Successful in 24s
CI / integration_tests (pull_request) Successful in 7m40s
CI / e2e_tests (pull_request) Successful in 8m18s
CI / unit_tests (pull_request) Successful in 9m26s
CI / docker (pull_request) Successful in 1m44s
CI / coverage (pull_request) Successful in 15m31s
CI / benchmark-regression (push) Failing after 0s
CI / benchmark-publish (push) Failing after 0s
CI / status-check (pull_request) Successful in 3s
CI / push-validation (push) Successful in 25s
CI / helm (push) Successful in 28s
CI / lint (push) Successful in 4m2s
CI / quality (push) Successful in 4m25s
CI / build (push) Successful in 3m41s
CI / typecheck (push) Successful in 4m41s
CI / security (push) Successful in 4m48s
CI / unit_tests (push) Failing after 8m47s
CI / docker (push) Has been skipped
CI / e2e_tests (push) Successful in 8m53s
CI / integration_tests (push) Successful in 10m42s
CI / coverage (push) Successful in 14m45s
CI / status-check (push) Failing after 3s
Replace all # type: ignore[misc] reassignment checks in features/steps/domain_model_immutability_steps.py with setattr() calls to exercise frozen model enforcement without suppressing type errors. Add B010 to per-file-ignores for features/steps/*.py in pyproject.toml since setattr with constant attribute names is intentional in immutability tests (exercises frozen Pydantic model enforcement). Update CONTRIBUTORS.md to document HAL 9000 contributions. ISSUES CLOSED: #7553
428 lines
16 KiB
Python
428 lines
16 KiB
Python
"""Step definitions for domain model immutability tests.
|
|
|
|
Verifies that Plan and Action identity fields are read-only after construction,
|
|
while mutable state fields remain assignable.
|
|
|
|
Issue #7553: enforce immutability on Plan and Action identity fields.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
from typing import Any
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from pydantic import ValidationError
|
|
|
|
from cleveragents.domain.models.core.action import Action, ActionState
|
|
from cleveragents.domain.models.core.plan import (
|
|
NamespacedName,
|
|
Plan,
|
|
PlanIdentity,
|
|
PlanPhase,
|
|
PlanTimestamps,
|
|
ProcessingState,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_VALID_ULID = "01HZTEST0000000000000000AA"
|
|
_VALID_ULID_2 = "01HZTEST0000000000000000BB"
|
|
_VALID_ULID_ROOT = "01HZTEST0000000000000000CC"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_plan(
|
|
plan_id: str = _VALID_ULID,
|
|
phase: PlanPhase = PlanPhase.STRATEGIZE,
|
|
processing_state: ProcessingState = ProcessingState.QUEUED,
|
|
created_at: dt.datetime | None = None,
|
|
namespaced_name_str: str = "local/test-plan",
|
|
) -> Plan:
|
|
"""Create a minimal valid Plan domain object."""
|
|
timestamps_kwargs: dict[str, Any] = {}
|
|
if created_at is not None:
|
|
timestamps_kwargs["created_at"] = created_at
|
|
|
|
return Plan(
|
|
identity=PlanIdentity(plan_id=plan_id),
|
|
namespaced_name=NamespacedName.parse(namespaced_name_str),
|
|
description="Test plan description",
|
|
action_name="local/test-action",
|
|
phase=phase,
|
|
processing_state=processing_state,
|
|
timestamps=PlanTimestamps(**timestamps_kwargs),
|
|
)
|
|
|
|
|
|
def _make_action(namespaced_name_str: str = "local/test-action") -> Action:
|
|
"""Create a minimal valid Action domain object."""
|
|
return Action(
|
|
namespaced_name=NamespacedName.parse(namespaced_name_str),
|
|
description="Test action description",
|
|
definition_of_done="All tests pass",
|
|
strategy_actor="local/strategy-actor",
|
|
execution_actor="local/execution-actor",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan identity — plan_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I create a Plan with a known ULID plan_id")
|
|
def step_create_plan_with_known_ulid(context: Context) -> None:
|
|
"""Create a Plan with a known ULID plan_id."""
|
|
context.known_ulid = _VALID_ULID
|
|
context.immut_plan = _make_plan(plan_id=_VALID_ULID)
|
|
context.immut_error = None
|
|
|
|
|
|
@then("the plan identity plan_id should match the known ULID")
|
|
def step_check_plan_identity_plan_id(context: Context) -> None:
|
|
"""Verify the plan_id matches the known ULID."""
|
|
assert context.immut_plan.identity.plan_id == context.known_ulid, (
|
|
f"Expected plan_id '{context.known_ulid}', "
|
|
f"got '{context.immut_plan.identity.plan_id}'"
|
|
)
|
|
|
|
|
|
@when("I attempt to reassign the plan identity plan_id")
|
|
def step_attempt_reassign_plan_id(context: Context) -> None:
|
|
"""Attempt to reassign plan_id on a frozen PlanIdentity."""
|
|
context.immut_error = None
|
|
try:
|
|
setattr(context.immut_plan.identity, "plan_id", _VALID_ULID_2)
|
|
except (ValidationError, TypeError) as exc:
|
|
context.immut_error = exc
|
|
|
|
|
|
@then("a frozen model error should be raised for plan_id")
|
|
def step_check_frozen_error_plan_id(context: Context) -> None:
|
|
"""Verify that a frozen model error was raised."""
|
|
assert context.immut_error is not None, (
|
|
"Expected a ValidationError or TypeError when reassigning plan_id, "
|
|
"but no error was raised"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan identity — root_plan_id auto-resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I create a Plan without specifying root_plan_id")
|
|
def step_create_plan_without_root_plan_id(context: Context) -> None:
|
|
"""Create a Plan without explicitly setting root_plan_id."""
|
|
context.immut_plan = _make_plan(plan_id=_VALID_ULID)
|
|
context.immut_error = None
|
|
|
|
|
|
@then("the plan identity root_plan_id should equal the plan_id")
|
|
def step_check_root_plan_id_auto_resolved(context: Context) -> None:
|
|
"""Verify root_plan_id was auto-resolved to plan_id."""
|
|
assert (
|
|
context.immut_plan.identity.root_plan_id == context.immut_plan.identity.plan_id
|
|
), (
|
|
f"Expected root_plan_id '{context.immut_plan.identity.plan_id}', "
|
|
f"got '{context.immut_plan.identity.root_plan_id}'"
|
|
)
|
|
|
|
|
|
@when("I attempt to reassign the plan identity root_plan_id")
|
|
def step_attempt_reassign_root_plan_id(context: Context) -> None:
|
|
"""Attempt to reassign root_plan_id on a frozen PlanIdentity."""
|
|
context.immut_error = None
|
|
try:
|
|
setattr(context.immut_plan.identity, "root_plan_id", _VALID_ULID_ROOT)
|
|
except (ValidationError, TypeError) as exc:
|
|
context.immut_error = exc
|
|
|
|
|
|
@then("a frozen model error should be raised for root_plan_id")
|
|
def step_check_frozen_error_root_plan_id(context: Context) -> None:
|
|
"""Verify that a frozen model error was raised for root_plan_id."""
|
|
assert context.immut_error is not None, (
|
|
"Expected a ValidationError or TypeError when reassigning root_plan_id, "
|
|
"but no error was raised"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan timestamps — created_at
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I create a Plan with a specific created_at timestamp")
|
|
def step_create_plan_with_specific_created_at(context: Context) -> None:
|
|
"""Create a Plan with a specific created_at timestamp."""
|
|
context.specific_created_at = dt.datetime(2026, 1, 15, 10, 0, 0, tzinfo=dt.UTC)
|
|
context.immut_plan = _make_plan(created_at=context.specific_created_at)
|
|
context.immut_error = None
|
|
|
|
|
|
@then("the plan timestamps created_at should match the specified timestamp")
|
|
def step_check_plan_created_at(context: Context) -> None:
|
|
"""Verify the created_at timestamp matches the specified value."""
|
|
actual = context.immut_plan.timestamps.created_at
|
|
expected = context.specific_created_at
|
|
assert actual == expected, f"Expected created_at '{expected}', got '{actual}'"
|
|
|
|
|
|
@when("I attempt to reassign the plan timestamps created_at")
|
|
def step_attempt_reassign_created_at(context: Context) -> None:
|
|
"""Attempt to reassign created_at on PlanTimestamps."""
|
|
context.immut_error = None
|
|
try:
|
|
context.immut_plan.timestamps.created_at = dt.datetime(
|
|
2099, 1, 1, tzinfo=dt.UTC
|
|
)
|
|
except AttributeError as exc:
|
|
context.immut_error = exc
|
|
|
|
|
|
@then("an AttributeError should be raised for created_at")
|
|
def step_check_attribute_error_created_at(context: Context) -> None:
|
|
"""Verify that an AttributeError was raised for created_at."""
|
|
assert context.immut_error is not None, (
|
|
"Expected an AttributeError when reassigning created_at, "
|
|
"but no error was raised"
|
|
)
|
|
assert isinstance(context.immut_error, AttributeError), (
|
|
f"Expected AttributeError, got {type(context.immut_error).__name__}"
|
|
)
|
|
assert (
|
|
"created_at" in str(context.immut_error).lower()
|
|
or "read-only" in str(context.immut_error).lower()
|
|
), (
|
|
f"Expected error message to mention 'created_at' or 'read-only', "
|
|
f"got: {context.immut_error}"
|
|
)
|
|
|
|
|
|
@when("I update the plan timestamps updated_at to a new datetime")
|
|
def step_update_plan_updated_at(context: Context) -> None:
|
|
"""Update the plan's updated_at timestamp."""
|
|
context.new_updated_at = dt.datetime(2026, 6, 1, 12, 0, 0, tzinfo=dt.UTC)
|
|
context.immut_plan.timestamps.updated_at = context.new_updated_at
|
|
context.immut_error = None
|
|
|
|
|
|
@then("the plan timestamps updated_at should reflect the new datetime")
|
|
def step_check_plan_updated_at(context: Context) -> None:
|
|
"""Verify the updated_at timestamp was updated."""
|
|
actual = context.immut_plan.timestamps.updated_at
|
|
expected = context.new_updated_at
|
|
assert actual == expected, f"Expected updated_at '{expected}', got '{actual}'"
|
|
|
|
|
|
@when("I set the plan timestamps strategize_started_at to a new datetime")
|
|
def step_set_plan_strategize_started_at(context: Context) -> None:
|
|
"""Set the plan's strategize_started_at timestamp."""
|
|
context.new_strategize_started_at = dt.datetime(2026, 6, 1, 13, 0, 0, tzinfo=dt.UTC)
|
|
context.immut_plan.timestamps.strategize_started_at = (
|
|
context.new_strategize_started_at
|
|
)
|
|
context.immut_error = None
|
|
|
|
|
|
@then("the plan timestamps strategize_started_at should reflect the new datetime")
|
|
def step_check_plan_strategize_started_at(context: Context) -> None:
|
|
"""Verify the strategize_started_at timestamp was set."""
|
|
actual = context.immut_plan.timestamps.strategize_started_at
|
|
expected = context.new_strategize_started_at
|
|
assert actual == expected, (
|
|
f"Expected strategize_started_at '{expected}', got '{actual}'"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action namespaced_name — name
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('I create an Action with namespaced name "{namespaced_name}"')
|
|
def step_create_action_with_namespaced_name(
|
|
context: Context, namespaced_name: str
|
|
) -> None:
|
|
"""Create an Action with the given namespaced name."""
|
|
context.immut_action = _make_action(namespaced_name_str=namespaced_name)
|
|
context.immut_error = None
|
|
|
|
|
|
@then('the action namespaced_name name should be "{expected}"')
|
|
def step_check_action_name(context: Context, expected: str) -> None:
|
|
"""Verify the action's namespaced_name.name."""
|
|
actual = context.immut_action.namespaced_name.name
|
|
assert actual == expected, f"Expected action name '{expected}', got '{actual}'"
|
|
|
|
|
|
@when("I attempt to reassign the action namespaced_name name")
|
|
def step_attempt_reassign_action_name(context: Context) -> None:
|
|
"""Attempt to reassign the action's namespaced_name.name."""
|
|
context.immut_error = None
|
|
try:
|
|
setattr(context.immut_action.namespaced_name, "name", "new-name")
|
|
except (ValidationError, TypeError) as exc:
|
|
context.immut_error = exc
|
|
|
|
|
|
@then("a frozen model error should be raised for action name")
|
|
def step_check_frozen_error_action_name(context: Context) -> None:
|
|
"""Verify that a frozen model error was raised for action name."""
|
|
assert context.immut_error is not None, (
|
|
"Expected a ValidationError or TypeError when reassigning action name, "
|
|
"but no error was raised"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action namespaced_name — namespace
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the action namespaced_name namespace should be "{expected}"')
|
|
def step_check_action_namespace(context: Context, expected: str) -> None:
|
|
"""Verify the action's namespaced_name.namespace."""
|
|
actual = context.immut_action.namespaced_name.namespace
|
|
assert actual == expected, f"Expected action namespace '{expected}', got '{actual}'"
|
|
|
|
|
|
@when("I attempt to reassign the action namespaced_name namespace")
|
|
def step_attempt_reassign_action_namespace(context: Context) -> None:
|
|
"""Attempt to reassign the action's namespaced_name.namespace."""
|
|
context.immut_error = None
|
|
try:
|
|
setattr(context.immut_action.namespaced_name, "namespace", "neworg")
|
|
except (ValidationError, TypeError) as exc:
|
|
context.immut_error = exc
|
|
|
|
|
|
@then("a frozen model error should be raised for action namespace")
|
|
def step_check_frozen_error_action_namespace(context: Context) -> None:
|
|
"""Verify that a frozen model error was raised for action namespace."""
|
|
assert context.immut_error is not None, (
|
|
"Expected a ValidationError or TypeError when reassigning action namespace, "
|
|
"but no error was raised"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mutable state fields
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I create a Plan in STRATEGIZE phase")
|
|
def step_create_plan_in_strategize(context: Context) -> None:
|
|
"""Create a Plan in STRATEGIZE phase."""
|
|
context.immut_plan = _make_plan(
|
|
phase=PlanPhase.STRATEGIZE,
|
|
processing_state=ProcessingState.QUEUED,
|
|
)
|
|
context.immut_error = None
|
|
|
|
|
|
@when("I update the plan phase to EXECUTE")
|
|
def step_update_plan_phase_to_execute(context: Context) -> None:
|
|
"""Update the plan's phase to EXECUTE."""
|
|
context.immut_plan.phase = PlanPhase.EXECUTE
|
|
context.immut_error = None
|
|
|
|
|
|
@then("the plan phase should be EXECUTE")
|
|
def step_check_plan_phase_execute(context: Context) -> None:
|
|
"""Verify the plan phase is EXECUTE."""
|
|
assert context.immut_plan.phase == PlanPhase.EXECUTE, (
|
|
f"Expected phase EXECUTE, got {context.immut_plan.phase}"
|
|
)
|
|
|
|
|
|
@when("I update the plan processing_state to PROCESSING")
|
|
def step_update_plan_processing_state(context: Context) -> None:
|
|
"""Update the plan's processing_state to PROCESSING."""
|
|
context.immut_plan.processing_state = ProcessingState.PROCESSING
|
|
context.immut_error = None
|
|
|
|
|
|
@then("the plan processing_state should be PROCESSING")
|
|
def step_check_plan_processing_state(context: Context) -> None:
|
|
"""Verify the plan processing_state is PROCESSING."""
|
|
assert context.immut_plan.processing_state == ProcessingState.PROCESSING, (
|
|
f"Expected processing_state PROCESSING, got {context.immut_plan.processing_state}"
|
|
)
|
|
|
|
|
|
@when("I update the action state to archived")
|
|
def step_update_action_state_archived(context: Context) -> None:
|
|
"""Update the action's state to archived."""
|
|
context.immut_action.state = ActionState.ARCHIVED
|
|
context.immut_error = None
|
|
|
|
|
|
@then("the action state should be archived")
|
|
def step_check_action_state_archived(context: Context) -> None:
|
|
"""Verify the action state is archived."""
|
|
assert context.immut_action.state == ActionState.ARCHIVED, (
|
|
f"Expected state ARCHIVED, got {context.immut_action.state}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan namespaced_name — frozen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('I create a Plan with namespaced name "{namespaced_name}"')
|
|
def step_create_plan_with_namespaced_name(
|
|
context: Context, namespaced_name: str
|
|
) -> None:
|
|
"""Create a Plan with the given namespaced name."""
|
|
context.immut_plan = _make_plan(namespaced_name_str=namespaced_name)
|
|
context.immut_error = None
|
|
|
|
|
|
@when("I attempt to reassign the plan namespaced_name name")
|
|
def step_attempt_reassign_plan_namespaced_name(context: Context) -> None:
|
|
"""Attempt to reassign the plan's namespaced_name.name."""
|
|
context.immut_error = None
|
|
try:
|
|
setattr(context.immut_plan.namespaced_name, "name", "new-name")
|
|
except (ValidationError, TypeError) as exc:
|
|
context.immut_error = exc
|
|
|
|
|
|
@then("a frozen model error should be raised for plan namespaced name")
|
|
def step_check_frozen_error_plan_namespaced_name(context: Context) -> None:
|
|
"""Verify that a frozen model error was raised for plan namespaced name."""
|
|
assert context.immut_error is not None, (
|
|
"Expected a ValidationError or TypeError when reassigning plan namespaced name, "
|
|
"but no error was raised"
|
|
)
|
|
|
|
|
|
@when("I attempt to reassign the plan namespaced_name namespace")
|
|
def step_attempt_reassign_plan_namespaced_namespace(context: Context) -> None:
|
|
"""Attempt to reassign the plan's namespaced_name.namespace."""
|
|
context.immut_error = None
|
|
try:
|
|
setattr(context.immut_plan.namespaced_name, "namespace", "neworg")
|
|
except (ValidationError, TypeError) as exc:
|
|
context.immut_error = exc
|
|
|
|
|
|
@then("a frozen model error should be raised for plan namespaced namespace")
|
|
def step_check_frozen_error_plan_namespaced_namespace(context: Context) -> None:
|
|
"""Verify that a frozen model error was raised for plan namespaced namespace."""
|
|
assert context.immut_error is not None, (
|
|
"Expected a ValidationError or TypeError when reassigning plan namespaced namespace, "
|
|
"but no error was raised"
|
|
)
|