798db9088e
CI / lint (pull_request) Successful in 15s
CI / typecheck (pull_request) Successful in 36s
CI / security (pull_request) Successful in 25s
CI / quality (pull_request) Successful in 19s
CI / build (pull_request) Successful in 23s
CI / integration_tests (pull_request) Successful in 6m49s
CI / coverage (pull_request) Successful in 12m1s
CI / unit_tests (pull_request) Successful in 16m28s
CI / docker (pull_request) Successful in 51s
598 lines
20 KiB
Python
598 lines
20 KiB
Python
"""Step definitions for Tool Registry persistence coverage.
|
|
|
|
Covers tools, tool_resource_bindings, validation_attachments tables
|
|
and the ToolRegistryService integration layer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from cleveragents.application.services.tool_registry_service import (
|
|
ToolRegistryService,
|
|
)
|
|
from cleveragents.infrastructure.database.models import Base
|
|
from cleveragents.infrastructure.database.repositories import (
|
|
DuplicateToolError,
|
|
ToolInUseError,
|
|
ToolRegistryRepository,
|
|
ValidationAttachmentRepository,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_tool_dict(
|
|
name: str = "core/test-tool",
|
|
source: str = "builtin",
|
|
tool_type: str = "tool",
|
|
description: str | None = None,
|
|
code: str | None = None,
|
|
mcp_server: str | None = None,
|
|
mcp_tool_name: str | None = None,
|
|
mode: str | None = None,
|
|
resource_bindings: list[dict[str, object]] | None = None,
|
|
) -> dict[str, object]:
|
|
"""Create a minimal valid tool dict for testing."""
|
|
now_iso = datetime.now().isoformat()
|
|
result: dict[str, object] = {
|
|
"name": name,
|
|
"description": description or f"Test tool {name}",
|
|
"tool_type": tool_type,
|
|
"source": source,
|
|
"timeout": 300,
|
|
"created_at": now_iso,
|
|
"updated_at": now_iso,
|
|
"resource_bindings": resource_bindings or [],
|
|
}
|
|
if code is not None:
|
|
result["code"] = code
|
|
if mcp_server is not None:
|
|
result["mcp_server"] = mcp_server
|
|
if mcp_tool_name is not None:
|
|
result["mcp_tool_name"] = mcp_tool_name
|
|
if mode is not None:
|
|
result["mode"] = mode
|
|
return result
|
|
|
|
|
|
def _make_binding(slot_name: str = "repo") -> dict[str, object]:
|
|
"""Create a resource binding dict."""
|
|
return {
|
|
"slot_name": slot_name,
|
|
"resource_type": "git-checkout",
|
|
"access_mode": "read_only",
|
|
"binding_mode": "contextual",
|
|
"required": True,
|
|
}
|
|
|
|
|
|
def _get_session_factory(context: Context) -> sessionmaker[Session]:
|
|
return context.tool_session_factory # type: ignore[no-any-return]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a clean in-memory database with the tool registry schema")
|
|
def step_clean_tool_db(context: Context) -> None:
|
|
"""Set up an in-memory SQLite database with all tables."""
|
|
engine = create_engine("sqlite:///:memory:", echo=False)
|
|
Base.metadata.create_all(engine)
|
|
factory: sessionmaker[Session] = sessionmaker(bind=engine, expire_on_commit=False)
|
|
context.tool_engine = engine
|
|
context.tool_session_factory = factory
|
|
# Keep a persistent session for attachment repo (needs same connection)
|
|
context.tool_session = factory()
|
|
|
|
|
|
@given("a tool registry repository backed by the database")
|
|
def step_tool_repo(context: Context) -> None:
|
|
"""Create a ToolRegistryRepository."""
|
|
context.tool_repo = ToolRegistryRepository(
|
|
session_factory=lambda: context.tool_session,
|
|
)
|
|
|
|
|
|
@given("a validation attachment repository backed by the database")
|
|
def step_attachment_repo(context: Context) -> None:
|
|
"""Create a ValidationAttachmentRepository."""
|
|
context.attachment_repo = ValidationAttachmentRepository(
|
|
session_factory=lambda: context.tool_session,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given: tool dicts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a valid tool dict named "{name}" with source "{source}"')
|
|
def step_valid_tool_dict(context: Context, name: str, source: str) -> None:
|
|
"""Build a tool dict."""
|
|
context.tool_dict = _make_tool_dict(name=name, source=source)
|
|
|
|
|
|
@given('the tool dict includes inline code "{code}"')
|
|
def step_tool_dict_code(context: Context, code: str) -> None:
|
|
context.tool_dict["code"] = code
|
|
|
|
|
|
@given('the tool dict includes mcp_server "{server}" and mcp_tool_name "{tool_name}"')
|
|
def step_tool_dict_mcp(context: Context, server: str, tool_name: str) -> None:
|
|
context.tool_dict["mcp_server"] = server
|
|
context.tool_dict["mcp_tool_name"] = tool_name
|
|
|
|
|
|
@given('the tool dict has tool_type "{tool_type}" and mode "{mode}"')
|
|
def step_tool_dict_type_mode(context: Context, tool_type: str, mode: str) -> None:
|
|
context.tool_dict["tool_type"] = tool_type
|
|
context.tool_dict["mode"] = mode
|
|
|
|
|
|
@given('the tool dict includes a resource binding for slot "{slot_name}"')
|
|
def step_tool_dict_binding(context: Context, slot_name: str) -> None:
|
|
bindings = list(context.tool_dict.get("resource_bindings", [])) # type: ignore[union-attr]
|
|
bindings.append(_make_binding(slot_name))
|
|
context.tool_dict["resource_bindings"] = bindings
|
|
|
|
|
|
@given("the tool has already been registered once in the tool registry")
|
|
def step_tool_already_registered(context: Context) -> None:
|
|
context.tool_repo.create(context.tool_dict)
|
|
context.tool_session.commit()
|
|
|
|
|
|
@given("the tool has been registered through the tool registry repository")
|
|
def step_tool_registered(context: Context) -> None:
|
|
context.tool_repo.create(context.tool_dict)
|
|
context.tool_session.commit()
|
|
|
|
|
|
@given("the following tools have been registered:")
|
|
def step_register_multiple_tools(context: Context) -> None:
|
|
for row in context.table:
|
|
tool_dict = _make_tool_dict(
|
|
name=row["name"],
|
|
source=row["source"],
|
|
tool_type=row["tool_type"],
|
|
)
|
|
context.tool_repo.create(tool_dict)
|
|
context.tool_session.commit()
|
|
|
|
|
|
@given('an unscoped validation attachment for "{val_name}" on resource "{res_id}"')
|
|
def step_attachment_exists(context: Context, val_name: str, res_id: str) -> None:
|
|
att = context.attachment_repo.attach(
|
|
validation_name=val_name,
|
|
resource_id=res_id,
|
|
mode="required",
|
|
)
|
|
context.tool_session.commit()
|
|
context.last_attachment = att
|
|
|
|
|
|
@given(
|
|
'a project-scoped validation attachment for "{val_name}" on resource "{res_id}" in project "{project}"'
|
|
)
|
|
def step_attachment_in_project(
|
|
context: Context, val_name: str, res_id: str, project: str
|
|
) -> None:
|
|
att = context.attachment_repo.attach(
|
|
validation_name=val_name,
|
|
resource_id=res_id,
|
|
mode="required",
|
|
project_name=project,
|
|
)
|
|
context.tool_session.commit()
|
|
context.last_attachment = att
|
|
|
|
|
|
@given(
|
|
'a plan-scoped validation attachment for "{val_name}" on resource "{res_id}" in plan "{plan_id}"'
|
|
)
|
|
def step_attachment_with_plan(
|
|
context: Context, val_name: str, res_id: str, plan_id: str
|
|
) -> None:
|
|
att = context.attachment_repo.attach(
|
|
validation_name=val_name,
|
|
resource_id=res_id,
|
|
mode="required",
|
|
plan_id=plan_id,
|
|
)
|
|
context.tool_session.commit()
|
|
context.last_attachment = att
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When: registration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("the tool is registered through the tool registry repository")
|
|
def step_register_tool(context: Context) -> None:
|
|
context.last_error = None
|
|
try:
|
|
context.tool_repo.create(context.tool_dict)
|
|
context.tool_session.commit()
|
|
except Exception as exc:
|
|
context.last_error = exc
|
|
|
|
|
|
@when('a second tool with the same registry name "{name}" is registered')
|
|
def step_register_duplicate_tool(context: Context, name: str) -> None:
|
|
dup = _make_tool_dict(name=name, source="builtin")
|
|
context.last_error = None
|
|
try:
|
|
context.tool_repo.create(dup)
|
|
context.tool_session.commit()
|
|
except Exception as exc:
|
|
context.last_error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When: retrieval
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('the tool is looked up by registry name "{name}"')
|
|
def step_lookup_tool(context: Context, name: str) -> None:
|
|
context.returned_tool = context.tool_repo.get_by_name(name)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When: listing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("all tools are listed from the tool registry")
|
|
def step_list_all_tools(context: Context) -> None:
|
|
context.returned_tools = context.tool_repo.list_all()
|
|
|
|
|
|
@when('tools in the "{ns}" namespace are listed from the registry')
|
|
def step_list_tools_ns(context: Context, ns: str) -> None:
|
|
context.returned_tools = context.tool_repo.list_all(namespace=ns)
|
|
|
|
|
|
@when('tools of type "{tool_type}" are listed from the registry')
|
|
def step_list_tools_type(context: Context, tool_type: str) -> None:
|
|
context.returned_tools = context.tool_repo.list_all(tool_type=tool_type)
|
|
|
|
|
|
@when('tools with source "{source}" are listed from the registry')
|
|
def step_list_tools_source(context: Context, source: str) -> None:
|
|
context.returned_tools = context.tool_repo.list_all(source=source)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When: update
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('the tool description is updated to "{desc}"')
|
|
def step_update_tool_desc(context: Context, desc: str) -> None:
|
|
context.tool_dict["description"] = desc
|
|
context.tool_repo.update(context.tool_dict)
|
|
context.tool_session.commit()
|
|
|
|
|
|
@when('the tool is updated with a new resource binding for slot "{slot_name}"')
|
|
def step_update_tool_binding(context: Context, slot_name: str) -> None:
|
|
context.tool_dict["resource_bindings"] = [_make_binding(slot_name)]
|
|
context.tool_repo.update(context.tool_dict)
|
|
context.tool_session.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When: deletion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('the tool "{name}" is removed from the registry')
|
|
def step_remove_tool(context: Context, name: str) -> None:
|
|
context.last_error = None
|
|
try:
|
|
context.removal_result = context.tool_repo.delete(name)
|
|
except Exception as exc:
|
|
context.last_error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When: attachments
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('the validation "{val_name}" is attached to resource "{res_id}"')
|
|
def step_attach_validation(context: Context, val_name: str, res_id: str) -> None:
|
|
context.last_attachment = context.attachment_repo.attach(
|
|
validation_name=val_name,
|
|
resource_id=res_id,
|
|
mode="required",
|
|
)
|
|
context.tool_session.commit()
|
|
|
|
|
|
@when("the validation attachment is detached by its identifier")
|
|
def step_detach_by_id(context: Context) -> None:
|
|
att_id = context.last_attachment["attachment_id"]
|
|
context.detach_result = context.attachment_repo.detach(att_id)
|
|
context.tool_session.commit()
|
|
|
|
|
|
@when('the validation attachment "{att_id}" is detached')
|
|
def step_detach_nonexistent(context: Context, att_id: str) -> None:
|
|
context.detach_result = context.attachment_repo.detach(att_id)
|
|
|
|
|
|
@when('all attachments for resource "{res_id}" are listed')
|
|
def step_list_attachments(context: Context, res_id: str) -> None:
|
|
context.returned_attachments = context.attachment_repo.list_for_resource(
|
|
resource_id=res_id
|
|
)
|
|
|
|
|
|
@when(
|
|
'project-filtered attachments for resource "{res_id}" in project "{project}" are listed'
|
|
)
|
|
def step_list_attachments_project(context: Context, res_id: str, project: str) -> None:
|
|
context.returned_attachments = context.attachment_repo.list_for_resource(
|
|
resource_id=res_id,
|
|
project_name=project,
|
|
)
|
|
|
|
|
|
@when(
|
|
'plan-filtered attachments for resource "{res_id}" with plan "{plan_id}" are listed'
|
|
)
|
|
def step_list_attachments_plan(context: Context, res_id: str, plan_id: str) -> None:
|
|
context.returned_attachments = context.attachment_repo.list_for_resource(
|
|
resource_id=res_id,
|
|
plan_id=plan_id,
|
|
)
|
|
|
|
|
|
@when("the attachment is retrieved by its stored identifier")
|
|
def step_get_attachment_by_id(context: Context) -> None:
|
|
att_id = context.last_attachment["attachment_id"]
|
|
context.returned_attachment = context.attachment_repo.get_by_id(att_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When: service layer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a tool registry service backed by the repositories")
|
|
def step_tool_service(context: Context) -> None:
|
|
context.tool_service = ToolRegistryService(
|
|
tool_repo=context.tool_repo,
|
|
attachment_repo=context.attachment_repo,
|
|
)
|
|
|
|
|
|
@when("the tool is registered through the service")
|
|
def step_service_register(context: Context) -> None:
|
|
context.tool_service.register_tool(context.tool_dict)
|
|
context.tool_session.commit()
|
|
|
|
|
|
@given("the tool has been registered through the service")
|
|
def step_service_given_registered(context: Context) -> None:
|
|
context.tool_service.register_tool(context.tool_dict)
|
|
context.tool_session.commit()
|
|
|
|
|
|
@when('the tool is retrieved from the service by name "{name}"')
|
|
def step_service_get(context: Context, name: str) -> None:
|
|
context.service_returned_tool = context.tool_service.get_tool(name)
|
|
|
|
|
|
@when('the service attaches validation "{val_name}" to resource "{res_id}"')
|
|
def step_service_attach(context: Context, val_name: str, res_id: str) -> None:
|
|
context.service_attachment = context.tool_service.attach_validation(
|
|
validation_name=val_name,
|
|
resource_id=res_id,
|
|
mode="required",
|
|
)
|
|
context.tool_session.commit()
|
|
|
|
|
|
@when("the service detaches the validation attachment")
|
|
def step_service_detach(context: Context) -> None:
|
|
att_id = context.service_attachment["attachment_id"]
|
|
context.service_detach_result = context.tool_service.detach_validation(att_id)
|
|
context.tool_session.commit()
|
|
|
|
|
|
@given('the service has attached validation "{val_name}" to resource "{res_id}"')
|
|
def step_service_given_attached(context: Context, val_name: str, res_id: str) -> None:
|
|
context.service_attachment = context.tool_service.attach_validation(
|
|
validation_name=val_name,
|
|
resource_id=res_id,
|
|
mode="required",
|
|
)
|
|
context.tool_session.commit()
|
|
|
|
|
|
@when('the service lists validations for resource "{res_id}"')
|
|
def step_service_list_validations(context: Context, res_id: str) -> None:
|
|
context.service_returned_attachments = (
|
|
context.tool_service.list_validations_for_resource(
|
|
resource_id=res_id,
|
|
)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the tool registry repository should not raise an error")
|
|
def step_no_tool_error(context: Context) -> None:
|
|
assert context.last_error is None, f"Unexpected error: {context.last_error}"
|
|
|
|
|
|
@then('the persisted tool should have the registry name "{name}"')
|
|
def step_tool_name(context: Context, name: str) -> None:
|
|
tool = context.tool_repo.get_by_name(name)
|
|
assert tool is not None, f"Tool {name} not found"
|
|
assert tool["name"] == name
|
|
|
|
|
|
@then('the persisted tool should have source "{source}"')
|
|
def step_tool_source(context: Context, source: str) -> None:
|
|
name = context.tool_dict["name"]
|
|
tool = context.tool_repo.get_by_name(name)
|
|
assert tool is not None
|
|
assert tool["source"] == source
|
|
|
|
|
|
@then('the persisted tool should have tool_type "{tool_type}"')
|
|
def step_tool_type(context: Context, tool_type: str) -> None:
|
|
name = context.tool_dict["name"]
|
|
tool = context.tool_repo.get_by_name(name)
|
|
assert tool is not None
|
|
assert tool["tool_type"] == tool_type
|
|
|
|
|
|
@then("the persisted tool should have {count:d} resource binding")
|
|
def step_tool_binding_count(context: Context, count: int) -> None:
|
|
name = context.tool_dict["name"]
|
|
tool = context.tool_repo.get_by_name(name)
|
|
assert tool is not None
|
|
assert len(tool.get("resource_bindings", [])) == count
|
|
|
|
|
|
@then('a DuplicateToolError should be raised mentioning "{name}"')
|
|
def step_dup_tool_error(context: Context, name: str) -> None:
|
|
assert isinstance(context.last_error, DuplicateToolError), (
|
|
f"Expected DuplicateToolError, got {type(context.last_error)}"
|
|
)
|
|
assert name in str(context.last_error)
|
|
|
|
|
|
@then('the returned tool should have the registry name "{name}"')
|
|
def step_returned_tool_name(context: Context, name: str) -> None:
|
|
assert context.returned_tool is not None
|
|
assert context.returned_tool["name"] == name
|
|
|
|
|
|
@then("no tool should be returned from the registry")
|
|
def step_no_tool_returned(context: Context) -> None:
|
|
assert context.returned_tool is None
|
|
|
|
|
|
@then("{count:d} tools should be returned from the registry")
|
|
def step_tool_count(context: Context, count: int) -> None:
|
|
assert len(context.returned_tools) == count
|
|
|
|
|
|
@then('the returned tool names from the registry should include "{name}"')
|
|
def step_tool_names_include(context: Context, name: str) -> None:
|
|
names = [t["name"] for t in context.returned_tools]
|
|
assert name in names
|
|
|
|
|
|
@then('the persisted tool should have description "{desc}"')
|
|
def step_tool_desc(context: Context, desc: str) -> None:
|
|
name = context.tool_dict["name"]
|
|
tool = context.tool_repo.get_by_name(name)
|
|
assert tool is not None
|
|
assert tool["description"] == desc
|
|
|
|
|
|
@then('the persisted tool binding slot should be "{slot_name}"')
|
|
def step_tool_binding_slot(context: Context, slot_name: str) -> None:
|
|
name = context.tool_dict["name"]
|
|
tool = context.tool_repo.get_by_name(name)
|
|
assert tool is not None
|
|
bindings = tool.get("resource_bindings", [])
|
|
assert len(bindings) > 0
|
|
assert bindings[0]["slot_name"] == slot_name
|
|
|
|
|
|
@then("the removal should return true from the registry")
|
|
def step_removal_true(context: Context) -> None:
|
|
assert context.last_error is None
|
|
assert context.removal_result is True
|
|
|
|
|
|
@then("the removal should return false from the registry")
|
|
def step_removal_false(context: Context) -> None:
|
|
assert context.last_error is None
|
|
assert context.removal_result is False
|
|
|
|
|
|
@then("the tool should no longer exist in the registry")
|
|
def step_tool_not_found(context: Context) -> None:
|
|
name = context.tool_dict["name"]
|
|
tool = context.tool_repo.get_by_name(name)
|
|
assert tool is None
|
|
|
|
|
|
@then('a ToolInUseError should be raised mentioning "{name}"')
|
|
def step_tool_in_use_error(context: Context, name: str) -> None:
|
|
assert isinstance(context.last_error, ToolInUseError), (
|
|
f"Expected ToolInUseError, got {type(context.last_error)}"
|
|
)
|
|
assert name in str(context.last_error)
|
|
|
|
|
|
@then("the attachment should have a valid ULID identifier")
|
|
def step_attachment_ulid(context: Context) -> None:
|
|
att_id = context.last_attachment["attachment_id"]
|
|
assert len(att_id) == 26
|
|
|
|
|
|
@then('the attachment should reference resource "{res_id}"')
|
|
def step_attachment_resource(context: Context, res_id: str) -> None:
|
|
assert context.last_attachment["resource_id"] == res_id
|
|
|
|
|
|
@then("the detachment should return true")
|
|
def step_detach_true(context: Context) -> None:
|
|
assert context.detach_result is True
|
|
|
|
|
|
@then("the non-existent detachment should return false")
|
|
def step_detach_false(context: Context) -> None:
|
|
assert context.detach_result is False
|
|
|
|
|
|
@then("{count:d} attachment should be returned")
|
|
def step_attachment_count(context: Context, count: int) -> None:
|
|
assert len(context.returned_attachments) == count
|
|
|
|
|
|
@then('the retrieved attachment should reference validation "{val_name}"')
|
|
def step_retrieved_att_val(context: Context, val_name: str) -> None:
|
|
assert context.returned_attachment is not None
|
|
assert context.returned_attachment["validation_name"] == val_name
|
|
|
|
|
|
@then('the service-returned tool should have the registry name "{name}"')
|
|
def step_service_tool_name(context: Context, name: str) -> None:
|
|
assert context.service_returned_tool is not None
|
|
assert context.service_returned_tool["name"] == name
|
|
|
|
|
|
@then("the service detachment should return true")
|
|
def step_service_detach_result(context: Context) -> None:
|
|
assert context.service_detach_result is True
|
|
|
|
|
|
@then("the service should return {count:d} validation attachment")
|
|
def step_service_attachment_count(context: Context, count: int) -> None:
|
|
assert len(context.service_returned_attachments) == count
|