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.
286 lines
9.2 KiB
Python
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"
|