"""Tests for tool naming and domain classification.""" from __future__ import annotations import json from collections import Counter from pathlib import Path import pytest from aegis_gitea_mcp.catalog import DOMAINS, load_catalog from aegis_gitea_mcp.catalog.naming import ( MAX_TOOL_NAME_LENGTH, NAME_OVERRIDES, TOOL_NAME_PATTERN, action_name, classify_domain, snake_case, tool_name_for, validate_tool_names, ) SPEC_PATH = Path(__file__).resolve().parents[1] / "spec" / "gitea-swagger.v1.json" def spec_operations() -> list[tuple[str, str, str]]: spec = json.loads(SPEC_PATH.read_text("utf-8")) found = [] for path, item in spec["paths"].items(): for method, operation in item.items(): if method == "parameters": continue found.append((path, method, operation["operationId"])) return found def test_domains_are_the_fourteen_known_names() -> None: assert len(DOMAINS) == 14 assert set(DOMAINS) == { "admin", "repo", "issue", "pull", "release", "wiki", "org", "team", "user", "actions", "hook", "package", "notification", "misc", } @pytest.mark.parametrize( ("path", "domain"), [ ("/admin/users", "admin"), ("/admin/hooks", "admin"), ("/admin/actions/runners", "admin"), ("/repos/{owner}/{repo}/actions/secrets/{secretname}", "actions"), ("/orgs/{org}/actions/variables", "actions"), ("/user/actions/runners", "actions"), ("/repos/{owner}/{repo}/hooks", "hook"), ("/repos/{owner}/{repo}/hooks/git/{id}", "hook"), ("/orgs/{org}/hooks", "hook"), ("/user/hooks", "hook"), ("/repos/{owner}/{repo}/pulls/{index}/reviews", "pull"), ("/repos/{owner}/{repo}/issues/{index}/labels", "issue"), ("/repos/{owner}/{repo}/labels", "issue"), ("/repos/{owner}/{repo}/milestones", "issue"), ("/repos/issues/search", "issue"), ("/repos/{owner}/{repo}/releases/tags/{tag}", "release"), ("/repos/{owner}/{repo}/wiki/pages", "wiki"), ("/repos/{owner}/{repo}/notifications", "notification"), ("/notifications/threads/{id}", "notification"), ("/teams/{id}/members", "team"), ("/orgs/{org}/teams", "team"), ("/orgs/{org}/members", "org"), ("/org/{org}/repos", "org"), ("/packages/{owner}", "package"), ("/user", "user"), ("/user/repos", "user"), ("/users/{username}/repos", "user"), ("/repos/{owner}/{repo}/branches", "repo"), ("/repos/search", "repo"), ("/repositories/{id}", "repo"), ("/version", "misc"), ("/markdown", "misc"), ("/settings/api", "misc"), ], ) def test_classify_domain(path: str, domain: str) -> None: assert classify_domain(path) == domain @pytest.mark.parametrize( ("operation_id", "expected"), [ ("repoListBranches", "repo_list_branches"), ("getGeneralAPISettings", "get_general_api_settings"), ("userCreateOAuth2Application", "user_create_oauth2_application"), ("GetWorkflowRun", "get_workflow_run"), ("repoGetByID", "repo_get_by_id"), ], ) def test_snake_case(operation_id: str, expected: str) -> None: assert snake_case(operation_id) == expected def test_action_name_strips_domain_words() -> None: assert action_name("repoListBranches", "repo") == "list_branches" assert action_name("issueGetComments", "issue") == "get_comments" assert action_name("adminCreateUser", "admin") == "create_user" assert action_name("orgListMembers", "org") == "list_members" assert action_name("userGetCurrent", "user") == "get_current" assert action_name("notifyGetList", "notification") == "get_list" # Scope words stay where stripping them would collide. assert action_name("orgListActionsSecrets", "actions") == "org_list_secrets" assert action_name("repoListHooks", "hook") == "repo_list_hooks" def test_action_name_never_returns_empty() -> None: assert action_name("repo", "repo") == "repo" assert action_name("actions", "actions") == "actions" def test_tool_name_examples() -> None: assert tool_name_for("repoListBranches", "/repos/{owner}/{repo}/branches")[0] == ( "gitea_repo_list_branches" ) assert tool_name_for("adminCreateUser", "/admin/users")[0] == "gitea_admin_create_user" name, domain, overridden = tool_name_for( "repoCreateRunnerRegistrationToken", "/repos/{owner}/{repo}/actions/runners/registration-token", ) assert domain == "actions" assert name.startswith("gitea_actions_") assert not overridden def test_override_must_agree_with_domain(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setitem(NAME_OVERRIDES, "repoListBranches", "gitea_issue_list_branches") with pytest.raises(ValueError, match="disagrees"): tool_name_for("repoListBranches", "/repos/{owner}/{repo}/branches") def test_every_override_is_used_and_valid() -> None: used = {operation_id for _, _, operation_id in spec_operations()} assert set(NAME_OVERRIDES) <= used for name in NAME_OVERRIDES.values(): assert TOOL_NAME_PATTERN.match(name) def test_naming_is_deterministic() -> None: first = [tool_name_for(op_id, path)[0] for path, _, op_id in spec_operations()] second = [tool_name_for(op_id, path)[0] for path, _, op_id in spec_operations()] assert first == second def test_real_catalog_names_are_valid_and_unique() -> None: names = [operation.tool_name for operation in load_catalog().operations] assert len(names) == 537 assert len(set(names)) == len(names) for name in names: assert TOOL_NAME_PATTERN.match(name), name assert len(name) <= MAX_TOOL_NAME_LENGTH, name validate_tool_names(names) def test_real_catalog_domain_counts_sum_to_537() -> None: counts = Counter(operation.domain for operation in load_catalog().operations) assert sum(counts.values()) == 537 assert set(counts) == set(DOMAINS) for operation in load_catalog().operations: assert operation.tool_name.startswith(f"gitea_{operation.domain}_") def test_real_catalog_specific_examples() -> None: catalog = load_catalog() by_id = {operation.operation_id: operation for operation in catalog.operations} assert by_id["repoListBranches"].tool_name == "gitea_repo_list_branches" assert by_id["adminCreateUser"].tool_name == "gitea_admin_create_user" assert by_id["updateRepoSecret"].domain == "actions" assert by_id["adminListHooks"].domain == "admin" assert by_id["orgListHooks"].domain == "hook" assert by_id["repoMergePullRequest"].domain == "pull" def test_validate_tool_names_reports_problems() -> None: with pytest.raises(ValueError, match="duplicate") as dup: validate_tool_names(["gitea_repo_get", "gitea_repo_get"]) assert "gitea_repo_get" in str(dup.value) with pytest.raises(ValueError, match="does not match"): validate_tool_names(["gitea_Repo_get"]) with pytest.raises(ValueError, match="longer than"): validate_tool_names(["gitea_repo_" + "a" * 60])