Files
wecom_it_smart_desk/backend/tests/test_invite_link.py
T

492 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 邀请链接回归测试 — 邀请 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: 主责坐席IDinvite_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: MagicMockadd/flush 已 mock
- wecom_service: Nonejoin_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: MagicMocksend_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}"
)