"""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