Files
chongming-core/test_passthrough_optimization.py
T

377 lines
14 KiB
Python
Raw Normal View History

2026-08-22 03:53:31 +08:00
"""
透传优化回归测试(依赖隔离:桩掉 chongming 包链、auth 与 chongming_cache)
覆盖:
A. 回复路径 bytes 透传:
1. 普通端点返回 worker 原始字节(byte-for-byte,零再序列化)+ application/json
2. 框架错误(call_raw 抛 RuntimeError)-> HTTP 503
3. 业务错误({"code":1,...} 普通回复)-> HTTP 200 透传(网关不解析)
B. validate=false 请求透传:
4. POST 信封字节拼接精确断言(网关零解析/零转义)
5. 空 body 兜底 {"meta":...,"data":{}}
6. validate=true 端点仍走 Pydantic 校验(422);validate=false 跳过校验(透传)
7. GET 端点不受 validate 影响(validate 仅对 body 端点生效)
C. OpenAPI:
8. validate=false 端点 requestBody $ref 注入 + components schema 完整
D. WorkerManager 单测(FakeSession):
9. call_raw 零解析返回原始字节;call() 仍解析为 dict(MQTT 语义保留)
10. cacheable: 缓存原始 bytes、二次命中不再发查询、业务错误不缓存
11. reply.err -> 重试 -> RuntimeError;空回复兜底 b"{}"
运行: uv run python test_passthrough_optimization.py
"""
import asyncio
import importlib.util
import logging
import sys
import types
from pathlib import Path
from types import SimpleNamespace
import orjson
from fastapi import FastAPI
from fastapi.testclient import TestClient
REPO_ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(REPO_ROOT / "src"))
sys.path.insert(0, str(REPO_ROOT / "utils" / "worker" / "src"))
# ---- 依赖隔离:桩掉 chongming 包链 / auth / chongming_cache ----
_pkg = types.ModuleType("chongming")
_pkg.__path__ = []
_app = types.ModuleType("chongming.app")
_app.__path__ = []
_core = types.ModuleType("chongming.app.core")
_core.__path__ = []
_logger_stub = types.ModuleType("chongming.app.core.logger")
_logger_stub.get_logger = lambda name: logging.getLogger(name)
_auth_stub = types.ModuleType("chongming.app.core.auth")
_auth_stub.store_user_to_request = lambda x: x # 仅用于 Depends,桩掉鉴权
_cache_stub = types.ModuleType("chongming_cache")
_cache_stub.Cache = object
for mod in (_pkg, _app, _core, _logger_stub, _auth_stub, _cache_stub):
sys.modules[mod.__name__] = mod
# 加载真实 worker_manager(route_generator 依赖其 StreamIdleTimeoutError)
_wm_path = REPO_ROOT / "src" / "chongming" / "app" / "core" / "worker_manager.py"
_wm_spec = importlib.util.spec_from_file_location(
"chongming.app.core.worker_manager", _wm_path
)
_wm_mod = importlib.util.module_from_spec(_wm_spec)
sys.modules["chongming.app.core.worker_manager"] = _wm_mod
_wm_spec.loader.exec_module(_wm_mod) # type: ignore[union-attr]
# 加载真实 route_generator
_rg_path = REPO_ROOT / "src" / "chongming" / "app" / "core" / "route_generator.py"
_rg_spec = importlib.util.spec_from_file_location(
"chongming.app.core.route_generator", _rg_path
)
_rg_mod = importlib.util.module_from_spec(_rg_spec)
sys.modules["chongming.app.core.route_generator"] = _rg_mod
_rg_spec.loader.exec_module(_rg_mod) # type: ignore[union-attr]
register_route_group = _rg_mod.register_route_group
_inject_schemas_to_openapi = _rg_mod._inject_schemas_to_openapi
WorkerManager = _wm_mod.WorkerManager
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:
"""模拟 WorkerManager:call_raw 返回原始字节;error="no_worker" 时抛 RuntimeError"""
def __init__(self, reply=b'{"result": 5}', error=None):
self.reply = reply
self.error = error
self.call_raw_payloads = [] # 捕获的预序列化请求字节
async def call_raw(self, worker_type, subject, payload, timeout=3.0, retries=1, cacheable=False):
self.call_raw_payloads.append(payload)
if self.error == "no_worker":
raise RuntimeError(f"类型 '{worker_type}' 没有可用 Worker。")
return self.reply
ROUTES = [
# validate=true 对照(默认)
{
"subject": "demo.echo", "path": "/echo", "method": "POST",
"params": ["msg: str"], "summary": "echo",
"timeout": 3.0, "auth_required": False,
},
{
"subject": "demo.echo2", "path": "/echo2", "method": "POST",
"params": ["n: int"], "summary": "typed echo",
"timeout": 3.0, "auth_required": False,
},
{
"subject": "demo.add", "path": "/add", "method": "GET",
"params": ["a: int", "b: int"], "summary": "add",
"timeout": 3.0, "auth_required": False,
},
# validate=false:请求体原样透传
{
"subject": "demo.raw", "path": "/raw/echo", "method": "POST",
"params": ["msg: str"], "summary": "raw echo",
"validate": False, "timeout": 3.0, "auth_required": False,
},
# validate=false + GET 对照(validate 仅对 body 端点生效)
{
"subject": "demo.rawget", "path": "/raw/get", "method": "GET",
"params": ["a: int"], "summary": "raw get",
"validate": False, "timeout": 3.0, "auth_required": False,
},
]
def build_app(fwm):
app = FastAPI()
extra_schemas = {}
raw_body_models = {}
register_route_group(
app, fwm, "demo", ROUTES, prefix="/api/v1",
extra_schemas=extra_schemas, raw_body_models=raw_body_models,
)
if extra_schemas:
_inject_schemas_to_openapi(app, lambda: extra_schemas, lambda: raw_body_models)
return TestClient(app)
# ---- A. 回复路径 bytes 透传 ----
def test_reply_bytes_passthrough():
fwm = FakeWorkerManager(reply=b'{"result": 5}')
client = build_app(fwm)
r = client.get("/api/v1/add?a=1&b=2")
check("GET 状态码 200", r.status_code == 200, str(r.status_code))
check(
"回复为 worker 原始字节(byte-for-byte 零再序列化)",
r.content == b'{"result": 5}',
repr(r.content),
)
check(
"Content-Type application/json",
r.headers["content-type"].startswith("application/json"),
r.headers.get("content-type", ""),
)
data = orjson.loads(fwm.call_raw_payloads[0])["data"]
check("query 参数透传到信封 data", data == {"a": 1, "b": 2}, str(data))
def test_framework_error_503():
fwm = FakeWorkerManager(error="no_worker")
client = build_app(fwm)
r = client.get("/api/v1/add?a=1&b=2")
check("框架错误(无 worker)-> 503", r.status_code == 503, str(r.status_code))
def test_business_error_passthrough_200():
fwm = FakeWorkerManager(reply=b'{"code": 1, "error": "nope"}')
client = build_app(fwm)
r = client.post("/api/v1/echo", json={"msg": "hi"})
check("业务错误(普通回复)-> 200 透传", r.status_code == 200, str(r.status_code))
check("业务错误体原样透传", r.content == b'{"code": 1, "error": "nope"}', repr(r.content))
# ---- B. validate=false 请求透传 ----
def test_validate_false_envelope_splice():
fwm = FakeWorkerManager()
client = build_app(fwm)
r = client.post("/api/v1/raw/echo", json={"msg": "hello"})
check("validate=false POST 状态码 200", r.status_code == 200, str(r.status_code))
expected = b'{"meta":{"method":"POST"},"data":{"msg":"hello"}}'
check(
"信封字节拼接(网关零解析/零转义)",
fwm.call_raw_payloads[0] == expected,
repr(fwm.call_raw_payloads[0]),
)
check("响应透传", r.content == b'{"result": 5}', repr(r.content))
def test_validate_false_empty_body():
fwm = FakeWorkerManager()
client = build_app(fwm)
r = client.post("/api/v1/raw/echo", content=b"")
check("空 body 状态码 200", r.status_code == 200, str(r.status_code))
check(
"空 body 兜底 data:{}",
fwm.call_raw_payloads[0] == b'{"meta":{"method":"POST"},"data":{}}',
repr(fwm.call_raw_payloads[0]),
)
def test_validate_true_still_422():
fwm = FakeWorkerManager()
client = build_app(fwm)
r = client.post("/api/v1/echo2", json={"n": "not-a-number"})
check("validate=true 仍 Pydantic 校验(422)", r.status_code == 422, str(r.status_code))
check("校验失败不发起调用", len(fwm.call_raw_payloads) == 0)
def test_validate_false_skips_validation():
fwm = FakeWorkerManager()
client = build_app(fwm)
r = client.post("/api/v1/raw/echo", json={"n": "not-a-number"})
check("validate=false 跳过校验(200)", r.status_code == 200, str(r.status_code))
check(
"原始 body 透传(含类型错误输入)",
fwm.call_raw_payloads[0] == b'{"meta":{"method":"POST"},"data":{"n":"not-a-number"}}',
repr(fwm.call_raw_payloads[0]),
)
def test_validate_false_get_unaffected():
fwm = FakeWorkerManager()
client = build_app(fwm)
r = client.get("/api/v1/raw/get?a=5")
check("validate=false GET 不受影响(200)", r.status_code == 200, str(r.status_code))
data = orjson.loads(fwm.call_raw_payloads[0])["data"]
check("GET 参数正常透传", data == {"a": 5}, str(data))
# ---- C. OpenAPI ----
def test_openapi_request_body_injected():
fwm = FakeWorkerManager()
client = build_app(fwm)
schema = client.app.openapi()
# validate=false 端点:requestBody $ref 注入
raw_op = schema["paths"]["/api/v1/raw/echo"]["post"]
check(
"validate=false 端点 requestBody 已注入",
raw_op.get("requestBody", {}).get("content", {}).get("application/json", {}).get("schema", {}).get("$ref")
== "#/components/schemas/demo_raw_echo_POST_Request",
str(raw_op.get("requestBody")),
)
check(
"请求模型注册到 components/schemas",
"demo_raw_echo_POST_Request" in schema["components"]["schemas"],
)
# validate=true 端点:FastAPI 原生 requestBody(不受影响)
ok_op = schema["paths"]["/api/v1/echo"]["post"]
check(
"validate=true 端点原生 requestBody 保留",
"requestBody" in ok_op,
str(ok_op.get("requestBody")),
)
# GET 端点无 requestBody
check(
"GET 端点无 requestBody",
"requestBody" not in schema["paths"]["/api/v1/raw/get"]["get"],
)
# ---- D. WorkerManager 单测(FakeSession)----
class FakeSession:
"""模拟 zenoh Session:get() 返回单回复迭代器"""
def __init__(self, payload=b'{"result": 5}', err_payload=None):
self.payload = payload
self.err_payload = err_payload
self.get_calls = 0
def get(self, *args, **kwargs):
self.get_calls += 1
if self.err_payload is not None:
reply = SimpleNamespace(ok=None, err=SimpleNamespace(payload=self.err_payload))
else:
reply = SimpleNamespace(ok=SimpleNamespace(payload=self.payload), err=None)
return iter([reply])
def make_wm(session, cache=None):
wm = WorkerManager(session, cache=cache if cache is not None else {})
wm._workers_cache = {"demo": ["worker/demo/w1"]}
return wm
async def test_wm_call_raw_no_parse():
session = FakeSession(payload=b'{"result": 5}')
wm = make_wm(session)
result = await wm.call_raw("demo", "calc.add", b'{"meta":{},"data":{}}')
check(
"call_raw 返回原始字节(零解析)",
isinstance(result, bytes) and result == b'{"result": 5}',
repr(result),
)
check("session.get 仅调用 1 次", session.get_calls == 1, str(session.get_calls))
async def test_wm_call_dict_semantics():
session = FakeSession(payload=b'{"result": 5}')
wm = make_wm(session)
result = await wm.call("demo", "calc.add", {"meta": {}, "data": {}})
check("call() 仍解析为 dict(MQTT 语义保留)", result == {"result": 5}, repr(result))
async def test_wm_cache_bytes():
cache = {}
session = FakeSession(payload=b'{"result": 5}')
wm = make_wm(session, cache)
r1 = await wm.call_raw("demo", "calc.add", b'{"meta":{},"data":{}}', cacheable=True)
r2 = await wm.call_raw("demo", "calc.add", b'{"meta":{},"data":{}}', cacheable=True)
check("缓存命中返回原始 bytes", r1 == r2 == b'{"result": 5}', repr(r1))
check("二次调用命中缓存(不再发查询)", session.get_calls == 1, str(session.get_calls))
check("缓存值为原始 bytes", all(isinstance(v, bytes) for v in cache.values()))
# 业务错误(code!=0)不缓存
session2 = FakeSession(payload=b'{"code": 1, "error": "x"}')
wm2 = make_wm(session2, {})
await wm2.call_raw("demo", "calc.add", b'{"meta":{},"data":{}}', cacheable=True)
check("业务错误不写入缓存", len(wm2.cache) == 0, str(wm2.cache))
async def test_wm_reply_err_retry():
session = FakeSession(err_payload=b'{"error": "boom"}')
wm = make_wm(session)
try:
await wm.call_raw("demo", "calc.add", b'{}')
check("reply.err 应抛 RuntimeError", False)
except RuntimeError as e:
check("reply.err 重试后抛 RuntimeError", "boom" in str(e), str(e))
check("reply.err 触发重试(2 次查询)", session.get_calls == 2, str(session.get_calls))
async def test_wm_empty_reply_fallback():
session = FakeSession(payload=b"")
wm = make_wm(session)
check("call_raw 空回复兜底 b\"{}\"", await wm.call_raw("demo", "calc.add", b"{}") == b"{}")
check("call() 空回复 → {}", await wm.call("demo", "calc.add", {"meta": {}, "data": {}}) == {})
def run():
# HTTP 层(TestClient)
test_reply_bytes_passthrough()
test_framework_error_503()
test_business_error_passthrough_200()
test_validate_false_envelope_splice()
test_validate_false_empty_body()
test_validate_true_still_422()
test_validate_false_skips_validation()
test_validate_false_get_unaffected()
test_openapi_request_body_injected()
# WorkerManager 层(FakeSession)
asyncio.run(test_wm_call_raw_no_parse())
asyncio.run(test_wm_call_dict_semantics())
asyncio.run(test_wm_cache_bytes())
asyncio.run(test_wm_reply_err_retry())
asyncio.run(test_wm_empty_reply_fallback())
print(f"\n结果: {len(failures)} 个失败")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(run())