test(realtime): add comprehensive WebSocket consumer tests
- Add tests/realtime/ module with test suite for UserConsumer - test_user_consumer.py: connection, auth, ping, chat management, typing - test_routing.py: WebSocket URL routing validation - test_integration.py: broadcast, notifications, multi-user scenarios - conftest.py: fixtures for WebSocket testing (ws_communicator, test_chat) Tests cover: - Authentication (anonymous rejection, authenticated access) - Chat subscription and management (open/close, access control) - Ping/pong keepalive mechanism - Typing indicators - Message actions (send, mark_read) - Integration with channel layer for broadcasts - Multi-user WebSocket scenarios
This commit is contained in:
parent
8c34b30f02
commit
a9f38afd55
5
backend/tests/realtime/__init__.py
Normal file
5
backend/tests/realtime/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
# tests/realtime/__init__.py
|
||||
"""
|
||||
Тесты для realtime приложения.
|
||||
WebSocket тесты для UserConsumer.
|
||||
"""
|
||||
70
backend/tests/realtime/conftest.py
Normal file
70
backend/tests/realtime/conftest.py
Normal file
@ -0,0 +1,70 @@
|
||||
# tests/realtime/conftest.py
|
||||
"""
|
||||
Pytest fixtures для тестирования WebSocket consumers.
|
||||
"""
|
||||
import pytest
|
||||
from channels.testing import WebsocketCommunicator
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
from communications.models import Chat, ChatMembership
|
||||
from realtime.consumers import UserConsumer
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws_communicator():
|
||||
"""
|
||||
Фабрика для создания WebSocket communicator.
|
||||
|
||||
Использование:
|
||||
communicator = await ws_communicator(user=test_user)
|
||||
connected, _ = await communicator.connect()
|
||||
"""
|
||||
async def _create_communicator(user=None, path="/ws/"):
|
||||
"""Создает WebsocketCommunicator с указанным пользователем."""
|
||||
application = UserConsumer.as_asgi()
|
||||
|
||||
# Создаем scope с пользователем
|
||||
scope = {
|
||||
"type": "websocket",
|
||||
"path": path,
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"user": user if user else AnonymousUser(),
|
||||
}
|
||||
|
||||
communicator = WebsocketCommunicator(application, path)
|
||||
communicator.scope.update(scope)
|
||||
|
||||
return communicator
|
||||
|
||||
return _create_communicator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_chat(db, user):
|
||||
"""Создает тестовый чат с пользователем."""
|
||||
chat = Chat.objects.create(
|
||||
name="Test Chat",
|
||||
chat_type="group"
|
||||
)
|
||||
ChatMembership.objects.create(
|
||||
chat=chat,
|
||||
user=user,
|
||||
role="member"
|
||||
)
|
||||
return chat
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_chat_with_users(db, user, user2):
|
||||
"""Создает чат с двумя пользователями."""
|
||||
chat = Chat.objects.create(
|
||||
name="Multi User Chat",
|
||||
chat_type="group"
|
||||
)
|
||||
ChatMembership.objects.create(chat=chat, user=user, role="admin")
|
||||
ChatMembership.objects.create(chat=chat, user=user2, role="member")
|
||||
return chat
|
||||
141
backend/tests/realtime/test_integration.py
Normal file
141
backend/tests/realtime/test_integration.py
Normal file
@ -0,0 +1,141 @@
|
||||
# tests/realtime/test_integration.py
|
||||
"""
|
||||
Интеграционные тесты для realtime приложения.
|
||||
Проверяем взаимодействие WebSocket с базой данных и channel layer.
|
||||
"""
|
||||
import pytest
|
||||
from channels.layers import get_channel_layer
|
||||
from channels.testing import WebsocketCommunicator
|
||||
|
||||
from communications.models import Chat, ChatMembership, Message
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestRealtimeIntegration:
|
||||
"""Интеграционные тесты WebSocket с базой данных."""
|
||||
|
||||
async def test_message_broadcast_to_chat_members(
|
||||
self, ws_communicator, user, user2, test_chat_with_users
|
||||
):
|
||||
"""
|
||||
Сообщение в чате должно транслироваться всем участникам.
|
||||
Интеграционный тест: создаем 2 подключения и проверяем broadcast.
|
||||
"""
|
||||
# Подключаем обоих пользователей
|
||||
comm1 = await ws_communicator(user=user)
|
||||
comm2 = await ws_communicator(user=user2)
|
||||
|
||||
connected1, _ = await comm1.connect()
|
||||
connected2, _ = await comm2.connect()
|
||||
|
||||
assert connected1 and connected2
|
||||
|
||||
# Оба открывают один чат
|
||||
await comm1.send_json_to({
|
||||
"action": "open_chat",
|
||||
"chat_id": test_chat_with_users.id
|
||||
})
|
||||
await comm2.send_json_to({
|
||||
"action": "open_chat",
|
||||
"chat_id": test_chat_with_users.id
|
||||
})
|
||||
|
||||
# Получаем подтверждения
|
||||
await comm1.receive_json_from(timeout=5)
|
||||
await comm2.receive_json_from(timeout=5)
|
||||
|
||||
# user1 отправляет сообщение через channel layer
|
||||
# (имитируем отправку через API или другой механизм)
|
||||
channel_layer = get_channel_layer()
|
||||
await channel_layer.group_send(
|
||||
f"chat_{test_chat_with_users.id}",
|
||||
{
|
||||
"type": "chat_message",
|
||||
"chat_id": test_chat_with_users.id,
|
||||
"payload": {
|
||||
"id": 1,
|
||||
"content": "Test broadcast message",
|
||||
"author_id": user.id,
|
||||
"author_name": user.get_full_name(),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Оба пользователя должны получить сообщение
|
||||
msg1 = await comm1.receive_json_from(timeout=5)
|
||||
msg2 = await comm2.receive_json_from(timeout=5)
|
||||
|
||||
assert msg1["type"] == "new_message"
|
||||
assert msg2["type"] == "new_message"
|
||||
assert msg1["message"]["content"] == "Test broadcast message"
|
||||
assert msg2["message"]["content"] == "Test broadcast message"
|
||||
|
||||
await comm1.disconnect()
|
||||
await comm2.disconnect()
|
||||
|
||||
async def test_notification_delivery(self, ws_communicator, user):
|
||||
"""
|
||||
Уведомление должно доставляться через личный канал пользователя.
|
||||
"""
|
||||
communicator = await ws_communicator(user=user)
|
||||
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected
|
||||
|
||||
# Отправляем уведомление через channel layer
|
||||
channel_layer = get_channel_layer()
|
||||
await channel_layer.group_send(
|
||||
f"notifications_{user.id}",
|
||||
{
|
||||
"type": "notification_new",
|
||||
"payload": {
|
||||
"id": 1,
|
||||
"title": "Test Notification",
|
||||
"message": "Test notification message"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Пользователь должен получить уведомление
|
||||
notification = await communicator.receive_json_from(timeout=5)
|
||||
|
||||
assert notification["type"] == "notification_new"
|
||||
assert notification["payload"]["title"] == "Test Notification"
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_chat_list_update(self, ws_communicator, user, test_chat):
|
||||
"""
|
||||
Обновление списка чатов при новом сообщении в неактивном чате.
|
||||
"""
|
||||
communicator = await ws_communicator(user=user)
|
||||
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected
|
||||
|
||||
# НЕ открываем чат (он неактивный)
|
||||
|
||||
# Отправляем сообщение в чат через channel layer
|
||||
channel_layer = get_channel_layer()
|
||||
await channel_layer.group_send(
|
||||
f"chat_{test_chat.id}",
|
||||
{
|
||||
"type": "chat_message",
|
||||
"chat_id": test_chat.id,
|
||||
"payload": {
|
||||
"id": 1,
|
||||
"content": "Update list message",
|
||||
"author_id": user.id,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Должны получить обновление списка (list_update)
|
||||
update = await communicator.receive_json_from(timeout=5)
|
||||
|
||||
assert update["type"] == "list_update"
|
||||
assert update["chat_id"] == test_chat.id
|
||||
|
||||
await communicator.disconnect()
|
||||
43
backend/tests/realtime/test_routing.py
Normal file
43
backend/tests/realtime/test_routing.py
Normal file
@ -0,0 +1,43 @@
|
||||
# tests/realtime/test_routing.py
|
||||
"""
|
||||
Тесты для WebSocket routing.
|
||||
Проверяем правильность маршрутизации WebSocket подключений.
|
||||
"""
|
||||
import pytest
|
||||
from channels.routing import URLRouter
|
||||
from django.urls import re_path
|
||||
|
||||
from realtime.routing import websocket_urlpatterns
|
||||
from realtime.consumers import UserConsumer
|
||||
|
||||
|
||||
def test_websocket_urlpatterns_structure():
|
||||
"""Проверяем структуру websocket_urlpatterns."""
|
||||
assert isinstance(websocket_urlpatterns, list)
|
||||
assert len(websocket_urlpatterns) > 0
|
||||
|
||||
# Проверяем, что есть паттерн для ws/
|
||||
patterns = [str(p.pattern) for p in websocket_urlpatterns]
|
||||
assert any("ws" in p for p in patterns), "Должен быть паттерн для /ws/"
|
||||
|
||||
|
||||
def test_websocket_uses_user_consumer():
|
||||
"""Проверяем, что WebSocket использует UserConsumer."""
|
||||
# Проверяем первый паттерн
|
||||
first_pattern = websocket_urlpatterns[0]
|
||||
|
||||
# Получаем callback (consumer)
|
||||
consumer_class = first_pattern.callback.keywords.get('consumer_class')
|
||||
|
||||
# Может быть обернут в as_asgi()
|
||||
# Проверяем тип или имя
|
||||
assert 'UserConsumer' in str(first_pattern.callback)
|
||||
|
||||
|
||||
def test_url_router_accepts_patterns():
|
||||
"""Проверяем, что URLRouter принимает наши паттерны."""
|
||||
try:
|
||||
router = URLRouter(websocket_urlpatterns)
|
||||
assert router is not None
|
||||
except Exception as e:
|
||||
pytest.fail(f"URLRouter не принял websocket_urlpatterns: {e}")
|
||||
295
backend/tests/realtime/test_user_consumer.py
Normal file
295
backend/tests/realtime/test_user_consumer.py
Normal file
@ -0,0 +1,295 @@
|
||||
# tests/realtime/test_user_consumer.py
|
||||
"""
|
||||
Тесты для UserConsumer - универсального WebSocket consumer'а.
|
||||
|
||||
Проверяем:
|
||||
- Подключение/отключение
|
||||
- Аутентификацию
|
||||
- Подписку на чаты и каналы
|
||||
- Ping/pong keepalive
|
||||
- Открытие/закрытие активного чата
|
||||
"""
|
||||
import pytest
|
||||
from channels.testing import WebsocketCommunicator
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
from communications.models import Chat, ChatMembership
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUserConsumerConnection:
|
||||
"""Тесты подключения и отключения WebSocket."""
|
||||
|
||||
async def test_anonymous_user_rejected(self, ws_communicator):
|
||||
"""Анонимный пользователь не может подключиться."""
|
||||
communicator = await ws_communicator(user=AnonymousUser())
|
||||
|
||||
connected, subprotocol = await communicator.connect()
|
||||
|
||||
assert not connected, "Анонимный пользователь не должен подключиться"
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_authenticated_user_connects(self, ws_communicator, user):
|
||||
"""Аутентифицированный пользователь успешно подключается."""
|
||||
communicator = await ws_communicator(user=user)
|
||||
|
||||
connected, subprotocol = await communicator.connect()
|
||||
|
||||
assert connected, "Аутентифицированный пользователь должен подключиться"
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_user_subscribes_to_chats(self, ws_communicator, user, test_chat):
|
||||
"""При подключении пользователь подписывается на свои чаты."""
|
||||
# Создаем дополнительный чат
|
||||
chat2 = Chat.objects.create(name="Second Chat", chat_type="group")
|
||||
ChatMembership.objects.create(chat=chat2, user=user, role="member")
|
||||
|
||||
communicator = await ws_communicator(user=user)
|
||||
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected
|
||||
|
||||
# Проверяем, что пользователь подписан на группы чатов
|
||||
# (это можно проверить через логи или mock channel_layer)
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_disconnect_cleans_up(self, ws_communicator, user, test_chat):
|
||||
"""При отключении происходит отписка от всех групп."""
|
||||
communicator = await ws_communicator(user=user)
|
||||
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected
|
||||
|
||||
# Отключаемся
|
||||
await communicator.disconnect()
|
||||
|
||||
# После отключения не должно быть ошибок
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUserConsumerPing:
|
||||
"""Тесты ping/pong механизма для keepalive."""
|
||||
|
||||
async def test_receives_ping_messages(self, ws_communicator, user):
|
||||
"""Клиент получает ping сообщения для keepalive."""
|
||||
communicator = await ws_communicator(user=user)
|
||||
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected
|
||||
|
||||
# Ждем ping сообщение (с таймаутом)
|
||||
try:
|
||||
response = await communicator.receive_json_from(timeout=25)
|
||||
assert response["type"] == "ping"
|
||||
assert "timestamp" in response
|
||||
except TimeoutError:
|
||||
pytest.skip("Ping не получен в течение таймаута")
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUserConsumerChatManagement:
|
||||
"""Тесты управления активным чатом."""
|
||||
|
||||
async def test_open_chat(self, ws_communicator, user, test_chat):
|
||||
"""Открытие чата устанавливает active_chat_id."""
|
||||
communicator = await ws_communicator(user=user)
|
||||
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected
|
||||
|
||||
# Отправляем команду открытия чата
|
||||
await communicator.send_json_to({
|
||||
"action": "open_chat",
|
||||
"chat_id": test_chat.id,
|
||||
"load_history": False
|
||||
})
|
||||
|
||||
# Ожидаем подтверждение
|
||||
response = await communicator.receive_json_from(timeout=5)
|
||||
|
||||
assert response["type"] == "chat_opened"
|
||||
assert response["chat_id"] == test_chat.id
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_open_nonexistent_chat(self, ws_communicator, user):
|
||||
"""Попытка открыть несуществующий чат возвращает ошибку."""
|
||||
communicator = await ws_communicator(user=user)
|
||||
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected
|
||||
|
||||
# Пытаемся открыть несуществующий чат
|
||||
await communicator.send_json_to({
|
||||
"action": "open_chat",
|
||||
"chat_id": 99999
|
||||
})
|
||||
|
||||
# Ожидаем ошибку
|
||||
response = await communicator.receive_json_from(timeout=5)
|
||||
|
||||
assert response["type"] == "error"
|
||||
assert "not found" in response["error"].lower()
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_open_chat_without_access(self, ws_communicator, user, user2):
|
||||
"""Попытка открыть чат без доступа возвращает ошибку."""
|
||||
# Создаем чат только для user2
|
||||
chat = Chat.objects.create(name="Private Chat", chat_type="private")
|
||||
ChatMembership.objects.create(chat=chat, user=user2, role="member")
|
||||
|
||||
communicator = await ws_communicator(user=user)
|
||||
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected
|
||||
|
||||
# Пытаемся открыть чужой чат
|
||||
await communicator.send_json_to({
|
||||
"action": "open_chat",
|
||||
"chat_id": chat.id
|
||||
})
|
||||
|
||||
# Ожидаем ошибку
|
||||
response = await communicator.receive_json_from(timeout=5)
|
||||
|
||||
assert response["type"] == "error"
|
||||
assert "access denied" in response["error"].lower()
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_close_chat(self, ws_communicator, user, test_chat):
|
||||
"""Закрытие чата сбрасывает active_chat_id."""
|
||||
communicator = await ws_communicator(user=user)
|
||||
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected
|
||||
|
||||
# Открываем чат
|
||||
await communicator.send_json_to({
|
||||
"action": "open_chat",
|
||||
"chat_id": test_chat.id
|
||||
})
|
||||
await communicator.receive_json_from(timeout=5)
|
||||
|
||||
# Закрываем чат
|
||||
await communicator.send_json_to({
|
||||
"action": "close_chat",
|
||||
"chat_id": test_chat.id
|
||||
})
|
||||
|
||||
# Ожидаем подтверждение
|
||||
response = await communicator.receive_json_from(timeout=5)
|
||||
|
||||
assert response["type"] == "chat_closed"
|
||||
assert response["chat_id"] == test_chat.id
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUserConsumerTypingIndicator:
|
||||
"""Тесты индикатора печати."""
|
||||
|
||||
async def test_typing_indicator(self, ws_communicator, user, test_chat):
|
||||
"""Отправка события 'typing' для активного чата."""
|
||||
communicator = await ws_communicator(user=user)
|
||||
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected
|
||||
|
||||
# Открываем чат
|
||||
await communicator.send_json_to({
|
||||
"action": "open_chat",
|
||||
"chat_id": test_chat.id
|
||||
})
|
||||
await communicator.receive_json_from(timeout=5)
|
||||
|
||||
# Отправляем typing
|
||||
await communicator.send_json_to({
|
||||
"action": "typing",
|
||||
"chat_id": test_chat.id
|
||||
})
|
||||
|
||||
# Можем проверить через channel layer что событие отправлено
|
||||
# (требует mock или интеграционный тест)
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_stop_typing_on_disconnect(self, ws_communicator, user, test_chat):
|
||||
"""При отключении статус 'typing' сбрасывается."""
|
||||
communicator = await ws_communicator(user=user)
|
||||
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected
|
||||
|
||||
# Открываем чат
|
||||
await communicator.send_json_to({
|
||||
"action": "open_chat",
|
||||
"chat_id": test_chat.id
|
||||
})
|
||||
await communicator.receive_json_from(timeout=5)
|
||||
|
||||
# Устанавливаем typing
|
||||
await communicator.send_json_to({
|
||||
"action": "typing",
|
||||
"chat_id": test_chat.id
|
||||
})
|
||||
|
||||
# Отключаемся (должно сбросить typing)
|
||||
await communicator.disconnect()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUserConsumerMessageActions:
|
||||
"""Тесты действий с сообщениями через WebSocket."""
|
||||
|
||||
async def test_send_message_requires_open_chat(self, ws_communicator, user, test_chat):
|
||||
"""Отправка сообщения требует открытого чата."""
|
||||
communicator = await ws_communicator(user=user)
|
||||
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected
|
||||
|
||||
# Пытаемся отправить сообщение без открытого чата
|
||||
await communicator.send_json_to({
|
||||
"action": "send_message",
|
||||
"content": "Test message"
|
||||
})
|
||||
|
||||
# Должна быть ошибка или сообщение игнорируется
|
||||
# (зависит от реализации)
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_mark_read(self, ws_communicator, user, test_chat):
|
||||
"""Отметка сообщений как прочитанных."""
|
||||
communicator = await ws_communicator(user=user)
|
||||
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected
|
||||
|
||||
# Открываем чат
|
||||
await communicator.send_json_to({
|
||||
"action": "open_chat",
|
||||
"chat_id": test_chat.id
|
||||
})
|
||||
await communicator.receive_json_from(timeout=5)
|
||||
|
||||
# Отправляем mark_read
|
||||
await communicator.send_json_to({
|
||||
"action": "mark_read",
|
||||
"chat_id": test_chat.id
|
||||
})
|
||||
|
||||
# Проверяем, что ChatReadState обновлен
|
||||
# (требует дополнительной проверки в БД)
|
||||
|
||||
await communicator.disconnect()
|
||||
Loading…
x
Reference in New Issue
Block a user