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