forked from HAL9000/cleveragents-core
8ea00f5185
Co-authored-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me> Co-committed-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
521 lines
18 KiB
Python
521 lines
18 KiB
Python
"""Step definitions for resource_registry_service_coverage.feature.
|
|
|
|
Covers:
|
|
- bootstrap_builtin_types / register_type / register_resource exception rollback
|
|
- _spec_to_db serialisation of auto_discovery and equivalence
|
|
- _db_to_spec parsing of auto_discover_json and equivalence_json
|
|
- Full round-trip through a real in-memory database
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import tempfile
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
from behave import given, then, when # type: ignore[import-untyped]
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from cleveragents.application.services.resource_registry_service import (
|
|
ResourceRegistryService,
|
|
_db_to_spec,
|
|
_spec_to_db,
|
|
)
|
|
from cleveragents.domain.models.core.resource_type import (
|
|
ResourceKind,
|
|
ResourceTypeSpec,
|
|
SandboxStrategy,
|
|
)
|
|
from cleveragents.infrastructure.database.models import (
|
|
Base,
|
|
ResourceTypeModel,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _in_memory_session_factory() -> tuple[Any, Any]:
|
|
"""Create an in-memory SQLite engine + unclosable session factory."""
|
|
engine = create_engine(
|
|
"sqlite:///:memory:",
|
|
echo=False,
|
|
connect_args={"check_same_thread": False},
|
|
)
|
|
Base.metadata.create_all(engine)
|
|
real_session = sessionmaker(
|
|
bind=engine,
|
|
expire_on_commit=False,
|
|
autoflush=True,
|
|
autocommit=False,
|
|
)()
|
|
|
|
class _Unclosable:
|
|
"""Wraps a real session but makes close() a no-op."""
|
|
|
|
def __init__(self, s: Session) -> None:
|
|
object.__setattr__(self, "_s", s)
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
return getattr(object.__getattribute__(self, "_s"), name)
|
|
|
|
def __setattr__(self, name: str, value: Any) -> None:
|
|
setattr(object.__getattribute__(self, "_s"), name, value)
|
|
|
|
wrapper = _Unclosable(real_session)
|
|
return engine, lambda: wrapper
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: bootstrap_builtin_types rolls back on unexpected exception
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a resource registry service with a faulty session for bootstrap")
|
|
def step_faulty_session_bootstrap(context: Any) -> None:
|
|
mock_session = MagicMock()
|
|
# Make query().filter_by().first() succeed the first time, then blow up
|
|
# on session.add() to trigger the generic Exception branch.
|
|
mock_session.query.return_value.filter_by.return_value.first.return_value = None
|
|
mock_session.add.side_effect = RuntimeError("unexpected DB error")
|
|
|
|
context.faulty_session = mock_session
|
|
context.cov_svc = ResourceRegistryService(
|
|
session_factory=lambda: mock_session,
|
|
)
|
|
|
|
|
|
@when("I call bootstrap_builtin_types and it fails")
|
|
def step_call_bootstrap_fails(context: Any) -> None:
|
|
context.bootstrap_exception = None
|
|
try:
|
|
context.cov_svc.bootstrap_builtin_types()
|
|
except RuntimeError as exc:
|
|
context.bootstrap_exception = exc
|
|
|
|
|
|
@then("the faulty session should have been rolled back")
|
|
def step_faulty_session_rolled_back(context: Any) -> None:
|
|
context.faulty_session.rollback.assert_called()
|
|
|
|
|
|
@then("the original exception should propagate from bootstrap")
|
|
def step_bootstrap_exception_propagates(context: Any) -> None:
|
|
assert context.bootstrap_exception is not None
|
|
assert "unexpected DB error" in str(context.bootstrap_exception)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: register_type rolls back on non-ValidationError exception
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a resource registry service with a faulty session for register_type")
|
|
def step_faulty_session_register_type(context: Any) -> None:
|
|
mock_session = MagicMock()
|
|
# Make the duplicate-check query return None (no existing type)
|
|
mock_session.query.return_value.filter_by.return_value.first.return_value = None
|
|
# Blow up on session.add() with a non-ValidationError
|
|
mock_session.add.side_effect = RuntimeError("disk full")
|
|
|
|
context.regtype_faulty_session = mock_session
|
|
context.cov_regtype_svc = ResourceRegistryService(
|
|
session_factory=lambda: mock_session,
|
|
)
|
|
|
|
|
|
@given("a temporary valid resource type YAML file exists")
|
|
def step_create_temp_yaml(context: Any) -> None:
|
|
yaml_content = (
|
|
"name: testns/cov-type\n"
|
|
"description: Coverage test type\n"
|
|
"resource_kind: physical\n"
|
|
"sandbox_strategy: none\n"
|
|
"user_addable: true\n"
|
|
)
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
|
|
tmp.write(yaml_content)
|
|
tmp.flush()
|
|
context.cov_yaml_path = tmp.name
|
|
|
|
|
|
@when("I call register_type and a non-validation exception occurs")
|
|
def step_call_register_type_fails(context: Any) -> None:
|
|
context.regtype_exception = None
|
|
try:
|
|
context.cov_regtype_svc.register_type(context.cov_yaml_path)
|
|
except RuntimeError as exc:
|
|
context.regtype_exception = exc
|
|
|
|
|
|
@then("the register_type faulty session should have been rolled back")
|
|
def step_regtype_session_rolled_back(context: Any) -> None:
|
|
context.regtype_faulty_session.rollback.assert_called()
|
|
|
|
|
|
@then("the original non-validation exception should propagate from register_type")
|
|
def step_regtype_exception_propagates(context: Any) -> None:
|
|
assert context.regtype_exception is not None
|
|
assert "disk full" in str(context.regtype_exception)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: register_resource rolls back on generic exception
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a resource registry service with a faulty session for register_resource")
|
|
def step_faulty_session_register_resource(context: Any) -> None:
|
|
mock_session = MagicMock()
|
|
# type_row lookup succeeds (returns a mock row with resource_kind)
|
|
type_row = MagicMock()
|
|
type_row.resource_kind = "physical"
|
|
mock_session.query.return_value.filter_by.return_value.first.return_value = type_row
|
|
# Blow up on session.add() with a generic Exception
|
|
mock_session.add.side_effect = OSError("filesystem gone")
|
|
|
|
context.regrsc_faulty_session = mock_session
|
|
context.cov_regrsc_svc = ResourceRegistryService(
|
|
session_factory=lambda: mock_session,
|
|
)
|
|
|
|
|
|
@when("I call register_resource and a generic exception occurs")
|
|
def step_call_register_resource_fails(context: Any) -> None:
|
|
context.regrsc_exception = None
|
|
try:
|
|
context.cov_regrsc_svc.register_resource(
|
|
type_name="git-checkout",
|
|
name="test/res",
|
|
location="/tmp/test",
|
|
)
|
|
except OSError as exc:
|
|
context.regrsc_exception = exc
|
|
|
|
|
|
@then("the register_resource faulty session should have been rolled back")
|
|
def step_regrsc_session_rolled_back(context: Any) -> None:
|
|
context.regrsc_faulty_session.rollback.assert_called()
|
|
|
|
|
|
@then("the original generic exception should propagate from register_resource")
|
|
def step_regrsc_exception_propagates(context: Any) -> None:
|
|
assert context.regrsc_exception is not None
|
|
assert "filesystem gone" in str(context.regrsc_exception)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: _spec_to_db serialises auto_discovery to JSON
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a ResourceTypeSpec with auto_discovery set")
|
|
def step_spec_with_auto_discovery(context: Any) -> None:
|
|
context.auto_discovery_data = {
|
|
"glob_pattern": "*.py",
|
|
"recursive": True,
|
|
}
|
|
context.cov_spec_ad = ResourceTypeSpec(
|
|
name="git-checkout",
|
|
description="Test type with auto-discovery",
|
|
resource_kind=ResourceKind.PHYSICAL,
|
|
sandbox_strategy=SandboxStrategy.GIT_WORKTREE,
|
|
user_addable=True,
|
|
auto_discovery=context.auto_discovery_data,
|
|
built_in=True,
|
|
)
|
|
|
|
|
|
@when("I convert the spec to a database model via _spec_to_db")
|
|
def step_convert_spec_to_db_ad(context: Any) -> None:
|
|
context.cov_db_model_ad = _spec_to_db(context.cov_spec_ad, source="test")
|
|
|
|
|
|
@then("the database model auto_discover_json should contain the auto_discovery data")
|
|
def step_verify_ad_json(context: Any) -> None:
|
|
raw = context.cov_db_model_ad.auto_discover_json
|
|
assert raw is not None, "auto_discover_json should not be None"
|
|
parsed = json.loads(raw)
|
|
assert parsed == context.auto_discovery_data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: _spec_to_db serialises equivalence to JSON
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a ResourceTypeSpec with equivalence set")
|
|
def step_spec_with_equivalence(context: Any) -> None:
|
|
context.equivalence_data = {
|
|
"criteria": ["content_hash", "location"],
|
|
"description": "Hash-based equivalence comparison",
|
|
}
|
|
# Virtual types require equivalence and all-false capabilities
|
|
context.cov_spec_eq = ResourceTypeSpec(
|
|
name="testns/virt-type",
|
|
description="Virtual type with equivalence",
|
|
resource_kind=ResourceKind.VIRTUAL,
|
|
sandbox_strategy=SandboxStrategy.NONE,
|
|
user_addable=False,
|
|
handler=None,
|
|
capabilities={
|
|
"read": False,
|
|
"write": False,
|
|
"sandbox": False,
|
|
"checkpoint": False,
|
|
},
|
|
equivalence=context.equivalence_data,
|
|
built_in=False,
|
|
)
|
|
|
|
|
|
@when("I convert the equivalence spec to a database model via _spec_to_db")
|
|
def step_convert_spec_to_db_eq(context: Any) -> None:
|
|
context.cov_db_model_eq = _spec_to_db(context.cov_spec_eq, source="test")
|
|
|
|
|
|
@then("the database model equivalence_json should contain the equivalence data")
|
|
def step_verify_eq_json(context: Any) -> None:
|
|
raw = context.cov_db_model_eq.equivalence_json
|
|
assert raw is not None, "equivalence_json should not be None"
|
|
parsed = json.loads(raw)
|
|
assert parsed == context.equivalence_data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: _db_to_spec parses auto_discover_json from a database row
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("an in-memory database with a resource type row containing auto_discover_json")
|
|
def step_db_row_with_auto_discover(context: Any) -> None:
|
|
engine, factory = _in_memory_session_factory()
|
|
context.cov_ad_engine = engine
|
|
context.cov_ad_factory = factory
|
|
|
|
context.ad_original = {"glob": "**/*.py", "depth": 3}
|
|
session = factory()
|
|
from datetime import UTC, datetime
|
|
|
|
now = datetime.now(tz=UTC).isoformat()
|
|
row = ResourceTypeModel(
|
|
name="git-checkout",
|
|
namespace="builtin",
|
|
description="Test row with auto_discover_json",
|
|
resource_kind="physical",
|
|
sandbox_strategy="git_worktree",
|
|
user_addable=True,
|
|
handler_ref=None,
|
|
args_schema_json=None,
|
|
allowed_parent_types_json=None,
|
|
allowed_child_types_json=None,
|
|
auto_discover_json=json.dumps(context.ad_original),
|
|
capabilities_json=json.dumps(
|
|
{"read": True, "write": True, "sandbox": True, "checkpoint": False}
|
|
),
|
|
equivalence_json=None,
|
|
source="test",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
session.add(row)
|
|
session.commit()
|
|
context.cov_ad_row = (
|
|
session.query(ResourceTypeModel).filter_by(name="git-checkout").first()
|
|
)
|
|
|
|
|
|
@when("I convert the database row back to a spec via _db_to_spec")
|
|
def step_convert_db_to_spec_ad(context: Any) -> None:
|
|
context.cov_spec_from_db_ad = _db_to_spec(context.cov_ad_row)
|
|
|
|
|
|
@then("the resulting spec auto_discovery should match the original data")
|
|
def step_verify_spec_ad(context: Any) -> None:
|
|
assert context.cov_spec_from_db_ad.auto_discovery is not None
|
|
assert context.cov_spec_from_db_ad.auto_discovery == context.ad_original
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: _db_to_spec parses equivalence_json from a database row
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("an in-memory database with a resource type row containing equivalence_json")
|
|
def step_db_row_with_equivalence(context: Any) -> None:
|
|
engine, factory = _in_memory_session_factory()
|
|
context.cov_eq_engine = engine
|
|
context.cov_eq_factory = factory
|
|
|
|
context.eq_original = {"criteria": ["hash"], "description": "Exact match"}
|
|
session = factory()
|
|
from datetime import UTC, datetime
|
|
|
|
now = datetime.now(tz=UTC).isoformat()
|
|
row = ResourceTypeModel(
|
|
name="testns/virt-eq",
|
|
namespace="testns",
|
|
description="Virtual type with equivalence in DB",
|
|
resource_kind="virtual",
|
|
sandbox_strategy="none",
|
|
user_addable=False,
|
|
handler_ref=None,
|
|
args_schema_json=None,
|
|
allowed_parent_types_json=None,
|
|
allowed_child_types_json=None,
|
|
auto_discover_json=None,
|
|
capabilities_json=json.dumps(
|
|
{"read": False, "write": False, "sandbox": False, "checkpoint": False}
|
|
),
|
|
equivalence_json=json.dumps(context.eq_original),
|
|
source="test",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
session.add(row)
|
|
session.commit()
|
|
context.cov_eq_row = (
|
|
session.query(ResourceTypeModel).filter_by(name="testns/virt-eq").first()
|
|
)
|
|
|
|
|
|
@when("I convert the equivalence database row back to a spec via _db_to_spec")
|
|
def step_convert_db_to_spec_eq(context: Any) -> None:
|
|
context.cov_spec_from_db_eq = _db_to_spec(context.cov_eq_row)
|
|
|
|
|
|
@then("the resulting spec equivalence should match the original equivalence data")
|
|
def step_verify_spec_eq(context: Any) -> None:
|
|
assert context.cov_spec_from_db_eq.equivalence is not None
|
|
assert context.cov_spec_from_db_eq.equivalence == context.eq_original
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario: auto_discovery and equivalence survive a full round-trip
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a real in-memory resource registry service is initialised")
|
|
def step_real_inmem_service(context: Any) -> None:
|
|
engine, factory = _in_memory_session_factory()
|
|
context.cov_rt_engine = engine
|
|
context.cov_rt_factory = factory
|
|
context.cov_rt_svc = ResourceRegistryService(session_factory=factory)
|
|
|
|
|
|
@given("a YAML config with auto_discovery and equivalence fields exists")
|
|
def step_yaml_with_ad_and_eq(context: Any) -> None:
|
|
# Virtual type requires equivalence
|
|
yaml_content = (
|
|
"name: covns/round-trip\n"
|
|
"description: Round-trip test type\n"
|
|
"resource_kind: virtual\n"
|
|
"sandbox_strategy: none\n"
|
|
"user_addable: false\n"
|
|
"auto_discovery:\n"
|
|
" glob_pattern: '*.md'\n"
|
|
" max_depth: 5\n"
|
|
"equivalence:\n"
|
|
" criteria:\n"
|
|
" - content_hash\n"
|
|
" description: Hash-based equivalence\n"
|
|
)
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
|
|
tmp.write(yaml_content)
|
|
tmp.flush()
|
|
context.cov_rt_yaml_path = tmp.name
|
|
|
|
|
|
@when("I register the type with auto_discovery and equivalence via the service")
|
|
def step_register_rt_type(context: Any) -> None:
|
|
context.cov_rt_spec = context.cov_rt_svc.register_type(context.cov_rt_yaml_path)
|
|
|
|
|
|
@when("I show the registered type with auto_discovery and equivalence")
|
|
def step_show_rt_type(context: Any) -> None:
|
|
context.cov_rt_shown = context.cov_rt_svc.show_type("covns/round-trip")
|
|
|
|
|
|
@then("the shown type should have the correct auto_discovery")
|
|
def step_verify_rt_ad(context: Any) -> None:
|
|
ad = context.cov_rt_shown.auto_discovery
|
|
assert ad is not None, "auto_discovery should be preserved"
|
|
assert ad["glob_pattern"] == "*.md"
|
|
assert ad["max_depth"] == 5
|
|
|
|
|
|
@then("the shown type should have the correct equivalence")
|
|
def step_verify_rt_eq(context: Any) -> None:
|
|
eq = context.cov_rt_shown.equivalence
|
|
assert eq is not None, "equivalence should be preserved"
|
|
assert eq["criteria"] == ["content_hash"]
|
|
assert eq["description"] == "Hash-based equivalence"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenarios: _db_to_spec derives built_in from namespace column
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a resource type DB row with namespace "{namespace}" and name "{name}"')
|
|
def step_db_row_with_namespace_and_name(
|
|
context: Any, namespace: str, name: str
|
|
) -> None:
|
|
"""Create an in-memory DB row with the given namespace and name."""
|
|
engine, factory = _in_memory_session_factory()
|
|
context.ns_builtin_engine = engine
|
|
context.ns_builtin_factory = factory
|
|
|
|
session = factory()
|
|
from datetime import UTC, datetime
|
|
|
|
now = datetime.now(tz=UTC).isoformat()
|
|
row = ResourceTypeModel(
|
|
name=name,
|
|
namespace=namespace,
|
|
description="Test row for namespace-based built_in detection",
|
|
resource_kind="physical",
|
|
sandbox_strategy="none",
|
|
user_addable=False,
|
|
handler_ref=None,
|
|
args_schema_json=None,
|
|
allowed_parent_types_json=None,
|
|
allowed_child_types_json=None,
|
|
auto_discover_json=None,
|
|
capabilities_json=None,
|
|
equivalence_json=None,
|
|
source="test",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
session.add(row)
|
|
session.commit()
|
|
context.ns_builtin_row = (
|
|
session.query(ResourceTypeModel).filter_by(name=name).first()
|
|
)
|
|
|
|
|
|
@when("I convert that row to a spec via _db_to_spec")
|
|
def step_convert_ns_row_to_spec(context: Any) -> None:
|
|
context.ns_builtin_spec = _db_to_spec(context.ns_builtin_row)
|
|
|
|
|
|
@then("the resulting spec built_in should be True")
|
|
def step_verify_builtin_true(context: Any) -> None:
|
|
assert context.ns_builtin_spec.built_in is True, (
|
|
f"Expected built_in=True but got {context.ns_builtin_spec.built_in!r}"
|
|
)
|
|
|
|
|
|
@then("the resulting spec built_in should be False")
|
|
def step_verify_builtin_false(context: Any) -> None:
|
|
assert context.ns_builtin_spec.built_in is False, (
|
|
f"Expected built_in=False but got {context.ns_builtin_spec.built_in!r}"
|
|
)
|