""" 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"]