forked from HAL9000/cleveragents-core
a808c395f9
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
519 lines
16 KiB
Python
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)}"
|
|
)
|