# #region Test.Assistant.ToolRegistry [C:2] [TYPE Module] [SEMANTICS test,assistant,registry,tool] # @BRIEF Tests for _tool_registry.py — core decorator, catalog, dispatch, permissions. # @RELATION BINDS_TO -> [AssistantToolRegistry] # @TEST_CONTRACT: assistant_tool decorator -> registers tool in _tools dict, sets __assistant_tool__ attr # @TEST_CONTRACT: AssistantTool dataclass -> stores all metadata attributes # @TEST_CONTRACT: get_safe_ops -> returns risk_level=='safe' operations + dataset_review_answer_context # @TEST_CONTRACT: get_permission_checks -> returns only tools with non-empty permission_checks # @TEST_CONTRACT: get_catalog -> filters by user permissions, includes defaults # @TEST_CONTRACT: dispatch -> routes to correct handler, raises 400 for unknown ops # @TEST_CONTRACT: _check_any_permission -> passes on first valid check, raises 403 on all fail # @TEST_CONTRACT: _has_any_permission -> boolean wrapper around _check_any_permission # @TEST_EDGE: unknown_operation -> HTTPException 400 # @TEST_EDGE: no_permission -> HTTPException 403 # @TEST_EDGE: catalog_filtered_by_permissions -> excluded tools not in catalog # @TEST_EDGE: dataset_review_dispatch_path -> special-case routing # @TEST_EDGE: get_safe_ops_includes_registry_and_extra -> both sources merged from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException from src.schemas.auth import User @pytest.fixture def current_user(): return User( id="user-1", username="testuser", email="test@example.com", is_active=True, auth_source="internal", created_at=datetime.now(UTC), last_login=datetime.now(UTC), roles=[], ) @pytest.fixture def config_manager(): cm = MagicMock() cm.get_environments = MagicMock(return_value=[]) return cm @pytest.fixture def task_manager(): tm = MagicMock() tm.create_task = AsyncMock(return_value=MagicMock(id="task-42")) tm.get_task = MagicMock(return_value=None) tm.get_tasks = MagicMock(return_value=[]) return tm @pytest.fixture def db(): return MagicMock() @pytest.fixture(autouse=True) def clear_registry(): """Clear _tools registry before and after each test.""" import src.api.routes.assistant._tool_registry as reg saved = dict(reg._tools) reg._tools.clear() yield reg._tools.clear() reg._tools.update(saved) class TestAssistantToolDataclass: """AssistantTool — metadata container.""" def test_minimal(self): from src.api.routes.assistant._tool_registry import AssistantTool async def dummy_handler(**kwargs): return ("ok", None, []) tool = AssistantTool( operation="test_op", domain="test", description="A test tool", handler=dummy_handler, ) assert tool.operation == "test_op" assert tool.risk_level == "safe" assert tool.requires_confirmation is False assert tool.permission_checks == [] assert tool.defaults is None def test_full(self): from src.api.routes.assistant._tool_registry import AssistantTool async def dummy(**kwargs): return ("ok", None, []) tool = AssistantTool( operation="risky_op", domain="admin", description="Risky", handler=dummy, required_entities=["env"], optional_entities=["dashboard_id"], risk_level="guarded", requires_confirmation=True, permission_checks=[("admin", "EXECUTE")], defaults={"timeout": 30}, ) assert tool.risk_level == "guarded" assert tool.requires_confirmation is True assert tool.permission_checks == [("admin", "EXECUTE")] assert tool.defaults == {"timeout": 30} class TestAssistantToolDecorator: """assistant_tool decorator — registration mechanics.""" @pytest.mark.asyncio async def test_registers_tool(self): from src.api.routes.assistant._tool_registry import assistant_tool, _tools @assistant_tool(operation="my_op", domain="test", description="My tool") async def my_handler(**kwargs): return ("done", None, []) assert "my_op" in _tools assert _tools["my_op"].operation == "my_op" assert _tools["my_op"].domain == "test" @pytest.mark.asyncio async def test_sets_handler_attr(self): from src.api.routes.assistant._tool_registry import assistant_tool @assistant_tool(operation="op_with_attr", domain="test", description="Check attr") async def handler_with_attr(**kwargs): return ("done", None, []) assert hasattr(handler_with_attr, "__assistant_tool__") assert handler_with_attr.__assistant_tool__.operation == "op_with_attr" @pytest.mark.asyncio async def test_registers_with_defaults(self): from src.api.routes.assistant._tool_registry import assistant_tool, _tools @assistant_tool( operation="with_defaults", domain="test", description="Has defaults", required_entities=["x"], optional_entities=["y"], risk_level="guarded", requires_confirmation=True, permission_checks=[("res", "action")], defaults={"key": "val"}, ) async def handler_defaults(**kwargs): return ("done", None, []) tool = _tools["with_defaults"] assert tool.required_entities == ["x"] assert tool.risk_level == "guarded" assert tool.requires_confirmation is True assert tool.permission_checks == [("res", "action")] assert tool.defaults == {"key": "val"} class TestGetSafeOps: """get_safe_ops — safe operations set.""" def test_empty_registry_returns_minimal(self): from src.api.routes.assistant._tool_registry import get_safe_ops ops = get_safe_ops() assert "dataset_review_answer_context" in ops # No registered tools yet — only the hardcoded extra assert len(ops) >= 1 def test_includes_safe_tools(self): from src.api.routes.assistant._tool_registry import assistant_tool, get_safe_ops @assistant_tool(operation="safe_op", domain="t", description="s", risk_level="safe") async def safe_h(**kwargs): return ("ok", None, []) @assistant_tool(operation="guarded_op", domain="t", description="g", risk_level="guarded") async def guarded_h(**kwargs): return ("ok", None, []) ops = get_safe_ops() assert "safe_op" in ops assert "guarded_op" not in ops assert "dataset_review_answer_context" in ops class TestGetPermissionChecks: """get_permission_checks — permission mapping.""" def test_no_tools_with_checks(self): from src.api.routes.assistant._tool_registry import get_permission_checks checks = get_permission_checks() # Should include dataset_review ops from _schemas assert isinstance(checks, dict) def test_includes_tool_checks(self): from src.api.routes.assistant._tool_registry import assistant_tool, get_permission_checks @assistant_tool( operation="protected_op", domain="t", description="p", permission_checks=[("admin", "WRITE")], ) async def protected_h(**kwargs): return ("ok", None, []) @assistant_tool(operation="public_op", domain="t", description="safe") async def public_h(**kwargs): return ("ok", None, []) checks = get_permission_checks() assert "protected_op" in checks assert checks["protected_op"] == [("admin", "WRITE")] # public_op has no checks, should not appear assert "public_op" not in checks class TestCatalog: """get_catalog — LLM tool catalog generation.""" def test_empty_registry(self, current_user, config_manager, db): from src.api.routes.assistant._tool_registry import get_catalog catalog = get_catalog(current_user, config_manager, db) assert catalog == [] def test_excludes_protected_tools_without_permission(self, current_user, config_manager, db): from src.api.routes.assistant._tool_registry import assistant_tool, get_catalog, _has_any_permission @assistant_tool( operation="admin_only", domain="admin", description="Admin tool", permission_checks=[("admin", "WRITE")], ) async def admin_h(**kwargs): return ("ok", None, []) @assistant_tool( operation="public", domain="public", description="Public tool", ) async def public_h(**kwargs): return ("ok", None, []) # Mock _has_any_permission to return False for admin_only with patch("src.api.routes.assistant._tool_registry._has_any_permission", side_effect=lambda u, checks: checks != [("admin", "WRITE")]): catalog = get_catalog(current_user, config_manager, db) ops = [c["operation"] for c in catalog] assert "public" in ops assert "admin_only" not in ops def test_includes_defaults_in_entry(self, current_user, config_manager, db): from src.api.routes.assistant._tool_registry import assistant_tool, get_catalog @assistant_tool( operation="with_defaults", domain="t", description="d", defaults={"timeout": 60}, ) async def h(**kwargs): return ("ok", None, []) catalog = get_catalog(current_user, config_manager, db) assert len(catalog) == 1 assert catalog[0]["defaults"] == {"timeout": 60} def test_entries_sorted(self, current_user, config_manager, db): from src.api.routes.assistant._tool_registry import assistant_tool, get_catalog @assistant_tool(operation="z_tool", domain="t", description="z") async def z_h(**kwargs): return ("ok", None, []) @assistant_tool(operation="a_tool", domain="t", description="a") async def a_h(**kwargs): return ("ok", None, []) catalog = get_catalog(current_user, config_manager, db) ops = [c["operation"] for c in catalog] assert ops == sorted(ops) class TestCheckAnyPermission: """_check_any_permission and _has_any_permission.""" def test_any_permission_passes(self, current_user): from src.api.routes.assistant._tool_registry import _check_any_permission with patch("src.api.routes.assistant._tool_registry.has_permission") as mock_hp: mock_hp.return_value = lambda u: None # passes _check_any_permission(current_user, [("res", "READ")]) mock_hp.assert_called_once_with("res", "READ") def test_any_permission_first_wins(self, current_user): from src.api.routes.assistant._tool_registry import _check_any_permission call_order = [] def first_check(u): call_order.append("first") raise HTTPException(status_code=403, detail="denied") def second_check(u): call_order.append("second") return None with patch("src.api.routes.assistant._tool_registry.has_permission") as mock_hp: mock_hp.side_effect = [first_check, second_check] _check_any_permission(current_user, [("res1", "READ"), ("res2", "WRITE")]) assert call_order == ["first", "second"] def test_all_permissions_fail_raises_403(self, current_user): from src.api.routes.assistant._tool_registry import _check_any_permission with patch("src.api.routes.assistant._tool_registry.has_permission") as mock_hp: mock_hp.return_value = lambda u: (_ for _ in ()).throw( HTTPException(status_code=403, detail="denied") ) with pytest.raises(HTTPException) as exc: _check_any_permission(current_user, [("res1", "X"), ("res2", "Y")]) assert exc.value.status_code == 403 def test_empty_checks_raises_403(self, current_user): from src.api.routes.assistant._tool_registry import _check_any_permission with pytest.raises(HTTPException) as exc: _check_any_permission(current_user, []) assert exc.value.status_code == 403 def test_has_any_permission_true(self, current_user): from src.api.routes.assistant._tool_registry import _has_any_permission with patch("src.api.routes.assistant._tool_registry._check_any_permission", return_value=None): assert _has_any_permission(current_user, [("res", "READ")]) is True def test_has_any_permission_false(self, current_user): from src.api.routes.assistant._tool_registry import _has_any_permission with patch("src.api.routes.assistant._tool_registry._check_any_permission", side_effect=HTTPException(403)): assert _has_any_permission(current_user, [("res", "READ")]) is False class TestDispatch: """dispatch — operation routing.""" @pytest.mark.asyncio async def test_dispatches_to_registered_handler( self, current_user, task_manager, config_manager, db ): from src.api.routes.assistant._tool_registry import assistant_tool, dispatch, _tools @assistant_tool(operation="my_dispatch_op", domain="test", description="Dispatch test") async def my_handler(intent, current_user, task_manager, config_manager, db): return ("executed", None, []) text, task_id, actions = await dispatch( "my_dispatch_op", {"entities": {}}, current_user, task_manager, config_manager, db, ) assert text == "executed" @pytest.mark.asyncio async def test_unknown_operation_raises_400( self, current_user, task_manager, config_manager, db ): from src.api.routes.assistant._tool_registry import dispatch with pytest.raises(HTTPException) as exc: await dispatch( "nonexistent_op", {"entities": {}}, current_user, task_manager, config_manager, db, ) assert exc.value.status_code == 400 assert "Unsupported operation" in exc.value.detail @pytest.mark.asyncio async def test_dataset_review_ops_go_to_subdispatcher( self, current_user, task_manager, config_manager, db ): from src.api.routes.assistant._tool_registry import dispatch # The sub-dispatcher is imported inside dispatch() via lazy import # Patch at the source module level — the lazy import will pick it up with patch( "src.api.routes.assistant._dataset_review_dispatch._dispatch_dataset_review_intent", new=AsyncMock(return_value=("review done", None, [])), ) as mock_sub: text, task_id, actions = await dispatch( "dataset_review_approve_mappings", {"entities": {"dataset_review_session_id": "s1", "session_version": 1}}, current_user, task_manager, config_manager, db, ) assert text == "review done" mock_sub.assert_awaited_once() @pytest.mark.asyncio async def test_dataset_review_answer_context_goes_to_subdispatcher( self, current_user, task_manager, config_manager, db ): from src.api.routes.assistant._tool_registry import dispatch with patch( "src.api.routes.assistant._dataset_review_dispatch._dispatch_dataset_review_intent", new=AsyncMock(return_value=("context summary", None, [])), ) as mock_sub: text, task_id, actions = await dispatch( "dataset_review_answer_context", {"entities": {"dataset_review_session_id": "s1", "session_version": 1}}, current_user, task_manager, config_manager, db, ) assert text == "context summary" mock_sub.assert_awaited_once() # #endregion Test.Assistant.ToolRegistry