"""链路追踪(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