Skip to content

Commit 8006f5e

Browse files
committed
chore(tests): Cover mqtt e2e scenarios for MqttSession
1 parent 2ffc17b commit 8006f5e

6 files changed

Lines changed: 280 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ jobs:
3838
- "3.11"
3939
- "3.14"
4040
runs-on: ubuntu-latest
41+
services:
42+
emqx:
43+
image: emqx/emqx:6.2.3
44+
ports:
45+
- 1888:1883
46+
options: >-
47+
--health-cmd "/opt/emqx/bin/emqx ctl status"
48+
--health-interval 5s
49+
--health-timeout 25s
50+
--health-retries 5
4151
steps:
4252
- uses: actions/checkout@v6
4353
- name: Set up uv

CONTRIBUTING.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,17 @@ pre-commit run --all-files
5353

5454
We use `pytest` for testing. Please ensure all tests pass and add new tests for your changes.
5555

56+
MQTT tests require a real EMQX broker at `127.0.0.1:1888`. Start it locally with
57+
Docker Compose before running the tests. CI provides the broker as a GitHub Actions service.
58+
5659
```bash
57-
# Run tests
60+
docker compose up -d --wait
5861
pytest
62+
docker compose down
5963
```
6064

65+
To run only the real broker tests, use `pytest -m mqtt_broker`.
66+
6167
## Pull Requests
6268

6369
1. **Create a branch** for your changes.

compose.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
services:
2+
emqx:
3+
image: emqx/emqx:6.2.3
4+
ports:
5+
- "127.0.0.1:1888:1883"
6+
healthcheck:
7+
test: ["CMD", "/opt/emqx/bin/emqx", "ctl", "status"]
8+
interval: 5s
9+
timeout: 25s
10+
retries: 5

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ dev = [
6767
"syrupy>=4.9.1,<6",
6868
"pdoc>=15.0.4,<17",
6969
"pytest-cov>=7.0.0",
70+
"zmqtt>=0.2.0,<0.3.0",
7071
]
7172

7273
[tool.hatch.build.targets.sdist]
@@ -120,6 +121,7 @@ module = ["roborock.map.proto.*"]
120121
ignore_errors = true
121122

122123
[tool.pytest.ini_options]
124+
markers = ["mqtt_broker: tests requiring a real MQTT broker"]
123125
asyncio_mode = "auto"
124126
asyncio_default_fixture_loop_scope = "function"
125127
timeout = 30

tests/e2e/test_mqtt_broker.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
"""Test the MQTT session and channel over TCP with a real broker."""
2+
3+
import asyncio
4+
import json
5+
from collections.abc import AsyncGenerator
6+
from uuid import uuid4
7+
8+
import pytest
9+
from zmqtt import MQTTClientV5, QoS, ReconnectConfig, create_client
10+
11+
from roborock.data import UserData
12+
from roborock.devices.transport.mqtt_channel import MqttChannel
13+
from roborock.mqtt.roborock_session import create_lazy_mqtt_session, create_mqtt_session
14+
from roborock.mqtt.session import MqttParams, MqttQos, MqttSession, MqttSessionException
15+
from roborock.protocol import MessageParser
16+
from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
17+
from tests.mock_data import LOCAL_KEY, USER_DATA
18+
19+
pytestmark = pytest.mark.mqtt_broker
20+
21+
22+
@pytest.fixture(name="mqtt_params")
23+
def mqtt_params_fixture() -> MqttParams:
24+
"""Use the broker exposed by Docker Compose or GitHub Actions."""
25+
return MqttParams(
26+
host="127.0.0.1",
27+
port=1888,
28+
tls=False,
29+
username="username",
30+
password="password",
31+
timeout=5.0,
32+
)
33+
34+
35+
@pytest.fixture(name="session")
36+
async def session_fixture(mqtt_params: MqttParams) -> AsyncGenerator[MqttSession, None]:
37+
"""Create and close the production MQTT session."""
38+
session = await create_mqtt_session(mqtt_params)
39+
try:
40+
assert session.connected
41+
yield session
42+
finally:
43+
await session.close()
44+
45+
46+
@pytest.fixture(name="peer")
47+
async def peer_fixture(mqtt_params: MqttParams) -> AsyncGenerator[MQTTClientV5, None]:
48+
"""Represent a device using an independent MQTT client."""
49+
async with create_client(
50+
mqtt_params.host,
51+
mqtt_params.port,
52+
version="5.0",
53+
mqtt_connect_timeout=mqtt_params.timeout,
54+
reconnect=ReconnectConfig(enabled=False),
55+
) as peer:
56+
yield peer
57+
58+
59+
@pytest.fixture(name="topic")
60+
def topic_fixture() -> str:
61+
"""Isolate each test's traffic on the shared broker."""
62+
return f"roborock-tests/{uuid4().hex}"
63+
64+
65+
async def test_receive_message(session: MqttSession, peer: MQTTClientV5, topic: str) -> None:
66+
"""Deliver an encrypted Roborock response to the session callback."""
67+
messages: asyncio.Queue[bytes] = asyncio.Queue()
68+
await session.subscribe(topic, messages.put_nowait)
69+
response = RoborockMessage(
70+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
71+
payload=b'{"result":"ok"}',
72+
seq=123,
73+
)
74+
payload = MessageParser.build(response, local_key=LOCAL_KEY, prefixed=False)
75+
76+
await peer.publish(topic, payload)
77+
received = await asyncio.wait_for(messages.get(), timeout=5)
78+
79+
assert received == payload
80+
parsed, remaining = MessageParser.parse(received, local_key=LOCAL_KEY)
81+
assert not remaining
82+
assert len(parsed) == 1
83+
assert parsed[0].protocol == response.protocol
84+
assert parsed[0].seq == response.seq
85+
assert parsed[0].payload == response.payload
86+
87+
88+
@pytest.mark.parametrize("qos", list(MqttQos))
89+
async def test_publish_message(session: MqttSession, peer: MQTTClientV5, topic: str, qos: MqttQos) -> None:
90+
"""Deliver the full payload and requested QoS to another MQTT client."""
91+
payload = MessageParser.build(
92+
RoborockMessage(protocol=RoborockMessageProtocol.RPC_REQUEST, payload=b'{"method":"get_status"}'),
93+
local_key=LOCAL_KEY,
94+
prefixed=False,
95+
)
96+
async with peer.subscribe(topic, qos=QoS.EXACTLY_ONCE) as subscription:
97+
await session.publish(topic, payload, qos=qos)
98+
received = await asyncio.wait_for(subscription.get_message(), timeout=5)
99+
100+
assert received.topic == topic
101+
assert received.payload == payload
102+
assert received.qos == qos
103+
104+
105+
async def test_subscriber_lifecycle(session: MqttSession, peer: MQTTClientV5, topic: str) -> None:
106+
"""Fan out messages, remove callbacks, and reuse an idle subscription."""
107+
first: asyncio.Queue[bytes] = asyncio.Queue()
108+
second: asyncio.Queue[bytes] = asyncio.Queue()
109+
unsub_first = await session.subscribe(topic, first.put_nowait)
110+
unsub_second = await session.subscribe(topic, second.put_nowait)
111+
112+
await peer.publish(topic, b"both")
113+
assert await asyncio.wait_for(first.get(), timeout=5) == b"both"
114+
assert await asyncio.wait_for(second.get(), timeout=5) == b"both"
115+
116+
unsub_first()
117+
await peer.publish(topic, b"second only")
118+
assert await asyncio.wait_for(second.get(), timeout=5) == b"second only"
119+
assert first.empty()
120+
121+
unsub_second()
122+
await session.subscribe(topic, first.put_nowait)
123+
await peer.publish(topic, b"first again")
124+
assert await asyncio.wait_for(first.get(), timeout=5) == b"first again"
125+
assert first.empty()
126+
assert second.empty()
127+
128+
129+
async def test_topic_routing(session: MqttSession, peer: MQTTClientV5, topic: str) -> None:
130+
"""Keep messages for different devices separate in a shared session."""
131+
first: asyncio.Queue[bytes] = asyncio.Queue()
132+
second: asyncio.Queue[bytes] = asyncio.Queue()
133+
await session.subscribe(f"{topic}/first", first.put_nowait)
134+
await session.subscribe(f"{topic}/second", second.put_nowait)
135+
136+
await peer.publish(f"{topic}/first", b"first")
137+
await peer.publish(f"{topic}/second", b"second")
138+
139+
assert await asyncio.wait_for(first.get(), timeout=5) == b"first"
140+
assert await asyncio.wait_for(second.get(), timeout=5) == b"second"
141+
assert first.empty()
142+
assert second.empty()
143+
144+
145+
async def test_restart_restores_subscriptions(session: MqttSession, peer: MQTTClientV5, topic: str) -> None:
146+
"""Restore message delivery after the session reconnects."""
147+
messages: asyncio.Queue[bytes] = asyncio.Queue()
148+
await session.subscribe(topic, messages.put_nowait)
149+
await peer.publish(topic, b"before restart")
150+
assert await asyncio.wait_for(messages.get(), timeout=5) == b"before restart"
151+
152+
await session.restart()
153+
async with asyncio.timeout(20):
154+
while session.connected:
155+
await asyncio.sleep(0.01)
156+
while not session.connected:
157+
await asyncio.sleep(0.01)
158+
159+
await peer.publish(topic, b"after restart")
160+
assert await asyncio.wait_for(messages.get(), timeout=5) == b"after restart"
161+
async with peer.subscribe(topic) as subscription:
162+
await session.publish(topic, b"outbound after restart")
163+
received = await asyncio.wait_for(subscription.get_message(), timeout=5)
164+
assert received.payload == b"outbound after restart"
165+
166+
167+
async def test_close(session: MqttSession, topic: str) -> None:
168+
"""Close a connected session and reject further publications."""
169+
await session.close()
170+
171+
assert not session.connected
172+
with pytest.raises(MqttSessionException):
173+
await session.publish(topic, b"closed")
174+
175+
176+
async def test_lazy_session_subscribe(mqtt_params: MqttParams, peer: MQTTClientV5, topic: str) -> None:
177+
"""Connect a lazy session on its first subscription and receive a message."""
178+
session = await create_lazy_mqtt_session(mqtt_params)
179+
assert not session.connected
180+
181+
messages: asyncio.Queue[bytes] = asyncio.Queue()
182+
await session.subscribe(topic, messages.put_nowait)
183+
assert session.connected
184+
185+
await peer.publish(topic, b"inbound")
186+
assert await asyncio.wait_for(messages.get(), timeout=5) == b"inbound"
187+
188+
await session.close()
189+
assert not session.connected
190+
191+
192+
async def test_lazy_session_publish(mqtt_params: MqttParams, peer: MQTTClientV5, topic: str) -> None:
193+
"""Connect a lazy session on its first publication and deliver a message."""
194+
session = await create_lazy_mqtt_session(mqtt_params)
195+
assert not session.connected
196+
197+
async with peer.subscribe(topic) as subscription:
198+
await session.publish(topic, b"outbound")
199+
assert session.connected
200+
received = await asyncio.wait_for(subscription.get_message(), timeout=5)
201+
assert received.payload == b"outbound"
202+
203+
await session.close()
204+
assert not session.connected
205+
206+
207+
async def test_channel_request_response(session: MqttSession, mqtt_params: MqttParams, peer: MQTTClientV5) -> None:
208+
"""Exchange encrypted commands and responses on the device's Roborock topics."""
209+
user_data = UserData.from_dict(USER_DATA)
210+
duid = uuid4().hex
211+
channel = MqttChannel(session, duid, LOCAL_KEY, user_data.rriot, mqtt_params)
212+
request_topic = f"rr/m/i/{user_data.rriot.u}/{mqtt_params.username}/{duid}"
213+
response_topic = f"rr/m/o/{user_data.rriot.u}/{mqtt_params.username}/{duid}"
214+
messages: asyncio.Queue[RoborockMessage] = asyncio.Queue()
215+
unsub = await channel.subscribe(messages.put_nowait)
216+
async with peer.subscribe(request_topic) as subscription:
217+
command = RoborockMessage(
218+
protocol=RoborockMessageProtocol.RPC_REQUEST,
219+
payload=json.dumps({"dps": {"101": json.dumps({"id": 123, "method": "get_status"})}}).encode(),
220+
seq=456,
221+
)
222+
await channel.publish(command)
223+
request = await asyncio.wait_for(subscription.get_message(), timeout=5)
224+
parsed, remaining = MessageParser.parse(request.payload, local_key=LOCAL_KEY)
225+
assert request.topic == request_topic
226+
assert not remaining
227+
assert len(parsed) == 1
228+
assert parsed[0].protocol == RoborockMessageProtocol.RPC_REQUEST
229+
assert parsed[0].seq == 456
230+
assert parsed[0].payload == command.payload
231+
232+
response = RoborockMessage(
233+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
234+
payload=json.dumps({"dps": {"102": json.dumps({"id": 123, "result": [{"state": 8}]})}}).encode(),
235+
)
236+
await peer.publish(response_topic, MessageParser.build(response, local_key=LOCAL_KEY, prefixed=False))
237+
received = await asyncio.wait_for(messages.get(), timeout=5)
238+
assert received.protocol == response.protocol
239+
assert received.payload == response.payload
240+
unsub()

uv.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)