feat: add profile candidate review workflow
This commit is contained in:
@@ -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")
|
||||
)
|
||||
Reference in New Issue
Block a user