Files
chongming-core/test_route_registry.py
T
2026-08-22 03:53:31 +08:00

185 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
RouteRegistry 单元测试(loopback Zenoh 会话 + httpx ASGI 功能断言)
验证 external 插件路由动态注册核心链路(含混合模式防双注册):
RoutePublisher.put 描述符 → 网关 RouteRegistry 动态挂载 FastAPI 路由(命中返回 503,worker 桩无实例)
RoutePublisher.delete → 动态摘除路由(404)
__meta__ 中的 mqtt_topics → 推送给 MQTT Bridge(update_dynamic_topics)
set_inline_types → 忽略 inline 插件的描述符(防双注册)
注:FastAPI 0.139 的 include_router 将路由包装为 _IncludedRouter(惰性挂载),
不能靠遍历 app.router.routes 的 path 判断,故用 ASGI 请求状态码做功能断言。
"""
import asyncio
import time
import httpx
import zenoh
from fastapi import FastAPI
from chongming_worker.routes_publisher import RoutePublisher
from src.chongming.app.core.route_registry import RouteRegistry
failures = []
def check(name, cond, detail=""):
status = "PASS" if cond else "FAIL"
print(f"[{status}] {name}" + (f" — {detail}" if detail else ""))
if not cond:
failures.append(name)
class FakeWorkerManager:
"""桩:无可用 worker,命中路由后 call_raw 抛 RuntimeError(网关转 503)。"""
async def call(self, **kwargs):
raise RuntimeError("无可用 worker(测试桩)")
async def call_raw(self, **kwargs):
raise RuntimeError("无可用 worker(测试桩)")
class FakeBridge:
"""记录 update_dynamic_topics 的调用结果。"""
def __init__(self):
self.topics = None
async def update_dynamic_topics(self, topics):
self.topics = list(topics)
async def wait_until(cond, timeout=10.0, interval=0.2) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if cond():
return True
await asyncio.sleep(interval)
return False
HTTP_DESCRIPTOR = {
"subject": "example.add",
"method": "GET",
"path": "/calc/add",
"params": ["a: float", "b: float"],
"response_model": {"result": {"type": "float", "required": True}},
"summary": "加法运算",
"timeout": 30.0,
"cacheable": False,
"auth_required": False,
"internal": False,
}
MQTT_DESCRIPTOR = {
"subject": "example.add",
"method": "MQTT",
"path": "/calc/add_mqtt",
"params": ["a: float", "b: float"],
"summary": "MQTT 加法",
"timeout": 30.0,
"cacheable": False,
"auth_required": False,
"internal": False,
}
MQTT_TOPICS = [{"topic": "box/aibox_sophon/board_ping"}]
# registry 会为每个主题补 worker 字段
MQTT_TOPICS_EXPECTED = [
{"topic": "box/aibox_sophon/board_ping", "worker": "example"}
]
async def main():
zconfig = zenoh.Config()
try:
zconfig.from_file("config/zenoh_config.json5")
except Exception:
pass # 回退默认配置
gw_session = zenoh.open(zconfig)
pub_session = zenoh.open(zconfig)
try:
app = FastAPI()
registry = RouteRegistry(app, FakeWorkerManager(), prefix="/api/v1")
await registry.start(gw_session)
await asyncio.sleep(0.5) # 等待订阅生效
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
# 1. 发布 → HTTP 路由动态挂载(命中 503:路由存在但无 worker)
publisher = RoutePublisher(pub_session, "example")
publisher.publish_routes(
[HTTP_DESCRIPTOR, MQTT_DESCRIPTOR], mqtt_topics=MQTT_TOPICS
)
async def mounted():
resp = await client.get("/api/v1/calc/add?a=1&b=2")
return resp.status_code == 503
ok = await wait_until(mounted)
check("put 后 HTTP 路由动态挂载(503 命中)", ok)
resp = await client.get("/api/v1/calc/add_mqtt?a=1&b=2")
check("MQTT 端点不挂载 HTTP 路由(404)", resp.status_code == 404,
f"status={resp.status_code}")
# 2. __meta__ 主题推送给 Bridge
bridge = FakeBridge()
registry.attach_bridge(bridge)
ok = await wait_until(lambda: bridge.topics == MQTT_TOPICS_EXPECTED)
check("mqtt_topics 推送给 Bridge", ok, f"bridge.topics={bridge.topics}")
# 3. 重复发布相同描述符 → 幂等(路由不重复、仍可命中)
publisher.publish_routes([HTTP_DESCRIPTOR])
await asyncio.sleep(3.5) # 覆盖防抖窗口
resp = await client.get("/api/v1/calc/add?a=1&b=2")
check("重复发布幂等(仍正常命中)", resp.status_code == 503,
f"status={resp.status_code}")
# 4. 摘除 → 路由动态移除(404)
publisher.unpublish()
async def unmounted():
resp = await client.get("/api/v1/calc/add?a=1&b=2")
return resp.status_code == 404
ok = await wait_until(unmounted)
check("delete 后 HTTP 路由动态摘除(404)", ok)
ok = await wait_until(lambda: bridge.topics == [])
check("元信息摘除后 Bridge 主题清空", ok, f"bridge.topics={bridge.topics}")
# 5. 混合模式:inline 类型的描述符被忽略(防与盘扫注册重复挂载)
registry.set_inline_types({"example"})
publisher.publish_routes([HTTP_DESCRIPTOR])
await asyncio.sleep(3.5) # 覆盖防抖窗口
resp = await client.get("/api/v1/calc/add?a=1&b=2")
check("inline 类型描述符被忽略(防双注册,404)", resp.status_code == 404,
f"status={resp.status_code}")
# 清除 inline 集合后恢复动态挂载
registry.set_inline_types(set())
publisher.publish_routes([HTTP_DESCRIPTOR])
async def remounted():
resp = await client.get("/api/v1/calc/add?a=1&b=2")
return resp.status_code == 503
ok = await wait_until(remounted)
check("清除 inline 集合后恢复动态挂载", ok)
publisher.unpublish()
finally:
registry.stop()
gw_session.close()
pub_session.close()
if __name__ == "__main__":
asyncio.run(main())
print("\n" + ("=" * 40))
if failures:
print(f"❌ {len(failures)} 项未通过: {failures}")
raise SystemExit(1)
print("✅ RouteRegistry 全部通过")