"""The outbound nodes reuse one connection instead of opening one per message.""" import asyncio from fluksio.flow.messages import DType, MessageSpec from fluksio.flow.nodes import MqttNode from fluksio.flow.nodes.http import close_shared_client, shared_client def test_http_senders_share_one_pooled_client(): first = shared_client() try: assert shared_client() is first finally: close_shared_client() # Closing lets the next request build a fresh one rather than reusing a # closed pool. assert shared_client() is not first close_shared_client() def _publisher() -> MqttNode: node = MqttNode( requires=[MessageSpec(name="setpoint", port="setpoint", dtype=DType.FLOAT)], params={"topic": {"setpoint": "heating/setpoint"}}, ) node.assign_flow("heating", "out") return node def test_a_started_publisher_queues_instead_of_connecting(): """The handler runs on a worker thread; it must not block on the broker.""" node = _publisher() async def scenario() -> None: node._publish_queue = asyncio.Queue(maxsize=4) node._loop = asyncio.get_running_loop() await asyncio.to_thread(node._publisher_handler, {}, setpoint=21.0) # call_soon_threadsafe lands on the next loop pass. await asyncio.sleep(0) assert node._publish_queue.qsize() == 1 assert node._publish_queue.get_nowait() == {"setpoint": 21.0} asyncio.run(scenario()) def test_a_full_publish_queue_drops_the_oldest(): """A broker that cannot keep up must not grow the queue without bound.""" node = _publisher() health: list[tuple[str, str | None]] = [] node._on_health = lambda _n, status, detail: health.append((status, detail)) async def scenario() -> None: queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=2) for value in (1.0, 2.0, 3.0): node._enqueue(queue, {"setpoint": value}) assert queue.qsize() == 2 assert queue.get_nowait() == {"setpoint": 2.0} assert queue.get_nowait() == {"setpoint": 3.0} assert health == [("degraded", "publish queue full")] asyncio.run(scenario()) def test_a_string_goes_on_the_wire_bare(): """Devices on a shared broker expect `ON`, not `"ON"`.""" class Recorder: def __init__(self) -> None: self.published: list[tuple[str, str]] = [] async def publish(self, topic, payload, **_): self.published.append((topic, payload)) node = MqttNode( requires=[ MessageSpec(name="plug", port="plug", dtype=DType.STR), MessageSpec(name="level", port="level", dtype=DType.INT), ], params={"topic": {"plug": "actor/plug", "level": "light/level"}}, ) node.assign_flow("house", "out") client = Recorder() asyncio.run(node._publish_with(client, {"plug": "ON", "level": 60})) assert client.published == [("actor/plug", "ON"), ("light/level", "60")] def test_the_configured_timeout_reaches_the_broker_client(monkeypatch): """Without one, a dead socket makes the disconnect ack wait forever.""" import aiomqtt seen: dict = {} class FakeClient: def __init__(self, **kwargs): seen.update(kwargs) async def __aenter__(self): return self async def __aexit__(self, *_): return False async def publish(self, *_, **__): return None monkeypatch.setattr(aiomqtt, "Client", FakeClient) node = MqttNode( requires=[MessageSpec(name="setpoint", port="setpoint", dtype=DType.FLOAT)], params={"topic": {"setpoint": "heating/setpoint"}, "timeout": 2.5}, ) asyncio.run(node._publish_once({"setpoint": 21.0})) assert seen["timeout"] == 2.5 def test_the_configured_backlog_reaches_the_publish_queue(monkeypatch): """The depth is read when the queue is built, so it has to be per node.""" import aiomqtt class FakeClient: def __init__(self, **kwargs): pass async def __aenter__(self): return self async def __aexit__(self, *_): return False monkeypatch.setattr(aiomqtt, "Client", FakeClient) node = MqttNode( requires=[MessageSpec(name="setpoint", port="setpoint", dtype=DType.FLOAT)], params={"topic": {"setpoint": "heating/setpoint"}, "publish_queue_size": 8}, ) node.assign_flow("heating", "out") async def scenario() -> int: await node.start_publisher() assert node._publish_queue is not None size = node._publish_queue.maxsize await node.stop_publisher() return size assert asyncio.run(scenario()) == 8