feat: generate the tool catalog from the pinned swagger
Adds scripts/generate_catalog.py (with --check), the naming rules, schema conversion, the committed tools.json for all 537 operations and the load_catalog() loader.
This commit is contained in:
1 parent
a807a70556
commit
041b3ea7ff
9 files changed
+2245
-2
No files matched your search
@@ -0,0 +1,576 @@
|
||||
"""Tests for schema generation, annotations and the catalog builder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from aegis_gitea_mcp.catalog import Operation, load_catalog
|
||||
from aegis_gitea_mcp.catalog.build import build_catalog, render_catalog, spec_sha256
|
||||
from aegis_gitea_mcp.catalog.schema import (
|
||||
MAX_DESCRIPTION_CHARS,
|
||||
SUDO_SCHEMA,
|
||||
resolve_schema,
|
||||
trim_description,
|
||||
)
|
||||
|
||||
FILE_SCHEMA_KEYS = {"filename", "content_base64", "content_type"}
|
||||
|
||||
|
||||
def mini_spec(paths: dict[str, Any], definitions: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"swagger": "2.0",
|
||||
"info": {"title": "Mini", "version": "9.9.9"},
|
||||
"basePath": "/api/v1",
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
"paths": paths,
|
||||
"definitions": definitions or {},
|
||||
}
|
||||
|
||||
|
||||
def build_ops(spec: dict[str, Any]) -> dict[str, Operation]:
|
||||
result = build_catalog(json.dumps(spec).encode())
|
||||
return {op["operation_id"]: Operation.from_dict(op) for op in result.document["operations"]}
|
||||
|
||||
|
||||
def path_param(name: str, **extra: Any) -> dict[str, Any]:
|
||||
return {"name": name, "in": "path", "required": True, "type": "string", **extra}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- schema generation
|
||||
|
||||
|
||||
def test_path_params_are_required_with_name_patterns() -> None:
|
||||
spec = mini_spec(
|
||||
{
|
||||
"/repos/{owner}/{repo}/tags/{tag}": {
|
||||
"get": {
|
||||
"operationId": "repoGetTag",
|
||||
"parameters": [
|
||||
path_param("owner"),
|
||||
path_param("repo"),
|
||||
path_param("tag"),
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
schema = build_ops(spec)["repoGetTag"].input_schema
|
||||
props = schema["properties"]
|
||||
assert schema["required"] == ["owner", "repo", "tag", "id"]
|
||||
assert props["owner"]["pattern"] == "^[A-Za-z0-9_.-]+$"
|
||||
assert props["owner"]["maxLength"] == 255
|
||||
assert props["owner"]["minLength"] == 1
|
||||
assert "pattern" not in props["tag"]
|
||||
assert props["tag"]["minLength"] == 1
|
||||
assert props["id"] == {"type": "integer", "format": "int64"}
|
||||
assert schema["additionalProperties"] is False
|
||||
assert schema["type"] == "object"
|
||||
|
||||
|
||||
def test_query_params_optional_enums_defaults_arrays() -> None:
|
||||
spec = mini_spec(
|
||||
{
|
||||
"/things": {
|
||||
"get": {
|
||||
"operationId": "listThings",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "state",
|
||||
"in": "query",
|
||||
"type": "string",
|
||||
"enum": ["a", "b"],
|
||||
"default": "a",
|
||||
},
|
||||
{"name": "q", "in": "query", "type": "string", "required": True},
|
||||
{
|
||||
"name": "labels",
|
||||
"in": "query",
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "format": "int64"},
|
||||
"collectionFormat": "multi",
|
||||
},
|
||||
{
|
||||
"name": "csv",
|
||||
"in": "query",
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"collectionFormat": "pipes",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
operation = build_ops(spec)["listThings"]
|
||||
props = operation.input_schema["properties"]
|
||||
assert operation.input_schema["required"] == ["q"]
|
||||
assert props["state"] == {"type": "string", "enum": ["a", "b"], "default": "a"}
|
||||
assert props["labels"] == {"type": "array", "items": {"type": "integer", "format": "int64"}}
|
||||
# A non-multi collection format is recorded on the Parameter only.
|
||||
csv = next(p for p in operation.parameters if p.name == "csv")
|
||||
assert csv.schema["x-collection-format"] == "pipes"
|
||||
assert "x-collection-format" not in props["csv"]
|
||||
|
||||
|
||||
def test_body_ref_is_inlined_and_vendor_keys_dropped() -> None:
|
||||
definitions = {
|
||||
"CreateThing": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {"type": "string", "x-go-name": "Name", "description": "x" * 500},
|
||||
"nested": {"$ref": "#/definitions/Nested"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"x-go-package": "code.gitea.io/gitea",
|
||||
"example": {"name": "x"},
|
||||
},
|
||||
"Nested": {"type": "object", "properties": {"n": {"type": "integer"}}},
|
||||
}
|
||||
spec = mini_spec(
|
||||
{
|
||||
"/things": {
|
||||
"post": {
|
||||
"operationId": "createThing",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": True,
|
||||
"schema": {"$ref": "#/definitions/CreateThing"},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
definitions,
|
||||
)
|
||||
operation = build_ops(spec)["createThing"]
|
||||
body = operation.input_schema["properties"]["body"]
|
||||
assert "$ref" not in json.dumps(operation.input_schema)
|
||||
assert operation.input_schema["required"] == ["body"]
|
||||
assert body["required"] == ["name"]
|
||||
assert body["additionalProperties"] is False
|
||||
assert body["properties"]["nested"]["properties"] == {"n": {"type": "integer"}}
|
||||
assert body["properties"]["nested"]["additionalProperties"] is False
|
||||
assert "x-go-package" not in body
|
||||
assert "example" not in body
|
||||
assert "x-go-name" not in body["properties"]["name"]
|
||||
assert len(body["properties"]["name"]["description"]) <= MAX_DESCRIPTION_CHARS
|
||||
parameter = operation.parameters[0]
|
||||
assert (parameter.arg_name, parameter.location, parameter.required) == ("body", "body", True)
|
||||
|
||||
|
||||
def test_optional_body_is_not_required() -> None:
|
||||
spec = mini_spec(
|
||||
{
|
||||
"/things": {
|
||||
"post": {
|
||||
"operationId": "createThing",
|
||||
"parameters": [{"name": "body", "in": "body", "schema": {"type": "string"}}],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
operation = build_ops(spec)["createThing"]
|
||||
assert operation.input_schema["required"] == []
|
||||
assert operation.input_schema["properties"]["body"] == {"type": "string"}
|
||||
|
||||
|
||||
def test_array_body_stays_array() -> None:
|
||||
spec = mini_spec(
|
||||
{
|
||||
"/things": {
|
||||
"put": {
|
||||
"operationId": "replaceThings",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": True,
|
||||
"schema": {"type": "array", "items": {"type": "string"}},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
body = build_ops(spec)["replaceThings"].input_schema["properties"]["body"]
|
||||
assert body == {"type": "array", "items": {"type": "string"}}
|
||||
|
||||
|
||||
def test_reference_cycle_collapses_to_plain_object() -> None:
|
||||
definitions = {
|
||||
"Node": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"parent": {"$ref": "#/definitions/Node"},
|
||||
"children": {"type": "array", "items": {"$ref": "#/definitions/Node"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
resolved = resolve_schema({"$ref": "#/definitions/Node"}, definitions)
|
||||
assert resolved["properties"]["parent"] == {"type": "object"}
|
||||
assert resolved["properties"]["children"]["items"] == {"type": "object"}
|
||||
assert resolved["additionalProperties"] is False
|
||||
|
||||
|
||||
def test_declared_additional_properties_are_kept() -> None:
|
||||
schema = resolve_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"a": {"type": "string"}},
|
||||
"additionalProperties": {"type": "string"},
|
||||
},
|
||||
{},
|
||||
)
|
||||
assert schema["additionalProperties"] == {"type": "string"}
|
||||
free_form = resolve_schema({"type": "object", "additionalProperties": {"type": "string"}}, {})
|
||||
assert free_form == {"type": "object", "additionalProperties": {"type": "string"}}
|
||||
|
||||
|
||||
def test_unknown_reference_is_an_error() -> None:
|
||||
with pytest.raises(ValueError, match="unknown definition"):
|
||||
resolve_schema({"$ref": "#/definitions/Missing"}, {})
|
||||
with pytest.raises(ValueError, match="unsupported"):
|
||||
resolve_schema({"$ref": "#/parameters/x"}, {})
|
||||
|
||||
|
||||
def test_file_params_become_base64_objects() -> None:
|
||||
spec = mini_spec(
|
||||
{
|
||||
"/uploads": {
|
||||
"post": {
|
||||
"operationId": "upload",
|
||||
"consumes": ["multipart/form-data"],
|
||||
"parameters": [
|
||||
{"name": "name", "in": "formData", "type": "string"},
|
||||
{
|
||||
"name": "attachment",
|
||||
"in": "formData",
|
||||
"type": "file",
|
||||
"required": True,
|
||||
"description": "the file",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
operation = build_ops(spec)["upload"]
|
||||
attachment = operation.input_schema["properties"]["attachment"]
|
||||
assert set(attachment["properties"]) == FILE_SCHEMA_KEYS
|
||||
assert attachment["required"] == ["filename", "content_base64"]
|
||||
assert attachment["additionalProperties"] is False
|
||||
assert attachment["description"] == "the file"
|
||||
file_param = next(p for p in operation.parameters if p.is_file)
|
||||
assert file_param.location == "formData"
|
||||
assert file_param.arg_name == "attachment"
|
||||
plain = next(p for p in operation.parameters if p.name == "name")
|
||||
assert plain.location == "formData"
|
||||
assert not plain.is_file
|
||||
assert operation.consumes == ("multipart/form-data",)
|
||||
assert operation.input_schema["required"] == ["attachment"]
|
||||
|
||||
|
||||
def test_every_tool_gets_optional_sudo() -> None:
|
||||
for operation in load_catalog().operations:
|
||||
props = operation.input_schema["properties"]
|
||||
assert props["sudo"] == SUDO_SCHEMA
|
||||
assert "sudo" not in operation.input_schema["required"]
|
||||
assert all(parameter.name != "sudo" for parameter in operation.parameters)
|
||||
|
||||
|
||||
def test_colliding_argument_names_are_prefixed() -> None:
|
||||
spec = mini_spec(
|
||||
{
|
||||
"/things/{ref}": {
|
||||
"post": {
|
||||
"operationId": "mixed",
|
||||
"parameters": [
|
||||
path_param("ref"),
|
||||
{"name": "ref", "in": "query", "type": "string"},
|
||||
{"name": "sudo", "in": "query", "type": "string"},
|
||||
{"name": "body", "in": "query", "type": "string"},
|
||||
{"name": "body", "in": "body", "schema": {"type": "string"}},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
operation = build_ops(spec)["mixed"]
|
||||
arg_names = [parameter.arg_name for parameter in operation.parameters]
|
||||
assert arg_names == ["ref", "query_ref", "query_sudo", "query_body", "body"]
|
||||
assert len(set(arg_names)) == len(arg_names)
|
||||
assert operation.input_schema["properties"]["sudo"] == SUDO_SCHEMA
|
||||
|
||||
|
||||
def test_path_level_parameters_are_merged() -> None:
|
||||
spec = mini_spec(
|
||||
{
|
||||
"/things/{name}": {
|
||||
"parameters": [path_param("name", description="shared")],
|
||||
"get": {
|
||||
"operationId": "getThing",
|
||||
"parameters": [{"name": "page", "in": "query", "type": "integer"}],
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "deleteThing",
|
||||
"parameters": [path_param("name", description="own")],
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
ops = build_ops(spec)
|
||||
assert [p.name for p in ops["getThing"].parameters] == ["name", "page"]
|
||||
assert ops["getThing"].input_schema["properties"]["name"]["description"] == "shared"
|
||||
assert ops["deleteThing"].input_schema["properties"]["name"]["description"] == "own"
|
||||
assert [p.name for p in ops["deleteThing"].parameters] == ["name"]
|
||||
|
||||
|
||||
def test_header_parameters_are_rejected() -> None:
|
||||
spec = mini_spec(
|
||||
{
|
||||
"/things": {
|
||||
"get": {
|
||||
"operationId": "getThings",
|
||||
"parameters": [{"name": "X-Thing", "in": "header", "type": "string"}],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match="header"):
|
||||
build_catalog(json.dumps(spec).encode())
|
||||
|
||||
|
||||
def test_name_collisions_are_reported() -> None:
|
||||
spec = mini_spec(
|
||||
{
|
||||
"/things": {"get": {"operationId": "listThings"}},
|
||||
"/things2": {"get": {"operationId": "listThings2"}},
|
||||
}
|
||||
)
|
||||
# Different operationIds that map to the same name: force one with a sibling path.
|
||||
spec["paths"]["/other"] = {"get": {"operationId": "ListThings"}}
|
||||
with pytest.raises(ValueError, match="collisions"):
|
||||
build_catalog(json.dumps(spec).encode())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- annotations and flags
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "read_only", "destructive", "idempotent"),
|
||||
[
|
||||
("get", True, False, True),
|
||||
("post", False, False, False),
|
||||
("put", False, True, True),
|
||||
("patch", False, True, False),
|
||||
("delete", False, True, True),
|
||||
],
|
||||
)
|
||||
def test_method_annotations(
|
||||
method: str, read_only: bool, destructive: bool, idempotent: bool
|
||||
) -> None:
|
||||
spec = mini_spec({"/things": {method: {"operationId": "doThing"}}})
|
||||
operation = build_ops(spec)["doThing"]
|
||||
assert operation.method == method.upper()
|
||||
assert operation.read_only is read_only
|
||||
assert operation.destructive is destructive
|
||||
assert operation.idempotent is idempotent
|
||||
|
||||
|
||||
def test_pagination_flag_needs_page_and_limit() -> None:
|
||||
def query(name: str) -> dict[str, Any]:
|
||||
return {"name": name, "in": "query", "type": "integer"}
|
||||
|
||||
spec = mini_spec(
|
||||
{
|
||||
"/both": {
|
||||
"get": {"operationId": "both", "parameters": [query("page"), query("limit")]}
|
||||
},
|
||||
"/page": {"get": {"operationId": "pageOnly", "parameters": [query("page")]}},
|
||||
"/none": {"get": {"operationId": "none"}},
|
||||
}
|
||||
)
|
||||
ops = build_ops(spec)
|
||||
assert ops["both"].paginated
|
||||
assert not ops["pageOnly"].paginated
|
||||
assert not ops["none"].paginated
|
||||
|
||||
|
||||
def test_consumes_and_produces_fall_back_to_spec_defaults() -> None:
|
||||
spec = mini_spec(
|
||||
{
|
||||
"/a": {"get": {"operationId": "a"}},
|
||||
"/b": {
|
||||
"post": {"operationId": "b", "consumes": ["text/plain"], "produces": ["text/html"]}
|
||||
},
|
||||
}
|
||||
)
|
||||
ops = build_ops(spec)
|
||||
assert (ops["a"].consumes, ops["a"].produces) == (("application/json",), ("application/json",))
|
||||
assert (ops["b"].consumes, ops["b"].produces) == (("text/plain",), ("text/html",))
|
||||
|
||||
|
||||
def test_descriptions_are_short_and_admin_note_present() -> None:
|
||||
spec = mini_spec(
|
||||
{
|
||||
"/admin/users": {
|
||||
"post": {
|
||||
"operationId": "adminCreateUser",
|
||||
"summary": "Create a user",
|
||||
"description": "d" * 900,
|
||||
},
|
||||
},
|
||||
"/old": {"get": {"operationId": "oldThing", "deprecated": True}},
|
||||
}
|
||||
)
|
||||
ops = build_ops(spec)
|
||||
admin = ops["adminCreateUser"]
|
||||
assert admin.summary == "Create a user"
|
||||
assert len(admin.description) <= 500
|
||||
created = build_ops(
|
||||
mini_spec(
|
||||
{
|
||||
"/admin/users": {
|
||||
"post": {"operationId": "adminCreateUser", "summary": "Create a user"}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
assert "Requires a site-admin token." in created["adminCreateUser"].description
|
||||
assert "Deprecated." in ops["oldThing"].description
|
||||
assert ops["oldThing"].summary == "oldThing"
|
||||
|
||||
|
||||
def test_trim_description_collapses_whitespace() -> None:
|
||||
assert trim_description("a\n b") == "a b"
|
||||
assert trim_description("x" * 400, 50).endswith("...")
|
||||
assert len(trim_description("x" * 400, 50)) == 50
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- whole catalog
|
||||
|
||||
|
||||
def test_build_is_deterministic_and_sorted() -> None:
|
||||
spec = mini_spec(
|
||||
{"/b": {"get": {"operationId": "bThing"}}, "/a": {"get": {"operationId": "aThing"}}}
|
||||
)
|
||||
raw = json.dumps(spec).encode()
|
||||
first = build_catalog(raw)
|
||||
second = build_catalog(raw)
|
||||
assert render_catalog(first.document) == render_catalog(second.document)
|
||||
names = [op["tool_name"] for op in first.document["operations"]]
|
||||
assert names == sorted(names)
|
||||
assert first.document["spec"] == {
|
||||
"title": "Mini",
|
||||
"version": "9.9.9",
|
||||
"sha256": spec_sha256(raw),
|
||||
}
|
||||
assert sum(first.counts.values()) == 2
|
||||
|
||||
|
||||
def test_rendered_catalog_is_valid_json_one_operation_per_line() -> None:
|
||||
spec = mini_spec(
|
||||
{"/a": {"get": {"operationId": "aThing"}}, "/b": {"get": {"operationId": "bThing"}}}
|
||||
)
|
||||
text = render_catalog(build_catalog(json.dumps(spec).encode()).document)
|
||||
assert text.endswith("\n")
|
||||
assert len(json.loads(text)["operations"]) == 2
|
||||
assert text.count("\n") == 4
|
||||
|
||||
|
||||
def test_every_real_input_schema_is_valid_draft_2020_12() -> None:
|
||||
for operation in load_catalog().operations:
|
||||
Draft202012Validator.check_schema(operation.input_schema)
|
||||
assert operation.input_schema["type"] == "object"
|
||||
assert operation.input_schema["additionalProperties"] is False
|
||||
assert "$ref" not in json.dumps(operation.input_schema)
|
||||
arg_names = [parameter.arg_name for parameter in operation.parameters]
|
||||
assert len(set(arg_names)) == len(arg_names)
|
||||
assert set(operation.input_schema["required"]) <= set(operation.input_schema["properties"])
|
||||
assert set(arg_names) | {"sudo"} == set(operation.input_schema["properties"])
|
||||
|
||||
|
||||
def test_real_schemas_reject_unknown_and_missing_arguments() -> None:
|
||||
operation = load_catalog().get("gitea_repo_list_branches")
|
||||
assert operation is not None
|
||||
validator = Draft202012Validator(operation.input_schema)
|
||||
assert not list(validator.iter_errors({"owner": "me", "repo": "r"}))
|
||||
assert list(validator.iter_errors({"owner": "me"}))
|
||||
assert list(validator.iter_errors({"owner": "me", "repo": "r", "extra": 1}))
|
||||
assert list(validator.iter_errors({"owner": "../x", "repo": "r"}))
|
||||
assert list(validator.iter_errors({"owner": "me", "repo": "r", "sudo": "a b"}))
|
||||
|
||||
|
||||
def test_real_body_and_file_operations() -> None:
|
||||
catalog = load_catalog()
|
||||
create = catalog.get("gitea_issue_create_issue")
|
||||
assert create is not None
|
||||
body = create.input_schema["properties"]["body"]
|
||||
assert "title" in body["properties"]
|
||||
assert "title" in body["required"]
|
||||
upload = catalog.get("gitea_issue_create_issue_attachment")
|
||||
assert upload is not None
|
||||
assert any(p.is_file and p.location == "formData" for p in upload.parameters)
|
||||
assert catalog.get("gitea_repo_get_file_contents") is not None
|
||||
query_body = catalog.get("gitea_repo_get_file_contents")
|
||||
assert query_body is not None
|
||||
assert any(p.name == "body" and p.arg_name == "query_body" for p in query_body.parameters)
|
||||
|
||||
|
||||
def test_real_annotations() -> None:
|
||||
catalog = load_catalog()
|
||||
for operation in catalog.operations:
|
||||
assert operation.read_only == (operation.method == "GET")
|
||||
assert operation.destructive == (operation.method in {"DELETE", "PUT", "PATCH"})
|
||||
assert not (operation.read_only and operation.destructive)
|
||||
if operation.paginated:
|
||||
names = {p.name for p in operation.parameters}
|
||||
assert {"page", "limit"} <= names
|
||||
assert any(operation.paginated for operation in catalog.operations)
|
||||
|
||||
|
||||
def test_shared_parameter_references_are_resolved() -> None:
|
||||
spec = mini_spec(
|
||||
{
|
||||
"/things": {
|
||||
"get": {"operationId": "listThings", "parameters": [{"$ref": "#/parameters/page"}]},
|
||||
"post": {"operationId": "makeThing", "parameters": [{"$ref": "#/parameters/nope"}]},
|
||||
}
|
||||
}
|
||||
)
|
||||
spec["parameters"] = {"page": {"name": "page", "in": "query", "type": "integer"}}
|
||||
with pytest.raises(ValueError, match="unknown parameter reference"):
|
||||
build_catalog(json.dumps(spec).encode())
|
||||
del spec["paths"]["/things"]["post"]
|
||||
assert [p.name for p in build_ops(spec)["listThings"].parameters] == ["page"]
|
||||
|
||||
|
||||
def test_unknown_parameter_location_is_rejected() -> None:
|
||||
spec = mini_spec(
|
||||
{
|
||||
"/things": {
|
||||
"get": {
|
||||
"operationId": "a",
|
||||
"parameters": [{"name": "x", "in": "cookie", "type": "string"}],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match="unknown parameter location"):
|
||||
build_catalog(json.dumps(spec).encode())
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Tests for the generator script (--check drift detection) and the loader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
|
||||
from aegis_gitea_mcp.catalog import DOMAINS, Catalog, Operation, load_catalog
|
||||
from aegis_gitea_mcp.catalog.build import (
|
||||
diff_summary,
|
||||
generate,
|
||||
render_catalog,
|
||||
spec_sha256,
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC_PATH = REPO_ROOT / "spec" / "gitea-swagger.v1.json"
|
||||
CATALOG_PATH = REPO_ROOT / "src" / "aegis_gitea_mcp" / "catalog" / "tools.json"
|
||||
SCRIPT_PATH = REPO_ROOT / "scripts" / "generate_catalog.py"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def script() -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location("generate_catalog_script", SCRIPT_PATH)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def generated() -> str:
|
||||
text, _ = generate(SPEC_PATH)
|
||||
return text
|
||||
|
||||
|
||||
def test_committed_catalog_is_up_to_date(generated: str) -> None:
|
||||
assert CATALOG_PATH.read_text("utf-8") == generated
|
||||
|
||||
|
||||
def test_catalog_header_matches_pinned_spec() -> None:
|
||||
document = json.loads(CATALOG_PATH.read_text("utf-8"))
|
||||
assert document["spec"] == {
|
||||
"title": "Gitea API",
|
||||
"version": "28.1.0",
|
||||
"sha256": spec_sha256(SPEC_PATH.read_bytes()),
|
||||
}
|
||||
assert len(document["operations"]) == 537
|
||||
|
||||
|
||||
def test_check_passes_on_fresh_file(
|
||||
script: ModuleType, generated: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
target = tmp_path / "tools.json"
|
||||
target.write_text(generated, "utf-8")
|
||||
assert script.main(["--check"], SPEC_PATH, target) == 0
|
||||
assert "up to date" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_check_detects_drift(
|
||||
script: ModuleType, generated: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
target = tmp_path / "tools.json"
|
||||
target.write_text(
|
||||
generated.replace("gitea_repo_list_branches", "gitea_repo_list_branch"), "utf-8"
|
||||
)
|
||||
assert script.main(["--check"], SPEC_PATH, target) == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "out of date" in err
|
||||
assert "changed lines" in err
|
||||
|
||||
|
||||
def test_check_fails_when_file_is_missing(script: ModuleType, tmp_path: Path) -> None:
|
||||
assert script.main(["--check"], SPEC_PATH, tmp_path / "missing.json") == 1
|
||||
|
||||
|
||||
def test_check_detects_spec_change(script: ModuleType, generated: str, tmp_path: Path) -> None:
|
||||
target = tmp_path / "tools.json"
|
||||
target.write_text(generated, "utf-8")
|
||||
changed_spec = tmp_path / "spec.json"
|
||||
spec = json.loads(SPEC_PATH.read_text("utf-8"))
|
||||
spec["info"]["version"] = "99.0.0"
|
||||
changed_spec.write_text(json.dumps(spec), "utf-8")
|
||||
assert script.main(["--check"], changed_spec, target) == 1
|
||||
|
||||
|
||||
def test_write_mode_creates_identical_file(
|
||||
script: ModuleType, generated: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
target = tmp_path / "out" / "tools.json"
|
||||
target.parent.mkdir()
|
||||
assert script.main([], SPEC_PATH, target) == 0
|
||||
assert target.read_text("utf-8") == generated
|
||||
out = capsys.readouterr().out
|
||||
assert "operations per domain" in out
|
||||
assert "total 537" in out
|
||||
assert "override:" in out
|
||||
shutil.rmtree(target.parent)
|
||||
|
||||
|
||||
def test_diff_summary_truncates() -> None:
|
||||
old = "\n".join(f"line {i}" for i in range(100))
|
||||
new = "\n".join(f"changed {i}" for i in range(100))
|
||||
summary = diff_summary(old, new, limit=5)
|
||||
assert summary.splitlines()[0] == "200 changed lines"
|
||||
assert "more changed lines" in summary
|
||||
|
||||
|
||||
def test_loader_round_trip() -> None:
|
||||
catalog = load_catalog()
|
||||
assert isinstance(catalog, Catalog)
|
||||
assert catalog.spec_version == "28.1.0"
|
||||
assert len(catalog.operations) == 537
|
||||
document = json.loads(CATALOG_PATH.read_text("utf-8"))
|
||||
assert [operation.to_dict() for operation in catalog.operations] == document["operations"]
|
||||
assert render_catalog(document) == CATALOG_PATH.read_text("utf-8")
|
||||
for operation in catalog.operations:
|
||||
assert Operation.from_dict(operation.to_dict()) == operation
|
||||
|
||||
|
||||
def test_loader_is_cached_and_indexed() -> None:
|
||||
assert load_catalog() is load_catalog()
|
||||
catalog = load_catalog()
|
||||
operation = catalog.get("gitea_repo_list_branches")
|
||||
assert operation is not None
|
||||
assert operation.operation_id == "repoListBranches"
|
||||
assert operation.path == "/repos/{owner}/{repo}/branches"
|
||||
assert operation.method == "GET"
|
||||
assert catalog.get("gitea_nope_nothing") is None
|
||||
names = [item.tool_name for item in catalog.operations]
|
||||
assert names == sorted(names)
|
||||
|
||||
|
||||
def test_loader_domains() -> None:
|
||||
assert load_catalog().domains() == sorted(DOMAINS, key=DOMAINS.index)
|
||||
assert set(load_catalog().domains()) == set(DOMAINS)
|
||||
empty = Catalog(spec_version="0", operations=())
|
||||
assert empty.domains() == []
|
||||
assert empty.get("gitea_repo_get") is None
|
||||
|
||||
|
||||
def test_tools_json_ships_in_the_package() -> None:
|
||||
from importlib import resources
|
||||
|
||||
assert resources.files("aegis_gitea_mcp.catalog").joinpath("tools.json").is_file()
|
||||
@@ -0,0 +1,199 @@
|
||||
"""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])
|
||||
Reference in new issue
Block a user