Files
wecom_it_smart_desk/backend/tests/test_collaboration.py
T

835 lines
33 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.
# =============================================================================
# 企微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"]