181 lines
6.2 KiB
Python
181 lines
6.2 KiB
Python
"""`RunQueryService` 契约测试(全部使用替身,不连数据库)。
|
|
|
|
关注三条对客户端与安全都重要的语义:
|
|
1. 运行不存在与越权访问**返回同一个 404**——不能靠错误码区分"存在但无权";
|
|
2. 未成功的运行**不返回任何结果内容**(`result` 为 None),避免未提交结果外泄;
|
|
3. 快照里的时间统一以 `...Z` 结尾(UTC),以及 `watch` 的心跳与终态退出行为。
|
|
"""
|
|
|
|
from dataclasses import dataclass, replace
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from app.core.contracts import RequestContext
|
|
from app.core.errors import RunNotFoundError
|
|
from app.service.run_query_service import RunQueryService, RunSnapshot
|
|
|
|
CONTEXT = RequestContext(user_id="9001", trace_id="trace-1")
|
|
NOW = datetime(2026, 9, 10, 12, 0, 0)
|
|
|
|
|
|
@dataclass
|
|
class FakeRun:
|
|
run_id: str = "run-1"
|
|
trace_id: str = "trace-1"
|
|
status: str = "running"
|
|
agent_type: str = "customer_service"
|
|
session_id: str = "session-1"
|
|
error_code: str | None = None
|
|
created_at: datetime = NOW
|
|
completed_at: datetime | None = None
|
|
|
|
|
|
@dataclass
|
|
class FakeMessage:
|
|
content: str = "稳健型"
|
|
tool_calls: Any = None
|
|
intent: str | None = "general"
|
|
confidence: Any = None
|
|
source_references: Any = None
|
|
|
|
|
|
class FakeRepository:
|
|
def __init__(self, rows: tuple[Any, Any] | None) -> None:
|
|
self.rows = rows
|
|
|
|
async def run_result(self, run_id: str, user_id: int) -> tuple[Any, Any] | None:
|
|
del run_id, user_id
|
|
return self.rows
|
|
|
|
|
|
class FakeSession:
|
|
async def __aenter__(self) -> "FakeSession":
|
|
return self
|
|
|
|
async def __aexit__(self, *exc: object) -> bool:
|
|
return False
|
|
|
|
|
|
def patch_repository(monkeypatch: pytest.MonkeyPatch, rows: tuple[Any, Any] | None) -> None:
|
|
monkeypatch.setattr("app.service.run_query_service.SessionFactory", FakeSession)
|
|
monkeypatch.setattr(
|
|
"app.service.run_query_service.ConversationRepository",
|
|
lambda _session: FakeRepository(rows),
|
|
)
|
|
|
|
|
|
async def test_unknown_run_raises_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""不存在与越权必须是同一个错误,否则可以据此探测资源是否存在。"""
|
|
patch_repository(monkeypatch, None)
|
|
|
|
with pytest.raises(RunNotFoundError) as excinfo:
|
|
await RunQueryService().get("run-x", CONTEXT)
|
|
|
|
assert excinfo.value.code == "RUN_NOT_FOUND"
|
|
assert excinfo.value.status_code == 404
|
|
|
|
|
|
async def test_running_run_does_not_expose_result(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
patch_repository(monkeypatch, (FakeRun(status="running"), None))
|
|
|
|
snapshot = await RunQueryService().get("run-1", CONTEXT)
|
|
|
|
assert snapshot.status == "running"
|
|
assert snapshot.result is None
|
|
assert snapshot.completed_at is None
|
|
assert snapshot.created_at.endswith("Z")
|
|
|
|
|
|
async def test_succeeded_run_exposes_result_with_string_confidence(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
message = FakeMessage(
|
|
content="稳健型",
|
|
tool_calls={"calls": []},
|
|
confidence=Decimal("0.9000"),
|
|
source_references=[{"source_type": "memory"}],
|
|
)
|
|
patch_repository(monkeypatch, (FakeRun(status="succeeded", completed_at=NOW), message))
|
|
|
|
snapshot = await RunQueryService().get("run-1", CONTEXT)
|
|
|
|
assert snapshot.result is not None
|
|
assert snapshot.result["content"] == "稳健型"
|
|
# DECIMAL(5,4) 经驱动回来是 Decimal,接口层统一转字符串避免浮点精度歧义。
|
|
assert snapshot.result["confidence"] == "0.9000"
|
|
assert snapshot.result["source_references"] == [{"source_type": "memory"}]
|
|
assert snapshot.completed_at is not None and snapshot.completed_at.endswith("Z")
|
|
|
|
|
|
async def test_failed_run_does_not_expose_result(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""即使库里有结果消息,失败运行也不得把它当作成功结果返回。"""
|
|
patch_repository(
|
|
monkeypatch, (FakeRun(status="failed", error_code="AGENT_INTERNAL_ERROR"), FakeMessage())
|
|
)
|
|
|
|
snapshot = await RunQueryService().get("run-1", CONTEXT)
|
|
|
|
assert snapshot.result is None
|
|
assert snapshot.error_code == "AGENT_INTERNAL_ERROR"
|
|
|
|
|
|
def terminal_snapshot(status: str = "succeeded") -> RunSnapshot:
|
|
return RunSnapshot(
|
|
run_id="run-1",
|
|
trace_id="trace-1",
|
|
status=status,
|
|
agent_type="customer_service",
|
|
session_id="session-1",
|
|
result={"content": "ok"},
|
|
error_code=None,
|
|
created_at="2026-09-10T12:00:00Z",
|
|
completed_at="2026-09-10T12:00:01Z",
|
|
)
|
|
|
|
|
|
async def test_watch_stops_immediately_on_terminal_snapshot(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""终态快照只发一次且不再轮询——断流重连也走这条路径。"""
|
|
calls = {"count": 0}
|
|
|
|
async def never_called(self: RunQueryService, run_id: str, context: RequestContext) -> Any:
|
|
calls["count"] += 1
|
|
raise AssertionError("终态不应继续轮询数据库")
|
|
|
|
monkeypatch.setattr(RunQueryService, "get", never_called)
|
|
initial = terminal_snapshot()
|
|
|
|
events = [snapshot async for snapshot in RunQueryService().watch(initial, CONTEXT)]
|
|
|
|
assert events == [initial]
|
|
assert calls["count"] == 0
|
|
|
|
|
|
async def test_watch_emits_heartbeat_between_polls(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
"app.service.run_query_service.get_settings",
|
|
lambda: SimpleNamespace(sse_max_connection_seconds=30, sse_heartbeat_seconds=0),
|
|
)
|
|
polls = {"count": 0}
|
|
|
|
async def fake_get(self: RunQueryService, run_id: str, context: RequestContext) -> RunSnapshot:
|
|
del self, run_id, context
|
|
polls["count"] += 1
|
|
return replace(terminal_snapshot(), status="succeeded")
|
|
|
|
monkeypatch.setattr(RunQueryService, "get", fake_get)
|
|
initial = replace(terminal_snapshot(), status="running", result=None, completed_at=None)
|
|
|
|
events = [snapshot async for snapshot in RunQueryService().watch(initial, CONTEXT)]
|
|
|
|
# 序列:非终态快照 → 心跳(None) → 轮询得到的终态快照
|
|
assert [event is None for event in events] == [False, True, False]
|
|
assert events[0] is initial
|
|
assert events[2] is not None and events[2].status == "succeeded"
|
|
assert polls["count"] == 1
|