Files
wecom_it_smart_desk/backend/tests/test_invite_status.py
T

496 lines
18 KiB
Python
Raw Normal View History

# =============================================================================
# 会话邀请参与者状态校验 — 单元测试
# =============================================================================
# 测试覆盖:
# 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: MagicMockadd/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"}