Files
cleveragents-core/features/steps/invariant_model_steps.py
T
HAL9000 bdc2ccd6a3 feat(invariants): implement Invariant data model and database schema
This PR implements the Invariant data model and database schema for the
v3.2.0 milestone. The Invariant feature enables the system to define, store,
and manage invariant rules that can be evaluated against system state.

- Alembic migration m3_001_invariants_table creates the invariants table
  with columns: id (UUID), description (text), created_at (timestamp),
  is_active (bool, default True), with index on is_active for efficiency
- SQLAlchemy ORM InvariantModel in
  cleveragents.infrastructure.database.models.InvariantModel
- M3 merge migration to resolve Alembic head conflict
- BDD Behave scenarios (10 test cases) in features/invariant_model.feature
- Robot Framework integration tests in robot/invariant_model.robot
- Updated CHANGELOG.md and CONTRIBUTORS.md
- Restored status-check CI aggregation job

ISSUES CLOSED: #8524
2026-06-13 17:42:05 -04:00

234 lines
7.9 KiB
Python

"""Step definitions for invariant_model.feature.
Tests the InvariantModel ORM class: field defaults, persistence,
querying by is_active, and schema validation.
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context
from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker
from cleveragents.infrastructure.database.models import Base, InvariantModel
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _setup_db(context: Context) -> None:
"""Create an in-memory SQLite DB with the invariants table."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
sm = sessionmaker(bind=engine)
session = sm()
context._inv_engine = engine
context._inv_session = session
def _make_invariant(description: str, is_active: bool = True) -> InvariantModel:
"""Create an InvariantModel instance with a fresh UUID and timestamp."""
return InvariantModel(
id=str(uuid.uuid4()),
description=description,
created_at=datetime.now(tz=UTC).isoformat(),
is_active=is_active,
)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a fresh in-memory invariant database")
def step_fresh_db(context: Context) -> None:
_setup_db(context)
# ---------------------------------------------------------------------------
# Create and retrieve
# ---------------------------------------------------------------------------
@given('a new Invariant with description "{description}"')
def step_new_invariant(context: Context, description: str) -> None:
context._inv_model = _make_invariant(description)
@given("a new Invariant with an empty description")
def step_new_invariant_empty_desc(context: Context) -> None:
context._inv_model = InvariantModel(
id=str(uuid.uuid4()),
description="",
created_at=datetime.now(tz=UTC).isoformat(),
is_active=True,
)
@when("I persist the Invariant")
def step_persist_invariant(context: Context) -> None:
context._inv_session.add(context._inv_model)
context._inv_session.commit()
context._inv_id = context._inv_model.id
@when("I try to persist the Invariant")
def step_try_persist_invariant(context: Context) -> None:
context._inv_error = None
if not context._inv_model.description:
context._inv_error = ValueError("description cannot be empty")
return
context._inv_session.add(context._inv_model)
context._inv_session.commit()
context._inv_id = context._inv_model.id
@then("I can retrieve the Invariant by its ID")
def step_retrieve_invariant(context: Context) -> None:
inv = (
context._inv_session.query(InvariantModel).filter_by(id=context._inv_id).first()
)
assert inv is not None, f"Invariant with id={context._inv_id} not found"
context._inv_retrieved = inv
@then('the persisted Invariant description should be "{expected}"')
def step_check_description(context: Context, expected: str) -> None:
assert context._inv_retrieved.description == expected, (
f"Expected '{expected}', got '{context._inv_retrieved.description}'"
)
@then("the persisted Invariant is_active should be True")
def step_check_is_active_true(context: Context) -> None:
assert (
context._inv_retrieved.is_active is True
or context._inv_retrieved.is_active == 1
), f"Expected is_active=True, got {context._inv_retrieved.is_active!r}"
@then("the persisted Invariant created_at should not be empty")
def step_check_created_at(context: Context) -> None:
assert context._inv_retrieved.created_at, "created_at should not be empty"
assert len(str(context._inv_retrieved.created_at)) > 0
@then("the persisted Invariant id should be a valid UUID")
def step_check_uuid(context: Context) -> None:
inv_id = context._inv_retrieved.id
try:
uuid.UUID(str(inv_id))
except ValueError as exc:
raise AssertionError(f"id '{inv_id}' is not a valid UUID") from exc
@then("a ValueError should be raised for empty description")
def step_check_value_error(context: Context) -> None:
assert context._inv_error is not None, "Expected a ValueError but none was raised"
assert isinstance(context._inv_error, ValueError)
# ---------------------------------------------------------------------------
# Filtering by is_active
# ---------------------------------------------------------------------------
@given("{active_count:d} active Invariants and {inactive_count:d} inactive Invariants")
def step_mixed_invariants(
context: Context, active_count: int, inactive_count: int
) -> None:
for i in range(active_count):
inv = _make_invariant(f"Active invariant {i}", is_active=True)
context._inv_session.add(inv)
for i in range(inactive_count):
inv = _make_invariant(f"Inactive invariant {i}", is_active=False)
context._inv_session.add(inv)
context._inv_session.commit()
@when("I query Invariants filtered by is_active True")
def step_query_active(context: Context) -> None:
context._inv_results = (
context._inv_session.query(InvariantModel).filter_by(is_active=True).all()
)
@when("I query Invariants filtered by is_active False")
def step_query_inactive(context: Context) -> None:
context._inv_results = (
context._inv_session.query(InvariantModel).filter_by(is_active=False).all()
)
@then("I should get {count:d} Invariants")
def step_check_count(context: Context, count: int) -> None:
actual = len(context._inv_results)
assert actual == count, f"Expected {count} Invariants, got {actual}"
# ---------------------------------------------------------------------------
# Deactivate
# ---------------------------------------------------------------------------
@given("a persisted active Invariant")
def step_persisted_active(context: Context) -> None:
inv = _make_invariant("Active invariant to deactivate", is_active=True)
context._inv_session.add(inv)
context._inv_session.commit()
context._inv_id = inv.id
context._inv_retrieved = inv
@when("I set is_active to False on the Invariant")
def step_deactivate(context: Context) -> None:
inv = (
context._inv_session.query(InvariantModel).filter_by(id=context._inv_id).first()
)
assert inv is not None
inv.is_active = False
context._inv_session.commit()
context._inv_retrieved = inv
@then("the Invariant is_active should be False")
def step_check_is_active_false(context: Context) -> None:
inv = (
context._inv_session.query(InvariantModel).filter_by(id=context._inv_id).first()
)
assert inv is not None
assert inv.is_active is False or inv.is_active == 0, (
f"Expected is_active=False, got {inv.is_active!r}"
)
# ---------------------------------------------------------------------------
# Schema validation
# ---------------------------------------------------------------------------
@then("the invariants table should exist")
def step_table_exists(context: Context) -> None:
inspector = inspect(context._inv_engine)
tables = inspector.get_table_names()
assert "invariants" in tables, (
f"Table 'invariants' not found. Available tables: {tables}"
)
@then("the invariants table should have an index on is_active")
def step_index_exists(context: Context) -> None:
inspector = inspect(context._inv_engine)
indexes = inspector.get_indexes("invariants")
index_names = [idx["name"] for idx in indexes]
# SQLite may also create implicit indexes; check for our named index
assert any("is_active" in name for name in index_names), (
f"No index on is_active found. Indexes: {index_names}"
)