feat: add profile candidate review workflow

This commit is contained in:
张胜宇
2026-09-11 17:47:06 +08:00
parent 3a21afa847
commit a769130658
7 changed files with 437 additions and 14 deletions
@@ -0,0 +1,144 @@
"""客户画像候选状态机的权限、输出和状态边界测试。"""
from datetime import UTC, datetime
from unittest.mock import AsyncMock
import pytest
from app.core.contracts import RequestContext
from app.core.errors import ForbiddenAgentError, InvalidStateError
from app.model.memory import MemoryUnit
from app.service.customer_profile_candidate_service import (
ADMIN_REVIEW_PERMISSION,
USER_CONFIRM_PERMISSION,
CustomerProfileCandidateService,
)
NOW = datetime.now(UTC).replace(tzinfo=None)
def candidate(*, status: str = "candidate") -> MemoryUnit:
"""构造不含原始证据的候选实体。"""
return MemoryUnit(
id=11, memory_uuid="candidate-11", customer_id=7,
memory_key="preference:risk_level", content="稳健型",
memory_type="preference", source_type="AI对话提取", source_confidence=0.9,
confidence=0.9, evidence_count=1, conflict_count=0, recall_count=0,
status=status, valid_from=NOW, version=1, created_at=NOW, updated_at=NOW,
)
@pytest.mark.asyncio
async def test_customer_confirmation_requires_dedicated_permission(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""用户确认不能借用客服运行权限,必须使用候选专用权限。"""
captured: list[tuple[str, bool]] = []
class RecordingAuthorization:
@staticmethod
async def require(context: RequestContext, permission: str, *, admin: bool = False) -> None:
del context
captured.append((permission, admin))
raise ForbiddenAgentError("stop at gate")
monkeypatch.setattr(
"app.service.customer_profile_candidate_service.AuthorizationService",
RecordingAuthorization,
)
with pytest.raises(ForbiddenAgentError):
await CustomerProfileCandidateService().decide_by_customer(
11, "confirmed", RequestContext(user_id="7", trace_id="candidate-test")
)
assert captured == [(USER_CONFIRM_PERMISSION, False)]
@pytest.mark.asyncio
async def test_admin_review_requires_admin_role_and_permission(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""管理员审核必须同时具备专用权限和管理员角色。"""
captured: list[tuple[str, bool]] = []
class RecordingAuthorization:
@staticmethod
async def require(context: RequestContext, permission: str, *, admin: bool = False) -> None:
del context
captured.append((permission, admin))
raise ForbiddenAgentError("stop at gate")
monkeypatch.setattr(
"app.service.customer_profile_candidate_service.AuthorizationService",
RecordingAuthorization,
)
with pytest.raises(ForbiddenAgentError):
await CustomerProfileCandidateService().review_by_admin(
11, "approved", RequestContext(user_id="9003", trace_id="candidate-admin")
)
assert captured == [(ADMIN_REVIEW_PERMISSION, True)]
def test_candidate_view_excludes_evidence_and_raw_customer_data() -> None:
"""对客和管理列表只返回结构化候选,不暴露证据字段。"""
data = CustomerProfileCandidateService._view(candidate())
assert data["candidate_id"] == 11
assert data["value"] == "稳健型"
assert "evidence_excerpt" not in data
assert "customer_id" in data
@pytest.mark.asyncio
async def test_candidate_transition_rejects_repeated_processing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""已处理候选不能重复确认或审核。"""
class AllowingAuthorization:
require = AsyncMock()
monkeypatch.setattr(
"app.service.customer_profile_candidate_service.AuthorizationService",
AllowingAuthorization,
)
class Transaction:
async def __aenter__(self) -> None:
return None
async def __aexit__(self, *args: object) -> None:
return None
class FakeSession:
def __init__(self) -> None:
self.target = candidate(status="verified")
async def __aenter__(self) -> "FakeSession":
return self
async def __aexit__(self, *args: object) -> None:
return None
def begin(self) -> Transaction:
return Transaction()
async def scalar(self, statement: object) -> MemoryUnit:
del statement
return self.target
def add(self, item: object) -> None:
del item
async def flush(self) -> None:
return None
class FakeFactory:
def __call__(self) -> FakeSession:
return FakeSession()
monkeypatch.setattr(
"app.service.customer_profile_candidate_service.SessionFactory", FakeFactory()
)
with pytest.raises(InvalidStateError):
await CustomerProfileCandidateService().decide_by_customer(
11, "confirmed", RequestContext(user_id="7", trace_id="candidate-test")
)