add: mqtt 客户端测试 + 文档更新

This commit is contained in:
2026-08-14 18:07:07 +08:00
parent 5ca88d4de8
commit aa56132d49
6 changed files with 406 additions and 1 deletions
+24 -1
View File
@@ -117,7 +117,10 @@ await client.stop()
## 与 aibox_sophon_adapter 插件的集成
插件在 `[workers.config]` 下分别配置这三个客户端:
插件在 `[workers.config]` 下分别配置这三个客户端,启动逻辑封装在插件的
`mqtt_clients.py` 能力组件中(`start_mqtt_forward` / `start_mqtt_subscribe` /
`start_internal_mqtt`,启动失败自动降级为 `None` 并告警),
由 `AiboxSophonAdapterPlugin.on_init` 调用:
```toml
[workers.config.mqtt_forward] # -> MQTTForwardClient
@@ -159,6 +162,26 @@ async def _cmd_some_op(payload: dict) -> dict:
---
## 测试
单元测试位于 `utils/mqtt/test/`,通过注入 `FakeMQTT` 模拟底层连接,**不依赖真实 Broker**,可离线快速运行。
```bash
uv run pytest utils/mqtt/test -v
```
覆盖范围:
| 测试文件 | 覆盖内容 |
| ------------------- | -------------------------------------------------------------------------------------------------- |
| `test_forward.py` | 凭据解码、默认/指定主题发布、未连接与异常时的行为、stop 断开 |
| `test_subscribe.py` | 主题通配符(`+`/`#`)编译、无效路由跳过、消息分发、`reply_topic` 回复、`None` 不回复、`{1}` 占位符 |
| `test_internal.py` | 凭据解码、回显 `request_id` 关联、`{rid}` 模板、超时、FIFO 兜底、并发乱序、订阅去重 |
> 若需对真实 Broker 做端到端验证,可参考项目根目录的 `test_mqtt_e2e.py` 模式,自行启动本地 broker 后联测。
---
## 构建集成
若通过 PyInstaller/Nuitka 打包,需在 `config.toml` 的 `[pyinstaller_build_import] base` 中加入:
+12
View File
@@ -0,0 +1,12 @@
"""
chongming_mqtt 测试公共配置
确保 ``chongming_mqtt`` 可导入:优先使用已安装的包(``uv pip install -e utils/mqtt``),
若未安装则回退到源码 ``utils/mqtt/src`` 目录。
"""
import sys
from pathlib import Path
_SRC = Path(__file__).resolve().parent.parent / "src"
if str(_SRC) not in sys.path:
sys.path.insert(0, str(_SRC))
+50
View File
@@ -0,0 +1,50 @@
"""
测试用模拟对象 — 不连接真实 MQTT Broker
提供 ``FakeMQTT``(模拟 amqtt ``MQTTClient`` 的最小接口)与辅助函数,
用于对 ``chongming_mqtt`` 各客户端做离线单元测试。
"""
import asyncio
class FakeMQTT:
"""
模拟 amqtt MQTTClient 的最小接口
记录 publish / subscribe / unsubscribe / disconnect 调用;
``on_publish`` 回调可在发布时模拟"对方应用"的行为(如回发响应)。
"""
def __init__(self, on_publish=None):
self.published: list[tuple[str, bytes, object]] = [] # (topic, payload, qos)
self.subscribed: list[tuple[str, object]] = [] # (topic, qos)
self.unsubscribed: list[str] = []
self.disconnected = False
self._on_publish = on_publish
async def publish(self, topic, payload, qos):
self.published.append((topic, payload, qos))
if self._on_publish is not None:
await self._on_publish(topic, payload)
async def subscribe(self, topics):
self.subscribed.extend(list(topics))
async def unsubscribe(self, topics):
self.unsubscribed.extend(list(topics))
async def disconnect(self):
self.disconnected = True
def attach(client, on_publish=None) -> FakeMQTT:
"""把 FakeMQTT 挂到被测客户端(跳过真实连接),并标记为运行中"""
fake = FakeMQTT(on_publish=on_publish)
client._client = fake
client._running = True
return fake
def run(coro):
"""运行一个异步协程(同步测试入口)"""
return asyncio.run(coro)
+64
View File
@@ -0,0 +1,64 @@
"""
MQTTForwardClient 单元测试
通过注入 FakeMQTT 验证发布逻辑,不连接真实 Broker。
"""
import json
from chongming_mqtt.mqtt_forward import MQTTForwardClient
from fakes import attach, run
def test_uri_credentials_decoded():
"""URI 中 percent-encoded 凭据应被解码(%40 -> @)"""
client = MQTTForwardClient("mqtt://admin:Mixc%402021@dg.datagoo.cn:48081", "/t")
assert client._username == "admin"
assert client._password == "Mixc@2021"
assert client._clean_uri == "mqtt://dg.datagoo.cn:48081"
def test_publish_not_running_returns_false():
"""未连接时 publish 应返回 False 且不抛异常"""
client = MQTTForwardClient("mqtt://h:1883", "/t")
assert run(client.publish({"x": 1})) is False
def test_publish_uses_default_topic():
"""未指定 topic 时发布到构造时的默认主题"""
client = MQTTForwardClient("mqtt://h:1883", "/board_ping")
fake = attach(client)
assert run(client.publish({"BoardId": "B1"})) is True
topic, payload, _ = fake.published[0]
assert topic == "/board_ping"
assert json.loads(payload) == {"BoardId": "B1"}
def test_publish_specified_topic():
"""显式 topic 应覆盖默认主题"""
client = MQTTForwardClient("mqtt://h:1883", "/default")
fake = attach(client)
run(client.publish({"a": 1}, topic="/custom"))
assert fake.published[0][0] == "/custom"
def test_publish_exception_returns_false():
"""底层发布异常时应返回 False"""
client = MQTTForwardClient("mqtt://h:1883", "/t")
class BoomMQTT:
async def publish(self, topic, payload, qos):
raise RuntimeError("boom")
client._client = BoomMQTT() # type: ignore
client._running = True
assert run(client.publish({"a": 1})) is False
def test_stop_disconnects_and_clears_flag():
"""stop() 应断开连接并复位运行标志"""
client = MQTTForwardClient("mqtt://h:1883", "/t")
fake = attach(client)
run(client.stop())
assert fake.disconnected is True
assert client.is_running is False
+132
View File
@@ -0,0 +1,132 @@
"""
InternalMQTTClient 单元测试
覆盖:URI 凭据解码、请求-响应关联(回显 request_id / {rid} 模板 / 兜底)、
超时、并发乱序、订阅去重。
"""
import asyncio
import json
from chongming_mqtt.internal_mqtt import InternalMQTTClient
from fakes import attach, run
def test_uri_decoded():
"""URI 中 percent-encoded 凭据应被解码"""
c = InternalMQTTClient("mqtt://u:p%40w@host:1883")
assert c._username == "u"
assert c._password == "p@w"
assert c._clean_uri == "mqtt://host:1883"
def test_request_not_running_returns_none():
"""未连接时 request 返回 None"""
c = InternalMQTTClient("mqtt://127.0.0.1:1883")
assert run(c.request("/req", "/resp", {})) is None
def test_request_echo_rid():
"""对方回显 request_id → 精确匹配响应"""
client = InternalMQTTClient("mqtt://127.0.0.1:1883")
async def respond(topic, payload):
rid = json.loads(payload)["request_id"]
await client._handle("/resp", json.dumps({"request_id": rid, "ok": True}).encode())
attach(client, on_publish=respond)
r = run(client.request("/req", "/resp", {"a": 1}, timeout=3))
assert r is not None
assert r.get("ok") is True
assert "request_id" in r
def test_request_rid_template():
"""response_topic 含 {rid} → 订阅唯一主题并按主题匹配"""
client = InternalMQTTClient("mqtt://127.0.0.1:1883")
async def respond(topic, payload):
rid = json.loads(payload)["request_id"]
await client._handle(f"/resp/{rid}", json.dumps({"request_id": rid, "tpl": True}).encode())
fake = attach(client, on_publish=respond)
r = run(client.request("/req", "/resp/{rid}", {}, timeout=3))
assert r is not None
assert r.get("tpl") is True
# {rid} 模板:订阅的是唯一主题(含实际 rid),而非原模板
assert any("/resp/" in t for t, _ in fake.subscribed)
def test_request_timeout_returns_none():
"""对方不回复 → 超时返回 None"""
class NoReply:
async def publish(self, topic, payload, qos):
pass
async def subscribe(self, topics):
pass
client = InternalMQTTClient("mqtt://127.0.0.1:1883")
client._client = NoReply()
client._running = True
assert run(client.request("/req", "/resp", {}, timeout=0.2)) is None
def test_request_fifo_fallback():
"""对方不回显 request_id(固定主题)→ 兜底 FIFO 取最早挂起请求"""
client = InternalMQTTClient("mqtt://127.0.0.1:1883")
async def respond(topic, payload):
await client._handle("/resp", json.dumps({"v": "first"}).encode())
attach(client, on_publish=respond)
r = run(client.request("/req", "/resp", {}, timeout=3))
assert r == {"v": "first"}
def test_request_concurrent_out_of_order():
"""并发请求 + 响应乱序 → 每个请求按 request_id 拿回自己的响应"""
client = InternalMQTTClient("mqtt://127.0.0.1:1883")
attach(client)
async def do_req(tag):
rid = f"r-{tag}"
fut = asyncio.get_running_loop().create_future()
client._pending[rid] = fut
if tag == "x":
await asyncio.sleep(0.05)
# 模拟响应乱序:先回 y,再回 x
await client._handle("/resp", json.dumps({"request_id": "r-y", "v": "y"}).encode())
await client._handle("/resp", json.dumps({"request_id": "r-x", "v": "x"}).encode())
return await asyncio.wait_for(fut, 2), rid
async def main():
t1 = asyncio.create_task(do_req("x"))
t2 = asyncio.create_task(do_req("y"))
return await asyncio.gather(t1, t2)
results = run(main())
by_rid = {rid: res for res, rid in results}
assert by_rid["r-x"]["v"] == "x"
assert by_rid["r-y"]["v"] == "y"
def test_subscribe_once_dedup():
"""同一响应主题重复订阅应去重"""
class SpySub:
def __init__(self):
self.topics = []
async def publish(self, topic, payload, qos):
pass
async def subscribe(self, topics):
self.topics.extend(t for t, _ in topics)
spy = SpySub()
client = InternalMQTTClient("mqtt://127.0.0.1:1883")
client._client = spy
client._running = True
run(client._subscribe_once("/resp"))
run(client._subscribe_once("/resp"))
assert spy.topics == ["/resp"]
+124
View File
@@ -0,0 +1,124 @@
"""
MQTTSubscribeClient 单元测试
覆盖:主题通配符编译、路由构建、消息分发、请求-响应(reply_topic)与不回复约定。
"""
import json
from chongming_mqtt.mqtt_subscribe import MQTTSubscribeClient, _compile_topic
from fakes import attach, run
# ---------- _compile_topic 通配符匹配 ----------
def test_compile_topic_exact():
p = _compile_topic("/edge_app_controller")
assert p.match("/edge_app_controller")
assert not p.match("/edge_app_controller/other")
assert not p.match("/other")
def test_compile_topic_plus_wildcard():
"""+ 只匹配单级"""
p = _compile_topic("/dev/+/cmd")
assert p.match("/dev/box1/cmd")
assert not p.match("/dev/box1/sub/cmd")
def test_compile_topic_hash_wildcard():
"""# 匹配多级且须在末尾"""
p = _compile_topic("/dev/#")
assert p.match("/dev/a/b/c")
assert p.match("/dev/x")
assert not p.match("/other/a")
# ---------- 路由构建 ----------
def test_routes_skip_invalid():
"""无效(空 topic / 无 handler)订阅项应被跳过"""
client = MQTTSubscribeClient("mqtt://h:1883", routes=[
{"topic": "", "handler": lambda d, t: None},
{"topic": "/t", "handler": lambda d, t: None},
{"topic": "/t2"}, # 无 handler
])
assert len(client._routes) == 1
# ---------- 消息分发与回复 ----------
async def _echo_handler(payload, topic):
return {"echo": payload.get("v")}
def test_dispatch_invokes_handler_and_replies():
"""收到消息 → 调用 handler → 结果发布到 reply_topic"""
client = MQTTSubscribeClient("mqtt://h:1883", routes=[
{"topic": "/req", "handler": _echo_handler, "reply_topic": "/resp"},
])
fake = attach(client)
run(client._dispatch("/req", b'{"v": 7}'))
assert len(fake.published) == 1
topic, payload, _ = fake.published[0]
assert topic == "/resp"
assert json.loads(payload) == {"echo": 7}
def test_dispatch_no_reply_when_handler_returns_none():
"""handler 返回 None 时不发布回复(静默命令)"""
async def silent(payload, topic):
return None
client = MQTTSubscribeClient("mqtt://h:1883", routes=[
{"topic": "/req", "handler": silent, "reply_topic": "/resp"},
])
fake = attach(client)
run(client._dispatch("/req", b'{"v": 1}'))
assert fake.published == []
def test_dispatch_no_reply_without_reply_topic():
"""未配置 reply_topic 时不发布"""
client = MQTTSubscribeClient("mqtt://h:1883", routes=[
{"topic": "/req", "handler": _echo_handler},
])
fake = attach(client)
run(client._dispatch("/req", b'{"v": 1}'))
assert fake.published == []
def test_dispatch_ignores_non_json():
"""非 JSON 消息被忽略,不调用 handler"""
calls = []
async def h(payload, topic):
calls.append(payload)
client = MQTTSubscribeClient("mqtt://h:1883", routes=[
{"topic": "/req", "handler": h, "reply_topic": "/resp"},
])
fake = attach(client)
run(client._dispatch("/req", b"not-json"))
assert calls == []
assert fake.published == []
def test_dispatch_unmatched_topic_ignored():
"""不匹配任何路由主题的消息被忽略"""
client = MQTTSubscribeClient("mqtt://h:1883", routes=[
{"topic": "/req", "handler": _echo_handler, "reply_topic": "/resp"},
])
fake = attach(client)
run(client._dispatch("/other/topic", b'{"v": 1}'))
assert fake.published == []
def test_reply_topic_placeholder_fill():
"""reply_topic 的 {1} 占位符填充订阅主题通配符捕获值"""
client = MQTTSubscribeClient("mqtt://h:1883", routes=[
{"topic": "/dev/+/cmd", "handler": _echo_handler, "reply_topic": "/resp/{1}"},
])
fake = attach(client)
run(client._dispatch("/dev/box9/cmd", b'{"v": 1}'))
assert fake.published[0][0] == "/resp/box9"