"""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"