Files
cleveragents-core/features/steps/tdd_invariant_persistence_steps.py
CleverAgents Bot 525a0e1d56
CI / push-validation (pull_request) Successful in 27s
CI / load-versions (pull_request) Successful in 27s
CI / lint (pull_request) Successful in 46s
CI / build (pull_request) Successful in 56s
CI / quality (pull_request) Successful in 1m27s
CI / typecheck (pull_request) Successful in 1m35s
CI / security (pull_request) Successful in 1m35s
CI / helm (pull_request) Successful in 3m17s
CI / unit_tests (pull_request) Successful in 5m42s
CI / integration_tests (pull_request) Successful in 9m8s
CI / docker (pull_request) Successful in 2m24s
CI / coverage (pull_request) Successful in 10m14s
CI / status-check (pull_request) Successful in 3s
fix(invariant): route persistence through repository
2026-06-18 11:36:35 -04:00

442 lines
17 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
import os
from tempfile import TemporaryDirectory
from unittest.mock import patch
from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
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 InvariantViolationError, NotFoundError
from cleveragents.domain.models.core.invariant import Invariant, InvariantScope
from cleveragents.infrastructure.database.invariant_repository import (
InvariantRepository,
)
from cleveragents.infrastructure.database.models import Base
runner = CliRunner()
class _FailingEventBus:
"""Event bus test double that fails every emit call."""
def emit(self, event: object) -> None:
raise RuntimeError(f"synthetic event bus failure for {event!r}")
# ---------------------------------------------------------------------------
# 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("a fresh standalone invariant repository")
def step_fresh_standalone_invariant_repository(context: Context) -> None:
"""Create an isolated SQLAlchemy-backed invariant repository."""
tmpdir = TemporaryDirectory()
context.invariant_repo_tmpdir = tmpdir
engine = create_engine(f"sqlite:///{tmpdir.name}/invariants.db", future=True)
Base.metadata.create_all(engine)
session_factory = sessionmaker(
bind=engine,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
context.invariant_repo = InvariantRepository(
session_factory=session_factory,
auto_commit=True,
)
@given(
'I repository-create inactive project invariant "{text}" for project "{project}"'
)
def step_repository_create_inactive_project_invariant(
context: Context, text: str, project: str
) -> None:
"""Persist an inactive project invariant directly through the repository."""
invariant = Invariant(
text=text,
scope=InvariantScope.PROJECT,
source_name=project,
active=False,
)
context.repository_created_invariant = invariant
context.invariant_repo.create(invariant)
@given("a fresh in-memory invariant service")
def step_fresh_in_memory_invariant_service(context: Context) -> None:
"""Create a fresh non-persistent invariant service."""
with patch.dict(os.environ, {}, clear=True):
context.in_memory_invariant_service = InvariantService()
@given("a fresh in-memory invariant service with a failing event bus")
def step_fresh_in_memory_invariant_service_with_failing_bus(
context: Context,
) -> None:
"""Create a fresh in-memory invariant service with a failing event bus."""
with patch.dict(os.environ, {}, clear=True):
context.in_memory_invariant_service = InvariantService(
event_bus=_FailingEventBus(),
)
@given('I add a global invariant "{text}" via the in-memory service')
def step_add_global_invariant_in_memory(context: Context, text: str) -> None:
"""Add a global invariant to the in-memory service."""
context.in_memory_global_invariant = (
context.in_memory_invariant_service.add_invariant(
text=text,
scope=InvariantScope.GLOBAL,
source_name="system",
)
)
@given(
'I add a project invariant "{text}" for project "{project}" '
"via the in-memory service"
)
def step_add_project_invariant_in_memory(
context: Context, text: str, project: str
) -> None:
"""Add a project invariant to the in-memory service."""
context.in_memory_project_invariant = (
context.in_memory_invariant_service.add_invariant(
text=text,
scope=InvariantScope.PROJECT,
source_name=project,
)
)
@given(
'I add an inactive project invariant "{text}" for project "{project}" '
"via the in-memory service"
)
def step_add_inactive_project_invariant_in_memory(
context: Context, text: str, project: str
) -> None:
"""Add, then deactivate, a project invariant in the in-memory cache."""
invariant = context.in_memory_invariant_service.add_invariant(
text=text,
scope=InvariantScope.PROJECT,
source_name=project,
)
inactive = invariant.model_copy(update={"active": False})
context.in_memory_invariant_service._invariants[invariant.id] = inactive
@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
@when('I repository-list inactive project invariants for "{project}"')
def step_repository_list_inactive_project_invariants(
context: Context, project: str
) -> None:
"""List inactive rows by disabling the repository active-only filter."""
context.repository_invariant_list = context.invariant_repo.list_invariants(
scope="project",
source_name=project,
active_only=False,
)
@when('I repository-get invariant "{invariant_id}"')
def step_repository_get_invariant(context: Context, invariant_id: str) -> None:
"""Retrieve an invariant by ID directly through the repository."""
context.repository_get_result = context.invariant_repo.get(invariant_id)
@when('I repository-update missing invariant "{invariant_id}"')
def step_repository_update_missing_invariant(
context: Context, invariant_id: str
) -> None:
"""Attempt to update a missing invariant directly through the repository."""
missing = Invariant(
id=invariant_id,
text="Missing invariant",
scope=InvariantScope.GLOBAL,
source_name="system",
)
try:
context.invariant_repo.update(missing)
context.repository_update_error = None
except NotFoundError as exc:
context.repository_update_error = exc
@when('I list effective project invariants for "{project}" via the in-memory service')
def step_list_effective_project_invariants_in_memory(
context: Context, project: str
) -> None:
"""List the effective invariant set for a project."""
context.in_memory_effective_invariants = (
context.in_memory_invariant_service.list_invariants(
scope=InvariantScope.PROJECT,
source_name=project,
effective=True,
)
)
@when('I load active invariants for project "{project}" via the in-memory service')
def step_load_active_project_invariants_in_memory(
context: Context, project: str
) -> None:
"""Load active invariants for a project."""
context.in_memory_loaded_invariants = (
context.in_memory_invariant_service.load_active_invariants(
project_name=project,
)
)
@when("I load global active invariants via the in-memory service")
def step_load_global_active_invariants_in_memory(context: Context) -> None:
"""Load global active invariants without a plan/project context."""
context.in_memory_global_loaded_invariants = (
context.in_memory_invariant_service.load_active_invariants()
)
@when('I check action "{action_text}" against the loaded invariants')
def step_check_action_against_loaded_invariants(
context: Context, action_text: str
) -> None:
"""Check action text against loaded invariants and capture violations."""
try:
context.in_memory_invariant_service.check_invariants(
action_text,
context.in_memory_loaded_invariants,
)
context.in_memory_violation_error = None
except InvariantViolationError as exc:
context.in_memory_violation_error = exc
@when('I enforce the global invariant for plan "{plan_id}" as violated')
def step_enforce_global_invariant_as_violated(context: Context, plan_id: str) -> None:
"""Enforce a global invariant while marking it as violated."""
context.in_memory_enforcement_records = (
context.in_memory_invariant_service.enforce_invariants(
plan_id=plan_id,
invariants=[context.in_memory_global_invariant],
actor_response="synthetic reconciliation response",
violated_invariant_ids=[context.in_memory_global_invariant.id],
)
)
# ---------------------------------------------------------------------------
# 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}"
)
@then('the repository invariant list should contain inactive "{text}"')
def step_assert_repository_list_contains_inactive(context: Context, text: str) -> None:
"""Assert the repository returned the inactive invariant on request."""
matches = [inv for inv in context.repository_invariant_list if inv.text == text]
assert matches, (
f"Expected repository list to contain '{text}' but got "
f"{context.repository_invariant_list!r}"
)
assert matches[0].active is False, "Expected returned invariant to be inactive"
@then("the repository get result should be missing")
def step_assert_repository_get_missing(context: Context) -> None:
"""Assert a missing repository lookup returns ``None``."""
assert context.repository_get_result is None
@then("the repository update should raise NotFoundError")
def step_assert_repository_update_not_found(context: Context) -> None:
"""Assert updating a missing invariant raised ``NotFoundError``."""
assert isinstance(context.repository_update_error, NotFoundError), (
f"Expected NotFoundError but got {context.repository_update_error!r}"
)
@then("an invariant violation should be raised")
def step_assert_invariant_violation_raised(context: Context) -> None:
"""Assert the service raised an invariant violation."""
assert isinstance(context.in_memory_violation_error, InvariantViolationError), (
f"Expected InvariantViolationError but got "
f"{context.in_memory_violation_error!r}"
)
@then('the effective invariant list should contain "{text}"')
def step_assert_effective_list_contains(context: Context, text: str) -> None:
"""Assert an invariant text appears in the effective list."""
texts = [inv.text for inv in context.in_memory_effective_invariants]
assert text in texts, f"Expected '{text}' in effective list but got {texts!r}"
@then('the effective invariant list should not contain "{text}"')
def step_assert_effective_list_excludes(context: Context, text: str) -> None:
"""Assert an inactive invariant text is excluded from the effective list."""
texts = [inv.text for inv in context.in_memory_effective_invariants]
assert text not in texts, f"Did not expect '{text}' in effective list: {texts!r}"
@then("the enforcement record should be marked not enforced")
def step_assert_enforcement_record_not_enforced(context: Context) -> None:
"""Assert the service still records a violated invariant."""
records = context.in_memory_enforcement_records
assert len(records) == 1, f"Expected one enforcement record, got {records!r}"
assert records[0].enforced is False