Files
cleveragents-core/features/steps/tdd_invariant_persistence_steps.py
T
HAL9000 6e29206e7e fix(invariant): make InvariantService DB-backed for ad-hoc constructors
The BDD persistence scenarios construct ``InvariantService()`` directly
(simulating fresh CLI invocations), bypassing the DI container that
otherwise wires database_url + UnitOfWork. Without these hooks the
no-arg constructor silently reverted to in-memory mode and the
"persists across process restarts" guarantee broke under tests.

- Read CLEVERAGENTS_DATABASE_URL in the constructor when no database_url
  is passed, so ad-hoc callers still get cross-instance persistence.
- Route every DB-backed branch (add/list/remove/get_by_id) through
  _ensure_session_factory() so the session factory is materialised on
  first use, not only after a prior add.
- Drop class_=Session from sessionmaker — Session was only imported
  under TYPE_CHECKING so this raised NameError at runtime.  The default
  Session class is fine.
- Invoke MigrationRunner.init_or_upgrade() before handing the engine to
  sessionmaker so the test harness's template-DB patch (and prod
  migrations) gets a chance to materialise the invariants table before
  the first INSERT.
- Drop @mock_only tag from tdd_invariant_persistence.feature.  The
  feature now exercises real SQLite via the persistence service; the
  tag was a leftover from the in-memory-only era and prevented the
  per-scenario template copy from triggering.
- Make CLI list-output assertion robust against Rich's table line-wrap
  (the long invariant text is split across two rows of the same
  column with │ borders interleaved; word-level membership is the
  reliable check).

ISSUES CLOSED: #8573
2026-06-18 11:36:35 -04:00

173 lines
6.9 KiB
Python

"""Step definitions for tdd_invariant_persistence.feature (bug #1022, now fixed).
Tests verify that ``InvariantService`` persists invariants across simulated
CLI process restarts (separate service instances), confirming that Bug #8573
/#1022 is resolved: the database-backed InvariantService stores data in SQLite,
so separate CLI invocations share the same underlying ``cleveragents.db`` and
cross-instance data visibility is confirmed.
"""
from __future__ import annotations
from unittest.mock import patch
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.application.services.invariant_service import InvariantService
from cleveragents.cli.commands.invariant import app as invariant_app
from cleveragents.core.exceptions import NotFoundError
from cleveragents.domain.models.core.invariant import InvariantScope
runner = CliRunner()
# ---------------------------------------------------------------------------
# Given steps — instance A
# ---------------------------------------------------------------------------
@given(
'I add a project invariant "{text}" to project "{project}" '
"via invariant service instance A"
)
def step_add_project_invariant_instance_a(
context: Context, text: str, project: str
) -> None:
"""Add a project-scoped invariant via a fresh InvariantService (instance A)."""
context.invariant_svc_a = InvariantService()
context.invariant_added_a = context.invariant_svc_a.add_invariant(
text=text,
scope=InvariantScope.PROJECT,
source_name=project,
)
@given('I add a global invariant "{text}" via invariant service instance A')
def step_add_global_invariant_instance_a(context: Context, text: str) -> None:
"""Add a global invariant via a fresh InvariantService (instance A)."""
context.invariant_svc_a = InvariantService()
context.invariant_added_a = context.invariant_svc_a.add_invariant(
text=text,
scope=InvariantScope.GLOBAL,
source_name="system",
)
@given("I capture the invariant ID from instance A")
def step_capture_invariant_id(context: Context) -> None:
"""Store the invariant ID from instance A for later use."""
context.captured_invariant_id = context.invariant_added_a.id
@given(
'I invoke invariant add via CLI with "{flags}" and text "{text}" '
"using service invocation {n:d}"
)
def step_invoke_add_cli(context: Context, flags: str, text: str, n: int) -> None:
"""Invoke ``invariant add`` via CLI with a fresh service (simulated invocation)."""
svc = InvariantService()
args = ["add", *flags.split(), text]
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(invariant_app, args)
setattr(context, f"invariant_cli_result_{n}", result)
setattr(context, f"invariant_svc_{n}", svc)
assert result.exit_code == 0, (
f"invariant add should exit 0 but got {result.exit_code}. "
f"output:\n{result.output}"
)
# ---------------------------------------------------------------------------
# When steps — instance B / fresh instance
# ---------------------------------------------------------------------------
@when("I create a fresh invariant service instance B")
def step_create_fresh_instance_b(context: Context) -> None:
"""Create a completely new InvariantService, simulating a new CLI process."""
context.invariant_svc_b = InvariantService()
@when('I list project invariants for "{project}" via instance B')
def step_list_project_invariants_instance_b(context: Context, project: str) -> None:
"""List project-scoped invariants via the fresh instance B."""
context.invariant_list_b = context.invariant_svc_b.list_invariants(
scope=InvariantScope.PROJECT,
source_name=project,
)
@when("I list global invariants via instance B")
def step_list_global_invariants_instance_b(context: Context) -> None:
"""List global invariants via the fresh instance B."""
context.invariant_list_b = context.invariant_svc_b.list_invariants(
scope=InvariantScope.GLOBAL,
)
@when('I invoke invariant list via CLI with "{flags}" using service invocation {n:d}')
def step_invoke_list_cli(context: Context, flags: str, n: int) -> None:
"""Invoke ``invariant list`` via CLI with a fresh service (simulated invocation)."""
svc = InvariantService()
args = ["list", *flags.split()]
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(invariant_app, args)
setattr(context, f"invariant_cli_result_{n}", result)
setattr(context, f"invariant_svc_{n}", svc)
assert result.exit_code == 0, (
f"invariant list should exit 0 but got {result.exit_code}. "
f"output:\n{result.output}"
)
@when("I attempt to remove the captured invariant ID via instance B")
def step_remove_via_instance_b(context: Context) -> None:
"""Attempt to soft-delete an invariant using the fresh instance B."""
try:
context.invariant_svc_b.remove_invariant(context.captured_invariant_id)
context.invariant_remove_b_error = None
except NotFoundError as exc:
context.invariant_remove_b_error = exc
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
@then('the invariant list from instance B should contain "{text}"')
def step_assert_list_b_contains(context: Context, text: str) -> None:
"""Assert the invariant list from instance B contains the given text."""
inv_texts = [inv.text for inv in context.invariant_list_b]
assert text in inv_texts, (
f"Expected invariant '{text}' in instance B list but got: {inv_texts}"
)
@then('the CLI list output from invocation {n:d} should contain "{text}"')
def step_assert_cli_list_contains(context: Context, n: int, text: str) -> None:
"""Assert the CLI list output from invocation N contains the given text.
Rich renders the invariant list as a table that line-wraps long text
values and splits the content across multiple row borders, so a single
substring match is brittle. Instead, assert that every word of the
expected text appears somewhere in the output.
"""
result = getattr(context, f"invariant_cli_result_{n}")
missing = [word for word in text.split() if word not in result.output]
assert not missing, (
f"Expected words {missing!r} (from '{text}') in CLI invocation {n} "
f"output but got:\n{result.output}"
)
@then("the remove operation via instance B should succeed without NotFoundError")
def step_assert_remove_b_success(context: Context) -> None:
"""Assert that the remove operation succeeded (no NotFoundError)."""
assert context.invariant_remove_b_error is None, (
f"Expected remove to succeed but got NotFoundError: "
f"{context.invariant_remove_b_error}"
)