初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
"""链路追踪(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",
session_id="sess_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 == ["sess_test"]
# 五条记录按 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 == ["sess_test"]
# 按 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