# ============================================================================= # 邀请链接回归测试 — 邀请 URL 绝对路径 & join_conversation 状态校验 # ============================================================================= # 测试覆盖(对应本次 3 个关联 Bug 修复): # # 一、join_conversation 状态校验(Bug 2 修复核心) # 1. ai_handling 状态 → join 成功(本次修复核心) # 2. serving 状态 → join 成功(原有功能不回归) # 3. resolved 状态 → join 失败(3033) # 4. queued 状态 → join 失败(3033) # 5. pending_close 状态 → join 失败(3033) # 6. 未被邀请的员工 → join 失败(3034) # # 二、邀请 URL 绝对路径校验(Bug 1 修复核心) # 7. invite_url 以 https:// 开头(包含完整域名) # 修复前:/h5/?invite=...(无域名,企微卡片无法跳转) # 修复后:https://itsupport.servyou.com.cn/h5/?invite=... # # 设计原则: # - mock SessionService 的内部辅助方法和 DB,聚焦业务逻辑 # - 参考 backend/tests/test_invite_status.py 的测试风格 # - 不依赖真实数据库 / 真实企微 API # ============================================================================= from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.services.session_service import SessionService from app.utils.response import AppException # ============================================================================= # 辅助:构造 mock 会话对象与 SessionService 实例 # ============================================================================= def _mock_conversation( status: str = "serving", conv_id: str = "conv-001", participants: list | None = None, assigned_agent_id: str = "agent_001", employee_id: str = "emp_001", ): """构造一个模拟会话对象,用于测试 join_conversation / invite_participants。 Args: status: 会话状态 conv_id: 会话ID participants: 参与者列表 assigned_agent_id: 主责坐席ID(invite_participants 权限校验需要) employee_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 _invited_participants(): """构造被邀请的参与者列表(1 个员工,未加入状态)。""" return [ {"id": "emp_002", "name": "张三", "type": "employee", "joined": False}, ] def _make_service(): """构造 SessionService 实例,所有外部依赖已 mock。 返回的 service 对象已 mock 以下方法/属性: - _get_conversation: AsyncMock(需在测试中设置 return_value) - _create_system_message: AsyncMock - _broadcast_participant_change: AsyncMock - db: MagicMock(add/flush 已 mock) - wecom_service: None(join_conversation 不需要) """ db = MagicMock() db.add = MagicMock() db.flush = AsyncMock() service = SessionService(db=db, wecom_service=None) service._get_conversation = AsyncMock() service._create_system_message = AsyncMock() service._broadcast_participant_change = AsyncMock() return service def _make_service_with_wecom(): """构造带 mock 企微服务的 SessionService 实例(用于邀请 URL 测试)。 额外 mock: - _get_employee_avatar: AsyncMock,返回 None(跳过头像获取) - wecom_service: MagicMock,send_card_message 为 AsyncMock """ db = MagicMock() db.add = MagicMock() db.flush = AsyncMock() wecom_service = MagicMock() wecom_service.send_card_message = AsyncMock() service = SessionService(db=db, wecom_service=wecom_service) service._get_conversation = AsyncMock() service._get_employee_avatar = AsyncMock(return_value=None) service._create_system_message = AsyncMock() service._broadcast_participant_change = AsyncMock() return service # ============================================================================= # 一、join_conversation 状态校验测试 # ============================================================================= class TestJoinConversationStatusCheck: """join_conversation 状态校验测试。 修复前:if conversation.status != "serving": → ai_handling 被拒 修复后:if conversation.status not in ("serving", "ai_handling"): """ # ----- 1.1 ai_handling 状态 → join 成功(本次修复核心) ----- @pytest.mark.asyncio async def test_ai_handling_status_join_succeeds(self): """ai_handling 状态下被邀请人加入应成功(本次修复核心)。""" # Arrange service = _make_service() conv = _mock_conversation( status="ai_handling", participants=_invited_participants(), ) service._get_conversation.return_value = conv # Act result = await service.join_conversation( conversation_id="conv-001", employee_id="emp_002", ) # Assert assert result is conv # joined 状态已更新 participants = conv.participants emp = next(p for p in participants if p["id"] == "emp_002") assert emp["joined"] is True assert "joined_at" in emp # DB 写入被调用 service.db.add.assert_called_once_with(conv) service.db.flush.assert_awaited_once() # 系统消息 + WebSocket 广播 service._create_system_message.assert_awaited_once() service._broadcast_participant_change.assert_awaited_once() # ----- 1.2 serving 状态 → join 成功(原有功能不回归) ----- @pytest.mark.asyncio async def test_serving_status_join_succeeds(self): """serving 状态下被邀请人加入应成功(原有功能不回归)。""" # Arrange service = _make_service() conv = _mock_conversation( status="serving", participants=_invited_participants(), ) service._get_conversation.return_value = conv # Act result = await service.join_conversation( conversation_id="conv-001", employee_id="emp_002", ) # Assert assert result is conv emp = next(p for p in conv.participants if p["id"] == "emp_002") assert emp["joined"] is True assert "joined_at" in emp service.db.add.assert_called_once_with(conv) service.db.flush.assert_awaited_once() # ----- 1.3 resolved 状态 → join 失败 ----- @pytest.mark.asyncio async def test_resolved_status_join_fails(self): """resolved 状态下加入应失败(已结单不应允许加入)。""" # Arrange service = _make_service() conv = _mock_conversation( status="resolved", participants=_invited_participants(), ) service._get_conversation.return_value = conv # Act & Assert with pytest.raises(AppException) as exc_info: await service.join_conversation( conversation_id="conv-001", employee_id="emp_002", ) assert exc_info.value.code == 3033 # 不应写 DB service.db.add.assert_not_called() service.db.flush.assert_not_awaited() # 不应创建系统消息 / 广播 service._create_system_message.assert_not_awaited() service._broadcast_participant_change.assert_not_awaited() # ----- 1.4 queued 状态 → join 失败 ----- @pytest.mark.asyncio async def test_queued_status_join_fails(self): """queued 状态下加入应失败(排队中不应允许加入)。""" # Arrange service = _make_service() conv = _mock_conversation( status="queued", participants=_invited_participants(), ) service._get_conversation.return_value = conv # Act & Assert with pytest.raises(AppException) as exc_info: await service.join_conversation( conversation_id="conv-001", employee_id="emp_002", ) assert exc_info.value.code == 3033 service.db.add.assert_not_called() service.db.flush.assert_not_awaited() # ----- 1.5 pending_close 状态 → join 失败 ----- @pytest.mark.asyncio async def test_pending_close_status_join_fails(self): """pending_close 状态下加入应失败(待关单不应允许加入)。""" # Arrange service = _make_service() conv = _mock_conversation( status="pending_close", participants=_invited_participants(), ) service._get_conversation.return_value = conv # Act & Assert with pytest.raises(AppException) as exc_info: await service.join_conversation( conversation_id="conv-001", employee_id="emp_002", ) assert exc_info.value.code == 3033 service.db.add.assert_not_called() service.db.flush.assert_not_awaited() # ============================================================================= # 二、join_conversation 权限校验测试 # ============================================================================= class TestJoinConversationPermissionCheck: """join_conversation 权限校验测试。 规则:只有在 participants 列表中的员工才能加入(被邀请过才能加入)。 """ # ----- 2.1 未被邀请的员工 → join 失败(3034) ----- @pytest.mark.asyncio async def test_not_invited_employee_join_fails(self): """未被邀请的员工加入应失败(3034 错误码)。""" # Arrange service = _make_service() conv = _mock_conversation( status="serving", participants=[ {"id": "emp_002", "name": "张三", "type": "employee", "joined": False}, ], ) service._get_conversation.return_value = conv # Act & Assert — emp_hacker 不在 participants 中 with pytest.raises(AppException) as exc_info: await service.join_conversation( conversation_id="conv-001", employee_id="emp_hacker", ) assert exc_info.value.code == 3034 service.db.add.assert_not_called() service.db.flush.assert_not_awaited() service._create_system_message.assert_not_awaited() # ----- 2.2 空参与者列表 → join 失败(3034) ----- @pytest.mark.asyncio async def test_empty_participants_join_fails(self): """participants 为空时任何员工加入应失败(3034)。""" # Arrange service = _make_service() conv = _mock_conversation( status="ai_handling", participants=[], ) service._get_conversation.return_value = conv # Act & Assert with pytest.raises(AppException) as exc_info: await service.join_conversation( conversation_id="conv-001", employee_id="emp_002", ) assert exc_info.value.code == 3034 # ----- 2.3 participants 为 None → join 失败(3034) ----- @pytest.mark.asyncio async def test_none_participants_join_fails(self): """participants 为 None 时加入应失败(3034)。""" # Arrange service = _make_service() conv = _mock_conversation( status="serving", participants=None, ) service._get_conversation.return_value = conv # Act & Assert with pytest.raises(AppException) as exc_info: await service.join_conversation( conversation_id="conv-001", employee_id="emp_002", ) assert exc_info.value.code == 3034 # ============================================================================= # 三、邀请 URL 绝对路径校验(Bug 1 修复核心) # ============================================================================= class TestInviteUrlAbsolute: """invite_participants 邀请 URL 绝对路径校验。 修复前:invite_url = f"{getattr(self, '_h5_base_url', '')}/h5/" → /h5/?invite=... 修复后:invite_url = f"{settings.wecom_sso_callback_base.rstrip('/')}/h5/" → https://... """ # ----- 3.1 invite_url 以 https:// 开头(包含完整域名) ----- @pytest.mark.asyncio async def test_invite_url_starts_with_https(self): """邀请 URL 应以 https:// 开头(包含完整域名)。 企微卡片消息要求 URL 必须以 https:// 开头,否则无法跳转。 """ # Arrange service = _make_service_with_wecom() conv = _mock_conversation(status="serving", participants=[]) service._get_conversation.return_value = conv with patch( "app.services.session_service.settings" ) as mock_settings: mock_settings.wecom_sso_callback_base = ( "https://itsupport.servyou.com.cn" ) # Act await service.invite_participants( conversation_id="conv-001", inviter_agent_id="agent_001", participants=[ {"id": "emp_002", "name": "张三", "type": "employee"}, ], ) # Assert service.wecom_service.send_card_message.assert_awaited_once() call_kwargs = service.wecom_service.send_card_message.call_args.kwargs invite_url = call_kwargs["url"] assert invite_url.startswith("https://"), ( f"invite_url 应以 https:// 开头,实际: {invite_url}" ) # ----- 3.2 invite_url 包含 invite 和 eid 参数 ----- @pytest.mark.asyncio async def test_invite_url_contains_invite_and_eid_params(self): """邀请 URL 应包含 invite(会话ID)和 eid(员工ID)参数。""" # Arrange service = _make_service_with_wecom() conv = _mock_conversation(status="ai_handling", participants=[]) service._get_conversation.return_value = conv with patch( "app.services.session_service.settings" ) as mock_settings: mock_settings.wecom_sso_callback_base = ( "https://itsupport.servyou.com.cn" ) # Act await service.invite_participants( conversation_id="conv-001", inviter_agent_id="agent_001", participants=[ {"id": "emp_002", "name": "张三", "type": "employee"}, ], ) # Assert call_kwargs = service.wecom_service.send_card_message.call_args.kwargs invite_url = call_kwargs["url"] assert "invite=conv-001" in invite_url assert "eid=emp_002" in invite_url # ----- 3.3 invite_url 不含尾部多余斜杠 ----- @pytest.mark.asyncio async def test_invite_url_no_trailing_slash_in_domain(self): """wecom_sso_callback_base 带尾部斜杠时,invite_url 不应有双斜杠。""" # Arrange service = _make_service_with_wecom() conv = _mock_conversation(status="serving", participants=[]) service._get_conversation.return_value = conv with patch( "app.services.session_service.settings" ) as mock_settings: # base 末尾带斜杠,rstrip 应去除 mock_settings.wecom_sso_callback_base = ( "https://itsupport.servyou.com.cn/" ) # Act await service.invite_participants( conversation_id="conv-001", inviter_agent_id="agent_001", participants=[ {"id": "emp_002", "name": "张三", "type": "employee"}, ], ) # Assert call_kwargs = service.wecom_service.send_card_message.call_args.kwargs invite_url = call_kwargs["url"] # 不应出现 //h5(base 尾部斜杠未去除会导致双斜杠) assert "//h5" not in invite_url, ( f"invite_url 不应含双斜杠,实际: {invite_url}" ) assert invite_url == ( "https://itsupport.servyou.com.cn/h5/" "?invite=conv-001&eid=emp_002" ) # ----- 3.4 修复前回归:URL 不应为相对路径 ----- @pytest.mark.asyncio async def test_invite_url_not_relative_path(self): """邀请 URL 不应为相对路径(/h5/ 开头)。 回归保障:修复前 invite_url 为 /h5/?invite=...,企微无法跳转。 """ # Arrange service = _make_service_with_wecom() conv = _mock_conversation(status="serving", participants=[]) service._get_conversation.return_value = conv with patch( "app.services.session_service.settings" ) as mock_settings: mock_settings.wecom_sso_callback_base = ( "https://itsupport.servyou.com.cn" ) # Act await service.invite_participants( conversation_id="conv-001", inviter_agent_id="agent_001", participants=[ {"id": "emp_002", "name": "张三", "type": "employee"}, ], ) # Assert call_kwargs = service.wecom_service.send_card_message.call_args.kwargs invite_url = call_kwargs["url"] # 不应以 /h5/ 开头(相对路径) assert not invite_url.startswith("/h5/"), ( f"invite_url 不应为相对路径,实际: {invite_url}" )