diff --git a/backend/tests/realtime/__init__.py b/backend/tests/realtime/__init__.py new file mode 100644 index 00000000..acee5286 --- /dev/null +++ b/backend/tests/realtime/__init__.py @@ -0,0 +1,5 @@ +# tests/realtime/__init__.py +""" +Тесты для realtime приложения. +WebSocket тесты для UserConsumer. +""" diff --git a/backend/tests/realtime/conftest.py b/backend/tests/realtime/conftest.py new file mode 100644 index 00000000..150c6ea8 --- /dev/null +++ b/backend/tests/realtime/conftest.py @@ -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 diff --git a/backend/tests/realtime/test_integration.py b/backend/tests/realtime/test_integration.py new file mode 100644 index 00000000..b0d11602 --- /dev/null +++ b/backend/tests/realtime/test_integration.py @@ -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() diff --git a/backend/tests/realtime/test_routing.py b/backend/tests/realtime/test_routing.py new file mode 100644 index 00000000..43ddd3e4 --- /dev/null +++ b/backend/tests/realtime/test_routing.py @@ -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}") diff --git a/backend/tests/realtime/test_user_consumer.py b/backend/tests/realtime/test_user_consumer.py new file mode 100644 index 00000000..afd151b3 --- /dev/null +++ b/backend/tests/realtime/test_user_consumer.py @@ -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()