"""main 集成冒烟(B7):路由挂载 / trace 中间件 / lifespan 启动期校验 / 统一错误体。 main app 全路由经 TestClient 走真实 lifespan(dev 环境);仓储 monkeypatch 注入 sqlite(audit_log 供 401 留痕);Redis 网关注入 fake(不依赖本机 Redis)。 全链路 trace 一致性与集成测试归 B8 conftest,此处只验中间件行为本身。 """ import re import pytest from fastapi.testclient import TestClient from sqlalchemy import text from _ddl import create_sqlite_engine from app.config.settings import settings from app.main import app from app.repository.risk_repository import RiskRepository from app.service.risk import redis_gateway TRACE_HEADER_PATTERN = r"^trc-[0-9a-f]{16}$" class FakeGateway: def __init__(self): self.messages = [] self.deletes = [] def publish(self, channel, payload): self.messages.append((channel, payload)) def delete(self, *keys): self.deletes.append(keys) @pytest.fixture() def client(monkeypatch): engine = create_sqlite_engine() # DDL 单一事实源(B4 评审 P3-12) repo = RiskRepository(engine=engine) from app.api import deps as deps_mod from app.api import risk as risk_api from app.api import simulate as simulate_mod monkeypatch.setattr(risk_api, "_repo", lambda: repo) monkeypatch.setattr(simulate_mod, "_repo", lambda: repo) monkeypatch.setattr(deps_mod, "RiskRepository", lambda: repo) with TestClient(app) as c: # lifespan:dev 放行;Redis 惰性连接不触网 monkeypatch.setattr(redis_gateway, "_gateway", FakeGateway()) yield c engine.dispose() def test_health(client): r = client.get("/health") assert r.status_code == 200 and r.json()["status"] == "ok" def test_all_routers_mounted(client): paths = client.get("/openapi.json").json()["paths"] assert set(paths) == { "/health", "/api/risk/alerts", "/api/risk/alerts/{alert_id}/handle", "/api/risk/suitability/check", "/api/risk/aml/scan", "/api/simulate/trade", "/api/chat", # 方案 B:前端拉侧三端点 "/api/chat/sessions", "/api/chat/sessions/{session_id}/messages", "/api/chat/sessions/{session_id}/close", # 方案 C:SSE 流式对话 "/api/chat/stream", } def test_trace_header_generated(client): r = client.get("/health") assert r.headers["X-Trace-Id"] and re.fullmatch(TRACE_HEADER_PATTERN, r.headers["X-Trace-Id"]) def test_trace_header_passthrough(client): tid = "trc-abc123def45678" assert client.get("/health", headers={"X-Trace-Id": tid}).headers["X-Trace-Id"] == tid def test_trace_header_invalid_regenerated(client): bad = "bad id!" tid = client.get("/health", headers={"X-Trace-Id": bad}).headers["X-Trace-Id"] assert tid != bad and re.fullmatch(TRACE_HEADER_PATTERN, tid) def test_unified_error_body_401_with_trace(client): """手册 §10 错误体四键 + trace_id/request_id 分别对齐响应头(T-02 独立双 ID)。""" r = client.get("/api/risk/alerts") # 无 debug 头 assert r.status_code == 401 body = r.json() assert body["error_code"] == "AUTH_401_MISSING_DEBUG_HEADERS" assert body["message"] assert set(body) == {"error_code", "message", "trace_id", "request_id"} assert body["trace_id"] == r.headers["X-Trace-Id"] assert body["request_id"] == r.headers["X-Request-Id"] assert body["trace_id"] != body["request_id"] def test_lifespan_rejects_debug_factory_in_non_dev(monkeypatch): """挂账⑤(T-01 后兜底分支):debug 工厂在场 + 非 dev → 拒绝启动。""" monkeypatch.setattr(settings, "app_env", "production") from app.api import deps as deps_mod monkeypatch.setattr(deps_mod, "AUTH_FACTORY_IS_DEBUG", True) with pytest.raises(RuntimeError, match="debug auth factory is wired"): with TestClient(app): pass def test_lifespan_rejects_jwt_not_ready_in_non_dev(monkeypatch): """T-01:非 dev 且 RS256 公钥未配置(HS256 dev secret)→ 拒绝启动。""" monkeypatch.setattr(settings, "app_env", "production") monkeypatch.setattr(settings, "jwt_public_key_path", "") with pytest.raises(RuntimeError, match="JWT auth not ready"): with TestClient(app): pass