f5b50c4484
Client.Python-022 README CLI examples for stream-alarms and
acknowledge-alarm now use the correct flags;
regression test parses every documented line through
Click.
Client.Python-023 Re-applied Client.Python-013 — _use_plaintext drops
the silent localhost / 127.0.0.1 auto-downgrade
branch; --plaintext and --tls are mutually exclusive
and TLS is the default.
Client.Python-024 batch dispatch routes through main.main(...,
standalone_mode=False) under a redirected stdout
instead of click.testing.CliRunner; recursive batch
lines are rejected outright.
Client.Python-025 Added behavioural tests for the five bulk SDK methods,
stream_alarms, and the new CLI subcommands.
Client.Python-026 _bench_read_bulk hoists 'import time' to module scope
and logs cleanup failures instead of swallowing them.
All resolved at 2026-05-24; python -m pytest is 65/65 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
790 lines
26 KiB
Python
790 lines
26 KiB
Python
"""Regression tests for Client.Python-022..026.
|
|
|
|
Each test corresponds to a finding from the latest re-review. Tests are
|
|
TDD-first — they failed against the pre-fix source and pass against the
|
|
fixed source.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import time as _time_module_ref
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
from zb_mom_ww_mxgateway import ClientOptions, GatewayClient
|
|
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
|
from zb_mom_ww_mxgateway_cli import commands as cli_commands
|
|
from zb_mom_ww_mxgateway_cli.commands import _use_plaintext, main
|
|
|
|
_BATCH_EOR = "__MXGW_BATCH_EOR__"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client.Python-022 — README CLI examples must parse against the implementation.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _readme_path() -> Path:
|
|
return Path(__file__).resolve().parent.parent / "README.md"
|
|
|
|
|
|
def _extract_mxgw_py_examples() -> list[list[str]]:
|
|
"""Return the README's ``mxgw-py ...`` example lines as click arg lists.
|
|
|
|
Replaces angle-bracket placeholders (``<id>``) with safe stub values and
|
|
leaves real flag names untouched. The returned arg lists drop the
|
|
``mxgw-py`` prefix.
|
|
"""
|
|
|
|
text = _readme_path().read_text(encoding="utf-8")
|
|
args: list[list[str]] = []
|
|
for raw_line in text.splitlines():
|
|
line = raw_line.strip()
|
|
if not line.startswith("mxgw-py "):
|
|
continue
|
|
# Strip the leading "mxgw-py " token.
|
|
body = line[len("mxgw-py ") :]
|
|
# Replace common placeholders so click does not error on the placeholder.
|
|
body = body.replace("<id>", "session-1")
|
|
# Backtick-quoted hostnames in the TLS example are not represented
|
|
# in CLI; safe to leave as-is.
|
|
tokens = _split_cli_tokens(body)
|
|
# Keep only examples that exercise a real subcommand. Skip TLS
|
|
# multi-flag example (we only need the README CLI examples added in
|
|
# commits 8738735 — stream-alarms / acknowledge-alarm).
|
|
args.append(tokens)
|
|
return args
|
|
|
|
|
|
def _split_cli_tokens(body: str) -> list[str]:
|
|
"""Split a CLI body into argv tokens, honouring double-quoted strings."""
|
|
|
|
tokens: list[str] = []
|
|
pattern = re.compile(r'"([^"]*)"|(\S+)')
|
|
for match in pattern.finditer(body):
|
|
quoted, plain = match.group(1), match.group(2)
|
|
tokens.append(quoted if quoted is not None else plain)
|
|
return tokens
|
|
|
|
|
|
def test_readme_alarm_examples_parse_against_cli() -> None:
|
|
"""README `stream-alarms` / `acknowledge-alarm` examples must parse without
|
|
triggering Click's ``no such option`` error.
|
|
|
|
Drives every README ``mxgw-py`` example through Click's ``--help`` style
|
|
parser by re-invoking the documented argv with a trailing ``--help`` flag so
|
|
only the parser runs (no RPC is attempted). If a documented flag does not
|
|
exist on the subcommand, Click prints ``no such option: --<flag>`` and
|
|
exits 2 — that is the regression we want to catch.
|
|
"""
|
|
|
|
runner = CliRunner()
|
|
examples = _extract_mxgw_py_examples()
|
|
assert any(
|
|
"stream-alarms" in args for args in examples
|
|
), "README must include a stream-alarms example."
|
|
assert any(
|
|
"acknowledge-alarm" in args for args in examples
|
|
), "README must include an acknowledge-alarm example."
|
|
|
|
for argv in examples:
|
|
# Strip "--json" (already a real flag) and any value-bearing flag that
|
|
# requires a host/file/value, then append --help so we exercise the
|
|
# parser only.
|
|
# We just append --help — Click parses all options up to --help and
|
|
# then prints help; an unknown option still errors out first.
|
|
result = runner.invoke(main, [*argv, "--help"])
|
|
# Either help text printed (exit 0) or some other parser issue (exit 2);
|
|
# we only want to assert NO "no such option" error.
|
|
assert "no such option" not in result.output.lower(), (
|
|
f"README example failed Click parsing: argv={argv!r}\n"
|
|
f"output={result.output!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client.Python-023 — REGRESSION of Client.Python-013. _use_plaintext must
|
|
# not silently auto-downgrade on localhost / 127.0.0.1.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_use_plaintext_does_not_auto_downgrade_for_localhost_endpoint() -> None:
|
|
"""A bare ``localhost:...`` endpoint with no flags must default to TLS."""
|
|
|
|
assert _use_plaintext({
|
|
"endpoint": "localhost:5001",
|
|
"plaintext": False,
|
|
"use_tls": False,
|
|
}) is False
|
|
|
|
|
|
def test_use_plaintext_does_not_auto_downgrade_for_loopback_ipv4_endpoint() -> None:
|
|
"""A bare ``127.0.0.1:...`` endpoint with no flags must default to TLS."""
|
|
|
|
assert _use_plaintext({
|
|
"endpoint": "127.0.0.1:5001",
|
|
"plaintext": False,
|
|
"use_tls": False,
|
|
}) is False
|
|
|
|
|
|
def test_use_plaintext_requires_explicit_plaintext_flag() -> None:
|
|
"""``--plaintext`` is the only way to opt in."""
|
|
|
|
assert _use_plaintext({
|
|
"endpoint": "localhost:5001",
|
|
"plaintext": True,
|
|
"use_tls": False,
|
|
}) is True
|
|
|
|
|
|
def test_use_plaintext_tls_flag_explicitly_disables_plaintext() -> None:
|
|
"""``--tls`` is accepted as an explicit affirmation of the default."""
|
|
|
|
assert _use_plaintext({
|
|
"endpoint": "localhost:5001",
|
|
"plaintext": False,
|
|
"use_tls": True,
|
|
}) is False
|
|
|
|
|
|
def test_use_plaintext_rejects_plaintext_and_tls_combined() -> None:
|
|
"""``--plaintext`` and ``--tls`` together must be rejected as ambiguous."""
|
|
|
|
import click as _click
|
|
|
|
with pytest.raises(_click.UsageError):
|
|
_use_plaintext({
|
|
"endpoint": "localhost:5001",
|
|
"plaintext": True,
|
|
"use_tls": True,
|
|
})
|
|
|
|
|
|
def test_cli_localhost_endpoint_with_no_flags_uses_tls_channel(monkeypatch) -> None:
|
|
"""End-to-end CLI: against ``localhost:...`` with no flags, the resolved
|
|
``ClientOptions.plaintext`` flowing into ``GatewayClient.connect`` must be
|
|
``False`` (TLS), so the API key bearer cannot leak over plaintext.
|
|
"""
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
class _FakeStub:
|
|
def __init__(self) -> None:
|
|
pass
|
|
|
|
async def OpenSession(self, request: Any, *, metadata: tuple[Any, ...]) -> Any:
|
|
captured["metadata"] = metadata
|
|
return pb.OpenSessionReply(
|
|
session_id="session-1",
|
|
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
|
)
|
|
|
|
real_connect = GatewayClient.connect
|
|
|
|
@classmethod
|
|
async def _spy_connect(cls, options: ClientOptions, **kwargs: Any) -> GatewayClient:
|
|
captured["options"] = options
|
|
return await real_connect(options, stub=_FakeStub())
|
|
|
|
monkeypatch.setattr(GatewayClient, "connect", _spy_connect)
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
main,
|
|
[
|
|
"open-session",
|
|
"--endpoint",
|
|
"localhost:5000",
|
|
"--api-key",
|
|
"mxgw_test_secret",
|
|
"--json",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "options" in captured
|
|
assert captured["options"].plaintext is False, (
|
|
"localhost endpoint without --plaintext must NOT auto-downgrade to plaintext"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client.Python-024 — `batch` must not use CliRunner from production code,
|
|
# and a recursive `batch` line must not silently re-enter.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_batch_command_does_not_use_clirunner_in_production() -> None:
|
|
"""`commands.py` must not import or instantiate the test-only CliRunner helper.
|
|
|
|
Docstring references explaining what the module deliberately avoids are
|
|
permitted; what is forbidden is an actual ``import`` of ``click.testing``
|
|
or an actual ``CliRunner()`` instantiation in executable code.
|
|
"""
|
|
|
|
source = Path(cli_commands.__file__).read_text(encoding="utf-8")
|
|
assert "from click.testing" not in source, (
|
|
"click.testing is a test-only helper and must not be used by production code"
|
|
)
|
|
assert "import click.testing" not in source, (
|
|
"click.testing is a test-only helper and must not be used by production code"
|
|
)
|
|
# `CliRunner()` (instantiation) must not appear in production code.
|
|
assert "CliRunner(" not in source, (
|
|
"CliRunner() must not be instantiated in production code"
|
|
)
|
|
|
|
|
|
def test_batch_recursive_batch_line_is_bounded() -> None:
|
|
"""A `batch` line nested inside `batch` stdin must not be silently spawned.
|
|
|
|
The pre-fix implementation re-invoked the test runner with empty stdin,
|
|
so `batch` inside `batch` exited cleanly with no error. The fix either
|
|
rejects the nested invocation or surfaces it as an error block so the
|
|
behaviour is auditable.
|
|
"""
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
main,
|
|
["batch"],
|
|
input="batch\nversion --json\n",
|
|
)
|
|
|
|
# Outer batch must still exit 0 and process both lines.
|
|
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]
|
|
# The first block — the recursive `batch` line — must surface an error
|
|
# JSON. (Either an explicit rejection, or some non-empty error block —
|
|
# NOT a silently empty block.)
|
|
first_block = blocks[0].strip()
|
|
assert first_block, "recursive batch line must not be silently swallowed"
|
|
payload = json.loads(first_block.splitlines()[-1])
|
|
assert "error" in payload, (
|
|
f"recursive batch line should surface an error: got {payload!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client.Python-025 — Behavioural tests for new bulk SDK methods,
|
|
# stream_alarms, and the new CLI subcommands.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _AlarmFakeStream:
|
|
def __init__(self, messages: list[pb.AlarmFeedMessage]) -> None:
|
|
self._messages = list(messages)
|
|
self.cancelled = False
|
|
|
|
def __aiter__(self) -> "_AlarmFakeStream":
|
|
return self
|
|
|
|
async def __anext__(self) -> pb.AlarmFeedMessage:
|
|
if not self._messages:
|
|
raise StopAsyncIteration
|
|
return self._messages.pop(0)
|
|
|
|
def cancel(self) -> None:
|
|
self.cancelled = True
|
|
|
|
|
|
class _BulkFakeUnary:
|
|
def __init__(self, replies: list[Any]) -> None:
|
|
self.replies = replies
|
|
self.requests: list[Any] = []
|
|
self.metadata: tuple[tuple[str, str], ...] | None = None
|
|
|
|
async def __call__(self, request: Any, *, metadata: tuple[tuple[str, str], ...]) -> Any:
|
|
self.requests.append(request)
|
|
self.metadata = metadata
|
|
return self.replies.pop(0)
|
|
|
|
|
|
class _BulkFakeStub:
|
|
def __init__(self) -> None:
|
|
self.open_session = _BulkFakeUnary(
|
|
[
|
|
pb.OpenSessionReply(
|
|
session_id="session-1",
|
|
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
|
),
|
|
],
|
|
)
|
|
self.invoke = _BulkFakeUnary([])
|
|
self.OpenSession = self.open_session
|
|
self.Invoke = self.invoke
|
|
self.stream_alarms_metadata: tuple[tuple[str, str], ...] | None = None
|
|
self._alarm_stream = _AlarmFakeStream([])
|
|
|
|
def set_invoke_replies(self, replies: list[Any]) -> None:
|
|
self.invoke.replies = replies
|
|
|
|
def set_alarm_stream(self, stream: _AlarmFakeStream) -> None:
|
|
self._alarm_stream = stream
|
|
|
|
def StreamAlarms(self, request: Any, *, metadata: tuple[tuple[str, str], ...]) -> Any:
|
|
self.stream_alarms_request = request
|
|
self.stream_alarms_metadata = metadata
|
|
return self._alarm_stream
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_read_bulk_sends_expected_request_shape() -> None:
|
|
stub = _BulkFakeStub()
|
|
stub.set_invoke_replies(
|
|
[
|
|
pb.MxCommandReply(
|
|
session_id="session-1",
|
|
kind=pb.MX_COMMAND_KIND_READ_BULK,
|
|
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
|
read_bulk=pb.BulkReadReply(
|
|
results=[
|
|
pb.BulkReadResult(
|
|
tag_address="Tank01.Level",
|
|
was_successful=True,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
)
|
|
client = await GatewayClient.connect(
|
|
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
|
stub=stub,
|
|
)
|
|
session = await client.open_session()
|
|
|
|
results = await session.read_bulk(12, ["Tank01.Level"], timeout_ms=1500)
|
|
|
|
assert len(results) == 1
|
|
assert results[0].tag_address == "Tank01.Level"
|
|
request = stub.invoke.requests[0]
|
|
assert request.command.kind == pb.MX_COMMAND_KIND_READ_BULK
|
|
assert request.command.read_bulk.server_handle == 12
|
|
assert list(request.command.read_bulk.tag_addresses) == ["Tank01.Level"]
|
|
assert request.command.read_bulk.timeout_ms == 1500
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_write_bulk_sends_expected_request_shape() -> None:
|
|
stub = _BulkFakeStub()
|
|
stub.set_invoke_replies(
|
|
[
|
|
pb.MxCommandReply(
|
|
session_id="session-1",
|
|
kind=pb.MX_COMMAND_KIND_WRITE_BULK,
|
|
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
|
write_bulk=pb.BulkWriteReply(
|
|
results=[pb.BulkWriteResult(was_successful=True)],
|
|
),
|
|
),
|
|
],
|
|
)
|
|
client = await GatewayClient.connect(
|
|
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
|
stub=stub,
|
|
)
|
|
session = await client.open_session()
|
|
|
|
from zb_mom_ww_mxgateway.values import to_mx_value
|
|
|
|
entries = [
|
|
pb.WriteBulkEntry(item_handle=34, user_id=99, value=to_mx_value(123)),
|
|
]
|
|
results = await session.write_bulk(12, entries)
|
|
|
|
assert results[0].was_successful is True
|
|
cmd = stub.invoke.requests[0].command
|
|
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_BULK
|
|
assert cmd.write_bulk.server_handle == 12
|
|
assert cmd.write_bulk.entries[0].item_handle == 34
|
|
assert cmd.write_bulk.entries[0].user_id == 99
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_write2_bulk_sends_expected_request_shape() -> None:
|
|
stub = _BulkFakeStub()
|
|
stub.set_invoke_replies(
|
|
[
|
|
pb.MxCommandReply(
|
|
session_id="session-1",
|
|
kind=pb.MX_COMMAND_KIND_WRITE2_BULK,
|
|
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
|
write2_bulk=pb.BulkWriteReply(
|
|
results=[pb.BulkWriteResult(was_successful=True)],
|
|
),
|
|
),
|
|
],
|
|
)
|
|
client = await GatewayClient.connect(
|
|
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
|
stub=stub,
|
|
)
|
|
session = await client.open_session()
|
|
|
|
from zb_mom_ww_mxgateway.values import to_mx_value
|
|
|
|
entries = [
|
|
pb.Write2BulkEntry(
|
|
item_handle=34,
|
|
user_id=99,
|
|
value=to_mx_value(123),
|
|
timestamp_value=to_mx_value(1.5),
|
|
),
|
|
]
|
|
results = await session.write2_bulk(12, entries)
|
|
|
|
assert results[0].was_successful is True
|
|
cmd = stub.invoke.requests[0].command
|
|
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE2_BULK
|
|
assert cmd.write2_bulk.server_handle == 12
|
|
assert cmd.write2_bulk.entries[0].item_handle == 34
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_write_secured_bulk_sends_expected_request_shape() -> None:
|
|
stub = _BulkFakeStub()
|
|
stub.set_invoke_replies(
|
|
[
|
|
pb.MxCommandReply(
|
|
session_id="session-1",
|
|
kind=pb.MX_COMMAND_KIND_WRITE_SECURED_BULK,
|
|
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
|
write_secured_bulk=pb.BulkWriteReply(
|
|
results=[pb.BulkWriteResult(was_successful=True)],
|
|
),
|
|
),
|
|
],
|
|
)
|
|
client = await GatewayClient.connect(
|
|
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
|
stub=stub,
|
|
)
|
|
session = await client.open_session()
|
|
|
|
from zb_mom_ww_mxgateway.values import to_mx_value
|
|
|
|
entries = [
|
|
pb.WriteSecuredBulkEntry(
|
|
item_handle=34,
|
|
current_user_id=42,
|
|
verifier_user_id=43,
|
|
value=to_mx_value("secret"),
|
|
),
|
|
]
|
|
results = await session.write_secured_bulk(12, entries)
|
|
|
|
assert results[0].was_successful is True
|
|
cmd = stub.invoke.requests[0].command
|
|
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_SECURED_BULK
|
|
assert cmd.write_secured_bulk.server_handle == 12
|
|
assert cmd.write_secured_bulk.entries[0].current_user_id == 42
|
|
assert cmd.write_secured_bulk.entries[0].verifier_user_id == 43
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_write_secured2_bulk_sends_expected_request_shape() -> None:
|
|
stub = _BulkFakeStub()
|
|
stub.set_invoke_replies(
|
|
[
|
|
pb.MxCommandReply(
|
|
session_id="session-1",
|
|
kind=pb.MX_COMMAND_KIND_WRITE_SECURED2_BULK,
|
|
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
|
write_secured2_bulk=pb.BulkWriteReply(
|
|
results=[pb.BulkWriteResult(was_successful=True)],
|
|
),
|
|
),
|
|
],
|
|
)
|
|
client = await GatewayClient.connect(
|
|
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
|
stub=stub,
|
|
)
|
|
session = await client.open_session()
|
|
|
|
from zb_mom_ww_mxgateway.values import to_mx_value
|
|
|
|
entries = [
|
|
pb.WriteSecured2BulkEntry(
|
|
item_handle=34,
|
|
current_user_id=42,
|
|
verifier_user_id=43,
|
|
value=to_mx_value("secret"),
|
|
timestamp_value=to_mx_value(1.5),
|
|
),
|
|
]
|
|
results = await session.write_secured2_bulk(12, entries)
|
|
|
|
assert results[0].was_successful is True
|
|
cmd = stub.invoke.requests[0].command
|
|
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_SECURED2_BULK
|
|
assert cmd.write_secured2_bulk.entries[0].current_user_id == 42
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_alarms_yields_feed_messages_and_cancels_on_close() -> None:
|
|
transitions = [
|
|
pb.AlarmFeedMessage(
|
|
transition=pb.OnAlarmTransitionEvent(
|
|
alarm_full_reference="Tank01.Level.HiHi",
|
|
transition_kind=pb.ALARM_TRANSITION_KIND_RAISE,
|
|
),
|
|
),
|
|
]
|
|
stream = _AlarmFakeStream(transitions)
|
|
stub = _BulkFakeStub()
|
|
stub.set_alarm_stream(stream)
|
|
client = await GatewayClient.connect(
|
|
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
|
stub=stub,
|
|
)
|
|
|
|
iterator = client.stream_alarms(pb.StreamAlarmsRequest(alarm_filter_prefix="Tank01."))
|
|
first = await anext(iterator)
|
|
await iterator.aclose()
|
|
|
|
assert first.transition.alarm_full_reference == "Tank01.Level.HiHi"
|
|
assert stream.cancelled
|
|
assert stub.stream_alarms_metadata == (("authorization", "Bearer mxgw_test_secret"),)
|
|
assert stub.stream_alarms_request.alarm_filter_prefix == "Tank01."
|
|
|
|
|
|
# ---- CLI happy-path coverage for the new subcommands ----
|
|
|
|
|
|
def _install_fake_connect(monkeypatch, stub: Any) -> dict[str, Any]:
|
|
"""Patch `GatewayClient.connect` so the CLI uses the supplied fake stub."""
|
|
|
|
captured: dict[str, Any] = {}
|
|
real_connect = GatewayClient.connect
|
|
|
|
@classmethod
|
|
async def _spy_connect(cls, options: ClientOptions, **kwargs: Any) -> GatewayClient:
|
|
captured["options"] = options
|
|
return await real_connect(options, stub=stub)
|
|
|
|
monkeypatch.setattr(GatewayClient, "connect", _spy_connect)
|
|
return captured
|
|
|
|
|
|
def test_cli_read_bulk_happy_path(monkeypatch) -> None:
|
|
stub = _BulkFakeStub()
|
|
stub.set_invoke_replies(
|
|
[
|
|
pb.MxCommandReply(
|
|
session_id="session-1",
|
|
kind=pb.MX_COMMAND_KIND_READ_BULK,
|
|
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
|
read_bulk=pb.BulkReadReply(
|
|
results=[
|
|
pb.BulkReadResult(
|
|
tag_address="Tank01.Level",
|
|
was_successful=True,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
)
|
|
_install_fake_connect(monkeypatch, stub)
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
main,
|
|
[
|
|
"read-bulk",
|
|
"--endpoint",
|
|
"localhost:5000",
|
|
"--plaintext",
|
|
"--session-id",
|
|
"session-1",
|
|
"--server-handle",
|
|
"12",
|
|
"--items",
|
|
"Tank01.Level",
|
|
"--timeout-ms",
|
|
"1500",
|
|
"--json",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
payload = json.loads(result.output)
|
|
assert payload["results"][0]["tagAddress"] == "Tank01.Level"
|
|
|
|
|
|
def test_cli_write_bulk_happy_path(monkeypatch) -> None:
|
|
stub = _BulkFakeStub()
|
|
stub.set_invoke_replies(
|
|
[
|
|
pb.MxCommandReply(
|
|
session_id="session-1",
|
|
kind=pb.MX_COMMAND_KIND_WRITE_BULK,
|
|
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
|
write_bulk=pb.BulkWriteReply(
|
|
results=[pb.BulkWriteResult(was_successful=True)],
|
|
),
|
|
),
|
|
],
|
|
)
|
|
_install_fake_connect(monkeypatch, stub)
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
main,
|
|
[
|
|
"write-bulk",
|
|
"--endpoint",
|
|
"localhost:5000",
|
|
"--plaintext",
|
|
"--session-id",
|
|
"session-1",
|
|
"--server-handle",
|
|
"12",
|
|
"--item-handles",
|
|
"34",
|
|
"--values",
|
|
"123",
|
|
"--type",
|
|
"int32",
|
|
"--json",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
payload = json.loads(result.output)
|
|
assert payload["results"][0]["wasSuccessful"] is True
|
|
cmd = stub.invoke.requests[0].command
|
|
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_BULK
|
|
|
|
|
|
def test_cli_stream_alarms_happy_path(monkeypatch) -> None:
|
|
transitions = [
|
|
pb.AlarmFeedMessage(
|
|
transition=pb.OnAlarmTransitionEvent(
|
|
alarm_full_reference="Tank01.Level.HiHi",
|
|
transition_kind=pb.ALARM_TRANSITION_KIND_RAISE,
|
|
),
|
|
),
|
|
]
|
|
stream = _AlarmFakeStream(transitions)
|
|
stub = _BulkFakeStub()
|
|
stub.set_alarm_stream(stream)
|
|
_install_fake_connect(monkeypatch, stub)
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
main,
|
|
[
|
|
"stream-alarms",
|
|
"--endpoint",
|
|
"localhost:5000",
|
|
"--plaintext",
|
|
"--max-messages",
|
|
"1",
|
|
"--timeout",
|
|
"5.0",
|
|
"--filter-prefix",
|
|
"Tank01.",
|
|
"--json",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
payload = json.loads(result.output)
|
|
assert payload["messages"][0]["transition"]["alarmFullReference"] == "Tank01.Level.HiHi"
|
|
|
|
|
|
def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
|
|
stub = _BulkFakeStub()
|
|
stub.acknowledge_alarm = _BulkFakeUnary(
|
|
[
|
|
pb.AcknowledgeAlarmReply(
|
|
correlation_id="corr-1",
|
|
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
|
status=pb.MxStatusProxy(success=1, category=pb.MX_STATUS_CATEGORY_OK),
|
|
),
|
|
],
|
|
)
|
|
stub.AcknowledgeAlarm = stub.acknowledge_alarm
|
|
_install_fake_connect(monkeypatch, stub)
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
main,
|
|
[
|
|
"acknowledge-alarm",
|
|
"--endpoint",
|
|
"localhost:5000",
|
|
"--plaintext",
|
|
"--reference",
|
|
"Tank01.Level.HiHi",
|
|
"--comment",
|
|
"investigating",
|
|
"--operator",
|
|
"alice",
|
|
"--json",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
captured_request = stub.acknowledge_alarm.requests[0]
|
|
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
|
|
assert captured_request.comment == "investigating"
|
|
assert captured_request.operator_user == "alice"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client.Python-026 — `import time` at module scope; tighter cleanup excepts.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_commands_module_imports_time_at_module_scope() -> None:
|
|
"""`time` must be imported at module scope, not inside `_bench_read_bulk`.
|
|
|
|
`inspect.getsource(_bench_read_bulk)` must not contain a function-local
|
|
``import time`` statement.
|
|
"""
|
|
|
|
import inspect
|
|
|
|
source = inspect.getsource(cli_commands._bench_read_bulk)
|
|
# The function body must NOT contain a function-local `import time` line.
|
|
for line in source.splitlines():
|
|
stripped = line.strip()
|
|
assert stripped != "import time", (
|
|
f"_bench_read_bulk must not have function-local `import time`: {line!r}"
|
|
)
|
|
|
|
# And the module-level `time` attribute must be present.
|
|
assert hasattr(cli_commands, "time"), (
|
|
"`time` must be imported at module scope on commands.py"
|
|
)
|
|
assert cli_commands.time is _time_module_ref
|
|
|
|
|
|
def test_commands_module_bench_read_bulk_does_not_use_bare_except_pass() -> None:
|
|
"""The two `except Exception: pass` cleanup blocks in `_bench_read_bulk`
|
|
must be removed in favour of either logging or a narrower exception class.
|
|
"""
|
|
|
|
import inspect
|
|
|
|
source = inspect.getsource(cli_commands._bench_read_bulk)
|
|
# Reject the bare `except Exception:` followed by `pass` pattern in
|
|
# `_bench_read_bulk`. We tolerate `except Exception as <name>:` because the
|
|
# fix logs the exception.
|
|
pattern = re.compile(r"except\s+Exception\s*:\s*\n\s*pass\b")
|
|
assert not pattern.search(source), (
|
|
"_bench_read_bulk cleanup blocks must log or narrow the except clause"
|
|
)
|