133 lines
4.3 KiB
Python
133 lines
4.3 KiB
Python
"""
|
|||
|
|
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"]
|