496 lines
18 KiB
Python
496 lines
18 KiB
Python
# =============================================================================
|
||
# 会话邀请参与者状态校验 — 单元测试
|
||
# =============================================================================
|
||
# 测试覆盖:
|
||
# 1. ai_handling 状态 → 邀请成功(本次修复核心)
|
||
# 2. serving 状态 → 邀请成功(原有功能不回归)
|
||
# 3. resolved 状态 → 邀请失败
|
||
# 4. queued 状态 → 邀请失败
|
||
# 5. pending_close 状态 → 邀请失败
|
||
# 6. 权限校验:非参与者邀请被拒绝(3030 错误码)
|
||
# 7. 重复邀请校验(3032 错误码)
|
||
#
|
||
# 设计原则:
|
||
# - mock SessionService 的内部辅助方法和 DB,聚焦 invite_participants 业务逻辑
|
||
# - 参考 backend/tests/test_org_tree.py 的测试风格
|
||
# =============================================================================
|
||
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
import pytest
|
||
|
||
from app.services.session_service import SessionService
|
||
from app.utils.response import AppException
|
||
|
||
|
||
# =============================================================================
|
||
# 辅助:构造 mock 会话对象与 SessionService 实例
|
||
# =============================================================================
|
||
|
||
def _mock_conversation(
|
||
status: str = "serving",
|
||
assigned_agent_id: str = "agent_001",
|
||
employee_id: str = "emp_001",
|
||
participants: list | None = None,
|
||
conv_id: str = "conv-001",
|
||
):
|
||
"""构造一个模拟会话对象,用于测试 invite_participants。
|
||
|
||
Args:
|
||
status: 会话状态
|
||
assigned_agent_id: 主责坐席ID
|
||
employee_id: 员工ID(会话发起人)
|
||
participants: 已有参与者列表
|
||
conv_id: 会话ID
|
||
"""
|
||
conv = MagicMock()
|
||
conv.id = conv_id
|
||
conv.status = status
|
||
conv.assigned_agent_id = assigned_agent_id
|
||
conv.employee_id = employee_id
|
||
conv.participants = participants if participants is not None else []
|
||
conv.updated_at = MagicMock()
|
||
return conv
|
||
|
||
|
||
def _new_participants():
|
||
"""构造被邀请人列表(1 个新员工)。"""
|
||
return [
|
||
{"id": "emp_002", "name": "张三", "department": "技术部", "type": "employee"},
|
||
]
|
||
|
||
|
||
def _make_service():
|
||
"""构造 SessionService 实例,所有外部依赖已 mock。
|
||
|
||
返回的 service 对象已 mock 以下方法/属性:
|
||
- _get_conversation: AsyncMock(需在测试中设置 return_value)
|
||
- _get_employee_avatar: AsyncMock,返回 None
|
||
- _create_system_message: AsyncMock
|
||
- _broadcast_participant_change: AsyncMock
|
||
- db: MagicMock(add/flush 已 mock)
|
||
- wecom_service: None(跳过通知发送)
|
||
"""
|
||
db = MagicMock()
|
||
db.add = MagicMock()
|
||
db.flush = AsyncMock()
|
||
|
||
service = SessionService(db=db, wecom_service=None)
|
||
service._get_conversation = AsyncMock()
|
||
service._get_employee_avatar = AsyncMock(return_value=None)
|
||
service._create_system_message = AsyncMock()
|
||
service._broadcast_participant_change = AsyncMock()
|
||
return service
|
||
|
||
|
||
# =============================================================================
|
||
# 一、状态校验:各状态下的邀请行为
|
||
# =============================================================================
|
||
|
||
class TestInviteStatusCheck:
|
||
"""invite_participants 状态校验测试。
|
||
|
||
修复前:仅 serving 状态允许邀请,ai_handling 被错误拒绝。
|
||
修复后:serving 和 ai_handling 均允许邀请。
|
||
"""
|
||
|
||
# ----- 1.1 ai_handling 状态 → 邀请成功(本次修复核心) -----
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ai_handling_status_invite_succeeds(self):
|
||
"""ai_handling 状态下邀请参与者应成功(本次修复核心)。"""
|
||
# Arrange
|
||
service = _make_service()
|
||
conv = _mock_conversation(status="ai_handling")
|
||
service._get_conversation.return_value = conv
|
||
|
||
# Act
|
||
result = await service.invite_participants(
|
||
conversation_id="conv-001",
|
||
inviter_agent_id="agent_001",
|
||
participants=_new_participants(),
|
||
)
|
||
|
||
# Assert
|
||
assert result is conv
|
||
# 参与者列表已更新,包含新邀请人
|
||
assert len(conv.participants) == 1
|
||
assert conv.participants[0]["id"] == "emp_002"
|
||
# DB 写入被调用
|
||
service.db.add.assert_called_once_with(conv)
|
||
service.db.flush.assert_awaited_once()
|
||
|
||
# ----- 1.2 serving 状态 → 邀请成功(原有功能不回归) -----
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_serving_status_invite_succeeds(self):
|
||
"""serving 状态下邀请参与者应成功(原有功能不回归)。"""
|
||
# Arrange
|
||
service = _make_service()
|
||
conv = _mock_conversation(status="serving")
|
||
service._get_conversation.return_value = conv
|
||
|
||
# Act
|
||
result = await service.invite_participants(
|
||
conversation_id="conv-001",
|
||
inviter_agent_id="agent_001",
|
||
participants=_new_participants(),
|
||
)
|
||
|
||
# Assert
|
||
assert result is conv
|
||
assert len(conv.participants) == 1
|
||
assert conv.participants[0]["id"] == "emp_002"
|
||
service.db.add.assert_called_once_with(conv)
|
||
service.db.flush.assert_awaited_once()
|
||
|
||
# ----- 1.3 resolved 状态 → 邀请失败 -----
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_resolved_status_invite_fails(self):
|
||
"""resolved 状态下邀请参与者应失败(已结单不应允许邀请)。"""
|
||
# Arrange
|
||
service = _make_service()
|
||
conv = _mock_conversation(status="resolved")
|
||
service._get_conversation.return_value = conv
|
||
|
||
# Act & Assert
|
||
with pytest.raises(AppException) as exc_info:
|
||
await service.invite_participants(
|
||
conversation_id="conv-001",
|
||
inviter_agent_id="agent_001",
|
||
participants=_new_participants(),
|
||
)
|
||
assert exc_info.value.code == 3031
|
||
# 不应写 DB
|
||
service.db.add.assert_not_called()
|
||
service.db.flush.assert_not_awaited()
|
||
|
||
# ----- 1.4 queued 状态 → 邀请失败 -----
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_queued_status_invite_fails(self):
|
||
"""queued 状态下邀请参与者应失败(排队中不应允许邀请)。"""
|
||
# Arrange
|
||
service = _make_service()
|
||
conv = _mock_conversation(status="queued")
|
||
service._get_conversation.return_value = conv
|
||
|
||
# Act & Assert
|
||
with pytest.raises(AppException) as exc_info:
|
||
await service.invite_participants(
|
||
conversation_id="conv-001",
|
||
inviter_agent_id="agent_001",
|
||
participants=_new_participants(),
|
||
)
|
||
assert exc_info.value.code == 3031
|
||
service.db.add.assert_not_called()
|
||
service.db.flush.assert_not_awaited()
|
||
|
||
# ----- 1.5 pending_close 状态 → 邀请失败 -----
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pending_close_status_invite_fails(self):
|
||
"""pending_close 状态下邀请参与者应失败(待关单不应允许邀请)。"""
|
||
# Arrange
|
||
service = _make_service()
|
||
conv = _mock_conversation(status="pending_close")
|
||
service._get_conversation.return_value = conv
|
||
|
||
# Act & Assert
|
||
with pytest.raises(AppException) as exc_info:
|
||
await service.invite_participants(
|
||
conversation_id="conv-001",
|
||
inviter_agent_id="agent_001",
|
||
participants=_new_participants(),
|
||
)
|
||
assert exc_info.value.code == 3031
|
||
service.db.add.assert_not_called()
|
||
service.db.flush.assert_not_awaited()
|
||
|
||
# ----- 1.6 错误消息包含当前状态信息 -----
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_error_message_contains_current_status(self):
|
||
"""状态校验失败时,错误消息应包含当前状态值。"""
|
||
# Arrange
|
||
service = _make_service()
|
||
conv = _mock_conversation(status="resolved")
|
||
service._get_conversation.return_value = conv
|
||
|
||
# Act & Assert
|
||
with pytest.raises(AppException) as exc_info:
|
||
await service.invite_participants(
|
||
conversation_id="conv-001",
|
||
inviter_agent_id="agent_001",
|
||
participants=_new_participants(),
|
||
)
|
||
assert "resolved" in exc_info.value.message
|
||
|
||
|
||
# =============================================================================
|
||
# 二、权限校验:非参与者邀请被拒绝
|
||
# =============================================================================
|
||
|
||
class TestInvitePermissionCheck:
|
||
"""invite_participants 权限校验测试。
|
||
|
||
权限规则:只有主责坐席、会话发起人、或已在 participants 中的参与者可以邀请。
|
||
"""
|
||
|
||
# ----- 2.1 非参与者邀请 → 被拒绝(3030) -----
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_non_participant_invite_rejected(self):
|
||
"""非主责坐席、非发起人、非参与者的用户邀请应被拒绝(3030)。"""
|
||
# Arrange
|
||
service = _make_service()
|
||
conv = _mock_conversation(
|
||
status="serving",
|
||
assigned_agent_id="agent_001",
|
||
employee_id="emp_001",
|
||
participants=[], # 没有已有参与者
|
||
)
|
||
service._get_conversation.return_value = conv
|
||
|
||
# Act & Assert — inviter 不是 agent_001 也不是 emp_001
|
||
with pytest.raises(AppException) as exc_info:
|
||
await service.invite_participants(
|
||
conversation_id="conv-001",
|
||
inviter_agent_id="outsider_999",
|
||
participants=_new_participants(),
|
||
)
|
||
assert exc_info.value.code == 3030
|
||
service.db.add.assert_not_called()
|
||
|
||
# ----- 2.2 主责坐席邀请 → 通过权限校验 -----
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_primary_agent_can_invite(self):
|
||
"""主责坐席可以发起邀请。"""
|
||
# Arrange
|
||
service = _make_service()
|
||
conv = _mock_conversation(
|
||
status="serving",
|
||
assigned_agent_id="agent_001",
|
||
employee_id="emp_001",
|
||
)
|
||
service._get_conversation.return_value = conv
|
||
|
||
# Act
|
||
result = await service.invite_participants(
|
||
conversation_id="conv-001",
|
||
inviter_agent_id="agent_001",
|
||
participants=_new_participants(),
|
||
)
|
||
|
||
# Assert
|
||
assert result is conv
|
||
assert len(conv.participants) == 1
|
||
|
||
# ----- 2.3 会话发起人邀请 → 通过权限校验 -----
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_creator_can_invite(self):
|
||
"""会话发起人(employee_id)可以发起邀请。"""
|
||
# Arrange
|
||
service = _make_service()
|
||
conv = _mock_conversation(
|
||
status="ai_handling",
|
||
assigned_agent_id="agent_001",
|
||
employee_id="emp_001",
|
||
)
|
||
service._get_conversation.return_value = conv
|
||
|
||
# Act
|
||
result = await service.invite_participants(
|
||
conversation_id="conv-001",
|
||
inviter_agent_id="emp_001",
|
||
participants=_new_participants(),
|
||
)
|
||
|
||
# Assert
|
||
assert result is conv
|
||
assert len(conv.participants) == 1
|
||
|
||
# ----- 2.4 已有参与者邀请 → 通过权限校验 -----
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_existing_participant_can_invite(self):
|
||
"""已在 participants 列表中的参与者可以邀请其他人。"""
|
||
# Arrange
|
||
service = _make_service()
|
||
conv = _mock_conversation(
|
||
status="serving",
|
||
assigned_agent_id="agent_001",
|
||
employee_id="emp_001",
|
||
participants=[
|
||
{"id": "emp_005", "name": "王五", "type": "employee"},
|
||
],
|
||
)
|
||
service._get_conversation.return_value = conv
|
||
|
||
# Act — emp_005 是已有参与者,邀请新的人
|
||
result = await service.invite_participants(
|
||
conversation_id="conv-001",
|
||
inviter_agent_id="emp_005",
|
||
participants=_new_participants(),
|
||
)
|
||
|
||
# Assert
|
||
assert result is conv
|
||
# 参与者列表包含原有的 + 新邀请的
|
||
assert len(conv.participants) == 2
|
||
participant_ids = {p["id"] for p in conv.participants}
|
||
assert "emp_005" in participant_ids
|
||
assert "emp_002" in participant_ids
|
||
|
||
|
||
# =============================================================================
|
||
# 三、重复邀请校验
|
||
# =============================================================================
|
||
|
||
class TestInviteDuplicateCheck:
|
||
"""invite_participants 重复邀请校验测试。"""
|
||
|
||
# ----- 3.1 所有被邀请人已在会话中 → 3032 -----
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_all_invitees_already_in_conversation(self):
|
||
"""所有被邀请人已在会话中时,应抛出 3032。"""
|
||
# Arrange
|
||
service = _make_service()
|
||
conv = _mock_conversation(
|
||
status="serving",
|
||
participants=[
|
||
{"id": "emp_002", "name": "张三", "type": "employee"},
|
||
],
|
||
)
|
||
service._get_conversation.return_value = conv
|
||
|
||
# Act & Assert — 邀请已在会话中的 emp_002
|
||
with pytest.raises(AppException) as exc_info:
|
||
await service.invite_participants(
|
||
conversation_id="conv-001",
|
||
inviter_agent_id="agent_001",
|
||
participants=[
|
||
{"id": "emp_002", "name": "张三", "type": "employee"},
|
||
],
|
||
)
|
||
assert exc_info.value.code == 3032
|
||
|
||
# ----- 3.2 部分新部分旧 → 新的被加入 -----
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_partial_new_invitees_added(self):
|
||
"""部分被邀请人已存在时,只添加新的人。"""
|
||
# Arrange
|
||
service = _make_service()
|
||
conv = _mock_conversation(
|
||
status="ai_handling",
|
||
participants=[
|
||
{"id": "emp_002", "name": "张三", "type": "employee"},
|
||
],
|
||
)
|
||
service._get_conversation.return_value = conv
|
||
|
||
# Act — 邀请 emp_002(已有)和 emp_003(新)
|
||
result = await service.invite_participants(
|
||
conversation_id="conv-001",
|
||
inviter_agent_id="agent_001",
|
||
participants=[
|
||
{"id": "emp_002", "name": "张三", "type": "employee"},
|
||
{"id": "emp_003", "name": "李四", "type": "employee"},
|
||
],
|
||
)
|
||
|
||
# Assert
|
||
assert result is conv
|
||
assert len(conv.participants) == 2
|
||
participant_ids = {p["id"] for p in conv.participants}
|
||
assert "emp_002" in participant_ids
|
||
assert "emp_003" in participant_ids
|
||
|
||
|
||
# =============================================================================
|
||
# 四、成功邀请的副作用验证
|
||
# =============================================================================
|
||
|
||
class TestInviteSideEffects:
|
||
"""invite_participants 成功时的副作用验证。"""
|
||
|
||
# ----- 4.1 创建系统消息广播 -----
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_system_message_created_on_success(self):
|
||
"""成功邀请后应创建系统消息广播。"""
|
||
# Arrange
|
||
service = _make_service()
|
||
conv = _mock_conversation(status="ai_handling")
|
||
service._get_conversation.return_value = conv
|
||
|
||
# Act
|
||
await service.invite_participants(
|
||
conversation_id="conv-001",
|
||
inviter_agent_id="agent_001",
|
||
participants=_new_participants(),
|
||
)
|
||
|
||
# Assert
|
||
service._create_system_message.assert_awaited_once()
|
||
call_kwargs = service._create_system_message.call_args.kwargs
|
||
assert call_kwargs["conversation_id"] == "conv-001"
|
||
assert "张三" in call_kwargs["content"]
|
||
|
||
# ----- 4.2 WebSocket 广播参与者变更 -----
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_broadcast_participant_change_on_success(self):
|
||
"""成功邀请后应广播参与者变更通知。"""
|
||
# Arrange
|
||
service = _make_service()
|
||
conv = _mock_conversation(status="serving")
|
||
service._get_conversation.return_value = conv
|
||
|
||
# Act
|
||
await service.invite_participants(
|
||
conversation_id="conv-001",
|
||
inviter_agent_id="agent_001",
|
||
participants=_new_participants(),
|
||
)
|
||
|
||
# Assert
|
||
service._broadcast_participant_change.assert_awaited_once()
|
||
call_args = service._broadcast_participant_change.call_args
|
||
assert call_args.args[0] is conv # 第一个参数是 conversation
|
||
assert call_args.args[1] == "participant_invited" # 事件类型
|
||
|
||
# ----- 4.3 多人邀请全部添加 -----
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_multiple_invitees_all_added(self):
|
||
"""一次邀请多人时,所有人都应被添加。"""
|
||
# Arrange
|
||
service = _make_service()
|
||
conv = _mock_conversation(status="ai_handling", participants=[])
|
||
service._get_conversation.return_value = conv
|
||
|
||
participants = [
|
||
{"id": "emp_010", "name": "赵六", "type": "employee"},
|
||
{"id": "emp_011", "name": "孙七", "type": "employee"},
|
||
{"id": "emp_012", "name": "周八", "type": "employee"},
|
||
]
|
||
|
||
# Act
|
||
result = await service.invite_participants(
|
||
conversation_id="conv-001",
|
||
inviter_agent_id="agent_001",
|
||
participants=participants,
|
||
)
|
||
|
||
# Assert
|
||
assert result is conv
|
||
assert len(conv.participants) == 3
|
||
added_ids = {p["id"] for p in conv.participants}
|
||
assert added_ids == {"emp_010", "emp_011", "emp_012"}
|