- Added new endpoints to the analyst API for managing assets, including `GET /assets` to list assets and `POST /assets/{kind}/{asset_id}/publish` to publish assets.
- Introduced `DictAmbiguityCheckRequest` schema for checking metric ambiguities, enhancing the analyst's ability to clarify definitions and aliases.
- Implemented `detect_dict_ambiguity` function to analyze potential ambiguities in metrics, providing structured feedback for users.
- Updated `AnalystAgent` to support the new asset management functionalities and ambiguity detection logic, improving overall user experience.
- Enhanced existing schemas and services to accommodate new features, ensuring robust data handling and validation.
This update significantly improves the analyst API's capabilities, allowing for better asset management and clarity in metric definitions.
204 lines
7.0 KiB
Python
204 lines
7.0 KiB
Python
"""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 import Request
|
||
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
|
||
from app.utils.exceptions import ApiError
|
||
|
||
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)
|
||
|
||
def exists(self, key: str) -> bool:
|
||
return False
|
||
|
||
def ping(self) -> bool:
|
||
return True
|
||
|
||
|
||
@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_health_probe(client):
|
||
r = client.get("/health?probe=1")
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["ok"] is True
|
||
assert body["checks"]["chat_close_all"] is True
|
||
|
||
|
||
def test_api_ready(client):
|
||
r = client.get("/api/ready")
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "checks" in body
|
||
assert body["checks"]["chat_close_all"] is True
|
||
assert body["checks"]["products_nav_history"] is True
|
||
assert body["ok"] is True
|
||
|
||
|
||
def test_api_ready_ok_when_redis_down_but_routes_mounted(client, monkeypatch):
|
||
class BrokenGateway:
|
||
def ping(self) -> bool:
|
||
raise ConnectionError("redis unavailable")
|
||
|
||
monkeypatch.setattr(redis_gateway, "_gateway", BrokenGateway())
|
||
body = client.get("/api/ready").json()
|
||
assert body["ok"] is True
|
||
assert body["degraded"] is True
|
||
assert body["checks"]["redis"] is False
|
||
|
||
|
||
def test_all_routers_mounted(client):
|
||
paths = client.get("/openapi.json").json()["paths"]
|
||
assert set(paths) == {
|
||
"/health",
|
||
"/api/ready",
|
||
"/api/auth/login",
|
||
"/api/customers/{customer_id}",
|
||
"/api/customers/{customer_id}/holdings",
|
||
"/api/customers/{customer_id}/threshold-check",
|
||
"/api/customers/{customer_id}/trades",
|
||
"/api/customers/{customer_id}/products",
|
||
"/api/products",
|
||
"/api/products/{product_id}",
|
||
"/api/products/{product_id}/nav",
|
||
"/api/products/{product_id}/nav/history",
|
||
"/api/advisors/{advisor_id}/customers",
|
||
"/api/staff/me",
|
||
"/api/compliance/suitability-check",
|
||
"/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/close-all",
|
||
"/api/chat/sessions/{session_id}/messages",
|
||
"/api/chat/sessions/{session_id}/close",
|
||
# 方案 C:SSE 流式对话
|
||
"/api/chat/stream",
|
||
"/api/chat/visitor",
|
||
"/api/analyst/chat",
|
||
"/api/analyst/interpret",
|
||
"/api/analyst/analyze",
|
||
"/api/analyst/template-prompts",
|
||
"/api/analyst/query/{trace_id}/sample",
|
||
"/api/analyst/escalate",
|
||
"/api/analyst/dashboard",
|
||
"/api/analyst/assets",
|
||
"/api/analyst/assets/{kind}/{asset_id}/publish",
|
||
"/api/analyst/dict/ambiguity-check",
|
||
"/api/analyst/ops/metrics",
|
||
}
|
||
|
||
|
||
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_trace_middleware_reraises_api_error(monkeypatch):
|
||
"""trace 中间件不得把 ApiError 吞成 500(须交给 FastAPI exception handler)。"""
|
||
from app.main import trace_middleware
|
||
|
||
async def _call_next(_request):
|
||
raise ApiError(403, "AUTH_403_TEST", "forbidden for test")
|
||
|
||
request = Request({"type": "http", "method": "GET", "path": "/", "headers": []})
|
||
with pytest.raises(ApiError) as exc_info:
|
||
import asyncio
|
||
|
||
asyncio.run(trace_middleware(request, _call_next))
|
||
assert exc_info.value.status_code == 403
|
||
|
||
|
||
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
|