Files
temp/features/steps/resource_registry_service_coverage_boost_steps.py
freemo a808c395f9 test(coverage): add Behave BDD tests to improve unit test coverage across 53 source modules
Add 53 new .feature files and corresponding step definition files targeting
uncovered lines identified in build/coverage.xml. Fix AmbiguousStep conflicts
in 7 pre-existing step files by disambiguating step text.

New tests cover: ACP clients/facade, actor CLI/config, application container,
ACMS service/strategies, async worker, automation profile CLI, autonomy
guardrail, bridge, change model, config CLI/service, context service,
cross-plan correction, database models, decision service, decomposition
clustering/service, discovery handler, langchain chat provider, langgraph
nodes, materializers, multi-project service, plan apply/CLI/lifecycle/model/
preflight/resume/service, PostgreSQL analyzer, project CLI/context CLI,
provider registry, reactive application/route, repositories, resolver handler,
resource registry service, resume model, retry patterns, sandbox protocol,
server CLI, skill CLI/service, skills registry, subplan execution/service,
system CLI, UKO loader, UoW, and YAML template engine.

Closes #645
2026-03-09 13:01:58 -04:00

519 lines
16 KiB
Python

"""Step definitions for resource_registry_service_coverage_boost.feature.
Covers the remaining uncovered lines in resource_registry_service.py:
- Lines 579, 581-582, 584-586: link_child type incompatibility ValidationError
- Lines 646-648: link_child generic exception rollback
- Lines 686-688: unlink_child generic exception rollback
- Line 785: _build_tree_node type_filter skips non-matching children
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
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,
)
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.resource import PhysVirt, Resource
from cleveragents.infrastructure.database.models import (
Base,
ResourceLinkModel,
ResourceModel,
ResourceTypeModel,
)
# ---------------------------------------------------------------------------
# Valid ULID constants (Crockford base32: [0-9A-HJKMNP-TV-Z]{26})
# ---------------------------------------------------------------------------
# Scenario 1: type incompatibility
_PARENT_RES_1 = "01JA00000000000000000000AA"
_CHILD_RES_1 = "01JB00000000000000000000BB"
# Scenario 2: link_child generic error
_PARENT_RES_2 = "01JC00000000000000000000CC"
_CHILD_RES_2 = "01JE00000000000000000000EE"
# Scenario 3: unlink_child generic error
_PARENT_RES_3 = "01JG00000000000000000000GG"
_CHILD_RES_3 = "01JH00000000000000000000HH"
# Scenario 4: tree type filter
_TREE_PARENT = "01JK00000000000000000000KK"
_TREE_CHILD1 = "01JM00000000000000000000MM"
_TREE_CHILD2 = "01JN00000000000000000000NN"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_inmem_session_factory() -> tuple[Any, Any]:
"""Create an in-memory SQLite engine + unclosable session factory.
Returns (engine, session_factory) where session_factory returns a
wrapper whose close() is a no-op so the service can call
session.close() without losing the shared in-memory database.
"""
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
def _seed_resource_type(
session: Any,
name: str,
*,
resource_kind: str = "physical",
sandbox_strategy: str = "none",
allowed_child_types: list[str] | None = None,
) -> None:
"""Insert a ResourceTypeModel row into the session."""
now = datetime.now(tz=UTC).isoformat()
child_json = json.dumps(allowed_child_types) if allowed_child_types else None
row = ResourceTypeModel(
name=name,
namespace="builtin" if "/" not in name else name.split("/", 1)[0],
description=f"Test type {name}",
resource_kind=resource_kind,
sandbox_strategy=sandbox_strategy,
user_addable=True,
handler_ref=None,
args_schema_json=None,
allowed_parent_types_json=None,
allowed_child_types_json=child_json,
auto_discover_json=None,
capabilities_json=json.dumps(
{"read": True, "write": True, "sandbox": False, "checkpoint": False}
),
equivalence_json=None,
source="test",
created_at=now,
updated_at=now,
)
session.add(row)
session.commit()
def _seed_resource(
session: Any,
resource_id: str,
type_name: str,
*,
namespaced_name: str | None = None,
) -> None:
"""Insert a ResourceModel row into the session."""
now = datetime.now(tz=UTC).isoformat()
row = ResourceModel(
resource_id=resource_id,
namespaced_name=namespaced_name,
namespace=None,
type_name=type_name,
resource_kind="physical",
location="/tmp/test",
description=None,
read_only=False,
auto_discovered=False,
sandbox_strategy=None,
content_hash=None,
properties_json=None,
metadata_json=None,
created_at=now,
updated_at=now,
)
session.add(row)
session.commit()
# ---------------------------------------------------------------------------
# Scenario: link_child rejects child type not in allowed_child_types
# (targets lines 579, 581-582, 584-586)
# ---------------------------------------------------------------------------
@given("an in-memory resource registry with two resources of incompatible types")
def step_setup_incompatible_resources(context: Any) -> None:
engine, factory = _make_inmem_session_factory()
context.boost_engine = engine
context.boost_factory = factory
session = factory()
# Parent type only allows "fs-directory" children
_seed_resource_type(
session,
"git-checkout",
resource_kind="physical",
sandbox_strategy="git_worktree",
allowed_child_types=["fs-directory"],
)
# Child type that is NOT in the allowed list
_seed_resource_type(
session,
"container-instance",
resource_kind="physical",
sandbox_strategy="snapshot",
)
# Seed parent resource of type git-checkout
_seed_resource(
session,
_PARENT_RES_1,
"git-checkout",
namespaced_name="test/parent-repo",
)
# Seed child resource of type container-instance (not allowed)
_seed_resource(
session,
_CHILD_RES_1,
"container-instance",
namespaced_name="test/bad-child",
)
# Build service (bootstrap will add other built-in types)
context.boost_svc = ResourceRegistryService(session_factory=factory)
@when("I attempt to link the child to the parent with incompatible types")
def step_link_incompatible_child(context: Any) -> None:
context.boost_link_error = None
try:
context.boost_svc.link_child(
"test/parent-repo",
"test/bad-child",
)
except ValidationError as exc:
context.boost_link_error = exc
@then("a ValidationError should be raised indicating the child type is not allowed")
def step_verify_type_incompatibility_error(context: Any) -> None:
assert context.boost_link_error is not None, (
"Expected ValidationError for type incompatibility"
)
assert "not allowed" in context.boost_link_error.message
@then("the error details should contain the parent and child type names")
def step_verify_error_details_types(context: Any) -> None:
details = context.boost_link_error.details
assert details["parent_type"] == "git-checkout"
assert details["child_type"] == "container-instance"
# ---------------------------------------------------------------------------
# Scenario: link_child generic exception rollback
# (targets lines 646-648)
# ---------------------------------------------------------------------------
@given("a resource registry service prepared for link_child with a faulty session")
def step_setup_faulty_link_child(context: Any) -> None:
# We need show_resource to return real Resource objects, but the
# session used inside link_child's main body should fail on flush.
parent_resource = Resource(
resource_id=_PARENT_RES_2,
name="test/link-parent",
resource_type_name="git-checkout",
classification=PhysVirt.PHYSICAL,
description=None,
properties={},
location="/tmp/p",
content_hash=None,
sandbox_strategy=None,
)
child_resource = Resource(
resource_id=_CHILD_RES_2,
name="test/link-child",
resource_type_name="fs-directory",
classification=PhysVirt.PHYSICAL,
description=None,
properties={},
location="/tmp/c",
content_hash=None,
sandbox_strategy=None,
)
# Mock session that blows up on flush()
mock_session = MagicMock()
parent_type_row = MagicMock()
parent_type_row.allowed_child_types_json = None
# query().filter_by().first() calls in sequence:
# 1st: parent_type_row (type lookup)
# 2nd: None (no existing link)
mock_session.query.return_value.filter_by.return_value.first.side_effect = [
parent_type_row,
None,
]
mock_session.query.return_value.filter_by.return_value.all.return_value = []
mock_session.flush.side_effect = RuntimeError("disk I/O error in link_child")
context.boost_link_mock_session = mock_session
svc = ResourceRegistryService.__new__(ResourceRegistryService)
svc._session_factory = lambda: mock_session
call_count = {"n": 0}
def fake_show(name_or_id: str) -> Resource:
call_count["n"] += 1
if call_count["n"] == 1:
return parent_resource
return child_resource
svc.show_resource = fake_show # type: ignore[assignment]
context.boost_link_svc = svc
@when("I attempt link_child and a generic exception occurs")
def step_attempt_link_child_generic_error(context: Any) -> None:
context.boost_link_generic_error = None
try:
context.boost_link_svc.link_child("test/link-parent", "test/link-child")
except RuntimeError as exc:
context.boost_link_generic_error = exc
@then("the link_child session should have been rolled back")
def step_verify_link_child_rollback(context: Any) -> None:
context.boost_link_mock_session.rollback.assert_called()
@then("the generic exception from link_child should propagate")
def step_verify_link_child_error_propagates(context: Any) -> None:
assert context.boost_link_generic_error is not None
assert "disk I/O error in link_child" in str(context.boost_link_generic_error)
# ---------------------------------------------------------------------------
# Scenario: unlink_child generic exception rollback
# (targets lines 686-688)
# ---------------------------------------------------------------------------
@given("a resource registry service prepared for unlink_child with a faulty session")
def step_setup_faulty_unlink_child(context: Any) -> None:
parent_resource = Resource(
resource_id=_PARENT_RES_3,
name="test/unlink-parent",
resource_type_name="git-checkout",
classification=PhysVirt.PHYSICAL,
description=None,
properties={},
location="/tmp/up",
content_hash=None,
sandbox_strategy=None,
)
child_resource = Resource(
resource_id=_CHILD_RES_3,
name="test/unlink-child",
resource_type_name="fs-directory",
classification=PhysVirt.PHYSICAL,
description=None,
properties={},
location="/tmp/uc",
content_hash=None,
sandbox_strategy=None,
)
mock_session = MagicMock()
# The link query returns a mock link (not None), so we pass the
# NotFoundError check. Then delete() explodes.
mock_link = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = (
mock_link
)
mock_session.delete.side_effect = RuntimeError("disk I/O error in unlink_child")
context.boost_unlink_mock_session = mock_session
svc = ResourceRegistryService.__new__(ResourceRegistryService)
svc._session_factory = lambda: mock_session
call_count = {"n": 0}
def fake_show(name_or_id: str) -> Resource:
call_count["n"] += 1
if call_count["n"] == 1:
return parent_resource
return child_resource
svc.show_resource = fake_show # type: ignore[assignment]
context.boost_unlink_svc = svc
@when("I attempt unlink_child and a generic exception occurs")
def step_attempt_unlink_child_generic_error(context: Any) -> None:
context.boost_unlink_generic_error = None
try:
context.boost_unlink_svc.unlink_child("test/unlink-parent", "test/unlink-child")
except RuntimeError as exc:
context.boost_unlink_generic_error = exc
@then("the unlink_child session should have been rolled back")
def step_verify_unlink_child_rollback(context: Any) -> None:
context.boost_unlink_mock_session.rollback.assert_called()
@then("the generic exception from unlink_child should propagate")
def step_verify_unlink_child_error_propagates(context: Any) -> None:
assert context.boost_unlink_generic_error is not None
assert "disk I/O error in unlink_child" in str(context.boost_unlink_generic_error)
# ---------------------------------------------------------------------------
# Scenario: get_resource_tree with type_filter excludes non-matching children
# (targets line 785)
# ---------------------------------------------------------------------------
@given(
"an in-memory resource registry with a parent and two children of different types"
)
def step_setup_tree_with_mixed_types(context: Any) -> None:
engine, factory = _make_inmem_session_factory()
context.boost_tree_engine = engine
context.boost_tree_factory = factory
session = factory()
# Seed resource types
_seed_resource_type(
session,
"git-checkout",
resource_kind="physical",
sandbox_strategy="git_worktree",
allowed_child_types=["fs-directory", "container-instance"],
)
_seed_resource_type(
session,
"fs-directory",
resource_kind="physical",
sandbox_strategy="copy_on_write",
)
_seed_resource_type(
session,
"container-instance",
resource_kind="physical",
sandbox_strategy="snapshot",
)
# Seed parent resource
_seed_resource(
session,
_TREE_PARENT,
"git-checkout",
namespaced_name="test/tree-parent",
)
# Seed child 1: fs-directory
_seed_resource(
session,
_TREE_CHILD1,
"fs-directory",
namespaced_name="test/child-dir",
)
# Seed child 2: container-instance
_seed_resource(
session,
_TREE_CHILD2,
"container-instance",
namespaced_name="test/child-container",
)
# Create links: parent -> child1, parent -> child2
now = datetime.now(tz=UTC).isoformat()
link1 = ResourceLinkModel(
parent_id=_TREE_PARENT,
child_id=_TREE_CHILD1,
created_at=now,
)
link2 = ResourceLinkModel(
parent_id=_TREE_PARENT,
child_id=_TREE_CHILD2,
created_at=now,
)
session.add(link1)
session.add(link2)
session.commit()
context.boost_tree_svc = ResourceRegistryService(session_factory=factory)
@when("I call get_resource_tree with a type_filter for only one child type")
def step_call_tree_with_type_filter(context: Any) -> None:
context.boost_tree_result = context.boost_tree_svc.get_resource_tree(
"test/tree-parent",
depth=-1,
type_filter="fs-directory",
)
@then("the tree should contain only the child matching the type filter")
def step_verify_tree_has_matching_child(context: Any) -> None:
tree = context.boost_tree_result
assert len(tree) == 1, "Tree should have exactly one root node"
root_node = tree[0]
children = root_node["children"]
# Only the fs-directory child should be present
child_type_names = [node["resource"].resource_type_name for node in children]
assert "fs-directory" in child_type_names, (
f"Expected fs-directory child in tree, got: {child_type_names}"
)
@then("the non-matching child should be excluded from the tree")
def step_verify_tree_excludes_non_matching(context: Any) -> None:
tree = context.boost_tree_result
root_node = tree[0]
children = root_node["children"]
child_type_names = [node["resource"].resource_type_name for node in children]
assert "container-instance" not in child_type_names, (
f"container-instance should be filtered out, got: {child_type_names}"
)
assert len(children) == 1, (
f"Expected exactly 1 child after filtering, got {len(children)}"
)