Files
group_fqcd_jr/tests/unit/service/test_conversation_service.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +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