1d36449a98
CI / build (push) Has been cancelled
CI / e2e_tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / integration_tests (push) Has been cancelled
CI / quality (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / benchmark-publish (push) Has been cancelled
CI / security (push) Has been cancelled
CI / helm (push) Has been cancelled
CI / unit_tests (push) Has been cancelled
CI / benchmark-regression (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / docker (push) Has been cancelled
CI / status-check (push) Has been cancelled
Remove the --mode/-m CLI flag from the validation attach command to align with the specification, which defines validation mode as an inherent property of the validation definition set at registration time via validation add, not as a per-attachment override. The service layer (ToolRegistryService.attach_validation) no longer accepts mode as a parameter; instead it reads the mode from the validation's registered definition. The ToolRegistryRepository _to_legacy_domain now exposes the mode field so the service can access it. All tests that passed --mode to the CLI or service have been updated or removed. The invalid-mode validation scenarios were removed since the mode is no longer caller-supplied. Coverage remains at 98.7%. ISSUES CLOSED: #913 Co-authored-by: Jeffrey Phillips Freeman <jeffrey.freeman@cleverthis.com> Co-committed-by: Jeffrey Phillips Freeman <jeffrey.freeman@cleverthis.com>
596 lines
20 KiB
Python
596 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,
|
|
)
|
|
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,
|
|
)
|
|
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
|