add: mqtt 客户端测试 + 文档更新
This commit is contained in:
@@ -117,7 +117,10 @@ await client.stop()
|
|||||||
|
|
||||||
## 与 aibox_sophon_adapter 插件的集成
|
## 与 aibox_sophon_adapter 插件的集成
|
||||||
|
|
||||||
插件在 `[workers.config]` 下分别配置这三个客户端:
|
插件在 `[workers.config]` 下分别配置这三个客户端,启动逻辑封装在插件的
|
||||||
|
`mqtt_clients.py` 能力组件中(`start_mqtt_forward` / `start_mqtt_subscribe` /
|
||||||
|
`start_internal_mqtt`,启动失败自动降级为 `None` 并告警),
|
||||||
|
由 `AiboxSophonAdapterPlugin.on_init` 调用:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[workers.config.mqtt_forward] # -> MQTTForwardClient
|
[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` 中加入:
|
若通过 PyInstaller/Nuitka 打包,需在 `config.toml` 的 `[pyinstaller_build_import] base` 中加入:
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user