209 lines
6.9 KiB
Python
209 lines
6.9 KiB
Python
"""链路追踪(GET /admin/traces/{trace_id})测试。"""
|
||
|
||
from collections.abc import AsyncGenerator
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
|
||
from app.api.v1.deps import require_admin
|
||
from app.db.session import AsyncSessionLocal
|
||
from app.main import app
|
||
from app.models.idempotency import IdempotencyKey
|
||
from app.models.runtime import Event, Run
|
||
from app.models.safety import AuditEvent
|
||
from app.services.admin import get_trace, list_traces
|
||
|
||
TRACE_ID = "trace_test_abc123"
|
||
BASE_TIME = datetime(2026, 6, 27, 12, 0, 0, tzinfo=UTC)
|
||
|
||
|
||
async def _seed_trace_data() -> None:
|
||
"""插入同一 trace_id、时间交错的三类记录 + 关联 run/session/character,
|
||
验证归并排序、归属聚合与字段映射。"""
|
||
async with AsyncSessionLocal() as session:
|
||
session.add(
|
||
Run(
|
||
id="run_001",
|
||
character_id="char_test",
|
||
status="completed",
|
||
origin="chat",
|
||
created_at=BASE_TIME,
|
||
updated_at=BASE_TIME,
|
||
)
|
||
)
|
||
# 时间顺序:event(t0) → audit(t1) → event(t2) → idempotency(t3) → audit(t4)
|
||
session.add(
|
||
Event(
|
||
type="run.started",
|
||
source="system",
|
||
sequence=1,
|
||
trace_id=TRACE_ID,
|
||
run_id="run_001",
|
||
character_id="char_test",
|
||
payload_json="{}",
|
||
created_at=BASE_TIME,
|
||
)
|
||
)
|
||
session.add(
|
||
AuditEvent(
|
||
action="model.request.started",
|
||
trace_id=TRACE_ID,
|
||
run_id="run_001",
|
||
character_id="char_test",
|
||
decision="allow",
|
||
risk_level="L1",
|
||
reason="模型调用",
|
||
created_at=BASE_TIME + timedelta(seconds=1),
|
||
)
|
||
)
|
||
session.add(
|
||
Event(
|
||
type="message.completed",
|
||
source="model",
|
||
sequence=2,
|
||
trace_id=TRACE_ID,
|
||
run_id="run_001",
|
||
character_id="char_test",
|
||
payload_json='{"message_id":"m1"}',
|
||
created_at=BASE_TIME + timedelta(seconds=2),
|
||
)
|
||
)
|
||
session.add(
|
||
IdempotencyKey(
|
||
key="sess:1",
|
||
scope="chat",
|
||
method="POST",
|
||
path="/api/v1/chat/sessions/s1/messages",
|
||
request_hash="h1",
|
||
response_body_json='{"run_id":"run_001"}',
|
||
response_status_code=200,
|
||
trace_id=TRACE_ID,
|
||
created_at=BASE_TIME + timedelta(seconds=3),
|
||
)
|
||
)
|
||
session.add(
|
||
AuditEvent(
|
||
action="model.response.completed",
|
||
trace_id=TRACE_ID,
|
||
run_id="run_001",
|
||
character_id="char_test",
|
||
decision="allow",
|
||
risk_level="L1",
|
||
created_at=BASE_TIME + timedelta(seconds=4),
|
||
)
|
||
)
|
||
await session.commit()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_trace_aggregates_and_sorts() -> None:
|
||
await _seed_trace_data()
|
||
detail = await get_trace(TRACE_ID)
|
||
|
||
assert detail.trace_id == TRACE_ID
|
||
assert detail.run_ids == ["run_001"]
|
||
assert detail.character_ids == ["char_test"]
|
||
assert detail.session_ids == [] # 会话归属随线性会话退役
|
||
# 五条记录按 created_at 升序归并
|
||
assert [item.kind for item in detail.items] == [
|
||
"event",
|
||
"audit",
|
||
"event",
|
||
"idempotency",
|
||
"audit",
|
||
]
|
||
# 字段映射
|
||
assert detail.items[0].source_label == "运行事件 / run.started"
|
||
assert detail.items[0].detail["sequence"] == 1
|
||
assert detail.items[2].detail["payload"] == {"message_id": "m1"}
|
||
assert detail.items[3].summary == "POST /api/v1/chat/sessions/s1/messages → 200"
|
||
assert detail.items[3].source_label == "请求幂等 / POST"
|
||
# audit 含 reason → 三段;无 reason → 两段(无尾 · )
|
||
assert detail.items[1].summary == "allow · L1 · 模型调用"
|
||
assert detail.items[4].summary == "allow · L1"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_trace_empty_for_unknown() -> None:
|
||
detail = await get_trace("trace_does_not_exist")
|
||
assert detail.items == []
|
||
assert detail.run_ids == []
|
||
|
||
|
||
@pytest.fixture
|
||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||
# 业务测试聚焦链路聚合逻辑,绕过管理端点鉴权(require_admin 的覆盖见 test_admin_auth)
|
||
app.dependency_overrides[require_admin] = lambda: None
|
||
transport = ASGITransport(app=app)
|
||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||
yield http_client
|
||
app.dependency_overrides.pop(require_admin, None)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_admin_trace_endpoint(client: AsyncClient) -> None:
|
||
await _seed_trace_data()
|
||
resp = await client.get(f"/api/v1/admin/traces/{TRACE_ID}")
|
||
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["trace_id"] == TRACE_ID
|
||
assert body["run_ids"] == ["run_001"]
|
||
assert [i["kind"] for i in body["items"]] == [
|
||
"event",
|
||
"audit",
|
||
"event",
|
||
"idempotency",
|
||
"audit",
|
||
]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_admin_trace_endpoint_unknown_returns_empty(client: AsyncClient) -> None:
|
||
resp = await client.get("/api/v1/admin/traces/trace_missing")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["items"] == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_traces_aggregates_and_sorts_recent_first() -> None:
|
||
await _seed_trace_data()
|
||
resp = await list_traces(limit=50)
|
||
|
||
assert resp.total >= 1
|
||
summaries = {s.trace_id: s for s in resp.items}
|
||
assert TRACE_ID in summaries
|
||
s = summaries[TRACE_ID]
|
||
assert s.event_count == 2
|
||
assert s.audit_count == 2
|
||
assert s.idempotency_count == 1
|
||
assert s.character_ids == ["char_test"]
|
||
assert s.session_ids == [] # 会话归属随线性会话退役
|
||
# 按 last_seen 倒序
|
||
assert all(
|
||
resp.items[i].last_seen >= resp.items[i + 1].last_seen for i in range(len(resp.items) - 1)
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_admin_trace_list_endpoint(client: AsyncClient) -> None:
|
||
await _seed_trace_data()
|
||
resp = await client.get("/api/v1/admin/traces?limit=50")
|
||
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["total"] >= 1
|
||
assert any(s["trace_id"] == TRACE_ID for s in body["items"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_admin_trace_list_empty_when_no_data(client: AsyncClient) -> None:
|
||
"""无额外数据时返回合法空结构(种子数据可能已有少量审计事件)。"""
|
||
resp = await client.get("/api/v1/admin/traces")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert "items" in body
|
||
assert "total" in body
|
||
assert body["total"] >= 0
|