Files
group_fqcd_jr/tests/unit/service/test_conversation_service.py
T
lzf_0626 8d79bd9767 补齐三个成功响应缺失的 meta 信封;信封实现抽为公共(docs/05 §3.3)
"缺 meta"很容易被误判成"有":X-Trace-ID 是**响应头**(中间件加),和 body 里的
meta.trace_id 是两件事;错误响应一直有 meta(异常处理器统一加),漏的只有成功路径。

核到三个端点在把 service 的内部结构直接当响应体返回:

- GET /conversations/{session_id}/messages → 裸 {"data": [...]},meta 整个缺失,
  游标也没地方放(§3.3 要求列表的 data 为纯数组、next_cursor/has_more 进 meta)
- POST /conversation-messages/{id}/feedback → 同样没有 meta
- GET /knowledge-references/{token} → 直接返回资源对象

改动:

- 新增 app/api/views/envelope.py,把 envelope / list_envelope 抽成一份公共实现,
  风控链路改为复用它 —— 同一份契约写两遍的结果就是其中一处漏了 meta。
- ConversationService.messages 改为返回内部结构 {items, next_cursor, has_more},
  用 limit + 1 判断 has_more:只看"取满没取满"会把恰好等于 limit 的最后一页说成
  还有下一页。next_cursor 取本页最后一条的 message_id —— 游标语义是"取更旧的一页",
  天然可续,集成测试本来就是这么翻页的。
- Controller 统一套信封,data 仍是数组、字段名不变,前端不需要改。

测试:新增 tests/unit/api/test_response_envelope.py,断言 set(body) == {"data","meta"}
(多或少一个顶层字段都会红),并覆盖 has_more / next_cursor / trace_id;
另更新两处既有断言(limit 20→21、feedback 返回裸对象)。

门禁:ruff 干净 / mypy 138 文件 / 696 unit+contract / 33 integration。
2026-09-11 15:21:36 +08:00

172 lines
5.8 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.
"""`ConversationService` 契约测试(全部替身,不连数据库)。
覆盖三条与安全/正确性直接相关的语义:
1. 消息列表必须按**当前用户**过滤(`user_id` 必须透传给 Repository,不能被绕过);
2. 反馈的四种结局各自独立:权限不足 / 消息不存在 / 重复反馈 / 成功;
3. 成功路径在同一事务里既写反馈又写审计,且审计带 `trace_id`。
"""
from datetime import datetime
from typing import Any
import pytest
from app.core.contracts import RequestContext
from app.core.errors import (
FeedbackAlreadyExistsError,
ForbiddenAgentError,
GenericResourceNotFoundError,
)
from app.service.conversation_service import ConversationService
CONTEXT = RequestContext(user_id="9001", trace_id="trace-42")
NOW = datetime(2026, 9, 10, 12, 0, 0)
class FakeRow:
def __init__(self) -> None:
self.id = 11
self.role = "user"
self.content = "稳健型"
self.created_at = NOW
class FakeMessage:
def __init__(self) -> None:
self.session_id = "session-1"
class FakeSession:
def __init__(self) -> None:
self.added: list[Any] = []
def add(self, value: Any) -> None:
self.added.append(value)
def begin(self) -> Any:
return _AsyncContext()
class _AsyncContext:
async def __aenter__(self) -> None:
return None
async def __aexit__(self, *exc: object) -> bool:
return False
class AllowingAuth:
@staticmethod
async def require(context: RequestContext, permission: str) -> None:
del context, permission
class DenyingAuth:
@staticmethod
async def require(context: RequestContext, permission: str) -> None:
del context, permission
raise ForbiddenAgentError("缺少 conversation:feedback 权限")
def patch(
monkeypatch: pytest.MonkeyPatch,
*,
rows: list[Any] | None = None,
message: Any = "present",
existing_feedback: Any = None,
auth: type = AllowingAuth,
) -> dict[str, Any]:
captured: dict[str, Any] = {}
class FakeRepository:
def __init__(self, _session: Any) -> None:
pass
async def messages(
self, session_id: str, user_id: int, limit: int, before: int | None = None
) -> list[Any]:
captured.update(session_id=session_id, user_id=user_id, limit=limit, before=before)
return rows or []
async def message(self, message_id: int, user_id: int) -> Any:
captured.update(message_id=message_id, owner=user_id)
return None if message == "missing" else FakeMessage()
async def feedback(self, message_id: int, user_id: int) -> Any:
del message_id, user_id
return existing_feedback
monkeypatch.setattr("app.service.conversation_service.ConversationRepository", FakeRepository)
monkeypatch.setattr("app.service.conversation_service.AuthorizationService", auth)
return captured
async def test_messages_are_scoped_to_current_user(monkeypatch: pytest.MonkeyPatch) -> None:
captured = patch(monkeypatch, rows=[FakeRow()])
result = await ConversationService(FakeSession()).messages("session-1", CONTEXT, 20)
# user_id 必须透传:Repository 靠它做归属过滤,漏传就等于不限范围。
assert captured["user_id"] == 9001
# 21 = limit + 1:service 多取一行判断"还有没有更旧的",用来填 §3.3 的 has_more。
# 只看"取满没取满"会把恰好等于 limit 的最后一页说成还有下一页。
assert captured["limit"] == 21
assert captured["session_id"] == "session-1"
# 不带游标时必须传 None,行为与加游标前一致(取最新一页)。
assert captured["before"] is None
# service 返回内部结构,Controller 用 list_envelope 拆成 {data, meta}(§3.3)。
# 一行数据小于 limit,所以没有下一页。
assert result["items"] == [
{"message_id": "11", "role": "user", "content": "稳健型",
"created_at": NOW.isoformat() + "Z"}
]
assert result["has_more"] is False
assert result["next_cursor"] is None
async def test_messages_forward_cursor_to_repository(monkeypatch: pytest.MonkeyPatch) -> None:
"""合法游标必须真正作为分页边界透传,而不是被静默忽略。"""
captured = patch(monkeypatch, rows=[FakeRow()])
await ConversationService(FakeSession()).messages("session-1", CONTEXT, 20, before=42)
assert captured["before"] == 42
async def test_feedback_requires_permission(monkeypatch: pytest.MonkeyPatch) -> None:
patch(monkeypatch, auth=DenyingAuth)
with pytest.raises(ForbiddenAgentError):
await ConversationService(FakeSession()).feedback(11, CONTEXT, 1, None, None)
async def test_feedback_on_missing_message_is_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
patch(monkeypatch, message="missing")
with pytest.raises(GenericResourceNotFoundError):
await ConversationService(FakeSession()).feedback(11, CONTEXT, 1, None, None)
async def test_duplicate_feedback_conflicts(monkeypatch: pytest.MonkeyPatch) -> None:
patch(monkeypatch, existing_feedback=object())
with pytest.raises(FeedbackAlreadyExistsError):
await ConversationService(FakeSession()).feedback(11, CONTEXT, 1, None, None)
async def test_feedback_writes_feedback_and_audit_with_trace_id(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session = FakeSession()
patch(monkeypatch)
result = await ConversationService(session).feedback(11, CONTEXT, 1, "rating", "很有帮助")
assert result["status"] == "open"
kinds = [type(item).__name__ for item in session.added]
assert kinds == ["ConversationFeedback", "InteractionAudit"]
audit = session.added[1]
assert audit.action_type == "conversation.feedback_created"
assert audit.detail["trace_id"] == "trace-42"
assert session.added[0].rating == 1