test: 补充权限、消息与会话服务回归用例

会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
This commit is contained in:
WangShen007 committed 2026-10-08 22:35:00 +08:00
1 parent 8e3a4d95da
commit 164f5f9615
97 files changed
+37281

No files matched your search

@@ -0,0 +1,550 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from app.models.widget import Widget
from app.models.agent import Agent, AgentType
from app.models.ai_config import AIConfig, AIModelType
from app.models.customer import Customer
from app.models.user import User
from app.models.workflow import Workflow, WorkflowStatus
from app.models.session_to_agent import SessionToAgent, SessionStatus
from app.repositories.agent import AgentRepository
from app.repositories.widget import WidgetRepository
from app.repositories.session_to_agent import SessionToAgentRepository
from app.repositories.chat import ChatRepository
from app.models.schemas.widget import WidgetCreate
from app.core.security import encrypt_api_key
from uuid import UUID, uuid4
from datetime import datetime, timezone
@pytest.fixture
def test_ai_config(db, test_organization) -> AIConfig:
"""Create a test AI config"""
ai_config = AIConfig(
organization_id=test_organization.id,
model_type=AIModelType.OPENAI,
model_name="gpt-4",
encrypted_api_key=encrypt_api_key("test_key"),
is_active=True
)
db.add(ai_config)
db.commit()
db.refresh(ai_config)
return ai_config
@pytest.fixture
def test_agent(db, test_organization) -> Agent:
"""Create a test agent"""
agent_repo = AgentRepository(db)
agent = agent_repo.create_agent(
name="Test Agent",
agent_type=AgentType.CUSTOMER_SUPPORT,
instructions=["Test instructions"],
org_id=test_organization.id
)
return agent
@pytest.fixture
def test_workflow(db, test_organization, test_user) -> Workflow:
"""Create a test workflow"""
workflow = Workflow(
id=uuid4(),
name="Test Workflow",
description="Test workflow",
status=WorkflowStatus.PUBLISHED,
version=1,
is_template=False,
default_language="en",
canvas_data={},
settings={},
organization_id=test_organization.id,
created_by=test_user.id
)
db.add(workflow)
db.commit()
db.refresh(workflow)
return workflow
@pytest.fixture
def test_widget(db, test_agent) -> Widget:
"""Create a test widget"""
widget_repo = WidgetRepository(db)
widget_create = WidgetCreate(
name="Test Widget",
agent_id=test_agent.id
)
widget = widget_repo.create_widget(
widget=widget_create,
organization_id=test_agent.organization_id
)
return widget
@pytest.fixture
def test_customer(db, test_organization) -> Customer:
"""Create a test customer"""
customer = Customer(
id=uuid4(),
organization_id=test_organization.id,
email="test.customer@example.com",
full_name="Test Customer"
)
db.add(customer)
db.commit()
db.refresh(customer)
return customer
@pytest.fixture
def test_session(db, test_widget, test_customer) -> SessionToAgent:
"""Create a test session"""
session_id = uuid4()
session = SessionToAgent(
session_id=session_id,
agent_id=test_widget.agent_id,
customer_id=test_customer.id,
organization_id=test_widget.organization_id,
status=SessionStatus.OPEN
)
db.add(session)
db.commit()
db.refresh(session)
return session
@pytest.fixture
def mock_sio():
"""Create a mock socket.io instance"""
mock = MagicMock()
mock.emit = AsyncMock()
mock.get_session = AsyncMock()
mock.get_environ = MagicMock()
return mock
class TestHandleWidgetChat:
"""Test the handle_widget_chat function"""
@pytest.mark.asyncio
async def test_handle_widget_chat_authentication_failure(self, mock_sio, monkeypatch):
"""Test widget chat with authentication failure"""
from app.api import widget_chat
monkeypatch.setattr(widget_chat, "sio", mock_sio)
# Mock failed authentication
mock_sio.get_session.return_value = {"test": "session"}
monkeypatch.setattr(
widget_chat,
"authenticate_socket_conversation_token",
AsyncMock(return_value=(None, None, None, None))
)
sid = "test_sid"
data = {"message": "Hello"}
await widget_chat.handle_widget_chat(sid, data)
# Verify error was emitted
mock_sio.emit.assert_called_with(
'error',
{'error': 'Authentication failed', 'type': 'auth_error'},
room=sid,
namespace='/widget'
)
@pytest.mark.asyncio
async def test_handle_widget_chat_empty_message(self, mock_sio, monkeypatch, test_widget, test_customer):
"""Test widget chat with empty message"""
from app.api import widget_chat
monkeypatch.setattr(widget_chat, "sio", mock_sio)
# Mock successful authentication
mock_auth_result = (str(test_widget.id), str(test_widget.organization_id), str(test_customer.id), "token")
mock_sio.get_session.return_value = {
"widget_id": str(test_widget.id),
"org_id": str(test_widget.organization_id),
"customer_id": str(test_customer.id),
"session_id": str(uuid4()),
"agent_id": str(test_widget.agent_id)
}
monkeypatch.setattr(
widget_chat,
"authenticate_socket_conversation_token",
AsyncMock(return_value=mock_auth_result)
)
sid = "test_sid"
data = {"message": " "} # Empty/whitespace message
await widget_chat.handle_widget_chat(sid, data)
# Should return early without processing, no error emitted
assert mock_sio.emit.call_count == 0
@pytest.mark.asyncio
async def test_handle_widget_chat_session_mismatch(self, mock_sio, monkeypatch, test_widget, test_customer):
"""Test widget chat with session data mismatch"""
from app.api import widget_chat
monkeypatch.setattr(widget_chat, "sio", mock_sio)
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([MagicMock()]))
# Mock successful authentication but mismatched session data
mock_auth_result = (str(test_widget.id), str(test_widget.organization_id), str(test_customer.id), "token")
mock_sio.get_session.return_value = {
"widget_id": "different_widget_id", # Mismatch
"org_id": str(test_widget.organization_id),
"customer_id": str(test_customer.id),
"session_id": str(uuid4()),
"agent_id": str(test_widget.agent_id)
}
monkeypatch.setattr(
widget_chat,
"authenticate_socket_conversation_token",
AsyncMock(return_value=mock_auth_result)
)
sid = "test_sid"
data = {"message": "Hello"}
await widget_chat.handle_widget_chat(sid, data)
# Should emit error due to session mismatch
mock_sio.emit.assert_called_with(
'error',
{'error': 'Unable to process your request, please try again later.', 'type': 'chat_error'},
to=sid,
namespace='/widget'
)
@pytest.mark.asyncio
async def test_handle_widget_chat_no_active_session(self, mock_sio, monkeypatch, test_widget, test_customer, db):
"""Test widget chat when no active session exists"""
from app.api import widget_chat
monkeypatch.setattr(widget_chat, "sio", mock_sio)
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
session_id = uuid4()
mock_auth_result = (str(test_widget.id), str(test_widget.organization_id), str(test_customer.id), "token")
mock_sio.get_session.return_value = {
"widget_id": str(test_widget.id),
"org_id": str(test_widget.organization_id),
"customer_id": str(test_customer.id),
"session_id": str(session_id),
"agent_id": str(test_widget.agent_id)
}
monkeypatch.setattr(
widget_chat,
"authenticate_socket_conversation_token",
AsyncMock(return_value=mock_auth_result)
)
# Mock no active session found
with patch('app.api.widget_chat._own_session', return_value=None):
with patch.object(SessionToAgentRepository, 'get_latest_customer_session', return_value=None):
sid = "test_sid"
data = {"message": "Hello"}
await widget_chat.handle_widget_chat(sid, data)
# Should emit error due to no session
mock_sio.emit.assert_called_with(
'error',
{'error': 'Unable to process your request, please try again later.', 'type': 'chat_error'},
to=sid,
namespace='/widget'
)
@pytest.mark.asyncio
async def test_handle_widget_chat_workflow_execution(self, mock_sio, monkeypatch, test_widget, test_customer, test_workflow, test_ai_config, db):
"""Test widget chat with workflow execution"""
from app.api import widget_chat
monkeypatch.setattr(widget_chat, "sio", mock_sio)
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
session_id = uuid4()
mock_auth_result = (str(test_widget.id), str(test_widget.organization_id), str(test_customer.id), "token")
mock_sio.get_session.return_value = {
"widget_id": str(test_widget.id),
"org_id": str(test_widget.organization_id),
"customer_id": str(test_customer.id),
"session_id": str(session_id),
"agent_id": str(test_widget.agent_id),
"ai_config": test_ai_config
}
monkeypatch.setattr(
widget_chat,
"authenticate_socket_conversation_token",
AsyncMock(return_value=mock_auth_result)
)
# Mock active session with workflow
mock_session = MagicMock()
mock_session.session_id = session_id
mock_session.workflow_id = test_workflow.id
mock_session.status = SessionStatus.OPEN
mock_session.user_id = None # No human agent
mock_session.current_node_id = None
mock_session.workflow_state = {}
# Mock workflow execution result
mock_workflow_result = MagicMock()
mock_workflow_result.success = True
mock_workflow_result.form_data = {}
mock_workflow_result.next_node_id = None
mock_workflow_result.workflow_state = {}
mock_workflow_result.response_messages = ["AI Response"]
mock_workflow_result.end_conversation = False
mock_workflow_result.transfer_to_human = False
with patch('app.api.widget_chat._own_session', return_value=mock_session):
with patch.object(ChatRepository, 'create_message', return_value=MagicMock()):
with patch('app.api.widget_chat.WorkflowChatService') as mock_workflow_chat_service:
mock_workflow_chat_service.return_value.handle_workflow_chat = AsyncMock(return_value=mock_workflow_result)
sid = "test_sid"
data = {"message": "Hello"}
await widget_chat.handle_widget_chat(sid, data)
# Verify workflow chat was executed
mock_workflow_chat_service.return_value.handle_workflow_chat.assert_called_once()
class TestGetWidgetChatHistory:
"""Test the get_widget_chat_history function"""
@pytest.mark.asyncio
async def test_get_widget_chat_history_authentication_failure(self, mock_sio, monkeypatch):
"""Test get chat history with authentication failure"""
from app.api import widget_chat
monkeypatch.setattr(widget_chat, "sio", mock_sio)
# Mock failed authentication
mock_sio.get_session.return_value = {"test": "session"}
monkeypatch.setattr(
widget_chat,
"authenticate_socket_conversation_token",
AsyncMock(return_value=(None, None, None, None))
)
sid = "test_sid"
await widget_chat.get_widget_chat_history(sid)
# Verify error was emitted
mock_sio.emit.assert_called_with(
'error',
{'error': 'Authentication failed', 'type': 'auth_error'},
room=sid,
namespace='/widget'
)
@pytest.mark.asyncio
async def test_get_widget_chat_history_serves_an_expired_token(
self, mock_sio, monkeypatch, test_widget, test_customer, db
):
"""A token that has since expired must not break the open connection.
The widget kept a socket for longer than the token's TTL and every event
on it started failing with "Authentication failed" (#315). Identity comes
from the session the connect handler saved.
"""
from app.api import widget_chat
monkeypatch.setattr(widget_chat, "sio", mock_sio)
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
mock_sio.get_session.return_value = {
"widget_id": str(test_widget.id),
"org_id": str(test_widget.organization_id),
"customer_id": str(test_customer.id),
"agent_id": str(test_widget.agent_id),
"conversation_token": "expired-token",
}
monkeypatch.setattr(
widget_chat,
"authenticate_socket_conversation_token",
AsyncMock(return_value=(None, None, None, None))
)
sid = "test_sid"
with patch('app.api.widget_chat._own_session', return_value=None):
await widget_chat.get_widget_chat_history(sid)
mock_sio.emit.assert_called_with(
'chat_history',
{'messages': [], 'type': 'chat_history'},
to=sid,
namespace='/widget'
)
@pytest.mark.asyncio
async def test_get_widget_chat_history_no_active_session(self, mock_sio, monkeypatch, test_widget, test_customer, db):
"""Test get chat history when no active session exists"""
from app.api import widget_chat
monkeypatch.setattr(widget_chat, "sio", mock_sio)
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
mock_auth_result = (str(test_widget.id), str(test_widget.organization_id), str(test_customer.id), "token")
mock_sio.get_session.return_value = {
"widget_id": str(test_widget.id),
"org_id": str(test_widget.organization_id),
"customer_id": str(test_customer.id),
"agent_id": str(test_widget.agent_id)
}
monkeypatch.setattr(
widget_chat,
"authenticate_socket_conversation_token",
AsyncMock(return_value=mock_auth_result)
)
# Mock no active session found
with patch('app.api.widget_chat._own_session', return_value=None):
sid = "test_sid"
await widget_chat.get_widget_chat_history(sid)
# Should return empty history
mock_sio.emit.assert_called_with(
'chat_history',
{'messages': [], 'type': 'chat_history'},
to=sid,
namespace='/widget'
)
@pytest.mark.asyncio
async def test_get_widget_chat_history_success(self, mock_sio, monkeypatch, test_widget, test_customer, test_session, db):
"""Test get chat history success case"""
from app.api import widget_chat
monkeypatch.setattr(widget_chat, "sio", mock_sio)
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
mock_auth_result = (str(test_widget.id), str(test_widget.organization_id), str(test_customer.id), "token")
mock_sio.get_session.return_value = {
"widget_id": str(test_widget.id),
"org_id": str(test_widget.organization_id),
"customer_id": str(test_customer.id),
"agent_id": str(test_widget.agent_id)
}
monkeypatch.setattr(
widget_chat,
"authenticate_socket_conversation_token",
AsyncMock(return_value=mock_auth_result)
)
# Create mock messages
mock_message1 = MagicMock()
mock_message1.message = "Hello"
mock_message1.message_type = "user"
mock_message1.created_at = datetime.now(timezone.utc)
mock_message1.attributes = {}
mock_message1.user = None
mock_message1.agent = None
mock_message2 = MagicMock()
mock_message2.message = "Hi there!"
mock_message2.message_type = "bot"
mock_message2.created_at = datetime.now(timezone.utc)
mock_message2.attributes = {}
mock_message2.user = None
mock_message2.agent = None
with patch('app.api.widget_chat._own_session', return_value=test_session):
with patch.object(ChatRepository, 'get_session_history', return_value=[mock_message1, mock_message2]):
with patch('app.api.widget_chat.format_datetime', return_value="2024-01-01T00:00:00Z"):
sid = "test_sid"
await widget_chat.get_widget_chat_history(sid)
# Verify chat history was emitted with formatted messages
expected_messages = [
{
'message': 'Hello',
'message_type': 'user',
'timestamp': '2024-01-01T00:00:00Z',
'attributes': {},
'user_name': None,
'agent_name': None,
'attachments': []
},
{
'message': 'Hi there!',
'message_type': 'bot',
'timestamp': '2024-01-01T00:00:00Z',
'attributes': {},
'user_name': None,
'agent_name': None,
'attachments': []
}
]
mock_sio.emit.assert_called_with(
'chat_history',
{'messages': expected_messages, 'type': 'chat_history'},
to=sid,
namespace='/widget'
)
@pytest.mark.asyncio
async def test_get_widget_chat_history_database_error(self, mock_sio, monkeypatch, test_widget, test_customer, db):
"""Test get chat history with database error"""
from app.api import widget_chat
monkeypatch.setattr(widget_chat, "sio", mock_sio)
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
mock_auth_result = (str(test_widget.id), str(test_widget.organization_id), str(test_customer.id), "token")
mock_sio.get_session.return_value = {
"widget_id": str(test_widget.id),
"org_id": str(test_widget.organization_id),
"customer_id": str(test_customer.id),
"agent_id": str(test_widget.agent_id)
}
monkeypatch.setattr(
widget_chat,
"authenticate_socket_conversation_token",
AsyncMock(return_value=mock_auth_result)
)
# Mock database error
with patch('app.api.widget_chat._own_session', side_effect=Exception("Database error")):
sid = "test_sid"
await widget_chat.get_widget_chat_history(sid)
# Should emit chat history error
mock_sio.emit.assert_called_with(
'error',
{'error': 'Failed to get chat history', 'type': 'chat_history_error'},
to=sid,
namespace='/widget'
)