test(cli): preserve invariant scope coverage after rebase

This commit is contained in:
cleveragents-auto
2026-06-17 21:38:08 -04:00
committed by Forgejo
parent 5a711f9776
commit 26662836aa
4 changed files with 731 additions and 179 deletions
+160 -18
View File
@@ -1,25 +1,167 @@
Feature: Invariant CLI new coverage for scope resolution and conflict detection
Feature: Invariant CLI commands coverage
As a developer
I want full test coverage for the invariant CLI commands module
So that all commands and helper functions are verified
The ``agents invariant`` CLI command group uses ``_resolve_scope()`` to
resolve mutually-exclusive scope flags (``--global``, ``--project``,
``--plan``, ``--action``). This feature tests that resolver directly,
covering both add-command usage paths and the shared list path.
# === _resolve_scope helper ===
Background:
Given invariant service mock is configured
Scenario: Resolve scope with --global flag returns GLOBAL scope
When I resolve invariant scope with global flag set
Then the resolved invariant scope should be "global"
And the resolved invariant source name should be "system"
Scenario: Resolve invariant with global-only flag returns GLOBAL scope
When I resolve invariant add scope with only global flag
Then invariant add with global flag returns GLOBAL scope
Scenario: Resolve scope with --project flag returns PROJECT scope
When I resolve invariant scope with project "myapp"
Then the resolved invariant scope should be "project"
And the resolved invariant source name should be "myapp"
Scenario: Resolve invariant with project-only flag returns PROJECT scope
When I resolve invariant add scope with only project flag
Then invariant add with project flag returns PROJECT scope
Scenario: Resolve scope with --plan flag returns PLAN scope
When I resolve invariant scope with plan "PLAN_01HX"
Then the resolved invariant scope should be "plan"
And the resolved invariant source name should be "PLAN_01HX"
Scenario: Resolve scope with --action flag returns ACTION scope
When I resolve invariant scope with action "deploy-service"
Then the resolved invariant scope should be "action"
And the resolved invariant source name should be "deploy-service"
Scenario: Resolve scope with no flags defaults to GLOBAL scope
When I resolve invariant scope with no flags
Then the resolved invariant scope should be "global"
And the resolved invariant source name should be "system"
Scenario: Resolve scope with conflicting flags raises BadParameter
When I resolve invariant add scope with --global and --project flags
Then invariant add rejects conflicting scope flags
When I resolve invariant scope with global and project flags
Then a BadParameter error should be raised for invariant scope
Scenario: List invariants rejects conflicting scope flags
When I resolve invariant list with conflicting scoped flags
Then list invariants rejects conflicting scope flags
# === _invariant_dict helper ===
Scenario: Invariant dict serializes an invariant correctly
Given a sample invariant with known fields for dict test
When I call invariant_dict on the sample invariant
Then the invariant dict should contain the correct id and text
And the invariant dict should contain scope and source_name
And the invariant dict should contain active and created_at ISO string
# === _get_service singleton ===
Scenario: Get service creates InvariantService lazily
When I call get_service with invariant module service reset to None
Then an InvariantService instance should be returned from get_service
And calling get_service again returns the same InvariantService instance
# === invariant add command ===
Scenario: Add invariant with --global flag via CLI
Given a mocked InvariantService for invariant CLI add
When I invoke invariant add with "--global" and text "Never delete prod data"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Invariant added"
And the invariant CLI output should contain "Never delete prod data"
Scenario: Add invariant with --project flag via CLI
Given a mocked InvariantService for invariant CLI add
When I invoke invariant add with "--project myapp" and text "All APIs need tests"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Invariant added"
Scenario: Add invariant without scope flag via CLI
Given a mocked InvariantService for invariant CLI add
When I invoke invariant add without scope flags and text "Missing scope flag"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Invariant added"
Scenario: Add invariant with --format json via CLI
Given a mocked InvariantService for invariant CLI add
When I invoke invariant add with "--global --format json" and text "JSON constraint"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "JSON constraint"
Scenario: Add invariant handles CleverAgentsError
Given a mocked InvariantService that raises CleverAgentsError on add
When I invoke invariant add with "--global" and text "will fail"
Then the invariant CLI exit code should be non-zero
And the invariant CLI output should contain "Error"
# === invariant list command ===
Scenario: List invariants with no results
Given a mocked InvariantService that returns empty invariant list
When I invoke invariant list with no filters
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "No invariants found"
Scenario: List invariants with results in rich format
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with no filters
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Invariants"
And the invariant CLI output should contain "Never delete prod"
Scenario: List invariants with --global filter
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with flags "--global"
Then the invariant CLI exit code should be 0
And the invariant service list was called with global scope
Scenario: List invariants with --project filter
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with flags "--project myapp"
Then the invariant CLI exit code should be 0
And the invariant service list was called with project scope and source "myapp"
Scenario: List invariants with --plan filter
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with flags "--plan PLAN_01HX"
Then the invariant CLI exit code should be 0
And the invariant service list was called with plan scope and source "PLAN_01HX"
Scenario: List invariants with --action filter
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with flags "--action deploy"
Then the invariant CLI exit code should be 0
And the invariant service list was called with action scope and source "deploy"
Scenario: List invariants with --effective flag
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with flags "--effective"
Then the invariant CLI exit code should be 0
And the invariant service list was called with effective true
Scenario: List invariants with regex filter matching
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with regex "prod"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Never delete prod"
Scenario: List invariants with invalid regex
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with regex "[invalid"
Then the invariant CLI exit code should be non-zero
And the invariant CLI output should contain "Invalid regex"
Scenario: List invariants with --format json
Given a mocked InvariantService that returns two invariants for list
When I invoke invariant list with flags "--format json"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Never delete prod"
# === invariant remove command ===
Scenario: Remove invariant with --yes flag
Given a mocked InvariantService for invariant CLI remove
When I invoke invariant remove with "--yes" for id "INV_01HX"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Invariant removed"
Scenario: Remove invariant without --yes cancelled by user
Given a mocked InvariantService for invariant CLI remove
When I invoke invariant remove without --yes for id "INV_01HX" and answer no
Then the invariant CLI exit code should be non-zero
And the invariant CLI output should contain "Cancelled"
Scenario: Remove invariant raises NotFoundError
Given a mocked InvariantService that raises NotFoundError on remove
When I invoke invariant remove with "--yes" for id "MISSING_ID"
Then the invariant CLI exit code should be non-zero
And the invariant CLI output should contain "not found"
+348 -106
View File
@@ -1,124 +1,366 @@
"""BDD step definitions for invariant CLI new coverage tests.
"""Step definitions for invariant_cli_new_coverage.feature.
This module provides Cucumber-style steps used in the BDD feature tests
for the ``invariant`` CLI command group, covering scope resolution and
conflict detection via ``_resolve_scope()``.
Based on scenarios from ``features/invariant_cli_new_coverage.feature``.
Tests all CLI commands and helper functions in
cleveragents.cli.commands.invariant to bring coverage from 0% to high.
"""
from __future__ import annotations
import sys
from typing import Annotated
from datetime import datetime
from unittest.mock import MagicMock, patch
import typer
from behave import given, then, when # type: ignore[import-untyped]
from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.cli.commands.invariant import (
_get_service,
_invariant_dict,
_resolve_scope,
app,
)
from cleveragents.core.exceptions import CleverAgentsError, NotFoundError
from cleveragents.domain.models.core.invariant import Invariant, InvariantScope
_runner = CliRunner()
_ULID = "01JTEST0000000000000000001"
_ULID2 = "01JTEST0000000000000000002"
_NOW = datetime(2025, 7, 1, 12, 0, 0)
@given("invariant service mock is configured")
def step_service_mock(context):
"""Prepare a mocked InvariantService for the scenario."""
context.inv_service_mocked = True
@given("a global scope invariant exists")
def step_global_invariant_exists(context):
"""Ensure a GLOBAL-scoped invariant is in the service store."""
from cleveragents.domain.models.core.invariant import Invariant, InvariantScope
context._test_inv_ids = []
context._test_invariants = [
Invariant(
id="01HZGLOBAL00000000000000A1",
text="Never delete production data",
scope=InvariantScope.GLOBAL,
source_name="system",
active=True,
created_at=context._now_override(),
)
]
# ---------------------------------------------------------------------------
# _resolve_scope step definitions (add command)
# ---------------------------------------------------------------------------
@when("I resolve invariant add scope with only global flag")
def step_resolve_global_only(context):
context.inv_add_global_ok = False
context.inv_add_global_scope = None
try:
from cleveragents.cli.commands.invariant import _resolve_scope, InvariantScope
scope, source_name = _resolve_scope(is_global=True, project=None, plan=None, action=None)
context.inv_add_global_ok = True
context.inv_add_global_scope = scope
context.inv_add_global_source = source_name
except Exception as exc:
context.inv_add_error = str(exc)
@when("I resolve invariant add scope with only project flag")
def step_resolve_project_only(context):
context.inv_add_project_ok = False
try:
from cleveragents.cli.commands.invariant import _resolve_scope, InvariantScope
scope, source_name = _resolve_scope(is_global=False, project="myapp", plan=None, action=None)
context.inv_add_project_ok = True
context.inv_add_project_scope = scope
context.inv_add_project_source = source_name
except Exception as exc:
context.inv_add_error = str(exc)
@when("I resolve invariant add scope with --global and --project flags")
def step_resolve_global_project(context):
context.inv_add_conflict_raised = False
try:
from cleveragents.cli.commands.invariant import _resolve_scope, InvariantScope
_resolve_scope(is_global=True, project="myapp", plan=None, action=None)
except typer.BadParameter:
context.inv_add_conflict_raised = True
@then("invariant add with global flag returns GLOBAL scope")
def step_check_global_scope(context):
assert context.inv_add_global_ok, "Expected _resolve_scope to succeed for --global"
from cleveragents.domain.models.core.invariant import InvariantScope
assert context.inv_add_global_scope == InvariantScope.GLOBAL
assert context.inv_add_global_source == "system"
@then("invariant add with project flag returns PROJECT scope")
def step_check_project_scope(context):
assert context.inv_add_project_ok, "Expected _resolve_scope to succeed for --project"
from cleveragents.domain.models.core.invariant import InvariantScope
assert context.inv_add_project_scope == InvariantScope.PROJECT
assert context.inv_add_project_source == "myapp"
@then("invariant add rejects conflicting scope flags")
def step_check_add_conflicting(context):
assert context.inv_add_conflict_raised, (
"Expected BadParameter for invariant add with conflicting scopes"
def _make_invariant(
*,
inv_id: str = _ULID,
text: str = "Never delete prod data",
scope: InvariantScope = InvariantScope.GLOBAL,
source_name: str = "system",
active: bool = True,
created_at: datetime = _NOW,
) -> Invariant:
"""Build a test Invariant with sensible defaults."""
return Invariant(
id=inv_id,
text=text,
scope=scope,
source_name=source_name,
active=active,
created_at=created_at,
)
# === list_invariants scope conflict detection ===
def _patch_svc(context, svc):
"""Patch the module-level _service and register cleanup."""
patcher = patch("cleveragents.cli.commands.invariant._service", svc)
patcher.start()
context.add_cleanup(patcher.stop)
@when("I resolve invariant list with conflicting scoped flags")
def step_resolve_list_conflicting(context):
context.inv_list_bad_parameter_raised = False
# ================================================================
# _resolve_scope helper steps
# ================================================================
@when("I resolve invariant scope with global flag set")
def step_resolve_scope_global(context):
context.resolved_scope, context.resolved_source = _resolve_scope(
is_global=True, project=None, plan=None, action=None
)
@when('I resolve invariant scope with project "{project}"')
def step_resolve_scope_project(context, project):
context.resolved_scope, context.resolved_source = _resolve_scope(
is_global=False, project=project, plan=None, action=None
)
@when('I resolve invariant scope with plan "{plan}"')
def step_resolve_scope_plan(context, plan):
context.resolved_scope, context.resolved_source = _resolve_scope(
is_global=False, project=None, plan=plan, action=None
)
@when('I resolve invariant scope with action "{action}"')
def step_resolve_scope_action(context, action):
context.resolved_scope, context.resolved_source = _resolve_scope(
is_global=False, project=None, plan=None, action=action
)
@when("I resolve invariant scope with no flags")
def step_resolve_scope_default(context):
context.resolved_scope, context.resolved_source = _resolve_scope(
is_global=False, project=None, plan=None, action=None
)
@when("I resolve invariant scope with global and project flags")
def step_resolve_scope_conflicting(context):
context.inv_bad_parameter_raised = False
try:
from cleveragents.cli.commands.invariant import _resolve_scope, InvariantScope
_resolve_scope(is_global=True, project="myapp", plan=None, action=None)
except typer.BadParameter:
context.inv_list_bad_parameter_raised = True
context.inv_bad_parameter_raised = True
@then("list invariants rejects conflicting scope flags")
def step_check_list_conflicting(context):
assert context.inv_list_bad_parameter_raised, (
"Expected BadParameter for list_invariants with conflicting scopes"
@then('the resolved invariant scope should be "{scope}"')
def step_check_resolved_scope(context, scope):
assert context.resolved_scope.value == scope, (
f"Expected scope '{scope}', got '{context.resolved_scope.value}'"
)
@then('the resolved invariant source name should be "{source}"')
def step_check_resolved_source(context, source):
assert context.resolved_source == source, (
f"Expected source '{source}', got '{context.resolved_source}'"
)
@then("a BadParameter error should be raised for invariant scope")
def step_check_bad_parameter(context):
assert context.inv_bad_parameter_raised, "Expected typer.BadParameter to be raised"
# ================================================================
# _invariant_dict helper steps
# ================================================================
@given("a sample invariant with known fields for dict test")
def step_create_sample_invariant(context):
context.sample_inv = _make_invariant()
@when("I call invariant_dict on the sample invariant")
def step_call_invariant_dict(context):
context.inv_dict = _invariant_dict(context.sample_inv)
@then("the invariant dict should contain the correct id and text")
def step_check_dict_id_text(context):
d = context.inv_dict
assert d["id"] == _ULID
assert d["text"] == "Never delete prod data"
@then("the invariant dict should contain scope and source_name")
def step_check_dict_scope_source(context):
d = context.inv_dict
assert d["scope"] == "global"
assert d["source_name"] == "system"
@then("the invariant dict should contain active and created_at ISO string")
def step_check_dict_active_created(context):
d = context.inv_dict
assert d["active"] is True
assert d["created_at"] == _NOW.isoformat()
# ================================================================
# _get_service singleton steps
# ================================================================
@when("I call get_service with invariant module service reset to None")
def step_get_service_reset(context):
import cleveragents.cli.commands.invariant as mod
# Save original and reset
context._orig_inv_service = mod._service
mod._service = None
context.add_cleanup(lambda: setattr(mod, "_service", context._orig_inv_service))
context.first_inv_service = _get_service()
@then("an InvariantService instance should be returned from get_service")
def step_check_service_instance(context):
from cleveragents.application.services.invariant_service import InvariantService
assert isinstance(context.first_inv_service, InvariantService)
@then("calling get_service again returns the same InvariantService instance")
def step_check_service_singleton(context):
second = _get_service()
assert second is context.first_inv_service
# ================================================================
# invariant add command steps
# ================================================================
@given("a mocked InvariantService for invariant CLI add")
def step_mock_service_for_add(context):
svc = MagicMock()
def _fake_add(text, scope, source_name):
return _make_invariant(text=text, scope=scope, source_name=source_name)
svc.add_invariant.side_effect = _fake_add
svc.list_invariants.return_value = []
context.inv_mock_svc = svc
_patch_svc(context, svc)
@when('I invoke invariant add with "{flags}" and text "{text}"')
def step_run_add(context, flags, text):
args = ["add", *flags.split(), text]
context.inv_result = _runner.invoke(app, args)
@when('I invoke invariant add without scope flags and text "{text}"')
def step_run_add_no_flags(context, text):
context.inv_result = _runner.invoke(app, ["add", text])
@then("the invariant CLI exit code should be 0")
def step_check_exit_zero(context):
assert context.inv_result.exit_code == 0, (
f"Expected exit code 0, got {context.inv_result.exit_code}.\n"
f"Output: {context.inv_result.output}"
)
@then("the invariant CLI exit code should be non-zero")
def step_check_exit_nonzero(context):
assert context.inv_result.exit_code != 0, (
f"Expected non-zero exit code, got {context.inv_result.exit_code}.\n"
f"Output: {context.inv_result.output}"
)
@then('the invariant CLI output should contain "{text}"')
def step_check_output_contains(context, text):
assert text in context.inv_result.output, (
f"Expected '{text}' in output, got:\n{context.inv_result.output}"
)
@given("a mocked InvariantService that raises CleverAgentsError on add")
def step_mock_service_error_add(context):
svc = MagicMock()
svc.add_invariant.side_effect = CleverAgentsError("Service failure")
_patch_svc(context, svc)
# ================================================================
# invariant list command steps
# ================================================================
@given("a mocked InvariantService that returns empty invariant list")
def step_mock_service_empty_list(context):
svc = MagicMock()
svc.list_invariants.return_value = []
context.inv_mock_svc = svc
_patch_svc(context, svc)
@given("a mocked InvariantService that returns two invariants for list")
def step_mock_service_two_invariants(context):
svc = MagicMock()
inv1 = _make_invariant(inv_id=_ULID, text="Never delete prod data")
inv2 = _make_invariant(
inv_id=_ULID2,
text="All changes need review",
scope=InvariantScope.PROJECT,
source_name="myapp",
)
svc.list_invariants.return_value = [inv1, inv2]
context.inv_mock_svc = svc
_patch_svc(context, svc)
@when("I invoke invariant list with no filters")
def step_run_list_no_filters(context):
context.inv_result = _runner.invoke(app, ["list"])
@when('I invoke invariant list with flags "{flags}"')
def step_run_list_with_flags(context, flags):
args = ["list", *flags.split()]
context.inv_result = _runner.invoke(app, args)
@when('I invoke invariant list with regex "{pattern}"')
def step_run_list_with_regex(context, pattern):
context.inv_result = _runner.invoke(app, ["list", pattern])
@then("the invariant service list was called with global scope")
def step_check_list_global(context):
call_kwargs = context.inv_mock_svc.list_invariants.call_args
assert call_kwargs is not None
kwargs = call_kwargs[1] if call_kwargs[1] else {}
assert kwargs.get("scope") == InvariantScope.GLOBAL
@then('the invariant service list was called with project scope and source "{source}"')
def step_check_list_project(context, source):
call_kwargs = context.inv_mock_svc.list_invariants.call_args
kwargs = call_kwargs[1] if call_kwargs[1] else {}
assert kwargs.get("scope") == InvariantScope.PROJECT
assert kwargs.get("source_name") == source
@then('the invariant service list was called with plan scope and source "{source}"')
def step_check_list_plan(context, source):
call_kwargs = context.inv_mock_svc.list_invariants.call_args
kwargs = call_kwargs[1] if call_kwargs[1] else {}
assert kwargs.get("scope") == InvariantScope.PLAN
assert kwargs.get("source_name") == source
@then('the invariant service list was called with action scope and source "{source}"')
def step_check_list_action(context, source):
call_kwargs = context.inv_mock_svc.list_invariants.call_args
kwargs = call_kwargs[1] if call_kwargs[1] else {}
assert kwargs.get("scope") == InvariantScope.ACTION
assert kwargs.get("source_name") == source
@then("the invariant service list was called with effective true")
def step_check_list_effective(context):
call_kwargs = context.inv_mock_svc.list_invariants.call_args
kwargs = call_kwargs[1] if call_kwargs[1] else {}
assert kwargs.get("effective") is True
# ================================================================
# invariant remove command steps
# ================================================================
@given("a mocked InvariantService for invariant CLI remove")
def step_mock_service_for_remove(context):
svc = MagicMock()
mock_inv = _make_invariant(active=False)
svc.remove_invariant.return_value = mock_inv
context.inv_mock_svc = svc
_patch_svc(context, svc)
@when('I invoke invariant remove with "{flags}" for id "{inv_id}"')
def step_run_remove_with_flags(context, flags, inv_id):
args = ["remove", *flags.split(), inv_id]
context.inv_result = _runner.invoke(app, args)
@when('I invoke invariant remove without --yes for id "{inv_id}" and answer no')
def step_run_remove_no_confirm(context, inv_id):
context.inv_result = _runner.invoke(app, ["remove", inv_id], input="n\n")
@given("a mocked InvariantService that raises NotFoundError on remove")
def step_mock_service_not_found_remove(context):
svc = MagicMock()
svc.remove_invariant.side_effect = NotFoundError(
resource_type="invariant", resource_id="MISSING_ID"
)
_patch_svc(context, svc)