forked from HAL9000/cleveragents-core
f678d611bb
Added CorrectionAttemptModel SQLAlchemy model with all spec-defined columns
(correction_attempt_id, plan_id, original_decision_id, new_decision_id,
mode, guidance, archived_artifacts_path, state, created_at, completed_at).
Added FK constraints to v3_plans and decisions tables. Created Alembic
migration and idx_corrections_plan index. Added repository layer for
CRUD operations.
Addressed code review feedback (rounds 1-12):
- Replaced Any type annotations with typed CorrectionAttemptRecord
signatures using TYPE_CHECKING imports.
- Replaced fragile time.sleep with deterministic timestamps.
- Added cascade deletion test with PRAGMA foreign_keys=ON.
- Changed update_state() to accept typed enum and datetime params.
- Added guidance non-empty validator with max_length=10_000.
- Added spec-aligned server_default for created_at column.
- Fixed timezone handling in to_domain() and from_domain().
- Added spec-defined lifecycle state transition validation via
validate_correction_state_transition() domain function.
- Improved FK-violation error messages in create() and update_state().
- Normalised timestamp to millisecond precision matching SQLite
server_default strftime('%f') output.
- Added auto-set completed_at on terminal transitions.
- Added CorrectionAttemptRecord field validators (strip, non-empty).
- Changed original_decision_id FK from CASCADE to RESTRICT matching
spec DDL default, preserving correction audit trail.
- Added input validation in update_state() for new_decision_id and
archived_artifacts_path (empty/whitespace rejection).
- Fixed dirty-session bug by moving validation before ORM mutations.
- Extracted _SQLITE_TIMESTAMP_MS_LEN constant for timestamp truncation.
Addressed thirteenth code review feedback:
- Changed InvalidCorrectionStateTransitionError base class from
DatabaseError to BusinessRuleViolation per CONTRIBUTING.md exception
semantics (state transition is a business rule, not a database error;
prevents incorrect retries by @database_retry decorator).
- Changed new_decision_id FK from SET NULL to RESTRICT matching the
spec DDL default (no ON DELETE clause) and consistent with the
RESTRICT approach used for original_decision_id.
- Changed update_state() input validation for new_decision_id and
archived_artifacts_path from DatabaseError to ValueError per
CONTRIBUTING.md argument validation guidelines.
- Defensive to_domain() coercion now defaults corrupted state to
'failed' (terminal) instead of 'pending', preventing re-execution
of completed/failed corrections with corrupted DB values.
- Extracted format_sqlite_timestamp() helper and SQLITE_TIMESTAMP_MS_LEN
public constant, removing duplicated timestamp formatting logic
between from_domain() and update_state().
- Added code comment explaining CASCADE on plan_id FK as a codebase
convention deviation from spec DDL default.
- Added BDD scenario verifying RESTRICT FK on original_decision_id
blocks decision deletion.
- Replaced weak cross-plan isolation test with stronger two-plan
scenario verifying list_by_plan returns only each plan's attempts.
- Fixed hardcoded assertion in step_check_archived_path to use
context variable.
- 45 BDD scenarios and 5 Robot integration tests.
Addressed fourteenth code review feedback:
- Added ORM-level relationship(cascade="all, delete-orphan") on
LifecyclePlanModel for CorrectionAttemptModel, consistent with all
other v3_plans child tables, ensuring ORM-level cascade deletes
work even when SQLite FK enforcement is disabled.
- Added defensive to_domain() coercion for corrupted guidance column
(defaults to "[corrupted]" with warning log), consistent with
existing mode/state coercion pattern.
- Added ValueError guard in format_sqlite_timestamp() rejecting naive
datetimes per CONTRIBUTING.md fail-fast argument validation.
- Fixed stale spec DDL line reference in CorrectionAttemptModel
docstring.
- Fixed duplicated docstring on SQLITE_TIMESTAMP_MS_LEN constant.
Addressed fifteenth code review feedback:
- Fixed update_state() to defensively handle corrupted DB state values
via try/except ValueError coercion to FAILED terminal state with
warning log, consistent with to_domain() defensive coercion pattern.
- Strengthened RESTRICT FK BDD assertion to verify exception type
(IntegrityError/DatabaseError) instead of only checking presence.
- Split multi-When/Then cross-plan isolation BDD scenario into
idiomatic single-When/Then scenarios per Gherkin best practice.
- 53 BDD scenarios (was 45) and 5 Robot integration tests.
ISSUES CLOSED: #920
1264 lines
44 KiB
Python
1264 lines
44 KiB
Python
"""Step definitions for correction_attempt_persistence.feature.
|
|
|
|
Tests the CorrectionAttemptRepository CRUD operations, state updates,
|
|
list queries, and constraint enforcement.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
from behave import given, then, when # type: ignore[import-untyped]
|
|
from behave.runner import Context
|
|
from pydantic import ValidationError
|
|
from sqlalchemy import create_engine, event
|
|
from sqlalchemy.exc import IntegrityError as SAIntegrityError
|
|
from sqlalchemy.orm import sessionmaker
|
|
from ulid import ULID
|
|
|
|
from cleveragents.core.exceptions import DatabaseError
|
|
from cleveragents.domain.models.core.action import Action, ActionState
|
|
from cleveragents.domain.models.core.correction import (
|
|
CorrectionAttemptRecord,
|
|
CorrectionAttemptState,
|
|
CorrectionMode,
|
|
)
|
|
from cleveragents.domain.models.core.decision import (
|
|
ContextSnapshot,
|
|
Decision,
|
|
DecisionType,
|
|
)
|
|
from cleveragents.domain.models.core.plan import (
|
|
NamespacedName,
|
|
Plan,
|
|
PlanIdentity,
|
|
PlanPhase,
|
|
PlanTimestamps,
|
|
ProcessingState,
|
|
)
|
|
from cleveragents.infrastructure.database.models import Base
|
|
from cleveragents.infrastructure.database.repositories import (
|
|
ActionRepository,
|
|
CorrectionAttemptNotFoundError,
|
|
CorrectionAttemptRepository,
|
|
DecisionRepository,
|
|
DuplicateCorrectionAttemptError,
|
|
InvalidCorrectionStateTransitionError,
|
|
LifecyclePlanRepository,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_PLAN_ID = "01HV000000000000000000CA01"
|
|
_DECISION_ID = "01HV000000000000000000CD01"
|
|
|
|
|
|
def _setup_db(context: Context) -> None:
|
|
"""Create an in-memory SQLite DB and attach repos."""
|
|
engine = create_engine("sqlite:///:memory:", echo=False)
|
|
|
|
@event.listens_for(engine, "connect")
|
|
def _enable_fk(dbapi_conn: Any, _rec: Any) -> None:
|
|
cursor = dbapi_conn.cursor()
|
|
cursor.execute("PRAGMA foreign_keys=ON")
|
|
cursor.close()
|
|
|
|
Base.metadata.create_all(engine)
|
|
sm = sessionmaker(bind=engine)
|
|
session = sm()
|
|
context._ca_engine = engine
|
|
context._ca_session = session
|
|
context._ca_factory = lambda: session
|
|
context._ca_repo = CorrectionAttemptRepository(
|
|
session_factory=context._ca_factory,
|
|
)
|
|
context._ca_action_repo = ActionRepository(
|
|
session_factory=context._ca_factory,
|
|
)
|
|
context._ca_plan_repo = LifecyclePlanRepository(
|
|
session_factory=context._ca_factory,
|
|
)
|
|
context._ca_decision_repo = DecisionRepository(
|
|
session_factory=context._ca_factory,
|
|
)
|
|
|
|
|
|
def _make_correction_attempt(
|
|
plan_id: str = _PLAN_ID,
|
|
original_decision_id: str = _DECISION_ID,
|
|
mode: CorrectionMode = CorrectionMode.REVERT,
|
|
guidance: str = "Fix the broken implementation",
|
|
state: CorrectionAttemptState = CorrectionAttemptState.PENDING,
|
|
new_decision_id: str | None = None,
|
|
archived_artifacts_path: str | None = None,
|
|
completed_at: datetime | None = None,
|
|
) -> CorrectionAttemptRecord:
|
|
return CorrectionAttemptRecord(
|
|
plan_id=plan_id,
|
|
original_decision_id=original_decision_id,
|
|
mode=mode,
|
|
guidance=guidance,
|
|
state=state,
|
|
new_decision_id=new_decision_id,
|
|
archived_artifacts_path=archived_artifacts_path,
|
|
completed_at=completed_at,
|
|
)
|
|
|
|
|
|
def _create_prerequisite_action(context: Context) -> None:
|
|
ns = NamespacedName.parse("local/correction-action")
|
|
action = Action(
|
|
namespaced_name=ns,
|
|
description="Prerequisite action for correction attempt tests",
|
|
definition_of_done="Action is complete",
|
|
strategy_actor="strategy-actor",
|
|
execution_actor="execution-actor",
|
|
state=ActionState.AVAILABLE,
|
|
)
|
|
context._ca_action_repo.create(action)
|
|
context._ca_session.commit()
|
|
|
|
|
|
def _create_prerequisite_plan(context: Context) -> None:
|
|
now = datetime.now(UTC)
|
|
plan = Plan(
|
|
identity=PlanIdentity(plan_id=_PLAN_ID, attempt=1),
|
|
namespaced_name=NamespacedName.parse("local/test-plan"),
|
|
action_name="local/correction-action",
|
|
description="Test plan for correction attempt persistence",
|
|
phase=PlanPhase.EXECUTE,
|
|
processing_state=ProcessingState.PROCESSING,
|
|
timestamps=PlanTimestamps(created_at=now, updated_at=now),
|
|
)
|
|
context._ca_plan_repo.create(plan)
|
|
context._ca_session.commit()
|
|
|
|
|
|
def _create_prerequisite_decision(context: Context) -> None:
|
|
decision = Decision(
|
|
decision_id=_DECISION_ID,
|
|
plan_id=_PLAN_ID,
|
|
sequence_number=0,
|
|
decision_type=DecisionType.PROMPT_DEFINITION,
|
|
question="What approach?",
|
|
chosen_option="Build a REST API",
|
|
context_snapshot=ContextSnapshot(
|
|
hot_context_hash="sha256:test",
|
|
hot_context_ref="ref:test",
|
|
relevant_resources=[],
|
|
actor_state_ref="",
|
|
),
|
|
)
|
|
context._ca_decision_repo.create(decision)
|
|
context._ca_session.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a fresh in-memory correction attempt database")
|
|
def step_fresh_db(context: Context) -> None:
|
|
_setup_db(context)
|
|
|
|
|
|
@given('a prerequisite action "local/correction-action" exists for correction attempts')
|
|
def step_prerequisite_action(context: Context) -> None:
|
|
_create_prerequisite_action(context)
|
|
|
|
|
|
@given("a prerequisite plan exists for correction attempts")
|
|
def step_prerequisite_plan(context: Context) -> None:
|
|
_create_prerequisite_plan(context)
|
|
|
|
|
|
@given("a prerequisite root decision exists for correction attempts")
|
|
def step_prerequisite_decision(context: Context) -> None:
|
|
_create_prerequisite_decision(context)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Create scenarios
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a new correction attempt in revert mode")
|
|
def step_new_revert_attempt(context: Context) -> None:
|
|
context._ca_record = _make_correction_attempt(mode=CorrectionMode.REVERT)
|
|
|
|
|
|
@given("a new correction attempt in append mode")
|
|
def step_new_append_attempt(context: Context) -> None:
|
|
context._ca_record = _make_correction_attempt(mode=CorrectionMode.APPEND)
|
|
|
|
|
|
@given("a new correction attempt with all fields populated")
|
|
def step_new_full_attempt(context: Context) -> None:
|
|
context._ca_record = _make_correction_attempt(
|
|
guidance="Detailed correction guidance text",
|
|
archived_artifacts_path="/tmp/archived/artifacts",
|
|
)
|
|
|
|
|
|
@when("I persist the correction attempt via the repository")
|
|
def step_persist_attempt(context: Context) -> None:
|
|
result = context._ca_repo.create(context._ca_record)
|
|
context._ca_session.commit()
|
|
context._ca_persisted = result
|
|
|
|
|
|
@then("I can retrieve the correction attempt by its ID")
|
|
def step_retrieve_by_id(context: Context) -> None:
|
|
attempt_id = context._ca_persisted.correction_attempt_id
|
|
result = context._ca_repo.get(attempt_id)
|
|
context._ca_retrieved = result
|
|
assert result is not None
|
|
assert result.correction_attempt_id == attempt_id
|
|
|
|
|
|
@then('the persisted correction attempt mode should be "{mode}"')
|
|
def step_check_mode(context: Context, mode: str) -> None:
|
|
assert context._ca_retrieved.mode.value == mode
|
|
|
|
|
|
@then('the persisted correction attempt state should be "{state}"')
|
|
def step_check_state(context: Context, state: str) -> None:
|
|
assert context._ca_retrieved.state.value == state
|
|
|
|
|
|
@then("the persisted correction attempt guidance should match")
|
|
def step_check_guidance(context: Context) -> None:
|
|
assert context._ca_retrieved.guidance == context._ca_record.guidance
|
|
|
|
|
|
@then("the persisted correction attempt plan_id should match")
|
|
def step_check_plan_id(context: Context) -> None:
|
|
assert context._ca_retrieved.plan_id == context._ca_record.plan_id
|
|
|
|
|
|
@then("the persisted correction attempt original_decision_id should match")
|
|
def step_check_original_decision_id(context: Context) -> None:
|
|
assert (
|
|
context._ca_retrieved.original_decision_id
|
|
== context._ca_record.original_decision_id
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# List by plan
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("3 persisted correction attempts for the same plan")
|
|
def step_three_attempts(context: Context) -> None:
|
|
context._ca_attempts = []
|
|
base_time = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
for i in range(3):
|
|
record = _make_correction_attempt(
|
|
guidance=f"Correction guidance {i}",
|
|
)
|
|
# Use deterministic timestamps to guarantee ordering
|
|
record.created_at = base_time + timedelta(seconds=i)
|
|
result = context._ca_repo.create(record)
|
|
context._ca_session.commit()
|
|
context._ca_attempts.append(result)
|
|
|
|
|
|
@when("I list correction attempts by plan ID")
|
|
def step_list_by_plan(context: Context) -> None:
|
|
context._ca_list_result = context._ca_repo.list_by_plan(_PLAN_ID)
|
|
|
|
|
|
@then("I should get 3 correction attempts in creation order")
|
|
def step_check_list_count(context: Context) -> None:
|
|
assert len(context._ca_list_result) == 3
|
|
# Check ordering
|
|
for i in range(len(context._ca_list_result) - 1):
|
|
assert (
|
|
context._ca_list_result[i].created_at
|
|
<= context._ca_list_result[i + 1].created_at
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Update state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a persisted correction attempt in pending state")
|
|
def step_persisted_pending(context: Context) -> None:
|
|
record = _make_correction_attempt()
|
|
result = context._ca_repo.create(record)
|
|
context._ca_session.commit()
|
|
context._ca_persisted = result
|
|
context._ca_record = record
|
|
|
|
|
|
@given("a persisted correction attempt in executing state")
|
|
def step_persisted_executing(context: Context) -> None:
|
|
record = _make_correction_attempt()
|
|
result = context._ca_repo.create(record)
|
|
context._ca_session.commit()
|
|
# Transition to executing first (valid: pending → executing)
|
|
result = context._ca_repo.update_state(
|
|
result.correction_attempt_id,
|
|
state=CorrectionAttemptState.EXECUTING,
|
|
)
|
|
context._ca_session.commit()
|
|
context._ca_persisted = result
|
|
context._ca_record = record
|
|
|
|
|
|
@when('I update the correction attempt state to "{state}"')
|
|
def step_update_state(context: Context, state: str) -> None:
|
|
context._ca_updated = context._ca_repo.update_state(
|
|
context._ca_persisted.correction_attempt_id,
|
|
state=CorrectionAttemptState(state),
|
|
)
|
|
context._ca_session.commit()
|
|
|
|
|
|
@when('I try to update the correction attempt state to "{state}"')
|
|
def step_try_update_state(context: Context, state: str) -> None:
|
|
try:
|
|
context._ca_repo.update_state(
|
|
context._ca_persisted.correction_attempt_id,
|
|
state=CorrectionAttemptState(state),
|
|
)
|
|
context._ca_session.commit()
|
|
context._ca_invalid_transition_error = None
|
|
except InvalidCorrectionStateTransitionError as exc:
|
|
context._ca_session.rollback()
|
|
context._ca_invalid_transition_error = exc
|
|
|
|
|
|
@then('the correction attempt state should be "{state}"')
|
|
def step_verify_state(context: Context, state: str) -> None:
|
|
refreshed = context._ca_repo.get(
|
|
context._ca_persisted.correction_attempt_id,
|
|
)
|
|
assert refreshed.state.value == state
|
|
|
|
|
|
@when("I update the correction attempt to complete with timestamp")
|
|
def step_update_complete_with_timestamp(context: Context) -> None:
|
|
context._ca_updated = context._ca_repo.update_state(
|
|
context._ca_persisted.correction_attempt_id,
|
|
state=CorrectionAttemptState.COMPLETE,
|
|
completed_at=datetime.now(UTC),
|
|
)
|
|
context._ca_session.commit()
|
|
|
|
|
|
@then("the correction attempt completed_at should be set")
|
|
def step_check_completed_at(context: Context) -> None:
|
|
refreshed = context._ca_repo.get(
|
|
context._ca_persisted.correction_attempt_id,
|
|
)
|
|
assert refreshed.completed_at is not None
|
|
|
|
|
|
@when("I update the correction attempt with a new decision ID")
|
|
def step_update_new_decision(context: Context) -> None:
|
|
new_dec_id = str(ULID())
|
|
# Create the new decision first
|
|
new_decision = Decision(
|
|
decision_id=new_dec_id,
|
|
plan_id=_PLAN_ID,
|
|
sequence_number=1,
|
|
decision_type=DecisionType.STRATEGY_CHOICE,
|
|
question="New approach?",
|
|
chosen_option="Use a new strategy",
|
|
context_snapshot=ContextSnapshot(
|
|
hot_context_hash="sha256:new",
|
|
hot_context_ref="ref:new",
|
|
relevant_resources=[],
|
|
actor_state_ref="",
|
|
),
|
|
)
|
|
context._ca_decision_repo.create(new_decision)
|
|
context._ca_session.commit()
|
|
|
|
context._ca_new_decision_id = new_dec_id
|
|
context._ca_updated = context._ca_repo.update_state(
|
|
context._ca_persisted.correction_attempt_id,
|
|
state=CorrectionAttemptState.EXECUTING,
|
|
new_decision_id=new_dec_id,
|
|
)
|
|
context._ca_session.commit()
|
|
|
|
|
|
@then("the correction attempt new_decision_id should be set")
|
|
def step_check_new_decision_id(context: Context) -> None:
|
|
refreshed = context._ca_repo.get(
|
|
context._ca_persisted.correction_attempt_id,
|
|
)
|
|
assert refreshed.new_decision_id == context._ca_new_decision_id
|
|
|
|
|
|
@when("I update the correction attempt with archived artifacts path")
|
|
def step_update_archived_path(context: Context) -> None:
|
|
context._ca_archived_path = "/tmp/archived/correction"
|
|
context._ca_updated = context._ca_repo.update_state(
|
|
context._ca_persisted.correction_attempt_id,
|
|
state=CorrectionAttemptState.EXECUTING,
|
|
archived_artifacts_path=context._ca_archived_path,
|
|
)
|
|
context._ca_session.commit()
|
|
|
|
|
|
@then("the correction attempt archived_artifacts_path should be set")
|
|
def step_check_archived_path(context: Context) -> None:
|
|
refreshed = context._ca_repo.get(
|
|
context._ca_persisted.correction_attempt_id,
|
|
)
|
|
assert refreshed.archived_artifacts_path == context._ca_archived_path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Delete
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I delete the correction attempt")
|
|
def step_delete_attempt(context: Context) -> None:
|
|
context._ca_delete_result = context._ca_repo.delete(
|
|
context._ca_persisted.correction_attempt_id,
|
|
)
|
|
context._ca_session.commit()
|
|
|
|
|
|
@then("the correction attempt should no longer exist")
|
|
def step_check_deleted(context: Context) -> None:
|
|
assert context._ca_delete_result is True
|
|
try:
|
|
context._ca_repo.get(
|
|
context._ca_persisted.correction_attempt_id,
|
|
)
|
|
msg = "Should have raised CorrectionAttemptNotFoundError"
|
|
raise AssertionError(msg)
|
|
except CorrectionAttemptNotFoundError:
|
|
pass
|
|
|
|
|
|
@when("I try to delete a non-existent correction attempt")
|
|
def step_delete_nonexistent(context: Context) -> None:
|
|
context._ca_delete_result = context._ca_repo.delete(
|
|
"01HV000000000000000NONEXIST",
|
|
)
|
|
|
|
|
|
@then("the correction attempt delete result should be false")
|
|
def step_check_delete_false(context: Context) -> None:
|
|
assert context._ca_delete_result is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cascade deletion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("the parent plan is deleted")
|
|
def step_delete_parent_plan(context: Context) -> None:
|
|
context._ca_plan_repo.delete(_PLAN_ID)
|
|
context._ca_session.commit()
|
|
|
|
|
|
@then("the correction attempt should have been cascade deleted")
|
|
def step_check_cascade_deleted(context: Context) -> None:
|
|
try:
|
|
context._ca_repo.get(
|
|
context._ca_persisted.correction_attempt_id,
|
|
)
|
|
msg = "Should have raised CorrectionAttemptNotFoundError after cascade delete"
|
|
raise AssertionError(msg)
|
|
except CorrectionAttemptNotFoundError:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Duplicate detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I try to persist the same correction attempt again")
|
|
def step_persist_duplicate(context: Context) -> None:
|
|
try:
|
|
context._ca_repo.create(context._ca_record)
|
|
context._ca_session.commit()
|
|
context._ca_duplicate_error = None
|
|
except DuplicateCorrectionAttemptError as exc:
|
|
context._ca_session.rollback()
|
|
context._ca_duplicate_error = exc
|
|
|
|
|
|
@then("a DuplicateCorrectionAttemptError should be raised")
|
|
def step_check_duplicate_error(context: Context) -> None:
|
|
assert context._ca_duplicate_error is not None
|
|
assert isinstance(
|
|
context._ca_duplicate_error,
|
|
DuplicateCorrectionAttemptError,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Not found
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I try to get a non-existent correction attempt")
|
|
def step_get_nonexistent(context: Context) -> None:
|
|
try:
|
|
context._ca_repo.get("01HV000000000000000NONEXIST")
|
|
context._ca_not_found_error = None
|
|
except CorrectionAttemptNotFoundError as exc:
|
|
context._ca_not_found_error = exc
|
|
|
|
|
|
@then("a CorrectionAttemptNotFoundError should be raised")
|
|
def step_check_not_found_error(context: Context) -> None:
|
|
assert context._ca_not_found_error is not None
|
|
assert isinstance(
|
|
context._ca_not_found_error,
|
|
CorrectionAttemptNotFoundError,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Invalid state transitions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("an InvalidCorrectionStateTransitionError should be raised")
|
|
def step_check_invalid_transition_error(context: Context) -> None:
|
|
assert context._ca_invalid_transition_error is not None
|
|
assert isinstance(
|
|
context._ca_invalid_transition_error,
|
|
InvalidCorrectionStateTransitionError,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I list correction attempts for a plan with no correction attempts")
|
|
def step_list_empty(context: Context) -> None:
|
|
context._ca_list_result = context._ca_repo.list_by_plan(
|
|
"01HV000000000000000NOATTEMPT",
|
|
)
|
|
|
|
|
|
@then("I should get 0 correction attempts")
|
|
def step_check_empty_list(context: Context) -> None:
|
|
assert len(context._ca_list_result) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Additional state transition coverage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I update the correction attempt to failed with timestamp")
|
|
def step_update_failed_with_timestamp(context: Context) -> None:
|
|
context._ca_updated = context._ca_repo.update_state(
|
|
context._ca_persisted.correction_attempt_id,
|
|
state=CorrectionAttemptState.FAILED,
|
|
completed_at=datetime.now(UTC),
|
|
)
|
|
context._ca_session.commit()
|
|
|
|
|
|
@given("a persisted correction attempt in complete state")
|
|
def step_persisted_complete(context: Context) -> None:
|
|
record = _make_correction_attempt()
|
|
result = context._ca_repo.create(record)
|
|
context._ca_session.commit()
|
|
# pending → executing
|
|
result = context._ca_repo.update_state(
|
|
result.correction_attempt_id,
|
|
state=CorrectionAttemptState.EXECUTING,
|
|
)
|
|
context._ca_session.commit()
|
|
# executing → complete
|
|
result = context._ca_repo.update_state(
|
|
result.correction_attempt_id,
|
|
state=CorrectionAttemptState.COMPLETE,
|
|
completed_at=datetime.now(UTC),
|
|
)
|
|
context._ca_session.commit()
|
|
context._ca_persisted = result
|
|
context._ca_record = record
|
|
|
|
|
|
@given("a persisted correction attempt in failed state")
|
|
def step_persisted_failed(context: Context) -> None:
|
|
record = _make_correction_attempt()
|
|
result = context._ca_repo.create(record)
|
|
context._ca_session.commit()
|
|
# pending → executing
|
|
result = context._ca_repo.update_state(
|
|
result.correction_attempt_id,
|
|
state=CorrectionAttemptState.EXECUTING,
|
|
)
|
|
context._ca_session.commit()
|
|
# executing → failed
|
|
result = context._ca_repo.update_state(
|
|
result.correction_attempt_id,
|
|
state=CorrectionAttemptState.FAILED,
|
|
completed_at=datetime.now(UTC),
|
|
)
|
|
context._ca_session.commit()
|
|
context._ca_persisted = result
|
|
context._ca_record = record
|
|
|
|
|
|
@when("I try to update the correction attempt to executing with completed_at")
|
|
def step_try_update_executing_with_completed_at(context: Context) -> None:
|
|
try:
|
|
context._ca_repo.update_state(
|
|
context._ca_persisted.correction_attempt_id,
|
|
state=CorrectionAttemptState.EXECUTING,
|
|
completed_at=datetime.now(UTC),
|
|
)
|
|
context._ca_session.commit()
|
|
context._ca_invalid_transition_error = None
|
|
except InvalidCorrectionStateTransitionError as exc:
|
|
context._ca_session.rollback()
|
|
context._ca_invalid_transition_error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Domain model guidance validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I try to create a correction attempt with empty guidance")
|
|
def step_create_empty_guidance(context: Context) -> None:
|
|
try:
|
|
_make_correction_attempt(guidance="")
|
|
context._ca_guidance_error = None
|
|
except ValidationError as exc:
|
|
context._ca_guidance_error = exc
|
|
|
|
|
|
@when("I try to create a correction attempt with whitespace-only guidance")
|
|
def step_create_whitespace_guidance(context: Context) -> None:
|
|
try:
|
|
_make_correction_attempt(guidance=" \t\n ")
|
|
context._ca_guidance_error = None
|
|
except ValidationError as exc:
|
|
context._ca_guidance_error = exc
|
|
|
|
|
|
@then("a guidance validation error should be raised")
|
|
def step_check_guidance_error(context: Context) -> None:
|
|
assert context._ca_guidance_error is not None
|
|
assert isinstance(context._ca_guidance_error, ValidationError)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Additional test coverage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I try to update the state of a non-existent correction attempt")
|
|
def step_try_update_nonexistent(context: Context) -> None:
|
|
try:
|
|
context._ca_repo.update_state(
|
|
"01HV000000000000000NONEXIST",
|
|
state=CorrectionAttemptState.EXECUTING,
|
|
)
|
|
context._ca_not_found_error = None
|
|
except CorrectionAttemptNotFoundError as exc:
|
|
context._ca_not_found_error = exc
|
|
|
|
|
|
@when("I try to update the correction attempt with a non-existent decision ID")
|
|
def step_try_update_bad_fk(context: Context) -> None:
|
|
try:
|
|
context._ca_repo.update_state(
|
|
context._ca_persisted.correction_attempt_id,
|
|
state=CorrectionAttemptState.EXECUTING,
|
|
new_decision_id="01HV000000000000000BADFK01",
|
|
)
|
|
context._ca_session.commit()
|
|
context._ca_db_error = None
|
|
except DatabaseError as exc:
|
|
context._ca_session.rollback()
|
|
context._ca_db_error = exc
|
|
|
|
|
|
@then("a DatabaseError should be raised")
|
|
def step_check_db_error(context: Context) -> None:
|
|
assert context._ca_db_error is not None
|
|
assert isinstance(context._ca_db_error, DatabaseError)
|
|
|
|
|
|
@given("a new correction attempt with guidance at max length")
|
|
def step_new_max_guidance(context: Context) -> None:
|
|
context._ca_record = _make_correction_attempt(
|
|
guidance="x" * 10_000,
|
|
)
|
|
|
|
|
|
@when("I try to create a correction attempt with guidance exceeding max length")
|
|
def step_create_over_max_guidance(context: Context) -> None:
|
|
try:
|
|
_make_correction_attempt(guidance="x" * 10_001)
|
|
context._ca_guidance_error = None
|
|
except ValidationError as exc:
|
|
context._ca_guidance_error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Timezone normalization coverage (M3)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a new correction attempt with non-UTC timezone timestamps")
|
|
def step_new_non_utc_attempt(context: Context) -> None:
|
|
from datetime import timezone as _tz
|
|
|
|
eastern = _tz(timedelta(hours=-5))
|
|
context._ca_record = _make_correction_attempt(
|
|
guidance="Timezone normalization test",
|
|
)
|
|
# Override created_at with a non-UTC timezone-aware datetime
|
|
context._ca_record.created_at = datetime(2026, 6, 15, 12, 0, 0, tzinfo=eastern)
|
|
|
|
|
|
@then("the persisted correction attempt created_at should be in UTC")
|
|
def step_check_created_at_utc(context: Context) -> None:
|
|
retrieved = context._ca_retrieved
|
|
assert retrieved.created_at.tzinfo is not None
|
|
assert retrieved.created_at.utcoffset() == timedelta(0)
|
|
# Original was 12:00 EST (-5h) -> should be 17:00 UTC
|
|
assert retrieved.created_at.hour == 17
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# archived_artifacts_path round-trip on create (M4)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a new correction attempt with archived_artifacts_path set")
|
|
def step_new_attempt_with_artifacts_path(context: Context) -> None:
|
|
context._ca_record = _make_correction_attempt(
|
|
guidance="Artifacts path round-trip test",
|
|
archived_artifacts_path="/archive/correction/2026-06-15",
|
|
)
|
|
|
|
|
|
@then("the persisted correction attempt archived_artifacts_path should match")
|
|
def step_check_archived_path_match(context: Context) -> None:
|
|
assert (
|
|
context._ca_retrieved.archived_artifacts_path
|
|
== context._ca_record.archived_artifacts_path
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Minimum-boundary guidance (L7)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a new correction attempt with single-character guidance")
|
|
def step_new_single_char_guidance(context: Context) -> None:
|
|
context._ca_record = _make_correction_attempt(guidance="X")
|
|
|
|
|
|
@then("the persisted correction attempt guidance should be a single character")
|
|
def step_check_single_char_guidance(context: Context) -> None:
|
|
assert context._ca_retrieved.guidance == "X"
|
|
assert len(context._ca_retrieved.guidance) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auto-set completed_at for terminal states (T-1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I update the correction attempt state to "complete" without completed_at')
|
|
def step_update_complete_no_timestamp(context: Context) -> None:
|
|
context._ca_updated = context._ca_repo.update_state(
|
|
context._ca_persisted.correction_attempt_id,
|
|
state=CorrectionAttemptState.COMPLETE,
|
|
)
|
|
context._ca_session.commit()
|
|
|
|
|
|
@when('I update the correction attempt state to "failed" without completed_at')
|
|
def step_update_failed_no_timestamp(context: Context) -> None:
|
|
context._ca_updated = context._ca_repo.update_state(
|
|
context._ca_persisted.correction_attempt_id,
|
|
state=CorrectionAttemptState.FAILED,
|
|
)
|
|
context._ca_session.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FK violation on create (T-2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I try to create a correction attempt with a non-existent plan_id")
|
|
def step_create_bad_plan_fk(context: Context) -> None:
|
|
try:
|
|
record = _make_correction_attempt(
|
|
plan_id="01HV000000000000000BADPLAN",
|
|
)
|
|
context._ca_repo.create(record)
|
|
context._ca_session.commit()
|
|
context._ca_db_error = None
|
|
except DatabaseError as exc:
|
|
context._ca_session.rollback()
|
|
context._ca_db_error = exc
|
|
|
|
|
|
@when("I try to create a correction attempt with a non-existent original_decision_id")
|
|
def step_create_bad_decision_fk(context: Context) -> None:
|
|
try:
|
|
record = _make_correction_attempt(
|
|
original_decision_id="01HV000000000000000BADDEC1",
|
|
)
|
|
context._ca_repo.create(record)
|
|
context._ca_session.commit()
|
|
context._ca_db_error = None
|
|
except DatabaseError as exc:
|
|
context._ca_session.rollback()
|
|
context._ca_db_error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Invalid mode at domain model level (T-3)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I try to create a correction attempt with an invalid mode")
|
|
def step_create_invalid_mode(context: Context) -> None:
|
|
try:
|
|
CorrectionAttemptRecord(
|
|
plan_id=_PLAN_ID,
|
|
original_decision_id=_DECISION_ID,
|
|
mode="invalid_mode", # type: ignore[arg-type]
|
|
guidance="Some guidance",
|
|
)
|
|
context._ca_mode_error = None
|
|
except ValidationError as exc:
|
|
context._ca_mode_error = exc
|
|
|
|
|
|
@then("a mode validation error should be raised")
|
|
def step_check_mode_error(context: Context) -> None:
|
|
assert context._ca_mode_error is not None
|
|
assert isinstance(context._ca_mode_error, ValidationError)
|
|
# Verify the error pertains to the mode field specifically
|
|
error_fields = {str(e["loc"]) for e in context._ca_mode_error.errors()}
|
|
assert any("mode" in field for field in error_fields)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Empty/whitespace new_decision_id via update_state (M-1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I try to update the correction attempt with whitespace-only new_decision_id")
|
|
def step_try_update_whitespace_decision_id(context: Context) -> None:
|
|
try:
|
|
context._ca_repo.update_state(
|
|
context._ca_persisted.correction_attempt_id,
|
|
state=CorrectionAttemptState.EXECUTING,
|
|
new_decision_id=" ",
|
|
)
|
|
context._ca_session.commit()
|
|
context._ca_value_error = None
|
|
except ValueError as exc:
|
|
context._ca_value_error = exc
|
|
|
|
|
|
@when("I try to update the correction attempt with empty new_decision_id")
|
|
def step_try_update_empty_decision_id(context: Context) -> None:
|
|
try:
|
|
context._ca_repo.update_state(
|
|
context._ca_persisted.correction_attempt_id,
|
|
state=CorrectionAttemptState.EXECUTING,
|
|
new_decision_id="",
|
|
)
|
|
context._ca_session.commit()
|
|
context._ca_value_error = None
|
|
except ValueError as exc:
|
|
context._ca_value_error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Combined field update (L-5)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when(
|
|
"I update the correction attempt with new_decision_id and archived_artifacts_path"
|
|
)
|
|
def step_update_combined_fields(context: Context) -> None:
|
|
new_dec_id = str(ULID())
|
|
# Create the new decision first
|
|
new_decision = Decision(
|
|
decision_id=new_dec_id,
|
|
plan_id=_PLAN_ID,
|
|
sequence_number=2,
|
|
decision_type=DecisionType.STRATEGY_CHOICE,
|
|
question="Combined update approach?",
|
|
chosen_option="Use both fields",
|
|
context_snapshot=ContextSnapshot(
|
|
hot_context_hash="sha256:combined",
|
|
hot_context_ref="ref:combined",
|
|
relevant_resources=[],
|
|
actor_state_ref="",
|
|
),
|
|
)
|
|
context._ca_decision_repo.create(new_decision)
|
|
context._ca_session.commit()
|
|
|
|
context._ca_new_decision_id = new_dec_id
|
|
context._ca_archived_path = "/tmp/archived/correction"
|
|
context._ca_updated = context._ca_repo.update_state(
|
|
context._ca_persisted.correction_attempt_id,
|
|
state=CorrectionAttemptState.EXECUTING,
|
|
new_decision_id=new_dec_id,
|
|
archived_artifacts_path=context._ca_archived_path,
|
|
)
|
|
context._ca_session.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Empty/whitespace archived_artifacts_path via update_state (L-2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I try to update the correction attempt with empty archived_artifacts_path")
|
|
def step_try_update_empty_artifacts_path(context: Context) -> None:
|
|
try:
|
|
context._ca_repo.update_state(
|
|
context._ca_persisted.correction_attempt_id,
|
|
state=CorrectionAttemptState.EXECUTING,
|
|
archived_artifacts_path="",
|
|
)
|
|
context._ca_session.commit()
|
|
context._ca_value_error = None
|
|
except ValueError as exc:
|
|
context._ca_value_error = exc
|
|
|
|
|
|
@when(
|
|
"I try to update the correction attempt with whitespace-only archived_artifacts_path"
|
|
)
|
|
def step_try_update_whitespace_artifacts_path(context: Context) -> None:
|
|
try:
|
|
context._ca_repo.update_state(
|
|
context._ca_persisted.correction_attempt_id,
|
|
state=CorrectionAttemptState.EXECUTING,
|
|
archived_artifacts_path=" ",
|
|
)
|
|
context._ca_session.commit()
|
|
context._ca_value_error = None
|
|
except ValueError as exc:
|
|
context._ca_value_error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Whitespace-padded archived_artifacts_path stripping (L-6)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I update the correction attempt with whitespace-padded archived_artifacts_path")
|
|
def step_update_whitespace_padded_artifacts_path(context: Context) -> None:
|
|
context._ca_updated = context._ca_repo.update_state(
|
|
context._ca_persisted.correction_attempt_id,
|
|
state=CorrectionAttemptState.EXECUTING,
|
|
archived_artifacts_path=" /tmp/archived/padded ",
|
|
)
|
|
context._ca_session.commit()
|
|
|
|
|
|
@then("the correction attempt archived_artifacts_path should be stripped")
|
|
def step_check_stripped_artifacts_path(context: Context) -> None:
|
|
refreshed = context._ca_repo.get(
|
|
context._ca_persisted.correction_attempt_id,
|
|
)
|
|
assert refreshed.archived_artifacts_path == "/tmp/archived/padded"
|
|
|
|
|
|
@then("a correction attempt ValueError should be raised")
|
|
def step_check_value_error(context: Context) -> None:
|
|
assert context._ca_value_error is not None
|
|
assert isinstance(context._ca_value_error, ValueError)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RESTRICT FK on original_decision_id (R-1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_SECOND_PLAN_ID = "01HV000000000000000000CA02"
|
|
|
|
|
|
@when("I try to delete the original decision")
|
|
def step_try_delete_original_decision(context: Context) -> None:
|
|
try:
|
|
context._ca_decision_repo.delete(_DECISION_ID)
|
|
context._ca_session.commit()
|
|
context._ca_integrity_error = None
|
|
except Exception as exc:
|
|
context._ca_session.rollback()
|
|
context._ca_integrity_error = exc
|
|
|
|
|
|
@then("a database integrity error should be raised for the decision")
|
|
def step_check_integrity_error(context: Context) -> None:
|
|
assert context._ca_integrity_error is not None
|
|
assert isinstance(context._ca_integrity_error, (SAIntegrityError, DatabaseError))
|
|
|
|
|
|
@then("the correction attempt should still exist")
|
|
def step_check_attempt_still_exists(context: Context) -> None:
|
|
result = context._ca_repo.get(
|
|
context._ca_persisted.correction_attempt_id,
|
|
)
|
|
assert result is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stronger cross-plan isolation (I-1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a second prerequisite plan exists for correction attempts")
|
|
def step_second_prerequisite_plan(context: Context) -> None:
|
|
now = datetime.now(UTC)
|
|
plan = Plan(
|
|
identity=PlanIdentity(plan_id=_SECOND_PLAN_ID, attempt=1),
|
|
namespaced_name=NamespacedName.parse("local/test-plan-2"),
|
|
action_name="local/correction-action",
|
|
description="Second test plan for cross-plan isolation",
|
|
phase=PlanPhase.EXECUTE,
|
|
processing_state=ProcessingState.PROCESSING,
|
|
timestamps=PlanTimestamps(created_at=now, updated_at=now),
|
|
)
|
|
context._ca_plan_repo.create(plan)
|
|
context._ca_session.commit()
|
|
|
|
|
|
@given("a persisted correction attempt for the first plan")
|
|
def step_persisted_first_plan(context: Context) -> None:
|
|
record = _make_correction_attempt(
|
|
plan_id=_PLAN_ID,
|
|
guidance="Attempt for first plan",
|
|
)
|
|
result = context._ca_repo.create(record)
|
|
context._ca_session.commit()
|
|
context._ca_first_plan_attempt = result
|
|
|
|
|
|
@given("a persisted correction attempt for the second plan")
|
|
def step_persisted_second_plan(context: Context) -> None:
|
|
record = _make_correction_attempt(
|
|
plan_id=_SECOND_PLAN_ID,
|
|
guidance="Attempt for second plan",
|
|
)
|
|
result = context._ca_repo.create(record)
|
|
context._ca_session.commit()
|
|
context._ca_second_plan_attempt = result
|
|
|
|
|
|
@when("I list correction attempts for the first plan")
|
|
def step_list_first_plan(context: Context) -> None:
|
|
context._ca_first_list_result = context._ca_repo.list_by_plan(_PLAN_ID)
|
|
|
|
|
|
@then("I should get exactly 1 correction attempt for the first plan")
|
|
def step_check_first_plan_count(context: Context) -> None:
|
|
assert len(context._ca_first_list_result) == 1
|
|
assert context._ca_first_list_result[0].plan_id == _PLAN_ID
|
|
|
|
|
|
@when("I list correction attempts for the second plan")
|
|
def step_list_second_plan(context: Context) -> None:
|
|
context._ca_second_list_result = context._ca_repo.list_by_plan(_SECOND_PLAN_ID)
|
|
|
|
|
|
@then("I should get exactly 1 correction attempt for the second plan")
|
|
def step_check_second_plan_count(context: Context) -> None:
|
|
assert len(context._ca_second_list_result) == 1
|
|
assert context._ca_second_list_result[0].plan_id == _SECOND_PLAN_ID
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Defensive to_domain() coercion for corrupted DB data (DC-1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _insert_corrupted_row(
|
|
context: Context,
|
|
attempt_id: str,
|
|
mode: str = "revert",
|
|
guidance: str = "test guidance",
|
|
state: str = "pending",
|
|
) -> None:
|
|
"""Insert a raw row bypassing ORM and CHECK constraints.
|
|
|
|
Temporarily disables SQLite CHECK constraints via PRAGMA so
|
|
corrupted values can be inserted for defensive-coercion tests.
|
|
"""
|
|
raw_conn = context._ca_engine.raw_connection()
|
|
cursor = raw_conn.cursor()
|
|
cursor.execute("PRAGMA ignore_check_constraints = ON")
|
|
cursor.execute(
|
|
"INSERT INTO correction_attempts "
|
|
"(correction_attempt_id, plan_id, original_decision_id, "
|
|
"mode, guidance, state, created_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%f', 'now'))",
|
|
(attempt_id, _PLAN_ID, _DECISION_ID, mode, guidance, state),
|
|
)
|
|
cursor.execute("PRAGMA ignore_check_constraints = OFF")
|
|
raw_conn.commit()
|
|
cursor.close()
|
|
raw_conn.close()
|
|
|
|
|
|
@given('a raw correction attempt row with corrupted mode "{mode}"')
|
|
def step_insert_corrupted_mode(context: Context, mode: str) -> None:
|
|
"""Insert a raw row with a corrupted mode value."""
|
|
attempt_id = str(ULID())
|
|
context._ca_corrupted_id = attempt_id
|
|
_insert_corrupted_row(context, attempt_id, mode=mode)
|
|
|
|
|
|
@given('a raw correction attempt row with corrupted state "{state}"')
|
|
def step_insert_corrupted_state(context: Context, state: str) -> None:
|
|
"""Insert a raw row with a corrupted state value."""
|
|
attempt_id = str(ULID())
|
|
context._ca_corrupted_id = attempt_id
|
|
_insert_corrupted_row(context, attempt_id, state=state)
|
|
|
|
|
|
@given("a raw correction attempt row with empty guidance")
|
|
def step_insert_empty_guidance(context: Context) -> None:
|
|
"""Insert a raw row with empty guidance."""
|
|
attempt_id = str(ULID())
|
|
context._ca_corrupted_id = attempt_id
|
|
_insert_corrupted_row(context, attempt_id, guidance="")
|
|
|
|
|
|
@when("I retrieve the corrupted correction attempt by ID")
|
|
def step_retrieve_corrupted(context: Context) -> None:
|
|
# Expire session cache so the ORM re-reads from the database,
|
|
# picking up data inserted via the raw connection.
|
|
context._ca_session.expire_all()
|
|
context._ca_corrupted_record = context._ca_repo.get(
|
|
context._ca_corrupted_id,
|
|
)
|
|
|
|
|
|
@then('the retrieved correction attempt mode should be "{mode}"')
|
|
def step_check_corrupted_mode(context: Context, mode: str) -> None:
|
|
assert context._ca_corrupted_record.mode.value == mode
|
|
|
|
|
|
@then('the retrieved correction attempt state should be "{state}"')
|
|
def step_check_corrupted_state(context: Context, state: str) -> None:
|
|
assert context._ca_corrupted_record.state.value == state
|
|
|
|
|
|
@then('the retrieved correction attempt guidance should be "{guidance}"')
|
|
def step_check_corrupted_guidance(context: Context, guidance: str) -> None:
|
|
assert context._ca_corrupted_record.guidance == guidance
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# format_sqlite_timestamp naive datetime rejection (TS-1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I call format_sqlite_timestamp with a naive datetime")
|
|
def step_call_format_sqlite_naive(context: Context) -> None:
|
|
from cleveragents.infrastructure.database.models import (
|
|
format_sqlite_timestamp,
|
|
)
|
|
|
|
try:
|
|
format_sqlite_timestamp(datetime(2026, 1, 1, 12, 0, 0))
|
|
context._ca_naive_dt_error = None
|
|
except ValueError as exc:
|
|
context._ca_naive_dt_error = exc
|
|
|
|
|
|
@then("a ValueError should be raised for naive datetime")
|
|
def step_check_naive_dt_error(context: Context) -> None:
|
|
assert context._ca_naive_dt_error is not None
|
|
assert isinstance(context._ca_naive_dt_error, ValueError)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Domain model naive datetime normalisation (TS-2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I create a CorrectionAttemptRecord with naive created_at")
|
|
def step_create_record_naive_created_at(context: Context) -> None:
|
|
context._ca_naive_record = CorrectionAttemptRecord(
|
|
plan_id=_PLAN_ID,
|
|
original_decision_id=_DECISION_ID,
|
|
mode=CorrectionMode.REVERT,
|
|
guidance="Naive datetime test",
|
|
created_at=datetime(2026, 6, 15, 12, 0, 0),
|
|
)
|
|
|
|
|
|
@then("the record created_at should have UTC timezone")
|
|
def step_check_record_created_at_utc(context: Context) -> None:
|
|
assert context._ca_naive_record.created_at.tzinfo is not None
|
|
assert context._ca_naive_record.created_at.utcoffset() == timedelta(0)
|
|
|
|
|
|
@when("I create a CorrectionAttemptRecord with naive completed_at")
|
|
def step_create_record_naive_completed_at(context: Context) -> None:
|
|
context._ca_naive_record = CorrectionAttemptRecord(
|
|
plan_id=_PLAN_ID,
|
|
original_decision_id=_DECISION_ID,
|
|
mode=CorrectionMode.REVERT,
|
|
guidance="Naive datetime test",
|
|
completed_at=datetime(2026, 6, 15, 12, 0, 0),
|
|
)
|
|
|
|
|
|
@then("the record completed_at should have UTC timezone")
|
|
def step_check_record_completed_at_utc(context: Context) -> None:
|
|
assert context._ca_naive_record.completed_at is not None
|
|
assert context._ca_naive_record.completed_at.tzinfo is not None
|
|
assert context._ca_naive_record.completed_at.utcoffset() == timedelta(0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Corrupted DB state in update_state (DC-2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I try to update the corrupted correction attempt state to "{state}"')
|
|
def step_try_update_corrupted_state(context: Context, state: str) -> None:
|
|
# Expire session cache so the ORM re-reads the corrupted row.
|
|
context._ca_session.expire_all()
|
|
try:
|
|
context._ca_repo.update_state(
|
|
context._ca_corrupted_id,
|
|
state=CorrectionAttemptState(state),
|
|
)
|
|
context._ca_session.commit()
|
|
context._ca_invalid_transition_error = None
|
|
except InvalidCorrectionStateTransitionError as exc:
|
|
context._ca_session.rollback()
|
|
context._ca_invalid_transition_error = exc
|