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