0e407f7f19
CI / build (push) Successful in 27s
CI / lint (push) Successful in 3m20s
CI / quality (push) Successful in 3m54s
CI / typecheck (push) Successful in 4m6s
CI / benchmark-regression (push) Has been skipped
CI / integration_tests (push) Successful in 6m55s
CI / unit_tests (push) Successful in 7m6s
CI / security (push) Successful in 8m4s
CI / e2e_tests (push) Successful in 10m47s
CI / docker (push) Successful in 1m9s
CI / coverage (push) Successful in 14m37s
CI / status-check (push) Successful in 1s
CI / benchmark-publish (push) Failing after 24m9s
## Summary Add Behave BDD and Robot Framework integration tests that capture bug #1022 — `InvariantService` stores invariants in an in-memory dict only, losing them across CLI process invocations. ### Motivation Per the project's TDD Bug Fix Workflow (CONTRIBUTING.md), every bug must first have a test written that captures the buggy behavior before the fix is implemented. This PR fulfills the TDD counterpart issue #1032 for bug #1022. ### What was done **Behave tests** (`features/tdd_invariant_persistence.feature`): Four scenarios tagged `@tdd_expected_fail @tdd_bug @tdd_bug_1022 @mock_only` that simulate separate CLI invocations via fresh `InvariantService` instances and assert cross-instance data visibility: 1. Project invariant persistence across service instances 2. Global invariant persistence across service instances 3. CLI add/list across separate invocations 4. Cross-instance soft-delete by invariant ID Step definitions in `features/steps/tdd_invariant_persistence_steps.py`. **Robot integration tests** (`robot/tdd_invariant_persistence.robot`): Three integration test cases with a helper script (`robot/helper_tdd_invariant_persistence.py`) that exercises add-then-list and add-then-remove across fresh service instances. ### How it passes CI All tests carry `@tdd_expected_fail`, which inverts the test result via the environment hooks (Behave) and listener (Robot). The underlying assertions fail (proving the bug exists), but are reported as passed to CI. The `@tdd_expected_fail` tag will be removed when bug #1022 is fixed, at which point the tests will run normally and must pass. ### Review fixes applied - **M1**: Moved `InvariantScope` import from function body in `robot/helper_tdd_invariant_persistence.py` to module-level top imports. - **m1**: Added `@mock_only` tag to `features/tdd_invariant_persistence.feature` — these tests use purely in-memory services and never touch the database. - **m2**: Added `exit_code == 0` assertion to `step_invoke_list_cli` in `features/steps/tdd_invariant_persistence_steps.py`, matching the pattern used in `step_invoke_add_cli`. - **m3**: Changed all `context: Any` parameter types to `context: Context` (from `behave.runner`), following the project-wide convention used in 335+ step files. - **m4**: Narrowed `except Exception` to `except NotFoundError` in `robot/helper_tdd_invariant_persistence.py`, matching the specific error being tested. - **n3**: Branch rebased onto latest `master`. ### Quality Gates - **lint**: ✅ passed - **typecheck**: ✅ passed (Pyright, 0 errors) - **unit_tests**: ✅ 1 feature, 4 scenarios, 16 steps — all passed via `@tdd_expected_fail` - **coverage**: ≥ 97% (test-only changes, no source modifications) Closes #1032 Co-authored-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me> Reviewed-on: #1109 Reviewed-by: Jeffrey Phillips Freeman <jeffrey.freeman@cleverthis.com> Co-authored-by: Brent E. Edwards <brent.edwards@cleverthis.com> Co-committed-by: Brent E. Edwards <brent.edwards@cleverthis.com>
135 lines
4.7 KiB
Python
135 lines
4.7 KiB
Python
"""Helper script for tdd_invariant_persistence.robot (bug #1022).
|
|
|
|
Exercises InvariantService cross-invocation persistence at the integration
|
|
level. Each subcommand simulates a fresh CLI process by creating a new
|
|
InvariantService instance, mirroring how the real CLI works (each
|
|
``python -m cleveragents`` call gets its own service).
|
|
|
|
Bug #1022: InvariantService stores invariants in an in-memory dict only.
|
|
Invariants added in one CLI invocation are lost when the process exits.
|
|
|
|
This helper is called from Robot Framework via ``Run Process``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
|
if _SRC not in sys.path:
|
|
sys.path.insert(0, _SRC)
|
|
|
|
from typer.testing import CliRunner # noqa: E402
|
|
|
|
from cleveragents.application.services.invariant_service import ( # noqa: E402
|
|
InvariantService,
|
|
)
|
|
from cleveragents.cli.commands.invariant import app as invariant_app # noqa: E402
|
|
from cleveragents.core.exceptions import NotFoundError # noqa: E402
|
|
from cleveragents.domain.models.core.invariant import InvariantScope # noqa: E402
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
def add_then_list_project() -> None:
|
|
"""Add a project invariant in invocation 1, list in invocation 2.
|
|
|
|
Simulates two separate CLI invocations by using fresh InvariantService
|
|
instances. The list in invocation 2 should show the invariant added
|
|
in invocation 1 — but it won't because of bug #1022.
|
|
"""
|
|
# Invocation 1: add
|
|
svc1 = InvariantService()
|
|
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc1):
|
|
add_result = runner.invoke(
|
|
invariant_app,
|
|
["add", "--project", "local/test-proj", "Must validate inputs"],
|
|
)
|
|
if add_result.exit_code != 0:
|
|
print(f"FAIL-ADD: exit={add_result.exit_code} out={add_result.output}")
|
|
sys.exit(1)
|
|
|
|
# Invocation 2: list (fresh service — simulates new process)
|
|
svc2 = InvariantService()
|
|
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc2):
|
|
list_result = runner.invoke(
|
|
invariant_app, ["list", "--project", "local/test-proj"]
|
|
)
|
|
|
|
# The list output should contain the invariant — if it doesn't, bug exists
|
|
if "Must validate inputs" in list_result.output:
|
|
print("invariant-persist-project-ok")
|
|
else:
|
|
print(
|
|
f"FAIL-PERSIST: invariant not found in second invocation. "
|
|
f"output:\n{list_result.output}"
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
def add_then_list_global() -> None:
|
|
"""Add a global invariant in invocation 1, list in invocation 2."""
|
|
svc1 = InvariantService()
|
|
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc1):
|
|
add_result = runner.invoke(
|
|
invariant_app,
|
|
["add", "--global", "Never expose credentials"],
|
|
)
|
|
if add_result.exit_code != 0:
|
|
print(f"FAIL-ADD: exit={add_result.exit_code} out={add_result.output}")
|
|
sys.exit(1)
|
|
|
|
svc2 = InvariantService()
|
|
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc2):
|
|
list_result = runner.invoke(invariant_app, ["list", "--global"])
|
|
|
|
if "Never expose credentials" in list_result.output:
|
|
print("invariant-persist-global-ok")
|
|
else:
|
|
print(
|
|
f"FAIL-PERSIST: invariant not found in second invocation. "
|
|
f"output:\n{list_result.output}"
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
def add_then_remove_cross_instance() -> None:
|
|
"""Add invariant in instance 1, remove by ID in instance 2."""
|
|
svc1 = InvariantService()
|
|
inv = svc1.add_invariant(
|
|
text="Temporary constraint",
|
|
scope=InvariantScope.PROJECT,
|
|
source_name="local/temp",
|
|
)
|
|
inv_id = inv.id
|
|
|
|
# Fresh instance — simulates new CLI process
|
|
svc2 = InvariantService()
|
|
try:
|
|
svc2.remove_invariant(inv_id)
|
|
print("invariant-cross-remove-ok")
|
|
except NotFoundError as exc:
|
|
print(f"FAIL-REMOVE: {type(exc).__name__}: {exc}")
|
|
sys.exit(1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dispatcher
|
|
# ---------------------------------------------------------------------------
|
|
|
|
COMMANDS = {
|
|
"add-then-list-project": add_then_list_project,
|
|
"add-then-list-global": add_then_list_global,
|
|
"add-then-remove-cross": add_then_remove_cross_instance,
|
|
}
|
|
|
|
if __name__ == "__main__":
|
|
cmd = sys.argv[1] if len(sys.argv) > 1 else None
|
|
if cmd not in COMMANDS:
|
|
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
|
|
sys.exit(2)
|
|
COMMANDS[cmd]()
|