diff --git a/features/decision_recording.feature b/features/decision_recording.feature index 3b02c2313..5505989a9 100644 --- a/features/decision_recording.feature +++ b/features/decision_recording.feature @@ -303,3 +303,70 @@ Feature: Decision recording and snapshot store Given a standalone snapshot store When I list snapshots for decisions with missing entries Then the dsvc standalone plan snapshots should have 0 entries + + # --- Persisted-mode (database-backed) --- + + Scenario: Persisted record and get decision round-trips through database + Given a persisted decision service + When I record a prompt_definition decision for plan "P1" with question "Persisted root" + And I get the decision by its ID + Then the dsvc retrieved decision should match the recorded decision + + Scenario: Persisted get non-existent decision raises DecisionNotFoundError + Given a persisted decision service + When I try to get decision "01NONEXISTENT00000000000000" + Then a dsvc decision not found error should be raised + + Scenario: Persisted list decisions returns ordered results from database + Given a persisted decision service + When I record a prompt_definition decision for plan "P1" with question "First persisted" + And I record a strategy_choice decision for plan "P1" with question "Second persisted" + And I list decisions for plan "P1" + Then the dsvc decision list should have 2 entries + And the dsvc decision list should be ordered by sequence number + + Scenario: Persisted list by type filters through database + Given a persisted decision service + When I record a prompt_definition decision for plan "P1" with question "Root" + And I record a strategy_choice decision for plan "P1" with question "Strategy" + And I record a strategy_choice decision for plan "P1" with question "Strategy 2" + And I filter decisions for plan "P1" by type "strategy_choice" + Then the dsvc decision list should have 2 entries + + Scenario: Persisted get path to root navigates through database + Given a persisted decision service + When I record a prompt_definition decision for plan "P1" with question "Root" + And I record a strategy_choice decision for plan "P1" with parent as child "Level 1" + And I record an implementation_choice decision for plan "P1" with second parent as child "Level 2" + And I get the path to root from the last decision + Then the dsvc path should have 3 decisions + And the dsvc last path decision should be the root + + Scenario: Persisted get superseded returns superseded decisions from database + Given a persisted decision service + When I record a prompt_definition decision for plan "P1" with question "Original" + And I record a strategy_choice decision for plan "P1" with question "Replacement" + And I mark the first decision as superseded by the second + And I get superseded decisions for plan "P1" + Then the dsvc superseded list should have 1 entry + + Scenario: Persisted mark superseded updates database and cache + Given a persisted decision service + When I record a prompt_definition decision for plan "P1" with question "Old" + And I record a strategy_choice decision for plan "P1" with question "New" + And I mark the first decision as superseded by the second + Then the dsvc first decision should be superseded + And the dsvc first decision superseded_by should match the second decision + + Scenario: Persisted delete removes decision from database + Given a persisted decision service + When I record a prompt_definition decision for plan "P1" with question "Ephemeral" + And I delete the recorded decision + Then the dsvc decision should be deleted + And the dsvc plan "P1" should have 0 decisions + + Scenario: Persisted duplicate decision raises DuplicateDecisionError + Given a persisted decision service + When I record a prompt_definition decision for plan "P1" with question "Original" + And I try to store a duplicate of the recorded decision + Then a dsvc duplicate decision error should be raised diff --git a/features/steps/decision_recording_steps.py b/features/steps/decision_recording_steps.py index b50b98f42..76064a3fb 100644 --- a/features/steps/decision_recording_steps.py +++ b/features/steps/decision_recording_steps.py @@ -7,7 +7,9 @@ defines similar assertion patterns for the domain model layer. from __future__ import annotations +import os import re +import tempfile from behave import given, then, when from behave.runner import Context @@ -948,3 +950,63 @@ def step_list_snapshots_missing(context: Context) -> None: @then("the dsvc standalone plan snapshots should have {count:d} entries") def step_standalone_plan_snapshots_count(context: Context, count: int) -> None: assert len(context.standalone_plan_snapshots) == count + + +# --------------------------------------------------------------------------- +# Persisted-mode (database-backed) steps +# --------------------------------------------------------------------------- + + +@given("a persisted decision service") +def step_persisted_decision_service(context: Context) -> None: + """Create a DecisionService backed by a real SQLite database via UnitOfWork.""" + from cleveragents.infrastructure.database.unit_of_work import UnitOfWork + + db_path = tempfile.mktemp(suffix=".db", prefix="dsvc_persisted_") + context._dsvc_db_path = db_path + uow = UnitOfWork(f"sqlite:///{db_path}") + uow.init_database() + + context.decision_service = DecisionService(unit_of_work=uow) + context.recorded_decisions = [] + context.decision_error = None + context.decision_result = None + context.decision_list = None + context.tree_result = None + context.path_result = None + context.superseded_result = None + context.snapshot_result = None + context.snapshots_dict = None + context.hash_query_result = None + context.delete_result = None + context.explicit_snapshot = None + context._plan_id_registry = {} + + # Register cleanup to remove temp DB file + def _cleanup_db() -> None: + for suffix in ("", "-journal", "-wal", "-shm"): + try: + os.unlink(db_path + suffix) + except OSError: + pass + + if not hasattr(context, "_cleanup_handlers"): + context._cleanup_handlers = [] + context._cleanup_handlers.append(_cleanup_db) + + +@when("I try to store a duplicate of the recorded decision") +def step_try_store_duplicate(context: Context) -> None: + """Attempt to store a decision with the same ID that already exists.""" + try: + decision = context.decision_result + context.decision_service._store_decision(decision) + context.decision_error = None + except DuplicateDecisionError as exc: + context.decision_error = exc + + +@then("a dsvc duplicate decision error should be raised") +def step_duplicate_error_raised(context: Context) -> None: + assert context.decision_error is not None + assert isinstance(context.decision_error, DuplicateDecisionError)