"""Tests for the Python CLI.""" import json import pytest from click.testing import CliRunner from zb_mom_ww_mxgateway import __version__ from zb_mom_ww_mxgateway_cli import commands as commands_module from zb_mom_ww_mxgateway_cli.commands import main _BATCH_EOR = "__MXGW_BATCH_EOR__" def test_require_certificate_validation_flag_flows_through_connect( monkeypatch: pytest.MonkeyPatch, ) -> None: """The --require-certificate-validation CLI flag must reach ClientOptions (Client.Python-027).""" captured: dict[str, object] = {} async def fake_connect(options, **_kwargs): captured["options"] = options # Return a minimal object that supports the async context-manager protocol # used by every CLI command body (async with await _connect(...) as client). return _FakeAsyncClient() monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect) result = CliRunner().invoke( main, [ "open-session", "--endpoint", "gateway.example:5001", "--require-certificate-validation", "--json", ], ) assert result.exit_code == 0, result.output assert captured["options"].require_certificate_validation is True def test_require_certificate_validation_defaults_off(monkeypatch: pytest.MonkeyPatch) -> None: """Without the flag the strict-validation posture stays off (TOFU default).""" captured: dict[str, object] = {} async def fake_connect(options, **_kwargs): captured["options"] = options return _FakeAsyncClient() monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect) result = CliRunner().invoke( main, ["open-session", "--endpoint", "gateway.example:5001", "--plaintext", "--json"], ) assert result.exit_code == 0, result.output assert captured["options"].require_certificate_validation is False class _FakeAsyncClient: """Minimal async-context-manager fake satisfying the open-session command body.""" async def __aenter__(self) -> "_FakeAsyncClient": return self async def __aexit__(self, *_exc: object) -> None: return None async def open_session_raw(self, *_args, **_kwargs): from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb return pb.OpenSessionReply(session_id="cli-test-session") def test_version_json_is_deterministic() -> None: runner = CliRunner() result = runner.invoke(main, ["version", "--json"]) assert result.exit_code == 0 assert json.loads(result.output) == { "client": "mxgw-py", "package": "mxaccess-gateway-client", "version": __version__, } def test_write_parser_rejects_unknown_value_type() -> None: runner = CliRunner() result = runner.invoke( main, [ "write", "--session-id", "session-1", "--server-handle", "12", "--item-handle", "34", "--type", "unsupported", "--value", "123", "--api-key", "mxgw_test_secret", "--json", ], ) assert result.exit_code != 0 assert "unsupported value type" in result.output def test_stream_alarms_is_registered() -> None: runner = CliRunner() result = runner.invoke(main, ["stream-alarms", "--help"]) assert result.exit_code == 0 assert "--filter-prefix" in result.output assert "--max-messages" in result.output def test_acknowledge_alarm_requires_reference() -> None: runner = CliRunner() result = runner.invoke( main, ["acknowledge-alarm", "--api-key", "mxgw_test_secret", "--json"], ) assert result.exit_code != 0 assert "--reference" in result.output def test_cli_error_output_redacts_api_key() -> None: runner = CliRunner() result = runner.invoke( main, [ "open-session", "--endpoint", "127.0.0.1:1", "--api-key", "mxgw_test_secret", "--plaintext", "--json", ], ) assert result.exit_code != 0 assert "mxgw_test_secret" not in result.output def test_batch_runs_version_command_and_writes_eor() -> None: runner = CliRunner() result = runner.invoke(main, ["batch"], input="version --json\n") assert result.exit_code == 0 blocks = [block for block in result.output.split(_BATCH_EOR + "\n") if block] assert len(blocks) == 1 payload = json.loads(blocks[0].strip()) assert payload == { "client": "mxgw-py", "package": "mxaccess-gateway-client", "version": __version__, } def test_batch_terminates_on_empty_line() -> None: runner = CliRunner() result = runner.invoke( main, ["batch"], input="version --json\n\nversion --json\n", ) assert result.exit_code == 0 # Only the first command runs; the empty line breaks the loop before the second. assert result.output.count(_BATCH_EOR) == 1 def test_batch_continues_after_error_line() -> None: runner = CliRunner() # First line is invalid (unknown subcommand), second is a valid version call. result = runner.invoke( main, ["batch"], input="not-a-real-command\nversion --json\n", ) assert result.exit_code == 0 assert result.output.count(_BATCH_EOR) == 2 blocks = [block for block in result.output.split(_BATCH_EOR + "\n") if block] assert len(blocks) == 2 # First block: error JSON ({"error": "...", "type": "..."}). error_payload = json.loads(blocks[0].strip().splitlines()[-1]) assert "error" in error_payload assert "type" in error_payload # Second block: successful version JSON. version_payload = json.loads(blocks[1].strip()) assert version_payload["version"] == __version__ class _FakeGalaxyClient: """Minimal async-context-manager fake satisfying the galaxy command bodies.""" def __init__(self, *, ok: bool = True, objects=None, last_deploy=None, events=None) -> None: self._ok = ok self._objects = objects or [] self._last_deploy = last_deploy self._events = events or [] async def __aenter__(self) -> "_FakeGalaxyClient": return self async def __aexit__(self, *_exc: object) -> None: return None async def test_connection(self) -> bool: return self._ok async def discover_hierarchy(self): return self._objects async def get_last_deploy_time(self): # Mirrors galaxy.py: protobuf ToDatetime() yields a timezone-NAIVE UTC datetime. return self._last_deploy def watch_deploy_events(self, _last_seen_deploy_time=None): events = self._events async def _iter(): for event in events: yield event return _iter() def _patch_galaxy_connect(monkeypatch: pytest.MonkeyPatch, fake: _FakeGalaxyClient) -> None: async def fake_connect(options, **_kwargs): return fake monkeypatch.setattr(commands_module.GalaxyRepositoryClient, "connect", fake_connect) def test_galaxy_test_connection_emits_ok(monkeypatch: pytest.MonkeyPatch) -> None: _patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(ok=True)) result = CliRunner().invoke( main, ["galaxy-test-connection", "--plaintext", "--json"], ) assert result.exit_code == 0, result.output payload = json.loads(result.output) assert payload == {"command": "galaxy-test-connection", "ok": True} def test_galaxy_discover_serializes_objects(monkeypatch: pytest.MonkeyPatch) -> None: from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb objects = [ galaxy_pb.GalaxyObject(gobject_id=7, tag_name="Area001", contained_name="Area001"), galaxy_pb.GalaxyObject(gobject_id=8, tag_name="Pump001", contained_name="Pump001"), ] _patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(objects=objects)) result = CliRunner().invoke( main, ["galaxy-discover", "--plaintext", "--json"], ) assert result.exit_code == 0, result.output payload = json.loads(result.output) assert payload["command"] == "galaxy-discover" assert len(payload["objects"]) == 2 assert payload["objects"][0]["tagName"] == "Area001" assert payload["objects"][1]["gobjectId"] == 8 def test_galaxy_last_deploy_emits_utc_iso(monkeypatch: pytest.MonkeyPatch) -> None: """The naive-UTC deploy time from the library must be emitted as unambiguous UTC ISO-8601.""" from datetime import datetime naive_utc = datetime(2025, 6, 15, 12, 0, 0) # noqa: DTZ001 -- mirrors protobuf ToDatetime() _patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(last_deploy=naive_utc)) result = CliRunner().invoke( main, ["galaxy-last-deploy", "--plaintext", "--json"], ) assert result.exit_code == 0, result.output payload = json.loads(result.output) assert payload["command"] == "galaxy-last-deploy" assert payload["present"] is True assert payload["timeOfLastDeploy"].endswith(("+00:00", "Z")) def test_galaxy_watch_serializes_deploy_events(monkeypatch: pytest.MonkeyPatch) -> None: from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb events = [galaxy_pb.DeployEvent(sequence=1)] _patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(events=events)) result = CliRunner().invoke( main, ["galaxy-watch", "--plaintext", "--max-events", "1", "--json"], ) assert result.exit_code == 0, result.output payload = json.loads(result.output) assert payload["command"] == "galaxy-watch" assert len(payload["events"]) == 1 def test_galaxy_commands_are_registered() -> None: runner = CliRunner() for command in ( "galaxy-test-connection", "galaxy-last-deploy", "galaxy-discover", "galaxy-watch", ): result = runner.invoke(main, [command, "--help"]) assert result.exit_code == 0, result.output assert "--endpoint" in result.output