Files
metin-release-cli/tests/mcp/test_tool_dispatch.py
Jan Nedbal 1201ec50d2 tests: cover m2pack cli and mcp additions
Add discovery-helper tests (env var override, PATH fallback, missing
binary) and command tests that point M2PACK_BINARY at a Python stub
script echoing canned JSON per subcommand. Cover success paths,
non-zero exit, non-JSON output, JSON-array output, and missing binary.
Extend the MCP schema mirror test to cover all 12 tools and add
dispatch tests for the new m2pack_* argv translation.
2026-04-14 22:31:11 +02:00

286 lines
9.2 KiB
Python

"""Dispatch tests — input dict to CLI argv translation."""
from __future__ import annotations
import json
import pytest
from metin_release_mcp import cli_runner, server
from metin_release_mcp.tool_defs import TOOLS_BY_NAME, build_cli_args
from .conftest import make_runner
def test_inspect_maps_source_to_flag():
spec = TOOLS_BY_NAME["release_inspect"]
argv = build_cli_args(spec, {"source": "/tmp/client"})
assert argv == ["release", "inspect", "--json", "--source", "/tmp/client"]
def test_build_manifest_full_flags():
spec = TOOLS_BY_NAME["release_build_manifest"]
argv = build_cli_args(
spec,
{
"source": "/src",
"version": "2026.04.14-1",
"out": "/out/manifest.json",
"previous": "2026.04.13-1",
"notes": "/notes.md",
"launcher": "Metin2Launcher.exe",
"created_at": "2026-04-14T00:00:00Z",
},
)
# Required args present in order declared
assert argv[0:3] == ["release", "build-manifest", "--json"]
assert "--source" in argv and argv[argv.index("--source") + 1] == "/src"
assert "--version" in argv and argv[argv.index("--version") + 1] == "2026.04.14-1"
assert "--out" in argv and argv[argv.index("--out") + 1] == "/out/manifest.json"
assert "--previous" in argv and argv[argv.index("--previous") + 1] == "2026.04.13-1"
assert "--created-at" in argv
assert argv[argv.index("--created-at") + 1] == "2026-04-14T00:00:00Z"
def test_build_manifest_omits_missing_optionals():
spec = TOOLS_BY_NAME["release_build_manifest"]
argv = build_cli_args(
spec,
{"source": "/src", "version": "v1", "out": "/m.json"},
)
assert "--previous" not in argv
assert "--notes" not in argv
assert "--launcher" not in argv
assert "--created-at" not in argv
def test_none_valued_optional_is_omitted():
spec = TOOLS_BY_NAME["release_build_manifest"]
argv = build_cli_args(
spec,
{"source": "/s", "version": "v", "out": "/m", "previous": None, "notes": None},
)
assert "--previous" not in argv
assert "--notes" not in argv
def test_boolean_flag_true_adds_flag_without_value():
spec = TOOLS_BY_NAME["release_upload_blobs"]
argv = build_cli_args(
spec,
{
"release_dir": "/rel",
"rsync_target": "user@host:/srv/updates",
"dry_run": True,
"yes": True,
},
)
assert "--dry-run" in argv
assert "--yes" in argv
# No value should follow --dry-run
i = argv.index("--dry-run")
# next element either end of list or another flag
assert i == len(argv) - 1 or argv[i + 1].startswith("--")
def test_boolean_flag_false_omits_flag():
spec = TOOLS_BY_NAME["release_upload_blobs"]
argv = build_cli_args(
spec,
{"release_dir": "/rel", "rsync_target": "tgt", "dry_run": False, "yes": False},
)
assert "--dry-run" not in argv
assert "--yes" not in argv
def test_path_with_spaces_passes_through_as_single_argv_element():
spec = TOOLS_BY_NAME["release_inspect"]
argv = build_cli_args(spec, {"source": "/tmp/has spaces/client root"})
# argv is a list — no shell involved, so spaces stay in one element
assert "/tmp/has spaces/client root" in argv
assert argv.count("--source") == 1
def test_diff_remote_numeric_timeout_serialised_as_string():
spec = TOOLS_BY_NAME["release_diff_remote"]
argv = build_cli_args(
spec,
{"manifest": "/m.json", "base_url": "https://x/", "timeout": 7.5},
)
assert argv[argv.index("--timeout") + 1] == "7.5"
def test_publish_mixes_booleans_and_strings():
spec = TOOLS_BY_NAME["release_publish"]
argv = build_cli_args(
spec,
{
"source": "/src",
"version": "v1",
"out": "/out",
"key": "/k",
"rsync_target": "t",
"base_url": "https://x/",
"public_key": "deadbeef",
"force": True,
"dry_run_upload": True,
"yes": False,
},
)
assert "--force" in argv
assert "--dry-run-upload" in argv
assert "--yes" not in argv
# ---------------------------------------------------------------------------
# server.dispatch integration against a stubbed CLI runner
# ---------------------------------------------------------------------------
@pytest.fixture
def stub_runner(monkeypatch):
def install(envelope=None, *, stdout=None, stderr="", returncode=0):
runner = make_runner(envelope, stdout=stdout, stderr=stderr, returncode=returncode)
monkeypatch.setattr(
server,
"run_cli",
lambda argv_tail: cli_runner.run_cli(
argv_tail, binary="/fake/metin-release", runner=runner
),
)
return runner
return install
def test_dispatch_success_returns_envelope(stub_runner):
runner = stub_runner(
{"ok": True, "command": "release inspect", "status": "inspected", "stats": {"file_count": 42}}
)
envelope, stderr = server.dispatch("release_inspect", {"source": "/tmp/x"})
assert envelope["ok"] is True
assert envelope["stats"]["file_count"] == 42
# the runner saw --json
assert "--json" in runner.calls[0]
assert "--source" in runner.calls[0]
assert "/tmp/x" in runner.calls[0]
def test_dispatch_cli_error_envelope_passes_through(stub_runner):
err = {
"ok": False,
"command": "release sign",
"status": "failed",
"error": {"code": "key_permission", "message": "mode 644"},
}
stub_runner(err, stderr="error\n", returncode=3)
envelope, stderr = server.dispatch(
"release_sign", {"manifest": "/m.json", "key": "/k"}
)
assert envelope == err
assert "error" in stderr
def test_dispatch_cli_binary_missing_returns_wrapper_error(monkeypatch):
def boom(argv_tail):
from metin_release_mcp.errors import CliNotFoundError
raise CliNotFoundError("metin-release binary not found")
monkeypatch.setattr(server, "run_cli", boom)
envelope, stderr = server.dispatch("release_inspect", {"source": "/x"})
assert envelope["ok"] is False
assert envelope["error"]["code"] == "cli_not_found"
def test_dispatch_invalid_input_returns_wrapper_error():
envelope, _ = server.dispatch("release_inspect", {})
assert envelope["ok"] is False
assert envelope["error"]["code"] == "invalid_tool_input"
def test_m2pack_build_translates_all_flags():
spec = TOOLS_BY_NAME["m2pack_build"]
argv = build_cli_args(
spec,
{
"input": "/src",
"output": "/out/a.m2p",
"key": "/ck",
"sign_secret_key": "/sk",
"key_id": 2,
},
)
assert argv[0:3] == ["m2pack", "build", "--json"]
assert "--input" in argv and argv[argv.index("--input") + 1] == "/src"
assert "--output" in argv and argv[argv.index("--output") + 1] == "/out/a.m2p"
assert "--sign-secret-key" in argv
assert argv[argv.index("--sign-secret-key") + 1] == "/sk"
assert "--key-id" in argv and argv[argv.index("--key-id") + 1] == "2"
def test_m2pack_verify_omits_optional_keys():
spec = TOOLS_BY_NAME["m2pack_verify"]
argv = build_cli_args(spec, {"archive": "/a.m2p"})
assert argv == ["m2pack", "verify", "--json", "--archive", "/a.m2p"]
def test_m2pack_diff_requires_both_sides():
from metin_release_mcp.errors import InvalidToolInputError
spec = TOOLS_BY_NAME["m2pack_diff"]
with pytest.raises(InvalidToolInputError):
build_cli_args(spec, {"left": "/L"})
argv = build_cli_args(spec, {"left": "/L", "right": "/R"})
assert argv == ["m2pack", "diff", "--json", "--left", "/L", "--right", "/R"]
def test_m2pack_export_runtime_key_with_format():
spec = TOOLS_BY_NAME["m2pack_export_runtime_key"]
argv = build_cli_args(
spec,
{
"key": "/ck",
"public_key": "/pk",
"output": "/out",
"format": "blob",
"key_id": 4,
},
)
assert argv[0:3] == ["m2pack", "export-runtime-key", "--json"]
assert "--format" in argv and argv[argv.index("--format") + 1] == "blob"
assert "--key-id" in argv and argv[argv.index("--key-id") + 1] == "4"
def test_dispatch_m2pack_build_through_fake_runner(stub_runner):
runner = stub_runner(
{"ok": True, "command": "m2pack build", "status": "built", "artifacts": {"archive_path": "/x.m2p"}}
)
envelope, _ = server.dispatch(
"m2pack_build",
{
"input": "/src",
"output": "/x.m2p",
"key": "/ck",
"sign_secret_key": "/sk",
},
)
assert envelope["ok"] is True
call = runner.calls[0]
assert "m2pack" in call and "build" in call
assert "--json" in call
assert "--input" in call and "/src" in call
assert "--sign-secret-key" in call and "/sk" in call
def test_dispatch_unparseable_output_returns_wrapper_error(monkeypatch):
def boom(argv_tail):
from metin_release_mcp.errors import CliUnparseableOutputError
raise CliUnparseableOutputError("not json")
monkeypatch.setattr(server, "run_cli", boom)
envelope, _ = server.dispatch("release_inspect", {"source": "/x"})
assert envelope["ok"] is False
assert envelope["error"]["code"] == "cli_unparseable_output"