forked from HAL9000/cleveragents-core
a4a6b061a6
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
542 lines
19 KiB
Python
542 lines
19 KiB
Python
"""Step definitions for ActionRepository persistence coverage.
|
|
|
|
Targets uncovered lines 711-998 and partial branches at lines 145 and 215
|
|
in ``src/cleveragents/infrastructure/database/repositories.py``.
|
|
"""
|
|
|
|
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 Session, sessionmaker
|
|
|
|
from cleveragents.core.exceptions import DatabaseError
|
|
from cleveragents.domain.models.core.action import Action, ActionState
|
|
from cleveragents.domain.models.core.plan import NamespacedName
|
|
from cleveragents.infrastructure.database.models import (
|
|
Base,
|
|
LifecyclePlanModel,
|
|
)
|
|
from cleveragents.infrastructure.database.repositories import (
|
|
ActionInUseError,
|
|
ActionRepository,
|
|
DuplicateActionError,
|
|
PlanRepository,
|
|
ProjectRepository,
|
|
)
|
|
|
|
# Valid ULIDs for deterministic tests (Crockford base32, 26 chars)
|
|
# Crockford base32 alphabet: 0123456789ABCDEFGHJKMNPQRSTVWXYZ
|
|
_ULID_COUNTER = 0
|
|
_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
|
|
|
|
def _next_ulid() -> str:
|
|
"""Return a unique, valid ULID string for each call."""
|
|
global _ULID_COUNTER
|
|
_ULID_COUNTER += 1
|
|
# Encode the counter into the last 8 Crockford base32 digits
|
|
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 _get_session(context: Context) -> Session:
|
|
"""Return the shared session stored on the behave context."""
|
|
return context.db_session
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a clean in-memory database with the lifecycle schema")
|
|
def step_clean_db(context: Context) -> None:
|
|
"""Create a fresh in-memory SQLite database with all tables.
|
|
|
|
The ``ActionRepository`` uses a session-factory pattern: each public
|
|
method calls ``self._session()`` to obtain a session. For the tests
|
|
to exercise commit / rollback semantics correctly the factory must
|
|
return the **same** session instance so that ``session.flush()``
|
|
inside the repository and ``session.commit()`` in the test step
|
|
operate on the same transaction.
|
|
"""
|
|
engine = create_engine("sqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
context.db_engine = engine
|
|
# Canonical session shared by all callers within one scenario
|
|
session = sessionmaker(bind=engine)()
|
|
context.db_session = session
|
|
context.db_session_factory = lambda: session
|
|
|
|
|
|
@given("an action repository backed by the database")
|
|
def step_action_repo(context: Context) -> None:
|
|
"""Instantiate an ActionRepository using the session factory."""
|
|
context.action_repo = ActionRepository(
|
|
session_factory=context.db_session_factory,
|
|
)
|
|
context.saved_action = None
|
|
context.result_action = None
|
|
context.error = None
|
|
context.delete_result = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Creating actions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a valid action domain object named "{name}"')
|
|
def step_make_action(context: Context, name: str) -> None:
|
|
"""Build an Action domain object with the given namespaced name."""
|
|
context.action = _make_action(name=name)
|
|
|
|
|
|
@given("the action has already been saved once")
|
|
def step_save_action_once(context: Context) -> None:
|
|
"""Persist the current action so a subsequent save will conflict."""
|
|
context.action_repo.create(context.action)
|
|
context.db_session.commit()
|
|
|
|
|
|
@when("the action is saved through the repository")
|
|
def step_save_action(context: Context) -> None:
|
|
"""Persist the action and capture any errors."""
|
|
try:
|
|
context.saved_action = context.action_repo.create(context.action)
|
|
context.db_session.commit()
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when('a second action with the same name "{name}" is saved')
|
|
def step_save_duplicate(context: Context, name: str) -> None:
|
|
"""Attempt to persist a second action with the same namespaced name.
|
|
|
|
The ``create`` method is wrapped in a retry decorator that may
|
|
re-attempt before surfacing the error. We catch the final exception
|
|
after all retries are exhausted.
|
|
"""
|
|
# Build a new Action that shares the same namespaced name but has a
|
|
# different action_id (the unique constraint is on the ``name`` column).
|
|
dup = _make_action(name=name)
|
|
try:
|
|
context.action_repo.create(dup)
|
|
context.db_session.commit()
|
|
except (DuplicateActionError, DatabaseError) as exc:
|
|
# The retry decorator may wrap the DuplicateActionError inside a
|
|
# RetryError. Unwrap if necessary.
|
|
context.error = exc
|
|
except Exception as exc:
|
|
# tenacity.RetryError wraps the last attempt's exception.
|
|
cause = exc.__cause__ or exc
|
|
context.error = cause
|
|
|
|
|
|
@then("the repository should not raise an error")
|
|
def step_no_error(context: Context) -> None:
|
|
assert context.error is None, f"Unexpected error: {context.error}"
|
|
|
|
|
|
@then('the persisted action should retain the name "{name}"')
|
|
def step_verify_name(context: Context, name: str) -> None:
|
|
assert context.saved_action is not None
|
|
assert str(context.saved_action.namespaced_name) == name, (
|
|
f"Expected '{name}', got '{context.saved_action.namespaced_name}'"
|
|
)
|
|
|
|
|
|
@then('a DuplicateActionError should be raised mentioning "{name}"')
|
|
def step_verify_dup_error(context: Context, name: str) -> None:
|
|
assert context.error is not None, "Expected DuplicateActionError"
|
|
assert isinstance(context.error, DuplicateActionError), (
|
|
f"Expected DuplicateActionError, got {type(context.error).__name__}"
|
|
)
|
|
assert name in str(context.error), (
|
|
f"Error message should mention '{name}': {context.error}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Retrieving actions by identifier
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the action has been saved through the repository")
|
|
def step_save_for_lookup(context: Context) -> None:
|
|
"""Persist the action for subsequent retrieval tests."""
|
|
context.saved_action = context.action_repo.create(context.action)
|
|
context.db_session.commit()
|
|
|
|
|
|
@when("the action is looked up by its identifier")
|
|
def step_get_by_id(context: Context) -> None:
|
|
context.result_action = context.action_repo.get_by_id(
|
|
str(context.action.namespaced_name),
|
|
)
|
|
|
|
|
|
@when('an action is looked up by the identifier "{action_id}"')
|
|
def step_get_by_id_direct(context: Context, action_id: str) -> None:
|
|
context.result_action = context.action_repo.get_by_id(action_id)
|
|
|
|
|
|
@then('the returned action should have the name "{name}"')
|
|
def step_verify_returned_name(context: Context, name: str) -> None:
|
|
assert context.result_action is not None, "Expected an action, got None"
|
|
assert str(context.result_action.namespaced_name) == name
|
|
|
|
|
|
@then("no action should be returned")
|
|
def step_verify_none(context: Context) -> None:
|
|
assert context.result_action is None, f"Expected None, got {context.result_action}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Retrieving actions by name
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('the action is looked up by name "{name}"')
|
|
def step_get_by_name(context: Context, name: str) -> None:
|
|
context.result_action = context.action_repo.get_by_name(name)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Listing actions by namespace
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the following actions have been saved:")
|
|
def step_save_multiple(context: Context) -> None:
|
|
"""Persist several actions described in a Behave table."""
|
|
assert context.table is not None, "Step requires a data table"
|
|
for row in context.table:
|
|
name = row["name"]
|
|
state = row.get("state", "available")
|
|
action = _make_action(name=name, state=state)
|
|
context.action_repo.create(action)
|
|
context.db_session.commit()
|
|
|
|
|
|
@when('actions in the "{namespace}" namespace are listed')
|
|
def step_list_namespace(context: Context, namespace: str) -> None:
|
|
context.result_list = context.action_repo.get_by_namespace(namespace)
|
|
|
|
|
|
@when('actions in the "{namespace}" namespace are listed with state "{state}"')
|
|
def step_list_namespace_state(context: Context, namespace: str, state: str) -> None:
|
|
context.result_list = context.action_repo.get_by_namespace(
|
|
namespace,
|
|
state=state,
|
|
)
|
|
|
|
|
|
@then("{count:d} action should be returned")
|
|
def step_verify_count_singular(context: Context, count: int) -> None:
|
|
assert len(context.result_list) == count, (
|
|
f"Expected {count}, got {len(context.result_list)}"
|
|
)
|
|
|
|
|
|
@then("{count:d} actions should be returned")
|
|
def step_verify_count_plural(context: Context, count: int) -> None:
|
|
assert len(context.result_list) == count, (
|
|
f"Expected {count}, got {len(context.result_list)}"
|
|
)
|
|
|
|
|
|
@then('the returned action names should include "{name}"')
|
|
def step_verify_includes_name(context: Context, name: str) -> None:
|
|
names = [str(a.namespaced_name) for a in context.result_list]
|
|
assert name in names, f"Expected '{name}' in {names}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Listing actions by state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('actions in the "{state}" state are listed')
|
|
def step_list_by_state(context: Context, state: str) -> None:
|
|
context.result_list = context.action_repo.get_by_state(state)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Listing available actions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("all available actions are listed")
|
|
def step_list_available(context: Context) -> None:
|
|
context.result_list = context.action_repo.list_available()
|
|
|
|
|
|
@when('available actions in the "{namespace}" namespace are listed')
|
|
def step_list_available_ns(context: Context, namespace: str) -> None:
|
|
context.result_list = context.action_repo.list_available(
|
|
namespace=namespace,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Updating actions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('the action description is changed to "{new_dod}"')
|
|
def step_change_dod(context: Context, new_dod: str) -> None:
|
|
context.action.definition_of_done = new_dod
|
|
|
|
|
|
@when("the action is updated through the repository")
|
|
def step_update_action(context: Context) -> None:
|
|
try:
|
|
context.result_action = context.action_repo.update(context.action)
|
|
context.db_session.commit()
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when("the phantom action is updated without being saved first")
|
|
def step_update_unsaved(context: Context) -> None:
|
|
try:
|
|
context.action_repo.update(context.action)
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then("a DatabaseError should be raised about the missing action")
|
|
def step_verify_db_error(context: Context) -> None:
|
|
assert context.error is not None, "Expected DatabaseError"
|
|
assert isinstance(context.error, DatabaseError), (
|
|
f"Expected DatabaseError, got {type(context.error).__name__}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Deleting actions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("the action is deleted by its identifier")
|
|
def step_delete_action(context: Context) -> None:
|
|
context.delete_result = context.action_repo.delete(
|
|
str(context.action.namespaced_name),
|
|
)
|
|
context.db_session.commit()
|
|
|
|
|
|
@when('an action with identifier "{action_id}" is deleted')
|
|
def step_delete_by_id(context: Context, action_id: str) -> None:
|
|
context.delete_result = context.action_repo.delete(action_id)
|
|
|
|
|
|
@then("the delete operation should return true")
|
|
def step_verify_deleted(context: Context) -> None:
|
|
assert context.delete_result is True, f"Expected True, got {context.delete_result}"
|
|
|
|
|
|
@then("the delete operation should return false")
|
|
def step_verify_not_deleted(context: Context) -> None:
|
|
assert context.delete_result is False, (
|
|
f"Expected False, got {context.delete_result}"
|
|
)
|
|
|
|
|
|
@then("looking up the deleted action by identifier should return nothing")
|
|
def step_verify_gone(context: Context) -> None:
|
|
found = context.action_repo.get_by_id(str(context.action.namespaced_name))
|
|
assert found is None, f"Expected None, got {found}"
|
|
|
|
|
|
@given("a lifecycle plan references that action")
|
|
def step_create_referencing_plan(context: Context) -> None:
|
|
"""Insert a LifecyclePlanModel row that references the saved action."""
|
|
session = _get_session(context)
|
|
now_iso = datetime.now().isoformat()
|
|
pid = _next_ulid()
|
|
plan_model = LifecyclePlanModel(
|
|
plan_id=pid,
|
|
root_plan_id=pid,
|
|
action_name=str(context.action.namespaced_name),
|
|
phase="strategize",
|
|
processing_state="queued",
|
|
attempt=1,
|
|
namespaced_name="local/test-plan",
|
|
namespace="local",
|
|
description="Plan referencing the 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("the referenced action is deleted")
|
|
def step_delete_referenced(context: Context) -> None:
|
|
try:
|
|
context.action_repo.delete(str(context.action.namespaced_name))
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then("an ActionInUseError should be raised")
|
|
def step_verify_in_use(context: Context) -> None:
|
|
assert context.error is not None, "Expected ActionInUseError"
|
|
assert isinstance(context.error, ActionInUseError), (
|
|
f"Expected ActionInUseError, got {type(context.error).__name__}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error classes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('a DuplicateActionError is created for name "{name}"')
|
|
def step_create_dup_error(context: Context, name: str) -> None:
|
|
context.error_instance = DuplicateActionError(name)
|
|
|
|
|
|
@then('the error should contain the message "{text}"')
|
|
def step_verify_error_msg(context: Context, text: str) -> None:
|
|
assert text in str(context.error_instance), (
|
|
f"Expected '{text}' in '{context.error_instance}'"
|
|
)
|
|
|
|
|
|
@then('the error should expose the action name "{name}"')
|
|
def step_verify_error_attr(context: Context, name: str) -> None:
|
|
assert context.error_instance.action_name == name
|
|
|
|
|
|
@when(
|
|
'an ActionInUseError is created for action "{aid}" with {count:d} referencing plans'
|
|
)
|
|
def step_create_in_use_error(context: Context, aid: str, count: int) -> None:
|
|
context.error_instance = ActionInUseError(aid, count)
|
|
|
|
|
|
@then('the error should mention "{aid}" and "{plans}"')
|
|
def step_verify_in_use_msg(context: Context, aid: str, plans: str) -> None:
|
|
msg = str(context.error_instance)
|
|
assert aid in msg, f"Expected '{aid}' in '{msg}'"
|
|
assert plans in msg, f"Expected '{plans}' in '{msg}'"
|
|
|
|
|
|
@then('the error should expose action identifier "{aid}" and plan count {count:d}')
|
|
def step_verify_in_use_attrs(context: Context, aid: str, count: int) -> None:
|
|
assert context.error_instance.action_name == aid
|
|
assert context.error_instance.plan_count == count
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Partial branch - ProjectRepository.delete (line 145)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a project repository backed by the database")
|
|
def step_project_repo(context: Context) -> None:
|
|
context.project_repo = ProjectRepository(
|
|
session=_get_session(context),
|
|
)
|
|
context.error = None
|
|
|
|
|
|
@when("a project with identifier {pid:d} is deleted")
|
|
def step_delete_project(context: Context, pid: int) -> None:
|
|
try:
|
|
context.project_repo.delete(pid)
|
|
context.db_session.commit()
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then("the delete operation should complete without error")
|
|
def step_verify_no_delete_error(context: Context) -> None:
|
|
assert context.error is None, f"Unexpected error: {context.error}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Partial branch - PlanRepository.update (line 215)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a plan repository backed by the database")
|
|
def step_plan_repo(context: Context) -> None:
|
|
from cleveragents.domain.models.core import PlanStatus
|
|
|
|
context.plan_repo = PlanRepository(session=_get_session(context))
|
|
context.error = None
|
|
context.plan_status_cls = PlanStatus
|
|
|
|
|
|
@given("a plan domain object with identifier {pid:d}")
|
|
def step_plan_with_id(context: Context, pid: int) -> None:
|
|
from cleveragents.domain.models.core import Plan as LegacyPlan
|
|
|
|
context.legacy_plan = LegacyPlan(
|
|
id=pid,
|
|
project_id=1,
|
|
name="nonexistent-plan",
|
|
prompt="test prompt",
|
|
status=context.plan_status_cls.PENDING,
|
|
current=False,
|
|
)
|
|
|
|
|
|
@when("the non-existent plan is updated through the repository")
|
|
def step_update_missing_plan(context: Context) -> None:
|
|
context.result_plan = context.plan_repo.update(context.legacy_plan)
|
|
|
|
|
|
@then("the plan object should be returned unchanged")
|
|
def step_verify_plan_unchanged(context: Context) -> None:
|
|
assert context.result_plan is not None
|
|
assert context.result_plan.id == context.legacy_plan.id
|
|
assert context.result_plan.name == context.legacy_plan.name
|