"""Tests for the async client and session wrappers.""" from __future__ import annotations import asyncio from typing import Any import pytest from zb_mom_ww_mxgateway import ClientOptions, GatewayClient, MxAccessError from zb_mom_ww_mxgateway import client as client_module from zb_mom_ww_mxgateway import galaxy as galaxy_module from zb_mom_ww_mxgateway.galaxy import GalaxyRepositoryClient from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb from zb_mom_ww_mxgateway.options import ChannelSecurity @pytest.mark.asyncio async def test_gateway_connect_forwards_require_certificate_validation( monkeypatch: pytest.MonkeyPatch, ) -> None: """The connect convenience kwarg must reach ClientOptions (Client.Python-027).""" captured: dict[str, Any] = {} def fake_resolve(options: ClientOptions) -> ChannelSecurity: captured["options"] = options return ChannelSecurity() monkeypatch.setattr(client_module, "resolve_channel_security", fake_resolve) monkeypatch.setattr(client_module, "create_channel", _stub_create_channel) monkeypatch.setattr(client_module.pb_grpc, "MxAccessGatewayStub", lambda channel: object()) await GatewayClient.connect( endpoint="gateway.example:5001", require_certificate_validation=True, ) assert captured["options"].require_certificate_validation is True @pytest.mark.asyncio async def test_galaxy_connect_forwards_require_certificate_validation( monkeypatch: pytest.MonkeyPatch, ) -> None: """GalaxyRepositoryClient.connect must thread the flag too (Client.Python-027).""" captured: dict[str, Any] = {} def fake_resolve(options: ClientOptions) -> ChannelSecurity: captured["options"] = options return ChannelSecurity() monkeypatch.setattr(galaxy_module, "resolve_channel_security", fake_resolve) monkeypatch.setattr(galaxy_module, "create_channel", _stub_create_channel) monkeypatch.setattr( galaxy_module.galaxy_pb_grpc, "GalaxyRepositoryStub", lambda channel: object() ) await GalaxyRepositoryClient.connect( endpoint="gateway.example:5001", require_certificate_validation=True, ) assert captured["options"].require_certificate_validation is True @pytest.mark.asyncio async def test_gateway_connect_splits_probe_off_loop_and_channel_on_loop( monkeypatch: pytest.MonkeyPatch, ) -> None: """The blocking probe runs off the loop; the channel is built on it. Client.Python-028 required the blocking TOFU probe off the event loop. The channel itself must nonetheless be constructed *on* the loop thread: a ``grpc.aio`` channel binds to the loop current on the constructing thread, and a ``to_thread`` worker has none, so building it off-loop raises ``RuntimeError: There is no current event loop``. Assert both halves. """ where = _record_connect_threads(monkeypatch, client_module) monkeypatch.setattr(client_module.pb_grpc, "MxAccessGatewayStub", lambda channel: object()) await GatewayClient.connect(endpoint="gateway.example:5001") assert where == {"resolve_off_loop": True, "create_on_loop": True} @pytest.mark.asyncio async def test_galaxy_connect_splits_probe_off_loop_and_channel_on_loop( monkeypatch: pytest.MonkeyPatch, ) -> None: """GalaxyRepositoryClient.connect splits the probe and the channel the same way.""" where = _record_connect_threads(monkeypatch, galaxy_module) monkeypatch.setattr( galaxy_module.galaxy_pb_grpc, "GalaxyRepositoryStub", lambda channel: object() ) await GalaxyRepositoryClient.connect(endpoint="gateway.example:5001") assert where == {"resolve_off_loop": True, "create_on_loop": True} def _stub_create_channel(options: ClientOptions, *, security: ChannelSecurity) -> object: return object() def _on_event_loop_thread() -> bool: try: asyncio.get_running_loop() except RuntimeError: return False return True def _record_connect_threads(monkeypatch: pytest.MonkeyPatch, module: Any) -> dict[str, bool]: """Patch *module*'s channel helpers to record which thread each ran on.""" where: dict[str, bool] = {} def fake_resolve(options: ClientOptions) -> ChannelSecurity: where["resolve_off_loop"] = not _on_event_loop_thread() return ChannelSecurity() def fake_create_channel(options: ClientOptions, *, security: ChannelSecurity) -> object: where["create_on_loop"] = _on_event_loop_thread() return object() monkeypatch.setattr(module, "resolve_channel_security", fake_resolve) monkeypatch.setattr(module, "create_channel", fake_create_channel) return where @pytest.mark.asyncio async def test_session_helpers_send_auth_metadata_and_preserve_raw_replies() -> None: stub = FakeGatewayStub() client = await GatewayClient.connect( ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True), stub=stub, ) session = await client.open_session(client_session_name="pytest") server_handle = await session.register("pytest-client") item_handle = await session.add_item(server_handle, "Object.Attribute") await session.advise(server_handle, item_handle) assert session.session_id == "session-1" assert server_handle == 12 assert item_handle == 34 assert stub.open_session.metadata == (("authorization", "Bearer mxgw_test_secret"),) assert stub.invoke.requests[0].command.register.client_name == "pytest-client" assert stub.invoke.requests[1].command.add_item.item_definition == "Object.Attribute" assert stub.invoke.requests[2].command.advise.item_handle == 34 @pytest.mark.asyncio async def test_mxaccess_error_preserves_raw_reply() -> None: stub = FakeGatewayStub() failure_reply = pb.MxCommandReply( session_id="session-1", kind=pb.MX_COMMAND_KIND_WRITE, protocol_status=pb.ProtocolStatus( code=pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE, message="MXAccess rejected write.", ), hresult=-1, ) stub.invoke.replies = [failure_reply] client = await GatewayClient.connect( ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True), stub=stub, ) session = await client.open_session() with pytest.raises(MxAccessError) as captured: await session.write(12, 34, 123) assert captured.value.raw_reply is failure_reply @pytest.mark.asyncio async def test_subscribe_bulk_sends_one_bulk_command_and_returns_results() -> None: stub = FakeGatewayStub() bulk_reply = pb.MxCommandReply( session_id="session-1", kind=pb.MX_COMMAND_KIND_SUBSCRIBE_BULK, protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK), subscribe_bulk=pb.BulkSubscribeReply( results=[ pb.SubscribeResult( server_handle=12, tag_address="Area001.Pump001.Speed", item_handle=34, was_successful=True, ), ], ), ) stub.invoke.replies = [bulk_reply] client = await GatewayClient.connect( ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True), stub=stub, ) session = await client.open_session() results = await session.subscribe_bulk(12, ["Area001.Pump001.Speed"]) assert results[0].item_handle == 34 assert len(stub.invoke.requests) == 1 assert stub.invoke.requests[0].command.kind == pb.MX_COMMAND_KIND_SUBSCRIBE_BULK assert list(stub.invoke.requests[0].command.subscribe_bulk.tag_addresses) == [ "Area001.Pump001.Speed", ] @pytest.mark.asyncio async def test_stream_events_cancels_underlying_call_when_closed() -> None: stream = FakeStream( [ pb.MxEvent( session_id="session-1", worker_sequence=1, family=pb.MX_EVENT_FAMILY_ON_DATA_CHANGE, ), ], ) stub = FakeGatewayStub(stream=stream) client = await GatewayClient.connect( ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True), stub=stub, ) session = await client.open_session() events = session.stream_events() first = await anext(events) await events.aclose() assert first.worker_sequence == 1 assert stream.cancelled assert stub.stream_metadata == (("authorization", "Bearer mxgw_test_secret"),) @pytest.mark.asyncio async def test_unary_task_cancellation_reaches_fake_call() -> None: blocking = BlockingCancellableUnary() stub = FakeGatewayStub() stub.OpenSession = blocking client = await GatewayClient.connect( ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True), stub=stub, ) task = asyncio.create_task(client.open_session()) await blocking.started.wait() task.cancel() with pytest.raises(asyncio.CancelledError): await task assert blocking.call is not None assert blocking.call.cancelled class FakeGatewayStub: def __init__(self, stream: "FakeStream | None" = None) -> None: self.open_session = FakeUnary( [ pb.OpenSessionReply( session_id="session-1", protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK), ), ], ) self.close_session = FakeUnary( [ pb.CloseSessionReply( session_id="session-1", protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK), ), ], ) self.invoke = FakeUnary( [ pb.MxCommandReply( session_id="session-1", kind=pb.MX_COMMAND_KIND_REGISTER, protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK), register=pb.RegisterReply(server_handle=12), ), pb.MxCommandReply( session_id="session-1", kind=pb.MX_COMMAND_KIND_ADD_ITEM, protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK), add_item=pb.AddItemReply(item_handle=34), ), pb.MxCommandReply( session_id="session-1", kind=pb.MX_COMMAND_KIND_ADVISE, protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK), ), ], ) self.OpenSession = self.open_session self.CloseSession = self.close_session self.Invoke = self.invoke self._stream = stream or FakeStream([]) self.stream_metadata: tuple[tuple[str, str], ...] | None = None def StreamEvents( self, request: pb.StreamEventsRequest, *, metadata: tuple[tuple[str, str], ...], ) -> "FakeStream": self.stream_request = request self.stream_metadata = metadata return self._stream class FakeUnary: 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 BlockingCancellableUnary: def __init__(self) -> None: self.started = asyncio.Event() self.call: BlockingCall | None = None def __call__(self, *_args: Any, **_kwargs: Any) -> "BlockingCall": self.call = BlockingCall(self.started) return self.call class BlockingCall: def __init__(self, started: asyncio.Event) -> None: self.started = started self.cancelled = False def __await__(self): return self._wait().__await__() async def _wait(self) -> Any: self.started.set() try: await asyncio.Event().wait() except asyncio.CancelledError: raise def cancel(self) -> None: self.cancelled = True class FakeStream: def __init__(self, events: list[pb.MxEvent]) -> None: self._events = events self.cancelled = False def __aiter__(self) -> "FakeStream": return self async def __anext__(self) -> pb.MxEvent: if not self._events: await asyncio.sleep(3600) return self._events.pop(0) def cancel(self) -> None: self.cancelled = True