Files
app/backend/tests/flow/test_senders.py
T
stroblme 0562753d9c http sender drops reply keys no output port declares
A sender without outputs no longer fails on a {"status": "ok"} reply.
2026-10-15 09:57:51 +02:00

178 lines
5.5 KiB
Python

"""The outbound nodes reuse one connection instead of opening one per message."""
import asyncio
import httpx
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes import HttpNode, 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 test_an_http_reply_keeps_only_the_declared_ports(monkeypatch):
"""A `status` beside the value is the endpoint's business, not an error."""
reply = {"temp": 21.5, "status": "ok"}
client = httpx.Client(
transport=httpx.MockTransport(lambda _: httpx.Response(200, json=reply))
)
monkeypatch.setattr("fluksio.flow.nodes.http.shared_client", lambda: client)
def sender(*provides: MessageSpec) -> HttpNode:
node = HttpNode(
requires=[MessageSpec(name="setpoint", dtype=DType.FLOAT)],
provides=provides,
params={"url": "http://thermostat.test/set"},
)
node.assign_flow("heating", "thermostat")
return node
temp = MessageSpec(name="temp", dtype=DType.FLOAT)
assert sender(temp).execute({"heating.setpoint": 21.0}) == {"heating.temp": 21.5}
assert sender().execute({"heating.setpoint": 21.0}) is None
def _sender() -> 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_sender_queues_instead_of_connecting():
"""The handler runs on a worker thread; it must not block on the broker."""
node = _sender()
async def scenario() -> None:
node._send_queue = asyncio.Queue(maxsize=4)
node._loop = asyncio.get_running_loop()
await asyncio.to_thread(node._sender_handler, {}, setpoint=21.0)
# call_soon_threadsafe lands on the next loop pass.
await asyncio.sleep(0)
assert node._send_queue.qsize() == 1
assert node._send_queue.get_nowait() == {"setpoint": 21.0}
asyncio.run(scenario())
def test_a_full_send_queue_drops_the_oldest():
"""A broker that cannot keep up must not grow the queue without bound."""
node = _sender()
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", "send 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_send_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"}, "send_queue_size": 8},
)
node.assign_flow("heating", "out")
async def scenario() -> int:
await node.start_publisher()
assert node._send_queue is not None
size = node._send_queue.maxsize
await node.stop_publisher()
return size
assert asyncio.run(scenario()) == 8