494 lines
15 KiB
Python
494 lines
15 KiB
Python
"""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,
|
|
browse_roots=None,
|
|
) -> None:
|
|
self._ok = ok
|
|
self._objects = objects or []
|
|
self._last_deploy = last_deploy
|
|
self._events = events or []
|
|
self._browse_roots = browse_roots or []
|
|
self.browse_options = None
|
|
|
|
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 browse(self, options=None):
|
|
self.browse_options = options
|
|
return self._browse_roots
|
|
|
|
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
|
|
|
|
|
|
class _FakeBrowseNode:
|
|
"""Minimal stand-in for LazyBrowseNode covering the CLI render path."""
|
|
|
|
def __init__(self, obj, *, has_children_hint=False, children=None) -> None:
|
|
self._object = obj
|
|
self._has_children_hint = has_children_hint
|
|
self._children = list(children or [])
|
|
self._is_expanded = bool(children)
|
|
self.expand_calls = 0
|
|
|
|
@property
|
|
def object(self):
|
|
return self._object
|
|
|
|
@property
|
|
def has_children_hint(self) -> bool:
|
|
return self._has_children_hint
|
|
|
|
@property
|
|
def children(self):
|
|
return list(self._children)
|
|
|
|
@property
|
|
def is_expanded(self) -> bool:
|
|
return self._is_expanded
|
|
|
|
async def expand(self) -> None:
|
|
self.expand_calls += 1
|
|
self._is_expanded = True
|
|
|
|
|
|
def test_galaxy_browse_serializes_nested_nodes(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
|
|
|
|
child = _FakeBrowseNode(
|
|
galaxy_pb.GalaxyObject(gobject_id=8, tag_name="Pump001", contained_name="Pump001"),
|
|
has_children_hint=False,
|
|
)
|
|
root = _FakeBrowseNode(
|
|
galaxy_pb.GalaxyObject(gobject_id=7, tag_name="Area001", contained_name="Area001"),
|
|
has_children_hint=True,
|
|
children=[child],
|
|
)
|
|
_patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(browse_roots=[root]))
|
|
|
|
result = CliRunner().invoke(
|
|
main,
|
|
["galaxy-browse", "--plaintext", "--json"],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
payload = json.loads(result.output)
|
|
assert payload["command"] == "galaxy-browse"
|
|
assert len(payload["nodes"]) == 1
|
|
node = payload["nodes"][0]
|
|
assert node["tagName"] == "Area001"
|
|
assert node["hasChildrenHint"] is True
|
|
assert len(node["children"]) == 1
|
|
assert node["children"][0]["gobjectId"] == 8
|
|
assert node["children"][0]["children"] == []
|
|
|
|
|
|
def test_galaxy_browse_renders_indented_text_tree(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
|
|
|
|
child = _FakeBrowseNode(
|
|
galaxy_pb.GalaxyObject(gobject_id=8, tag_name="Pump001", browse_name="Pump001"),
|
|
)
|
|
root = _FakeBrowseNode(
|
|
galaxy_pb.GalaxyObject(gobject_id=7, tag_name="Area001", browse_name="Area001"),
|
|
has_children_hint=True,
|
|
children=[child],
|
|
)
|
|
_patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(browse_roots=[root]))
|
|
|
|
result = CliRunner().invoke(
|
|
main,
|
|
["galaxy-browse", "--plaintext"],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
lines = result.output.splitlines()
|
|
assert lines[0] == "1"
|
|
assert lines[1] == "+ Area001 Area001 (gobject 7)"
|
|
assert lines[2] == " - Pump001 Pump001 (gobject 8)"
|
|
|
|
|
|
def test_galaxy_browse_forwards_filter_options(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = _FakeGalaxyClient(browse_roots=[])
|
|
_patch_galaxy_connect(monkeypatch, fake)
|
|
|
|
result = CliRunner().invoke(
|
|
main,
|
|
[
|
|
"galaxy-browse",
|
|
"--plaintext",
|
|
"--category-id",
|
|
"10",
|
|
"--category-id",
|
|
"12",
|
|
"--template-chain-contains",
|
|
"$Pump",
|
|
"--tag-name-glob",
|
|
"Area*",
|
|
"--include-attributes",
|
|
"--alarm-bearing-only",
|
|
"--historized-only",
|
|
"--json",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
options = fake.browse_options
|
|
assert tuple(options.category_ids) == (10, 12)
|
|
assert tuple(options.template_chain_contains) == ("$Pump",)
|
|
assert options.tag_name_glob == "Area*"
|
|
assert options.include_attributes is True
|
|
assert options.alarm_bearing_only is True
|
|
assert options.historized_only is True
|
|
|
|
|
|
def test_galaxy_browse_expands_to_depth(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
|
|
|
|
root = _FakeBrowseNode(
|
|
galaxy_pb.GalaxyObject(gobject_id=7, tag_name="Area001"),
|
|
has_children_hint=True,
|
|
)
|
|
_patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(browse_roots=[root]))
|
|
|
|
result = CliRunner().invoke(
|
|
main,
|
|
["galaxy-browse", "--plaintext", "--depth", "2", "--json"],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert root.expand_calls == 1
|
|
|
|
|
|
def test_galaxy_commands_are_registered() -> None:
|
|
runner = CliRunner()
|
|
for command in (
|
|
"galaxy-test-connection",
|
|
"galaxy-last-deploy",
|
|
"galaxy-discover",
|
|
"galaxy-watch",
|
|
"galaxy-browse",
|
|
):
|
|
result = runner.invoke(main, [command, "--help"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "--endpoint" in result.output
|