初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user