# ============================================================================= # 企微IT智能服务台 — 坐席会话全局可见 + 接手功能 测试 # ============================================================================= # 测试覆盖: # 一、会话列表接口(GET /api/conversations) # 1. 返回全部活跃会话(queued + serving + 其他坐席 serving) # 2. is_mine / assigned_agent_name / can_grab 字段标注正确 # 3. N+1 查询优化(坐席信息批量查询) # # 二、接手接口(POST /api/conversations/{id}/grab) # 1. 成功接手:原坐席 load-1,新坐席 load+1,assigned_agent_id 切换 # 2. 不能接手未分配坐席的会话 → 3011 # 3. 不能接手自己的会话 → 3012 # 4. 不能接手非 serving 状态会话 → 3013 # 5. 不能接手已结单会话 → 3002 # 6. 接手后 WebSocket 广播 conversation_updated # # 三、边界情况 # 1. 满负荷坐席接手 → 3005 # 2. 会话不存在 → 3003 # 3. 接手成功后返回字段验证(is_mine=True, can_grab=False) # ============================================================================= 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_and_assign_conversation( db_session: AsyncSession, employee_id: str = "emp_001", agent_user_id: str = "agent_001", ) -> Conversation: """创建一个已分配坐席的 serving 状态会话。""" conv = create_test_conversation( employee_id=employee_id, status="serving", ) conv.assigned_agent_id = agent_user_id db_session.add(conv) await db_session.flush() return conv # ============================================================================= # 一、会话列表接口测试 # ============================================================================= class TestConversationListGlobalVisibility: """测试会话列表全局可见功能。""" @pytest.mark.asyncio async def test_list_returns_all_active_conversations( self, client, db_session, mock_redis ): """验证 GET /api/conversations 返回全部活跃会话。 场景:数据库中有 queued、serving(自己的)、serving(其他坐席的)三种会话, 当前坐席应能看到所有这些会话。 """ # 准备:创建坐席 agent = create_test_agent(user_id="viewer_001", name="查看坐席") other_agent = create_test_agent(user_id="other_001", name="其他坐席") db_session.add_all([agent, other_agent]) await db_session.flush() # 创建三种状态的会话 conv_queued = create_test_conversation( employee_id="emp_queued", status="queued" ) conv_my_serving = create_test_conversation( employee_id="emp_my", status="serving" ) conv_my_serving.assigned_agent_id = "viewer_001" conv_other_serving = create_test_conversation( employee_id="emp_other", status="serving" ) conv_other_serving.assigned_agent_id = "other_001" db_session.add_all([conv_queued, conv_my_serving, conv_other_serving]) await db_session.flush() # 登录并请求 headers = await login_agent(client, "viewer_001", "查看坐席") response = await client.get("/conversations", headers=headers) assert response.status_code == 200 data = response.json() assert data["code"] == 0 items = data["data"]["items"] total = data["data"]["total"] # 应至少包含我们创建的3个活跃会话 assert total >= 3 # 验证三种类型的会话都出现在结果中 conv_ids = {item["id"] for item in items} assert str(conv_queued.id) in conv_ids assert str(conv_my_serving.id) in conv_ids assert str(conv_other_serving.id) in conv_ids @pytest.mark.asyncio async def test_is_mine_field_correctness( self, client, db_session, mock_redis ): """验证 is_mine 字段标注正确。 - 自己的会话 is_mine=True - 其他坐席的会话 is_mine=False - 未分配坐席的会话 is_mine=False """ agent = create_test_agent(user_id="mine_agent", name="我的坐席") other = create_test_agent(user_id="other_agent", name="他人坐席") db_session.add_all([agent, other]) await db_session.flush() conv_mine = create_test_conversation( employee_id="emp_mine", status="serving" ) conv_mine.assigned_agent_id = "mine_agent" conv_other = create_test_conversation( employee_id="emp_other2", status="serving" ) conv_other.assigned_agent_id = "other_agent" conv_unassigned = create_test_conversation( employee_id="emp_unassigned", status="queued" ) db_session.add_all([conv_mine, conv_other, conv_unassigned]) await db_session.flush() headers = await login_agent(client, "mine_agent", "我的坐席") response = await client.get("/conversations", headers=headers) data = response.json() items = data["data"]["items"] # 构建一个 id → item 的映射 item_map = {item["id"]: item for item in items} # 自己的会话 → is_mine=True assert item_map[str(conv_mine.id)]["is_mine"] is True # 其他坐席的会话 → is_mine=False assert item_map[str(conv_other.id)]["is_mine"] is False # 未分配坐席的会话 → is_mine=False assert item_map[str(conv_unassigned.id)]["is_mine"] is False @pytest.mark.asyncio async def test_assigned_agent_name_field( self, client, db_session, mock_redis ): """验证 assigned_agent_name 字段正确返回坐席姓名。 - 已分配坐席的会话应返回坐席姓名 - 未分配坐席的会话应返回 None """ agent = create_test_agent(user_id="name_agent", name="坐席张三") db_session.add(agent) await db_session.flush() conv_assigned = create_test_conversation( employee_id="emp_assigned", status="serving" ) conv_assigned.assigned_agent_id = "name_agent" conv_unassigned = create_test_conversation( employee_id="emp_no_agent", status="queued" ) db_session.add_all([conv_assigned, conv_unassigned]) await db_session.flush() headers = await login_agent(client, "name_agent", "坐席张三") response = await client.get("/conversations", headers=headers) data = response.json() items = data["data"]["items"] item_map = {item["id"]: item for item in items} # 已分配的会话应包含坐席姓名 assert item_map[str(conv_assigned.id)]["assigned_agent_name"] == "坐席张三" # 未分配的会话坐席姓名为 None assert item_map[str(conv_unassigned.id)]["assigned_agent_name"] is None @pytest.mark.asyncio async def test_can_grab_field_correctness( self, client, db_session, mock_redis ): """验证 can_grab 字段标注正确。 can_grab = True 的条件:assigned_agent_id 非空 且 不是自己 且 status=serving - 其他坐席的 serving 会话 → can_grab=True - 自己的会话 → can_grab=False - 未分配的 queued 会话 → can_grab=False - 其他坐席的 queued 会话 → can_grab=False - 其他坐席的 resolved 会话 → can_grab=False """ agent = create_test_agent(user_id="grab_checker", name="检查坐席") other = create_test_agent(user_id="grab_other", name="他人坐席") db_session.add_all([agent, other]) await db_session.flush() # 其他坐席的 serving 会话 → 可接手 conv_other_serving = create_test_conversation( employee_id="emp_other_serving", status="serving" ) conv_other_serving.assigned_agent_id = "grab_other" # 自己的会话 → 不可接手 conv_my_serving = create_test_conversation( employee_id="emp_my_serving", status="serving" ) conv_my_serving.assigned_agent_id = "grab_checker" # 未分配的 queued 会话 → 不可接手 conv_queued = create_test_conversation( employee_id="emp_queued_grab", status="queued" ) # 已结单会话 → 不可接手 conv_resolved = create_test_conversation( employee_id="emp_resolved_grab", status="resolved" ) conv_resolved.assigned_agent_id = "grab_other" db_session.add_all([ conv_other_serving, conv_my_serving, conv_queued, conv_resolved ]) await db_session.flush() headers = await login_agent(client, "grab_checker", "检查坐席") response = await client.get("/conversations", headers=headers) data = response.json() items = data["data"]["items"] item_map = {item["id"]: item for item in items} # 其他坐席 serving → can_grab=True assert item_map[str(conv_other_serving.id)]["can_grab"] is True # 自己的会话 → can_grab=False assert item_map[str(conv_my_serving.id)]["can_grab"] is False # queued 会话 → can_grab=False assert item_map[str(conv_queued.id)]["can_grab"] is False # resolved 会话 → can_grab=False assert item_map[str(conv_resolved.id)]["can_grab"] is False # ============================================================================= # 二、接手接口测试 # ============================================================================= class TestGrabConversation: """测试接手会话接口 POST /api/conversations/{id}/grab。""" @pytest.mark.asyncio async def test_grab_success_switches_agent_and_load( self, client, db_session, mock_redis ): """验证成功接手:原坐席 load-1,新坐席 load+1,assigned_agent_id 切换。""" # 创建原坐席(已有1个会话) old_agent = create_test_agent( user_id="old_agent", name="原坐席", status="online" ) old_agent.current_load = 1 old_agent.max_load = 5 # 创建新坐席(准备接手) new_agent = create_test_agent( user_id="new_agent", name="新坐席", status="online" ) new_agent.current_load = 0 new_agent.max_load = 5 db_session.add_all([old_agent, new_agent]) await db_session.flush() # 创建一个 serving 状态的会话,分配给原坐席 conv = create_test_conversation( employee_id="emp_grab_success", status="serving" ) conv.assigned_agent_id = "old_agent" db_session.add(conv) await db_session.flush() # 新坐席登录并发起接手 headers = await login_agent(client, "new_agent", "新坐席") with patch("app.services.ws_manager.manager.broadcast", new_callable=AsyncMock) as mock_broadcast: response = await client.post( f"/conversations/{conv.id}/grab", headers=headers, ) assert response.status_code == 200 data = response.json() assert data["code"] == 0 # 验证会话的 assigned_agent_id 已切换 assert data["data"]["assigned_agent_id"] == "new_agent" # 验证原坐席 current_load 减 1 stmt = select(Agent).where(Agent.user_id == "old_agent") result = await db_session.execute(stmt) refreshed_old = result.scalars().first() assert refreshed_old.current_load == 0 # 1 - 1 = 0 # 验证新坐席 current_load 加 1 stmt = select(Agent).where(Agent.user_id == "new_agent") result = await db_session.execute(stmt) refreshed_new = result.scalars().first() assert refreshed_new.current_load == 1 # 0 + 1 = 1 @pytest.mark.asyncio async def test_grab_no_agent_error_3011( self, client, db_session, mock_redis ): """验证不能接手未分配坐席的会话 → 3011。""" agent = create_test_agent(user_id="grab_no_agent_user", name="测试坐席") db_session.add(agent) await db_session.flush() # 创建一个 queued 状态(未分配坐席)的会话 conv = create_test_conversation( employee_id="emp_no_agent", status="queued" ) db_session.add(conv) await db_session.flush() headers = await login_agent(client, "grab_no_agent_user", "测试坐席") response = await client.post( f"/conversations/{conv.id}/grab", headers=headers, ) data = response.json() assert data["code"] == 3011 assert "尚未分配坐席" in data["message"] @pytest.mark.asyncio async def test_grab_self_error_3012( self, client, db_session, mock_redis ): """验证不能接手自己的会话 → 3012。""" agent = create_test_agent(user_id="grab_self_user", name="自接坐席") db_session.add(agent) await db_session.flush() # 创建一个分配给自己的 serving 会话 conv = create_test_conversation( employee_id="emp_self_grab", status="serving" ) conv.assigned_agent_id = "grab_self_user" db_session.add(conv) await db_session.flush() headers = await login_agent(client, "grab_self_user", "自接坐席") response = await client.post( f"/conversations/{conv.id}/grab", headers=headers, ) data = response.json() assert data["code"] == 3012 assert "不能接手自己的会话" in data["message"] @pytest.mark.asyncio async def test_grab_not_serving_error_3013( self, client, db_session, mock_redis ): """验证不能接手非 serving 状态的会话 → 3013。""" other_agent = create_test_agent(user_id="other_for_3013", name="他人坐席") grabber = create_test_agent(user_id="grabber_for_3013", name="接手坐席") db_session.add_all([other_agent, grabber]) await db_session.flush() # 创建一个 queued 状态但已分配坐席的会话(边界:assigned + queued) conv = create_test_conversation( employee_id="emp_not_serving", status="queued" ) conv.assigned_agent_id = "other_for_3013" db_session.add(conv) await db_session.flush() headers = await login_agent(client, "grabber_for_3013", "接手坐席") response = await client.post( f"/conversations/{conv.id}/grab", headers=headers, ) data = response.json() assert data["code"] == 3013 assert "只能接手服务中的会话" in data["message"] @pytest.mark.asyncio async def test_grab_resolved_error_3002( self, client, db_session, mock_redis ): """验证不能接手已结单的会话 → 3002。 注意:源码中 resolved 检查在 status != serving 检查之前, 所以 resolved 会优先命中 3002 而非 3013。 """ other_agent = create_test_agent(user_id="other_for_3002", name="他人坐席") grabber = create_test_agent(user_id="grabber_for_3002", name="接手坐席") db_session.add_all([other_agent, grabber]) await db_session.flush() # 创建已结单但分配了坐席的会话 conv = create_test_conversation( employee_id="emp_resolved_grab_test", status="resolved" ) conv.assigned_agent_id = "other_for_3002" db_session.add(conv) await db_session.flush() headers = await login_agent(client, "grabber_for_3002", "接手坐席") response = await client.post( f"/conversations/{conv.id}/grab", headers=headers, ) data = response.json() # resolved 检查在 status != serving 之前,应返回 3002 assert data["code"] == 3002 assert "已结单" in data["message"] @pytest.mark.asyncio async def test_grab_broadcasts_websocket( self, client, db_session, mock_redis ): """验证接手成功后广播 WebSocket conversation_updated 事件。""" old_agent = create_test_agent( user_id="ws_old_agent", name="原坐席", status="online" ) old_agent.current_load = 1 new_agent = create_test_agent( user_id="ws_new_agent", name="新坐席", status="online" ) db_session.add_all([old_agent, new_agent]) await db_session.flush() conv = create_test_conversation( employee_id="emp_ws_grab", status="serving" ) conv.assigned_agent_id = "ws_old_agent" db_session.add(conv) await db_session.flush() headers = await login_agent(client, "ws_new_agent", "新坐席") with patch("app.services.ws_manager.manager.broadcast", new_callable=AsyncMock) as mock_broadcast: response = await client.post( f"/conversations/{conv.id}/grab", headers=headers, ) # 验证广播被调用 mock_broadcast.assert_called_once() broadcast_data = mock_broadcast.call_args[0][0] assert broadcast_data["type"] == "conversation_updated" assert broadcast_data["data"]["conversation_id"] == str(conv.id) assert broadcast_data["data"]["old_agent_id"] == "ws_old_agent" assert broadcast_data["data"]["new_agent_id"] == "ws_new_agent" @pytest.mark.asyncio async def test_grab_success_response_fields( self, client, db_session, mock_redis ): """验证接手成功后返回的扩展字段:is_mine=True, can_grab=False, assigned_agent_name。""" old_agent = create_test_agent( user_id="resp_old_agent", name="原坐席", status="online" ) old_agent.current_load = 1 new_agent = create_test_agent( user_id="resp_new_agent", name="新坐席", status="online" ) db_session.add_all([old_agent, new_agent]) await db_session.flush() conv = create_test_conversation( employee_id="emp_resp_grab", status="serving" ) conv.assigned_agent_id = "resp_old_agent" db_session.add(conv) await db_session.flush() headers = await login_agent(client, "resp_new_agent", "新坐席") with patch("app.services.ws_manager.manager.broadcast", new_callable=AsyncMock): response = await client.post( f"/conversations/{conv.id}/grab", headers=headers, ) data = response.json() assert data["code"] == 0 result = data["data"] # 接手后该会话属于当前坐席 assert result["is_mine"] is True # 自己的会话不能再接手 assert result["can_grab"] is False # 坐席姓名应为新坐席姓名 assert result["assigned_agent_name"] == "新坐席" # ============================================================================= # 三、边界情况测试 # ============================================================================= class TestGrabEdgeCases: """测试接手功能的边界情况。""" @pytest.mark.asyncio async def test_grab_when_agent_at_max_load_error_3005( self, client, db_session, mock_redis ): """验证满负荷坐席无法接手 → 3005。""" # 原坐席有1个会话 old_agent = create_test_agent( user_id="max_old_agent", name="原坐席", status="online" ) old_agent.current_load = 1 # 新坐席已满负荷 full_agent = create_test_agent( user_id="full_agent", name="满负荷坐席", status="online" ) full_agent.current_load = 5 full_agent.max_load = 5 db_session.add_all([old_agent, full_agent]) await db_session.flush() conv = create_test_conversation( employee_id="emp_max_load", status="serving" ) conv.assigned_agent_id = "max_old_agent" db_session.add(conv) await db_session.flush() headers = await login_agent(client, "full_agent", "满负荷坐席") response = await client.post( f"/conversations/{conv.id}/grab", headers=headers, ) data = response.json() assert data["code"] == 3005 assert "满负荷" in data["message"] @pytest.mark.asyncio async def test_grab_nonexistent_conversation_error_3003( self, client, db_session, mock_redis ): """验证接手不存在的会话 → 3003。""" agent = create_test_agent( user_id="grab_ghost_agent", name="幽灵坐席", status="online" ) db_session.add(agent) await db_session.flush() fake_id = str(uuid.uuid4()) headers = await login_agent(client, "grab_ghost_agent", "幽灵坐席") response = await client.post( f"/conversations/{fake_id}/grab", headers=headers, ) data = response.json() # SessionService._get_conversation 抛出 ERR_CONVERSATION_NOT_FOUND (3003) assert data["code"] == 3003 @pytest.mark.asyncio async def test_grab_ai_handling_status_error_3013( self, client, db_session, mock_redis ): """验证不能接手 ai_handling 状态的会话 → 3013。""" other_agent = create_test_agent( user_id="ai_other", name="AI坐席", status="online" ) grabber = create_test_agent( user_id="ai_grabber", name="接手坐席", status="online" ) db_session.add_all([other_agent, grabber]) await db_session.flush() conv = create_test_conversation( employee_id="emp_ai_handling", status="ai_handling" ) conv.assigned_agent_id = "ai_other" db_session.add(conv) await db_session.flush() headers = await login_agent(client, "ai_grabber", "接手坐席") response = await client.post( f"/conversations/{conv.id}/grab", headers=headers, ) data = response.json() # ai_handling 不是 serving,应返回 3013 assert data["code"] == 3013 @pytest.mark.asyncio async def test_grab_without_auth_returns_unauthorized( self, client, db_session, mock_redis ): """验证未登录时接手请求返回未授权错误。""" conv = create_test_conversation( employee_id="emp_no_auth", status="serving" ) conv.assigned_agent_id = "some_agent" db_session.add(conv) await db_session.flush() # 不带 Authorization 头 response = await client.post( f"/conversations/{conv.id}/grab", ) data = response.json() assert data["code"] == 1002 # ERR_UNAUTHORIZED @pytest.mark.asyncio async def test_grab_old_agent_load_never_goes_negative( self, client, db_session, mock_redis ): """验证原坐席 current_load 不会变为负数(源码有 if > 0 保护)。""" # 原坐席 current_load 为 0(异常数据场景) old_agent = create_test_agent( user_id="zero_load_old", name="零负荷原坐席", status="online" ) old_agent.current_load = 0 new_agent = create_test_agent( user_id="zero_load_new", name="新坐席", status="online" ) new_agent.current_load = 0 db_session.add_all([old_agent, new_agent]) await db_session.flush() conv = create_test_conversation( employee_id="emp_zero_load", status="serving" ) conv.assigned_agent_id = "zero_load_old" db_session.add(conv) await db_session.flush() headers = await login_agent(client, "zero_load_new", "新坐席") with patch("app.services.ws_manager.manager.broadcast", new_callable=AsyncMock): response = await client.post( f"/conversations/{conv.id}/grab", headers=headers, ) data = response.json() assert data["code"] == 0 # 原坐席 load 应仍为 0(不会变为负数) stmt = select(Agent).where(Agent.user_id == "zero_load_old") result = await db_session.execute(stmt) refreshed_old = result.scalars().first() assert refreshed_old.current_load == 0 # 新坐席 load 应为 1 stmt = select(Agent).where(Agent.user_id == "zero_load_new") result = await db_session.execute(stmt) refreshed_new = result.scalars().first() assert refreshed_new.current_load == 1 # ============================================================================= # 四、会话列表 N+1 查询优化验证 # ============================================================================= class TestConversationListN1Optimization: """测试会话列表接口的 N+1 查询优化。""" @pytest.mark.asyncio async def test_batch_query_agent_names( self, client, db_session, mock_redis ): """验证多个会话涉及多个坐席时,assigned_agent_name 全部正确返回。 这间接验证了 N+1 优化:所有坐席姓名通过一次 IN 查询获取。 如果 N+1 没优化,此测试仍会通过,但此测试确保批量查询结果映射正确。 """ # 创建3个坐席 agents = [ create_test_agent(user_id=f"batch_agent_{i}", name=f"坐席{i+1}") for i in range(3) ] db_session.add_all(agents) await db_session.flush() # 创建3个会话,分别分配给不同坐席 convs = [ create_test_conversation( employee_id=f"emp_batch_{i}", status="serving" ) for i in range(3) ] for i, conv in enumerate(convs): conv.assigned_agent_id = f"batch_agent_{i}" db_session.add_all(convs) await db_session.flush() headers = await login_agent(client, "batch_agent_0", "坐席1") 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} # 验证所有坐席姓名正确 for i, conv in enumerate(convs): item = item_map[str(conv.id)] assert item["assigned_agent_name"] == f"坐席{i+1}", \ f"会话 {conv.id} 的坐席姓名应为 '坐席{i+1}',实际为 '{item['assigned_agent_name']}'"