Files
wecom_it_smart_desk/backend/tests/test_collaboration.py
T

835 lines
33 KiB
Python
Raw Normal View History

# =============================================================================
# 企微IT智能服务台 — 摇人多坐席协作功能 测试
# =============================================================================
# 测试覆盖:
# 一、邀请协作(POST /api/conversations/{id}/invite
# 1. 成功邀请:collaborating_agent_ids 更新,WS广播,WS定向推送
# 2. 邀请已结单会话 → 3002
# 3. 邀请未接单会话(queued)→ 3020
# 4. 非主责/协作坐席邀请 → 3021
# 5. 邀请主责坐席本人 → 3022
# 6. 邀请已在协作中的坐席 → 3023
# 7. 邀请离线坐席 → 3024
# 8. 协作坐席也可以摇人(再摇第三人)
# 9. 邀请不存在的坐席 → 3004
# 10. 邀请不存在的会话 → 3003
#
# 二、退出协作(POST /api/conversations/{id}/leave
# 1. 成功退出:从 collaborating_agent_ids 移除,WS 广播
# 2. 主责坐席尝试退出 → 3025
# 3. 非协作坐席退出 → 3026
# 4. 退出后清空当前选中会话
#
# 三、列表集成测试
# 1. collaborating_agent_ids 字段正确
# 2. collaborating_agent_names 姓名映射正确
# 3. is_collaborator 字段正确
# 4. 协作坐席仍能查看和回复
#
# 四、权限矩阵验证(端到端)
# 1. 协作坐席不能结单
# 2. 协作坐席不能转接
# 3. 协作坐席不占负载
# =============================================================================
import uuid
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.agent import Agent
from app.models.conversation import Conversation
from app.services.session_service import SessionService
from app.utils.response import AppException
from tests.conftest import create_test_conversation, create_test_agent, MockRedis
# =============================================================================
# 辅助函数
# =============================================================================
async def login_agent(client: AsyncClient, user_id: str, name: str) -> dict:
"""登录坐席并返回认证头字典。"""
response = await client.post(
"/agents/login",
json={"user_id": user_id, "name": name},
)
data = response.json()
token = data["data"]["token"]
return {"Authorization": f"Bearer {token}"}
async def create_serving_conversation(
db_session: AsyncSession,
employee_id: str = "emp_001",
agent_user_id: str = "agent_owner",
collab_ids: list = None,
) -> Conversation:
"""创建一个 serving 状态且有主责坐席的会话(可选已有协作坐席)。"""
conv = create_test_conversation(
employee_id=employee_id,
status="serving",
)
conv.assigned_agent_id = agent_user_id
conv.collaborating_agent_ids = collab_ids or []
db_session.add(conv)
await db_session.flush()
return conv
# =============================================================================
# 一、邀请协作测试
# =============================================================================
class TestInviteCollaborator:
"""测试邀请协作接口 POST /api/conversations/{id}/invite。"""
@pytest.mark.asyncio
async def test_invite_success_updates_collaborating_ids(
self, client, db_session, mock_redis
):
"""验证成功邀请:collaborating_agent_ids 更新,WS广播+定向推送。
场景:坐席A(owner)在处理会话,邀请在线坐席B加入协作。
"""
# 创建坐席
owner = create_test_agent(user_id="owner_001", name="坐席A", status="online")
invitee = create_test_agent(user_id="invitee_001", name="坐席B", status="online")
db_session.add_all([owner, invitee])
await db_session.flush()
# 创建 serving 会话,分配给 owner
conv = await create_serving_conversation(
db_session, employee_id="emp_invite", agent_user_id="owner_001"
)
# 坐席A 登录并发起邀请
headers = await login_agent(client, "owner_001", "坐席A")
with patch("app.services.ws_manager.manager.broadcast", new_callable=AsyncMock) as mock_broadcast, \
patch("app.services.ws_manager.manager.send_to_agent", new_callable=AsyncMock) as mock_send:
response = await client.post(
f"/conversations/{conv.id}/invite",
json={"agent_id": "invitee_001"},
headers=headers,
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
# 验证 collaborating_agent_ids 包含被邀请坐席
result = data["data"]
assert "invitee_001" in result["collaborating_agent_ids"]
# 验证 WS 广播被调用(collaborator_joined
mock_broadcast.assert_called_once()
broadcast_msg = mock_broadcast.call_args[0][0]
assert broadcast_msg["type"] == "collaborator_joined"
assert broadcast_msg["data"]["agent_id"] == "invitee_001"
assert broadcast_msg["data"]["inviter_agent_id"] == "owner_001"
# 验证 WS 定向推送被调用(collaborator_invited
mock_send.assert_called_once_with("invitee_001", mock_send.call_args[0][1])
sent_msg = mock_send.call_args[0][1]
assert sent_msg["type"] == "collaborator_invited"
assert sent_msg["data"]["invitee_agent_id"] == "invitee_001"
# 验证数据库持久化
stmt = select(Conversation).where(Conversation.id == conv.id)
result_db = await db_session.execute(stmt)
db_conv = result_db.scalars().first()
assert "invitee_001" in db_conv.collaborating_agent_ids
@pytest.mark.asyncio
async def test_invite_resolved_conversation_error_3002(
self, client, db_session, mock_redis
):
"""验证不能邀请已结单会话 → 3002。"""
owner = create_test_agent(user_id="owner_resolved", name="坐席A", status="online")
invitee = create_test_agent(user_id="invitee_resolved", name="坐席B", status="online")
db_session.add_all([owner, invitee])
await db_session.flush()
conv = create_test_conversation(
employee_id="emp_resolved_invite", status="resolved"
)
conv.assigned_agent_id = "owner_resolved"
db_session.add(conv)
await db_session.flush()
headers = await login_agent(client, "owner_resolved", "坐席A")
response = await client.post(
f"/conversations/{conv.id}/invite",
json={"agent_id": "invitee_resolved"},
headers=headers,
)
data = response.json()
assert data["code"] == 3002 # ERR_CONVERSATION_RESOLVED
@pytest.mark.asyncio
async def test_invite_queued_conversation_error_3020(
self, client, db_session, mock_redis
):
"""验证不能邀请未接单(queued)的会话 → 3020。"""
owner = create_test_agent(user_id="owner_queued", name="坐席A", status="online")
invitee = create_test_agent(user_id="invitee_queued", name="坐席B", status="online")
db_session.add_all([owner, invitee])
await db_session.flush()
# queued 状态,未分配坐席 → 应先报 3021(不是 owner/collaborator
# 但如果有 assigned_agent_id=owner,则是 queued 状态 → 3020
conv = create_test_conversation(
employee_id="emp_queued_invite", status="queued"
)
conv.assigned_agent_id = "owner_queued"
db_session.add(conv)
await db_session.flush()
headers = await login_agent(client, "owner_queued", "坐席A")
response = await client.post(
f"/conversations/{conv.id}/invite",
json={"agent_id": "invitee_queued"},
headers=headers,
)
data = response.json()
assert data["code"] == 3020
assert "服务中" in data["message"]
@pytest.mark.asyncio
async def test_invite_by_non_owner_error_3021(
self, client, db_session, mock_redis
):
"""验证非主责/协作坐席不能摇人 → 3021。"""
owner = create_test_agent(user_id="owner_3021", name="坐席A", status="online")
invitee = create_test_agent(user_id="invitee_3021", name="坐席B", status="online")
stranger = create_test_agent(user_id="stranger_3021", name="路过的坐席", status="online")
db_session.add_all([owner, invitee, stranger])
await db_session.flush()
conv = await create_serving_conversation(
db_session, employee_id="emp_3021", agent_user_id="owner_3021"
)
# 用路过的坐席登录(既不是主责也不是协作坐席)
headers = await login_agent(client, "stranger_3021", "路过的坐席")
response = await client.post(
f"/conversations/{conv.id}/invite",
json={"agent_id": "invitee_3021"},
headers=headers,
)
data = response.json()
assert data["code"] == 3021
assert "摇人" in data["message"]
@pytest.mark.asyncio
async def test_invite_owner_self_error_3022(
self, client, db_session, mock_redis
):
"""验证不能邀请主责坐席本人 → 3022。"""
owner = create_test_agent(user_id="owner_3022", name="坐席A", status="online")
db_session.add(owner)
await db_session.flush()
conv = await create_serving_conversation(
db_session, employee_id="emp_3022", agent_user_id="owner_3022"
)
headers = await login_agent(client, "owner_3022", "坐席A")
response = await client.post(
f"/conversations/{conv.id}/invite",
json={"agent_id": "owner_3022"}, # 邀请自己
headers=headers,
)
data = response.json()
assert data["code"] == 3022
@pytest.mark.asyncio
async def test_invite_duplicate_collaborator_error_3023(
self, client, db_session, mock_redis
):
"""验证不能重复邀请已在协作中的坐席 → 3023。"""
owner = create_test_agent(user_id="owner_3023", name="坐席A", status="online")
invitee = create_test_agent(user_id="invitee_3023", name="坐席B", status="online")
db_session.add_all([owner, invitee])
await db_session.flush()
# 坐席B 已在协作列表中
conv = await create_serving_conversation(
db_session,
employee_id="emp_3023",
agent_user_id="owner_3023",
collab_ids=["invitee_3023"],
)
headers = await login_agent(client, "owner_3023", "坐席A")
response = await client.post(
f"/conversations/{conv.id}/invite",
json={"agent_id": "invitee_3023"},
headers=headers,
)
data = response.json()
assert data["code"] == 3023
@pytest.mark.asyncio
async def test_invite_offline_agent_error_3024(
self, client, db_session, mock_redis
):
"""验证不能邀请离线坐席 → 3024。"""
owner = create_test_agent(user_id="owner_3024", name="坐席A", status="online")
offline_agent = create_test_agent(user_id="offline_3024", name="离线坐席", status="offline")
db_session.add_all([owner, offline_agent])
await db_session.flush()
conv = await create_serving_conversation(
db_session, employee_id="emp_3024", agent_user_id="owner_3024"
)
headers = await login_agent(client, "owner_3024", "坐席A")
response = await client.post(
f"/conversations/{conv.id}/invite",
json={"agent_id": "offline_3024"},
headers=headers,
)
data = response.json()
assert data["code"] == 3024
assert "不在线" in data["message"]
@pytest.mark.asyncio
async def test_invite_nonexistent_agent_error_3004(
self, client, db_session, mock_redis
):
"""验证邀请不存在的坐席 → 3004。"""
owner = create_test_agent(user_id="owner_3004", name="坐席A", status="online")
db_session.add(owner)
await db_session.flush()
conv = await create_serving_conversation(
db_session, employee_id="emp_3004", agent_user_id="owner_3004"
)
headers = await login_agent(client, "owner_3004", "坐席A")
response = await client.post(
f"/conversations/{conv.id}/invite",
json={"agent_id": "nonexistent_agent"},
headers=headers,
)
data = response.json()
assert data["code"] == 3004 # ERR_AGENT_NOT_FOUND
@pytest.mark.asyncio
async def test_invite_nonexistent_conversation_error_3003(
self, client, db_session, mock_redis
):
"""验证邀请不存在的会话 → 3003。"""
agent = create_test_agent(user_id="agent_3003", name="坐席A", status="online")
invitee = create_test_agent(user_id="invitee_3003", name="坐席B", status="online")
db_session.add_all([agent, invitee])
await db_session.flush()
fake_id = str(uuid.uuid4())
headers = await login_agent(client, "agent_3003", "坐席A")
response = await client.post(
f"/conversations/{fake_id}/invite",
json={"agent_id": "invitee_3003"},
headers=headers,
)
data = response.json()
assert data["code"] == 3003 # ERR_CONVERSATION_NOT_FOUND
@pytest.mark.asyncio
async def test_collaborator_can_also_invite_others(
self, client, db_session, mock_redis
):
"""验证协作坐席也可以摇人(再摇第三人加入)。
场景:坐席A(owner)邀请坐席B → 坐席B 再摇坐席C
"""
owner = create_test_agent(user_id="chain_owner", name="坐席A", status="online")
collab1 = create_test_agent(user_id="chain_collab1", name="坐席B", status="online")
collab2 = create_test_agent(user_id="chain_collab2", name="坐席C", status="online")
db_session.add_all([owner, collab1, collab2])
await db_session.flush()
# 坐席A 创建会话,邀请坐席B
conv = await create_serving_conversation(
db_session,
employee_id="emp_chain",
agent_user_id="chain_owner",
collab_ids=["chain_collab1"],
)
# 坐席B 登录并发起邀请坐席C
headers = await login_agent(client, "chain_collab1", "坐席B")
with patch("app.services.ws_manager.manager.broadcast", new_callable=AsyncMock), \
patch("app.services.ws_manager.manager.send_to_agent", new_callable=AsyncMock):
response = await client.post(
f"/conversations/{conv.id}/invite",
json={"agent_id": "chain_collab2"},
headers=headers,
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
# 验证 collaborating_agent_ids 包含两个协作坐席
result = data["data"]
assert "chain_collab1" in result["collaborating_agent_ids"]
assert "chain_collab2" in result["collaborating_agent_ids"]
@pytest.mark.asyncio
async def test_invite_without_auth_returns_unauthorized(
self, client, db_session, mock_redis
):
"""验证未登录时邀请返回未授权错误。"""
owner = create_test_agent(user_id="noauth_owner", name="坐席A", status="online")
invitee = create_test_agent(user_id="noauth_invitee", name="坐席B", status="online")
db_session.add_all([owner, invitee])
await db_session.flush()
conv = await create_serving_conversation(
db_session, employee_id="emp_noauth", agent_user_id="noauth_owner"
)
response = await client.post(
f"/conversations/{conv.id}/invite",
json={"agent_id": "noauth_invitee"},
)
data = response.json()
assert data["code"] == 1002 # ERR_UNAUTHORIZED
# =============================================================================
# 二、退出协作测试
# =============================================================================
class TestLeaveCollaboration:
"""测试退出协作接口 POST /api/conversations/{id}/leave。"""
@pytest.mark.asyncio
async def test_leave_success_removes_from_list(
self, client, db_session, mock_redis
):
"""验证成功退出:从 collaborating_agent_ids 移除,WS 广播。"""
owner = create_test_agent(user_id="leave_owner", name="坐席A", status="online")
collab = create_test_agent(user_id="leave_collab", name="坐席B", status="online")
db_session.add_all([owner, collab])
await db_session.flush()
# 坐席B 已在协作列表中
conv = await create_serving_conversation(
db_session,
employee_id="emp_leave",
agent_user_id="leave_owner",
collab_ids=["leave_collab"],
)
headers = await login_agent(client, "leave_collab", "坐席B")
with patch("app.services.ws_manager.manager.broadcast", new_callable=AsyncMock) as mock_broadcast:
response = await client.post(
f"/conversations/{conv.id}/leave",
headers=headers,
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
# 验证 collaborating_agent_ids 不再包含坐席B
result = data["data"]
assert "leave_collab" not in result["collaborating_agent_ids"]
# 验证 WS 广播被调用(collaborator_left
mock_broadcast.assert_called_once()
broadcast_msg = mock_broadcast.call_args[0][0]
assert broadcast_msg["type"] == "collaborator_left"
assert broadcast_msg["data"]["agent_id"] == "leave_collab"
# 验证数据库持久化
stmt = select(Conversation).where(Conversation.id == conv.id)
result_db = await db_session.execute(stmt)
db_conv = result_db.scalars().first()
assert "leave_collab" not in db_conv.collaborating_agent_ids
@pytest.mark.asyncio
async def test_leave_owner_error_3025(
self, client, db_session, mock_redis
):
"""验证主责坐席不能退出协作 → 3025。"""
owner = create_test_agent(user_id="leave_owner_3025", name="坐席A", status="online")
db_session.add(owner)
await db_session.flush()
conv = await create_serving_conversation(
db_session,
employee_id="emp_leave_owner",
agent_user_id="leave_owner_3025",
collab_ids=["some_collab"],
)
headers = await login_agent(client, "leave_owner_3025", "坐席A")
response = await client.post(
f"/conversations/{conv.id}/leave",
headers=headers,
)
data = response.json()
assert data["code"] == 3025
assert "主责坐席" in data["message"]
@pytest.mark.asyncio
async def test_leave_non_collaborator_error_3026(
self, client, db_session, mock_redis
):
"""验证不在协作列表中的坐席不能退出 → 3026。"""
owner = create_test_agent(user_id="leave_owner_3026", name="坐席A", status="online")
stranger = create_test_agent(user_id="stranger_3026", name="路过的坐席", status="online")
db_session.add_all([owner, stranger])
await db_session.flush()
conv = await create_serving_conversation(
db_session,
employee_id="emp_leave_stranger",
agent_user_id="leave_owner_3026",
)
headers = await login_agent(client, "stranger_3026", "路过的坐席")
response = await client.post(
f"/conversations/{conv.id}/leave",
headers=headers,
)
data = response.json()
assert data["code"] == 3026
assert "协作列表" in data["message"]
@pytest.mark.asyncio
async def test_leave_without_auth_returns_unauthorized(
self, client, db_session, mock_redis
):
"""验证未登录时退出返回未授权错误。"""
owner = create_test_agent(user_id="noauth_leave_owner", name="坐席A", status="online")
collab = create_test_agent(user_id="noauth_leave_collab", name="坐席B", status="online")
db_session.add_all([owner, collab])
await db_session.flush()
conv = await create_serving_conversation(
db_session,
employee_id="emp_noauth_leave",
agent_user_id="noauth_leave_owner",
collab_ids=["noauth_leave_collab"],
)
response = await client.post(
f"/conversations/{conv.id}/leave",
)
data = response.json()
assert data["code"] == 1002 # ERR_UNAUTHORIZED
# =============================================================================
# 三、列表集成测试
# =============================================================================
class TestCollaborationListIntegration:
"""测试会话列表接口的协作字段集成。"""
@pytest.mark.asyncio
async def test_list_includes_collaboration_fields(
self, client, db_session, mock_redis
):
"""验证列表接口返回 collaborating_agent_ids 和 _names 字段。"""
owner = create_test_agent(user_id="list_owner", name="坐席A", status="online")
collab1 = create_test_agent(user_id="list_collab1", name="坐席B", status="online")
collab2 = create_test_agent(user_id="list_collab2", name="坐席C", status="online")
db_session.add_all([owner, collab1, collab2])
await db_session.flush()
conv = await create_serving_conversation(
db_session,
employee_id="emp_list_collab",
agent_user_id="list_owner",
collab_ids=["list_collab1", "list_collab2"],
)
# 以坐席A身份查看列表
headers = await login_agent(client, "list_owner", "坐席A")
response = await client.get("/conversations", headers=headers)
data = response.json()
assert data["code"] == 0
items = data["data"]["items"]
item_map = {item["id"]: item for item in items}
conv_item = item_map[str(conv.id)]
# 验证 collaborating_agent_ids
assert "list_collab1" in conv_item["collaborating_agent_ids"]
assert "list_collab2" in conv_item["collaborating_agent_ids"]
assert len(conv_item["collaborating_agent_ids"]) == 2
# 验证 collaborating_agent_names
assert conv_item["collaborating_agent_names"]["list_collab1"] == "坐席B"
assert conv_item["collaborating_agent_names"]["list_collab2"] == "坐席C"
# 验证 is_collaborator(坐席A是主责不是协作坐席)
assert conv_item["is_collaborator"] is False
@pytest.mark.asyncio
async def test_list_is_collaborator_field_correctness(
self, client, db_session, mock_redis
):
"""验证 is_collaborator 字段标注正确。
- 主责坐席 → is_collaborator=False
- 协作坐席(且非主责)→ is_collaborator=True
- 既非主责也非协作 → is_collaborator=False
"""
owner = create_test_agent(user_id="iscoll_owner", name="主责坐席", status="online")
collab = create_test_agent(user_id="iscoll_collab", name="协作坐席", status="online")
stranger = create_test_agent(user_id="iscoll_stranger", name="路人坐席", status="online")
db_session.add_all([owner, collab, stranger])
await db_session.flush()
conv = await create_serving_conversation(
db_session,
employee_id="emp_iscoll",
agent_user_id="iscoll_owner",
collab_ids=["iscoll_collab"],
)
# 主责坐席查看 → is_collaborator=False
headers_owner = await login_agent(client, "iscoll_owner", "主责坐席")
resp = await client.get("/conversations", headers=headers_owner)
items = resp.json()["data"]["items"]
item_map = {item["id"]: item for item in items}
assert item_map[str(conv.id)]["is_collaborator"] is False
# 协作坐席查看 → is_collaborator=True
headers_collab = await login_agent(client, "iscoll_collab", "协作坐席")
resp = await client.get("/conversations", headers=headers_collab)
items = resp.json()["data"]["items"]
item_map = {item["id"]: item for item in items}
assert item_map[str(conv.id)]["is_collaborator"] is True
# 路人坐席查看 → is_collaborator=False
headers_stranger = await login_agent(client, "iscoll_stranger", "路人坐席")
resp = await client.get("/conversations", headers=headers_stranger)
items = resp.json()["data"]["items"]
item_map = {item["id"]: item for item in items}
assert item_map[str(conv.id)]["is_collaborator"] is False
@pytest.mark.asyncio
async def test_list_no_collaborators_returns_empty_arrays(
self, client, db_session, mock_redis
):
"""验证无协作坐席时返回空数组和空对象。"""
owner = create_test_agent(user_id="empty_owner", name="坐席A", status="online")
db_session.add(owner)
await db_session.flush()
conv = await create_serving_conversation(
db_session, employee_id="emp_empty_collab", agent_user_id="empty_owner"
)
headers = await login_agent(client, "empty_owner", "坐席A")
response = await client.get("/conversations", headers=headers)
data = response.json()
items = data["data"]["items"]
item_map = {item["id"]: item for item in items}
conv_item = item_map[str(conv.id)]
assert conv_item["collaborating_agent_ids"] == []
assert conv_item["collaborating_agent_names"] == {}
assert conv_item["is_collaborator"] is False
# =============================================================================
# 四、权限矩阵验证(端到端)
# =============================================================================
class TestCollaborationPermissions:
"""测试协作坐席的权限边界。
协作坐席可以:查看会话、发送回复、摇人(再邀请)
协作坐席不能:结单、转接、置顶/代办
协作坐席不占负载。
"""
@pytest.mark.asyncio
async def test_collaborator_does_not_count_load(
self, client, db_session, mock_redis
):
"""验证协作坐席加入后负载不变(不占负载)。
主责坐席 current_load 应保持为1,协作坐席 current_load 保持不变。
"""
owner = create_test_agent(user_id="load_owner", name="坐席A", status="online")
owner.current_load = 1
collab = create_test_agent(user_id="load_collab", name="坐席B", status="online")
collab.current_load = 0
db_session.add_all([owner, collab])
await db_session.flush()
conv = await create_serving_conversation(
db_session, employee_id="emp_load", agent_user_id="load_owner"
)
headers = await login_agent(client, "load_owner", "坐席A")
with patch("app.services.ws_manager.manager.broadcast", new_callable=AsyncMock), \
patch("app.services.ws_manager.manager.send_to_agent", new_callable=AsyncMock):
await client.post(
f"/conversations/{conv.id}/invite",
json={"agent_id": "load_collab"},
headers=headers,
)
# 验证主责坐席 load 不变(=1)
stmt = select(Agent).where(Agent.user_id == "load_owner")
result = await db_session.execute(stmt)
db_owner = result.scalars().first()
assert db_owner.current_load == 1
# 验证协作坐席 load 不变(=0)
stmt = select(Agent).where(Agent.user_id == "load_collab")
result = await db_session.execute(stmt)
db_collab = result.scalars().first()
assert db_collab.current_load == 0 # 协作不占负载
@pytest.mark.asyncio
async def test_collaborator_cannot_resolve_conversation(
self, client, db_session, mock_redis
):
"""验证协作坐席不能结单。
场景:坐席A(owner)邀请坐席B协作 → 坐席B 尝试结单(应失败)
"""
owner = create_test_agent(user_id="perm_owner", name="坐席A", status="online")
collab = create_test_agent(user_id="perm_collab", name="坐席B", status="online")
db_session.add_all([owner, collab])
await db_session.flush()
conv = await create_serving_conversation(
db_session,
employee_id="emp_perm",
agent_user_id="perm_owner",
collab_ids=["perm_collab"],
)
# 协作坐席登录并尝试结单
headers = await login_agent(client, "perm_collab", "坐席B")
with patch("app.services.ws_manager.manager.broadcast", new_callable=AsyncMock):
response = await client.post(
f"/conversations/{conv.id}/resolve",
headers=headers,
)
data = response.json()
# 协作坐席不是主责坐席,resolve 应返回 3027(只有主责坐席才能结单)
assert data["code"] == 3027, f"协作坐席不应该能结单,期望 code=3027,实际 code={data['code']}"
@pytest.mark.asyncio
async def test_full_invite_leave_cycle(
self, client, db_session, mock_redis
):
"""端到端测试:邀请→查看列表→退出→验证清理。
完整流程:
1. 坐席A 邀请坐席B
2. 坐席B 查看列表,确认协作会话出现
3. 坐席A 邀请坐席C
4. 坐席A 查看列表,验证两个协作坐席
5. 坐席B 退出协作
6. 验证坐席B 不再出现在协作列表中,坐席C 仍存在
"""
# 创建坐席
owner = create_test_agent(user_id="e2e_owner", name="坐席A", status="online")
collab_b = create_test_agent(user_id="e2e_b", name="坐席B", status="online")
collab_c = create_test_agent(user_id="e2e_c", name="坐席C", status="online")
db_session.add_all([owner, collab_b, collab_c])
await db_session.flush()
# 创建会话
conv = await create_serving_conversation(
db_session, employee_id="emp_e2e", agent_user_id="e2e_owner"
)
headers_a = await login_agent(client, "e2e_owner", "坐席A")
# Step 1: 坐席A 邀请坐席B
with patch("app.services.ws_manager.manager.broadcast", new_callable=AsyncMock), \
patch("app.services.ws_manager.manager.send_to_agent", new_callable=AsyncMock):
resp = await client.post(
f"/conversations/{conv.id}/invite",
json={"agent_id": "e2e_b"},
headers=headers_a,
)
assert resp.json()["code"] == 0
# Step 2: 坐席B 查看列表,确认协作会话出现
headers_b = await login_agent(client, "e2e_b", "坐席B")
resp = await client.get("/conversations", headers=headers_b)
items = resp.json()["data"]["items"]
item_map = {item["id"]: item for item in items}
assert str(conv.id) in item_map
assert item_map[str(conv.id)]["is_collaborator"] is True
# Step 3: 坐席A 邀请坐席C
with patch("app.services.ws_manager.manager.broadcast", new_callable=AsyncMock), \
patch("app.services.ws_manager.manager.send_to_agent", new_callable=AsyncMock):
resp = await client.post(
f"/conversations/{conv.id}/invite",
json={"agent_id": "e2e_c"},
headers=headers_a,
)
assert resp.json()["code"] == 0
# Step 4: 坐席A 查看列表,验证两个协作坐席
resp = await client.get("/conversations", headers=headers_a)
items = resp.json()["data"]["items"]
item_map = {item["id"]: item for item in items}
conv_item = item_map[str(conv.id)]
assert "e2e_b" in conv_item["collaborating_agent_ids"]
assert "e2e_c" in conv_item["collaborating_agent_ids"]
assert conv_item["collaborating_agent_names"]["e2e_b"] == "坐席B"
assert conv_item["collaborating_agent_names"]["e2e_c"] == "坐席C"
# Step 5: 坐席B 退出协作
with patch("app.services.ws_manager.manager.broadcast", new_callable=AsyncMock):
resp = await client.post(
f"/conversations/{conv.id}/leave",
headers=headers_b,
)
assert resp.json()["code"] == 0
# Step 6: 验证坐席B 已移除,坐席C 仍存在
resp = await client.get("/conversations", headers=headers_a)
items = resp.json()["data"]["items"]
item_map = {item["id"]: item for item in items}
conv_item = item_map[str(conv.id)]
assert "e2e_b" not in conv_item["collaborating_agent_ids"]
assert "e2e_c" in conv_item["collaborating_agent_ids"]