Files
cleveragents-core/features/steps/resource_registry_tables_steps.py
T
brent.edwards 5d2e70cff0
CI / lint (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 28s
CI / security (pull_request) Successful in 25s
CI / quality (pull_request) Successful in 47s
CI / unit_tests (pull_request) Successful in 16m4s
CI / build (pull_request) Successful in 15s
CI / integration_tests (pull_request) Successful in 9m36s
CI / coverage (pull_request) Successful in 8m43s
CI / docker (pull_request) Successful in 40s
test(db): add resource registry robot smoke test
2026-02-17 21:07:54 +00:00

975 lines
34 KiB
Python

"""Step definitions for resource registry database table tests.
Tests the ResourceTypeModel, ResourceModel, and ResourceEdgeModel ORM models
and their corresponding database tables (resource_types, resources,
resource_edges). Uses an in-memory SQLite database with schema created via
``Base.metadata.create_all``.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from behave import given, then, when # type: ignore[import-untyped]
from sqlalchemy import create_engine, event, inspect, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker
from cleveragents.infrastructure.database.models import (
Base,
ResourceEdgeModel,
ResourceModel,
ResourceTypeModel,
)
def _now_iso() -> str:
"""Return current UTC time as ISO-8601 string."""
return datetime.now(tz=UTC).isoformat()
def _make_ulid(suffix: str = "0") -> str:
"""Create a deterministic 26-char fake ULID for testing."""
return ("0" * (26 - len(suffix))) + suffix
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
def _set_sqlite_pragma(dbapi_conn: Any, _connection_record: Any) -> None:
"""Enable FK enforcement on every new SQLite connection."""
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys = ON")
cursor.close()
@given("a resource registry in-memory database is initialized")
def step_resource_registry_init_db(context: Any) -> None:
"""Create an in-memory SQLite database with all tables."""
engine = create_engine("sqlite:///:memory:")
# Enable FK enforcement on every connection via event listener
event.listen(engine, "connect", _set_sqlite_pragma)
# Re-establish FK pragma on the initial connection used for metadata
with engine.connect() as conn:
conn.execute(text("PRAGMA foreign_keys = ON"))
conn.commit()
Base.metadata.create_all(engine)
context.rr_engine = engine
context.rr_session_factory = sessionmaker(bind=engine)
context.rr_session = context.rr_session_factory()
# Storage for cross-step references
context.rr_resource_type = None
context.rr_resource = None
context.rr_parent_resource = None
context.rr_parent_resource_2 = None
context.rr_child_resource = None
context.rr_child_resource_2 = None
context.rr_edge = None
context.rr_error = None
# ---------------------------------------------------------------------------
# Table existence
# ---------------------------------------------------------------------------
@then('the resource registry database should contain table "{table_name}"')
def step_resource_registry_table_exists(context: Any, table_name: str) -> None:
inspector = inspect(context.rr_engine)
tables = inspector.get_table_names()
assert table_name in tables, f"Table {table_name} not found. Tables: {tables}"
# ---------------------------------------------------------------------------
# ResourceTypeModel CRUD
# ---------------------------------------------------------------------------
@when('I create a resource type "{name}" with kind "{kind}" and user_addable {addable}')
def step_resource_registry_create_type(
context: Any, name: str, kind: str, addable: str
) -> None:
session: Session = context.rr_session
namespace = name.split("/")[0]
rt = ResourceTypeModel()
rt.name = name
rt.namespace = namespace
rt.resource_kind = kind
rt.user_addable = addable.lower() == "true"
rt.created_at = _now_iso()
rt.updated_at = _now_iso()
session.add(rt)
session.commit()
context.rr_resource_type = rt
@then('I can retrieve resource type "{name}"')
def step_resource_registry_get_type(context: Any, name: str) -> None:
session: Session = context.rr_session
rt = session.get(ResourceTypeModel, name)
assert rt is not None, f"ResourceType {name} not found"
context.rr_resource_type = rt
@then('the retrieved resource type namespace is "{expected}"')
def step_resource_registry_type_namespace(context: Any, expected: str) -> None:
assert context.rr_resource_type.namespace == expected
@then('the retrieved resource type kind is "{expected}"')
def step_resource_registry_type_kind(context: Any, expected: str) -> None:
assert context.rr_resource_type.resource_kind == expected
@then("the retrieved resource type user_addable is {expected}")
def step_resource_registry_type_user_addable(context: Any, expected: str) -> None:
expected_bool = expected.lower() == "true"
assert context.rr_resource_type.user_addable == expected_bool
@when('I create a resource type "{name}" with JSON fields')
def step_resource_registry_create_type_json(context: Any, name: str) -> None:
session: Session = context.rr_session
namespace = name.split("/")[0]
rt = ResourceTypeModel()
rt.name = name
rt.namespace = namespace
rt.resource_kind = "physical"
rt.user_addable = True
rt.args_schema_json = json.dumps(
[{"flag": "--path", "help": "Path to repo", "required": True}]
)
rt.allowed_parent_types_json = json.dumps([])
rt.allowed_child_types_json = json.dumps(
[{"type": "builtin/fs-directory"}, {"type": "builtin/git"}]
)
rt.auto_discover_json = json.dumps({"enabled": True, "max_depth": 10})
rt.capabilities_json = json.dumps(
{
"readable": True,
"writable": True,
"sandboxable": True,
"checkpointable": True,
}
)
rt.created_at = _now_iso()
rt.updated_at = _now_iso()
session.add(rt)
session.commit()
context.rr_resource_type = rt
@then("the resource type args_schema_json round-trips correctly")
def step_resource_registry_type_args_json(context: Any) -> None:
data = json.loads(context.rr_resource_type.args_schema_json)
assert isinstance(data, list)
assert data[0]["flag"] == "--path"
@then("the resource type allowed_parent_types_json round-trips correctly")
def step_resource_registry_type_parents_json(context: Any) -> None:
data = json.loads(context.rr_resource_type.allowed_parent_types_json)
assert isinstance(data, list)
@then("the resource type allowed_child_types_json round-trips correctly")
def step_resource_registry_type_children_json(context: Any) -> None:
data = json.loads(context.rr_resource_type.allowed_child_types_json)
assert isinstance(data, list)
assert len(data) == 2
@then("the resource type auto_discover_json round-trips correctly")
def step_resource_registry_type_autodiscover_json(context: Any) -> None:
data = json.loads(context.rr_resource_type.auto_discover_json)
assert data["enabled"] is True
assert data["max_depth"] == 10
@then("the resource type capabilities_json round-trips correctly")
def step_resource_registry_type_capabilities_json(context: Any) -> None:
data = json.loads(context.rr_resource_type.capabilities_json)
assert data["readable"] is True
assert data["sandboxable"] is True
@then('creating a duplicate resource type "{name}" raises an integrity error')
def step_resource_registry_duplicate_type(context: Any, name: str) -> None:
session: Session = context.rr_session
namespace = name.split("/")[0]
rt = ResourceTypeModel()
rt.name = name
rt.namespace = namespace
rt.resource_kind = "physical"
rt.user_addable = False
rt.created_at = _now_iso()
rt.updated_at = _now_iso()
try:
session.add(rt)
session.commit()
msg = "Expected IntegrityError for duplicate resource type"
raise AssertionError(msg)
except IntegrityError:
session.rollback()
@then('creating a resource type with kind "{kind}" raises an integrity error')
def step_resource_registry_invalid_kind(context: Any, kind: str) -> None:
session: Session = context.rr_session
try:
session.execute(
text(
"INSERT INTO resource_types (name, namespace, resource_kind, "
"user_addable, created_at, updated_at) "
"VALUES (:name, :ns, :kind, 0, :ts, :ts)"
),
{
"name": "test/invalid-kind",
"ns": "test",
"kind": kind,
"ts": _now_iso(),
},
)
session.commit()
msg = "Expected IntegrityError for invalid resource_kind"
raise AssertionError(msg)
except IntegrityError:
session.rollback()
@then('listing resource types by namespace "{ns}" returns {count:d} results')
def step_resource_registry_list_types_ns_plural(
context: Any, ns: str, count: int
) -> None:
session: Session = context.rr_session
results = (
session.query(ResourceTypeModel).filter(ResourceTypeModel.namespace == ns).all()
)
assert len(results) == count, f"Expected {count}, got {len(results)}"
@then('listing resource types by namespace "{ns}" returns {count:d} result')
def step_resource_registry_list_types_ns_singular(
context: Any, ns: str, count: int
) -> None:
session: Session = context.rr_session
results = (
session.query(ResourceTypeModel).filter(ResourceTypeModel.namespace == ns).all()
)
assert len(results) == count, f"Expected {count}, got {len(results)}"
@then('listing resource types by kind "{kind}" returns {count:d} results')
def step_resource_registry_list_types_kind_plural(
context: Any, kind: str, count: int
) -> None:
session: Session = context.rr_session
results = (
session.query(ResourceTypeModel)
.filter(ResourceTypeModel.resource_kind == kind)
.all()
)
assert len(results) == count, f"Expected {count}, got {len(results)}"
@then('listing resource types by kind "{kind}" returns {count:d} result')
def step_resource_registry_list_types_kind_singular(
context: Any, kind: str, count: int
) -> None:
session: Session = context.rr_session
results = (
session.query(ResourceTypeModel)
.filter(ResourceTypeModel.resource_kind == kind)
.all()
)
assert len(results) == count, f"Expected {count}, got {len(results)}"
# ---------------------------------------------------------------------------
# ResourceModel CRUD - prerequisites
# ---------------------------------------------------------------------------
@given('a resource type "{name}" exists')
def step_resource_registry_ensure_type_physical(context: Any, name: str) -> None:
session: Session = context.rr_session
existing = session.get(ResourceTypeModel, name)
if existing is None:
namespace = name.split("/")[0]
rt = ResourceTypeModel()
rt.name = name
rt.namespace = namespace
rt.resource_kind = "physical"
rt.user_addable = True
rt.created_at = _now_iso()
rt.updated_at = _now_iso()
session.add(rt)
session.commit()
@given('a resource type "{name}" exists as virtual')
def step_resource_registry_ensure_type_virtual(context: Any, name: str) -> None:
session: Session = context.rr_session
existing = session.get(ResourceTypeModel, name)
if existing is None:
namespace = name.split("/")[0]
rt = ResourceTypeModel()
rt.name = name
rt.namespace = namespace
rt.resource_kind = "virtual"
rt.user_addable = False
rt.created_at = _now_iso()
rt.updated_at = _now_iso()
session.add(rt)
session.commit()
@given('a resource type "{name}" exists as physical')
def step_resource_registry_ensure_type_physical_explicit(
context: Any, name: str
) -> None:
session: Session = context.rr_session
existing = session.get(ResourceTypeModel, name)
if existing is None:
namespace = name.split("/")[0]
rt = ResourceTypeModel()
rt.name = name
rt.namespace = namespace
rt.resource_kind = "physical"
rt.user_addable = True
rt.created_at = _now_iso()
rt.updated_at = _now_iso()
session.add(rt)
session.commit()
# ---------------------------------------------------------------------------
# ResourceModel CRUD
# ---------------------------------------------------------------------------
@when('I create a resource "{rname}" of type "{tname}" with location "{loc}"')
def step_resource_registry_create_resource(
context: Any, rname: str, tname: str, loc: str
) -> None:
session: Session = context.rr_session
namespace = rname.split("/")[0]
rid = _make_ulid(rname.replace("/", "_"))
# Look up the type to get the kind
rt = session.get(ResourceTypeModel, tname)
kind = rt.resource_kind if rt else "physical"
r = ResourceModel()
r.resource_id = rid
r.namespaced_name = rname
r.namespace = namespace
r.type_name = tname
r.resource_kind = kind
r.location = loc
r.created_at = _now_iso()
r.updated_at = _now_iso()
session.add(r)
session.commit()
context.rr_resource = r
@then("I can retrieve the resource by id")
def step_resource_registry_get_resource(context: Any) -> None:
session: Session = context.rr_session
rid = context.rr_resource.resource_id
r = session.get(ResourceModel, rid)
assert r is not None, f"Resource {rid} not found"
context.rr_resource = r
@then('the retrieved resource namespaced_name is "{expected}"')
def step_resource_registry_resource_name(context: Any, expected: str) -> None:
assert context.rr_resource.namespaced_name == expected
@then('the retrieved resource namespace is "{expected}"')
def step_resource_registry_resource_namespace(context: Any, expected: str) -> None:
assert context.rr_resource.namespace == expected
@then('the retrieved resource type_name is "{expected}"')
def step_resource_registry_resource_type(context: Any, expected: str) -> None:
assert context.rr_resource.type_name == expected
@then('the retrieved resource kind is "{expected}"')
def step_resource_registry_resource_kind(context: Any, expected: str) -> None:
assert context.rr_resource.resource_kind == expected
@then('the retrieved resource location is "{expected}"')
def step_resource_registry_resource_location(context: Any, expected: str) -> None:
assert context.rr_resource.location == expected
@when('I create an unnamed resource of type "{tname}"')
def step_resource_registry_create_unnamed(context: Any, tname: str) -> None:
session: Session = context.rr_session
rid = _make_ulid("unnamed1")
rt = session.get(ResourceTypeModel, tname)
kind = rt.resource_kind if rt else "virtual"
r = ResourceModel()
r.resource_id = rid
r.namespaced_name = None
r.namespace = None
r.type_name = tname
r.resource_kind = kind
r.created_at = _now_iso()
r.updated_at = _now_iso()
session.add(r)
session.commit()
context.rr_resource = r
@then("I can retrieve the unnamed resource by id")
def step_resource_registry_get_unnamed(context: Any) -> None:
session: Session = context.rr_session
rid = context.rr_resource.resource_id
r = session.get(ResourceModel, rid)
assert r is not None, f"Resource {rid} not found"
context.rr_resource = r
@then("the unnamed resource namespaced_name is null")
def step_resource_registry_unnamed_null(context: Any) -> None:
assert context.rr_resource.namespaced_name is None
@when("I create a resource with JSON properties")
def step_resource_registry_create_resource_json(context: Any) -> None:
session: Session = context.rr_session
rid = _make_ulid("jsonprops")
r = ResourceModel()
r.resource_id = rid
r.namespaced_name = "local/json-test"
r.namespace = "local"
r.type_name = "builtin/git-checkout"
r.resource_kind = "physical"
r.location = "/path/to/repo"
r.properties_json = json.dumps({"branch": "main", "remote": "origin"})
r.metadata_json = json.dumps({"tags": ["important"], "priority": 1})
r.created_at = _now_iso()
r.updated_at = _now_iso()
session.add(r)
session.commit()
context.rr_resource = r
@then("the resource properties_json round-trips correctly")
def step_resource_registry_resource_props(context: Any) -> None:
data = json.loads(context.rr_resource.properties_json)
assert data["branch"] == "main"
assert data["remote"] == "origin"
@then("the resource metadata_json round-trips correctly")
def step_resource_registry_resource_meta(context: Any) -> None:
data = json.loads(context.rr_resource.metadata_json)
assert data["tags"] == ["important"]
assert data["priority"] == 1
@then('creating a resource with kind "{kind}" raises an integrity error')
def step_resource_registry_invalid_resource_kind(context: Any, kind: str) -> None:
session: Session = context.rr_session
try:
session.execute(
text(
"INSERT INTO resources (resource_id, type_name, resource_kind, "
"read_only, auto_discovered, created_at, updated_at) "
"VALUES (:rid, :tname, :kind, 0, 0, :ts, :ts)"
),
{
"rid": _make_ulid("badkind"),
"tname": "builtin/git-checkout",
"kind": kind,
"ts": _now_iso(),
},
)
session.commit()
msg = "Expected IntegrityError for invalid resource_kind"
raise AssertionError(msg)
except IntegrityError:
session.rollback()
@then("creating a resource with non-existent type raises an integrity error")
def step_resource_registry_missing_type(context: Any) -> None:
session: Session = context.rr_session
r = ResourceModel()
r.resource_id = _make_ulid("orphan")
r.type_name = "nonexistent/type"
r.resource_kind = "physical"
r.created_at = _now_iso()
r.updated_at = _now_iso()
try:
session.add(r)
session.commit()
msg = "Expected IntegrityError for missing FK"
raise AssertionError(msg)
except IntegrityError:
session.rollback()
@then('creating a duplicate named resource "{name}" raises an integrity error')
def step_resource_registry_duplicate_resource(context: Any, name: str) -> None:
session: Session = context.rr_session
r = ResourceModel()
r.resource_id = _make_ulid("dup")
r.namespaced_name = name
r.namespace = name.split("/")[0]
r.type_name = "builtin/git-checkout"
r.resource_kind = "physical"
r.created_at = _now_iso()
r.updated_at = _now_iso()
try:
session.add(r)
session.commit()
msg = "Expected IntegrityError for duplicate named resource"
raise AssertionError(msg)
except IntegrityError:
session.rollback()
@then('deleting resource type "{name}" raises an integrity error')
def step_resource_registry_restrict_type_delete(context: Any, name: str) -> None:
session: Session = context.rr_session
rt = session.get(ResourceTypeModel, name)
assert rt is not None
try:
session.delete(rt)
session.flush()
msg = "Expected IntegrityError for RESTRICT delete"
raise AssertionError(msg)
except IntegrityError:
session.rollback()
@then('querying resources by type "{tname}" returns {count:d} result')
def step_resource_registry_query_by_type(context: Any, tname: str, count: int) -> None:
session: Session = context.rr_session
results = (
session.query(ResourceModel).filter(ResourceModel.type_name == tname).all()
)
assert len(results) == count, f"Expected {count}, got {len(results)}"
@then('querying resources by namespace "{ns}" returns {count:d} result')
def step_resource_registry_query_by_ns(context: Any, ns: str, count: int) -> None:
session: Session = context.rr_session
results = session.query(ResourceModel).filter(ResourceModel.namespace == ns).all()
assert len(results) == count, f"Expected {count}, got {len(results)}"
# ---------------------------------------------------------------------------
# ResourceEdgeModel - prerequisites
# ---------------------------------------------------------------------------
@given('a parent resource "{rname}" of type "{tname}"')
def step_resource_registry_create_parent(context: Any, rname: str, tname: str) -> None:
session: Session = context.rr_session
rid = _make_ulid("parent_" + rname.replace("/", "_"))
rt = session.get(ResourceTypeModel, tname)
kind = rt.resource_kind if rt else "physical"
r = ResourceModel()
r.resource_id = rid
r.namespaced_name = rname
r.namespace = rname.split("/")[0]
r.type_name = tname
r.resource_kind = kind
r.created_at = _now_iso()
r.updated_at = _now_iso()
session.add(r)
session.commit()
context.rr_parent_resource = r
@given('a second parent resource "{rname}" of type "{tname}"')
def step_resource_registry_create_second_parent(
context: Any, rname: str, tname: str
) -> None:
session: Session = context.rr_session
rid = _make_ulid("parent2_" + rname.replace("/", "_"))
rt = session.get(ResourceTypeModel, tname)
kind = rt.resource_kind if rt else "physical"
r = ResourceModel()
r.resource_id = rid
r.namespaced_name = rname
r.namespace = rname.split("/")[0]
r.type_name = tname
r.resource_kind = kind
r.created_at = _now_iso()
r.updated_at = _now_iso()
session.add(r)
session.commit()
context.rr_parent_resource_2 = r
@given('a child resource of type "{tname}"')
def step_resource_registry_create_child(context: Any, tname: str) -> None:
session: Session = context.rr_session
rid = _make_ulid("child1")
rt = session.get(ResourceTypeModel, tname)
kind = rt.resource_kind if rt else "physical"
r = ResourceModel()
r.resource_id = rid
r.type_name = tname
r.resource_kind = kind
r.created_at = _now_iso()
r.updated_at = _now_iso()
session.add(r)
session.commit()
context.rr_child_resource = r
@given('a first child resource of type "{tname}"')
def step_resource_registry_create_first_child(context: Any, tname: str) -> None:
session: Session = context.rr_session
rid = _make_ulid("child_a")
rt = session.get(ResourceTypeModel, tname)
kind = rt.resource_kind if rt else "physical"
r = ResourceModel()
r.resource_id = rid
r.type_name = tname
r.resource_kind = kind
r.created_at = _now_iso()
r.updated_at = _now_iso()
session.add(r)
session.commit()
context.rr_child_resource = r
@given('a second child resource of type "{tname}"')
def step_resource_registry_create_second_child(context: Any, tname: str) -> None:
session: Session = context.rr_session
rid = _make_ulid("child_b")
rt = session.get(ResourceTypeModel, tname)
kind = rt.resource_kind if rt else "physical"
r = ResourceModel()
r.resource_id = rid
r.type_name = tname
r.resource_kind = kind
r.created_at = _now_iso()
r.updated_at = _now_iso()
session.add(r)
session.commit()
context.rr_child_resource_2 = r
# ---------------------------------------------------------------------------
# ResourceEdgeModel CRUD
# ---------------------------------------------------------------------------
@given('I create an edge from parent to child with link_type "{lt}"')
@when('I create an edge from parent to child with link_type "{lt}"')
def step_resource_registry_create_edge(context: Any, lt: str) -> None:
session: Session = context.rr_session
edge = ResourceEdgeModel()
edge.parent_id = context.rr_parent_resource.resource_id
edge.child_id = context.rr_child_resource.resource_id
edge.link_type = lt
edge.created_at = _now_iso()
session.add(edge)
session.commit()
context.rr_edge = edge
@when('I create an edge from second parent to child with link_type "{lt}"')
def step_resource_registry_create_edge_second_parent(context: Any, lt: str) -> None:
session: Session = context.rr_session
edge = ResourceEdgeModel()
edge.parent_id = context.rr_parent_resource_2.resource_id
edge.child_id = context.rr_child_resource.resource_id
edge.link_type = lt
edge.created_at = _now_iso()
session.add(edge)
session.commit()
@when("I create edges from parent to both children")
def step_resource_registry_create_edges_multi(context: Any) -> None:
session: Session = context.rr_session
e1 = ResourceEdgeModel()
e1.parent_id = context.rr_parent_resource.resource_id
e1.child_id = context.rr_child_resource.resource_id
e1.link_type = "contains"
e1.created_at = _now_iso()
session.add(e1)
e2 = ResourceEdgeModel()
e2.parent_id = context.rr_parent_resource.resource_id
e2.child_id = context.rr_child_resource_2.resource_id
e2.link_type = "contains"
e2.created_at = _now_iso()
session.add(e2)
session.commit()
@then("the edge exists in the database")
def step_resource_registry_edge_exists(context: Any) -> None:
session: Session = context.rr_session
edge = (
session.query(ResourceEdgeModel)
.filter(
ResourceEdgeModel.parent_id == context.rr_parent_resource.resource_id,
ResourceEdgeModel.child_id == context.rr_child_resource.resource_id,
)
.first()
)
assert edge is not None
@then('the edge link_type is "{expected}"')
def step_resource_registry_edge_link_type(context: Any, expected: str) -> None:
assert context.rr_edge.link_type == expected
@then("querying children of the parent returns {count:d} result")
def step_resource_registry_children_count_single(context: Any, count: int) -> None:
session: Session = context.rr_session
edges = (
session.query(ResourceEdgeModel)
.filter(ResourceEdgeModel.parent_id == context.rr_parent_resource.resource_id)
.all()
)
assert len(edges) == count, f"Expected {count}, got {len(edges)}"
@then("querying children of the parent returns {count:d} results")
def step_resource_registry_children_count(context: Any, count: int) -> None:
session: Session = context.rr_session
edges = (
session.query(ResourceEdgeModel)
.filter(ResourceEdgeModel.parent_id == context.rr_parent_resource.resource_id)
.all()
)
assert len(edges) == count, f"Expected {count}, got {len(edges)}"
@then("querying parents of the child returns {count:d} result")
def step_resource_registry_parents_count_single(context: Any, count: int) -> None:
session: Session = context.rr_session
edges = (
session.query(ResourceEdgeModel)
.filter(ResourceEdgeModel.child_id == context.rr_child_resource.resource_id)
.all()
)
assert len(edges) == count, f"Expected {count}, got {len(edges)}"
@then("querying parents of the child returns {count:d} results")
def step_resource_registry_parents_count(context: Any, count: int) -> None:
session: Session = context.rr_session
edges = (
session.query(ResourceEdgeModel)
.filter(ResourceEdgeModel.child_id == context.rr_child_resource.resource_id)
.all()
)
assert len(edges) == count, f"Expected {count}, got {len(edges)}"
@then("creating a duplicate edge raises an integrity error")
def step_resource_registry_duplicate_edge(context: Any) -> None:
session: Session = context.rr_session
edge = ResourceEdgeModel()
edge.parent_id = context.rr_parent_resource.resource_id
edge.child_id = context.rr_child_resource.resource_id
edge.link_type = "contains"
edge.created_at = _now_iso()
try:
session.add(edge)
session.commit()
msg = "Expected IntegrityError for duplicate edge"
raise AssertionError(msg)
except IntegrityError:
session.rollback()
@then("creating a self-loop edge raises an integrity error")
def step_resource_registry_self_loop(context: Any) -> None:
session: Session = context.rr_session
parent_id = context.rr_parent_resource.resource_id
try:
session.execute(
text(
"INSERT INTO resource_edges (parent_id, child_id, link_type, "
"auto_discovered, created_at) "
"VALUES (:pid, :cid, 'contains', 0, :ts)"
),
{"pid": parent_id, "cid": parent_id, "ts": _now_iso()},
)
session.commit()
msg = "Expected IntegrityError for self-loop"
raise AssertionError(msg)
except IntegrityError:
session.rollback()
@then("creating an edge with invalid link_type raises an integrity error")
def step_resource_registry_invalid_link_type(context: Any) -> None:
session: Session = context.rr_session
try:
session.execute(
text(
"INSERT INTO resource_edges (parent_id, child_id, link_type, "
"auto_discovered, created_at) "
"VALUES (:pid, :cid, 'invalid_type', 0, :ts)"
),
{
"pid": context.rr_parent_resource.resource_id,
"cid": context.rr_child_resource.resource_id,
"ts": _now_iso(),
},
)
session.commit()
msg = "Expected IntegrityError for invalid link_type"
raise AssertionError(msg)
except IntegrityError:
session.rollback()
@when("I delete the parent resource")
def step_resource_registry_delete_parent(context: Any) -> None:
session: Session = context.rr_session
r = session.get(ResourceModel, context.rr_parent_resource.resource_id)
assert r is not None
session.delete(r)
session.commit()
@when("I delete the child resource")
def step_resource_registry_delete_child(context: Any) -> None:
session: Session = context.rr_session
r = session.get(ResourceModel, context.rr_child_resource.resource_id)
assert r is not None
session.delete(r)
session.commit()
@then("the edge no longer exists in the database")
def step_resource_registry_edge_gone(context: Any) -> None:
session: Session = context.rr_session
count = session.query(ResourceEdgeModel).count()
assert count == 0, f"Expected 0 edges, got {count}"
# ---------------------------------------------------------------------------
# ORM relationship navigation
# ---------------------------------------------------------------------------
@then('navigating from resource to resource_type returns "{expected}"')
def step_resource_registry_nav_to_type(context: Any, expected: str) -> None:
session: Session = context.rr_session
r = session.get(ResourceModel, context.rr_resource.resource_id)
assert r is not None
assert r.resource_type is not None
assert r.resource_type.name == expected
@then("navigating from resource_type to resources returns {count:d} items")
def step_resource_registry_nav_to_resources(context: Any, count: int) -> None:
session: Session = context.rr_session
rt = session.get(ResourceTypeModel, "builtin/git-checkout")
assert rt is not None
assert len(rt.resources) == count, f"Expected {count}, got {len(rt.resources)}"
@then("navigating from parent to child_edges returns {count:d} edge")
def step_resource_registry_nav_child_edges(context: Any, count: int) -> None:
session: Session = context.rr_session
r = session.get(ResourceModel, context.rr_parent_resource.resource_id)
assert r is not None
assert len(r.child_edges) == count, f"Expected {count}, got {len(r.child_edges)}"
@then("navigating from child to parent_edges returns {count:d} edge")
def step_resource_registry_nav_parent_edges(context: Any, count: int) -> None:
session: Session = context.rr_session
r = session.get(ResourceModel, context.rr_child_resource.resource_id)
assert r is not None
assert len(r.parent_edges) == count, f"Expected {count}, got {len(r.parent_edges)}"
# ---------------------------------------------------------------------------
# Timestamps
# ---------------------------------------------------------------------------
@then("the resource type has valid created_at and updated_at timestamps")
def step_resource_registry_type_timestamps(context: Any) -> None:
rt = context.rr_resource_type
assert rt.created_at is not None
assert rt.updated_at is not None
# Parse to verify ISO-8601 format
datetime.fromisoformat(rt.created_at)
datetime.fromisoformat(rt.updated_at)
@then("the resource has valid created_at and updated_at timestamps")
def step_resource_registry_resource_timestamps(context: Any) -> None:
r = context.rr_resource
assert r.created_at is not None
assert r.updated_at is not None
datetime.fromisoformat(r.created_at)
datetime.fromisoformat(r.updated_at)
@then("the edge has a valid created_at timestamp")
def step_resource_registry_edge_timestamp(context: Any) -> None:
edge = context.rr_edge
assert edge.created_at is not None
datetime.fromisoformat(edge.created_at)
# ---------------------------------------------------------------------------
# Migration smoke tests
# ---------------------------------------------------------------------------
@when("I run init_database to simulate migration")
def step_run_init_database(context: Any) -> None:
"""Run init_database on a fresh temp file to simulate migration."""
import tempfile
from cleveragents.infrastructure.database.models import init_database
tmp = tempfile.mktemp(suffix=".db")
db_url = f"sqlite:///{tmp}"
engine = init_database(db_url)
context.rr_migrated_engine = engine
context.rr_migrated_tmp = tmp
@when("I run init_database twice on the same database file")
def step_run_init_database_twice(context: Any) -> None:
"""Run init_database twice on the same file to verify idempotency."""
import tempfile
from cleveragents.infrastructure.database.models import init_database
tmp = tempfile.mktemp(suffix=".db")
db_url = f"sqlite:///{tmp}"
engine1 = init_database(db_url)
engine1.dispose()
engine2 = init_database(db_url)
context.rr_migrated_engine = engine2
context.rr_migrated_tmp = tmp
@then('the migrated database should contain table "{table_name}"')
def step_migrated_table_exists(context: Any, table_name: str) -> None:
inspector = inspect(context.rr_migrated_engine)
tables = inspector.get_table_names()
assert table_name in tables, f"Table {table_name} not found. Tables: {tables}"