bdc2ccd6a3
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
234 lines
7.9 KiB
Python
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}"
|
|
)
|