Files
hunjing/backend/tests/test_admin_trace.py
T

209 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""链路追踪(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