# tests/realtime/test_user_consumer.py """ Тесты для UserConsumer - универсального WebSocket consumer'а. Проверяем: - Подключение/отключение - Аутентификацию - Подписку на чаты и каналы - Ping/pong keepalive - Открытие/закрытие активного чата """ import pytest from channels.db import database_sync_to_async from channels.layers import get_channel_layer from django.contrib.auth.models import AnonymousUser from commerce.realtime import COMMERCE_CONNECTIONS_GROUP from communications.models import ActiveChatSession, Chat, ChatMembership from notifications.models import Notification 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 = await database_sync_to_async(Chat.objects.create)( name="Second Chat", type="group" ) await database_sync_to_async(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 TestUserConsumerTaskBoardEvents: """Тесты realtime-событий доски задач.""" async def test_task_board_update_event(self, ws_communicator, user): """Пользователь получает событие обновления доски через личный канал.""" communicator = await ws_communicator(user=user) connected, _ = await communicator.connect() assert connected channel_layer = get_channel_layer() await channel_layer.group_send( f"user_{user.id}", { "type": "task_board_update", "event": "moved", "data": { "board_id": 15, "model": "task", "object_id": 42, "column_id": 7, }, }, ) response = await communicator.receive_json_from(timeout=5) assert response["type"] == "task_board_update" assert response["event"] == "moved" assert response["data"]["board_id"] == 15 assert response["data"]["model"] == "task" assert response["data"]["object_id"] == 42 assert response["data"]["column_id"] == 7 await communicator.disconnect() @pytest.mark.asyncio async def test_user_receives_commerce_connection_update(ws_communicator, user): communicator = await ws_communicator(user=user) connected, _ = await communicator.connect() assert connected await get_channel_layer().group_send( COMMERCE_CONNECTIONS_GROUP, { "type": "commerce_connection_updated", "connection": {"id": "connection-1", "status": "ready"}, }, ) response = await communicator.receive_json_from(timeout=5) assert response == { "type": "commerce_connection_updated", "connection": {"id": "connection-1", "status": "ready"}, } 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 presence_exists = await database_sync_to_async( ActiveChatSession.objects.filter( user=user, chat=test_chat, ).exists )() assert presence_exists is True await communicator.disconnect() presence_exists = await database_sync_to_async( ActiveChatSession.objects.filter( user=user, chat=test_chat, ).exists )() assert presence_exists is False async def test_open_chat_does_not_mark_notifications_as_read( self, ws_communicator, user, test_chat ): """WebSocket presence alone must not mutate notification read state.""" related_notification = await database_sync_to_async( Notification.objects.create )( recipient=user, verb="chat_new_message", description="Chat notification", action_url=f"/messages/{test_chat.id}", data={"chat_id": test_chat.id, "message_id": 10}, ) unrelated_notification = await database_sync_to_async( Notification.objects.create )( recipient=user, verb="chat_new_message", description="Other chat notification", action_url="/messages/999", data={"chat_id": 999, "message_id": 11}, ) 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 related_notification = await database_sync_to_async(Notification.objects.get)( id=related_notification.id ) unrelated_notification = await database_sync_to_async(Notification.objects.get)( id=unrelated_notification.id ) assert related_notification.unread is True assert unrelated_notification.unread is True await communicator.disconnect() async def test_open_comments_chat_does_not_mark_notifications_as_read( self, ws_communicator, user, test_chat ): """Comments notifications also require an explicit HTTP read command.""" related_notification = await database_sync_to_async( Notification.objects.create )( recipient=user, verb="commented", description="Department chat comment notification", action_url=f"/messages/{test_chat.id}", data={ "chat_id": test_chat.id, "message_id": 12, "object_type": "Department", }, ) unrelated_notification = await database_sync_to_async( Notification.objects.create )( recipient=user, verb="commented", description="Other comments chat notification", action_url="/messages/999", data={"chat_id": 999, "message_id": 13, "object_type": "Department"}, ) 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 related_notification = await database_sync_to_async(Notification.objects.get)( id=related_notification.id ) unrelated_notification = await database_sync_to_async(Notification.objects.get)( id=unrelated_notification.id ) assert related_notification.unread is True assert unrelated_notification.unread is True 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 = await database_sync_to_async(Chat.objects.create)( name="Private Chat", type="private" ) await database_sync_to_async(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 presence_exists = await database_sync_to_async( ActiveChatSession.objects.filter( user=user, chat=test_chat, ).exists )() assert presence_exists is False 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() async def test_disconnect_notifies_other_viewers_that_typing_stopped( self, ws_communicator, user, user2, test_chat_with_users, ): sender = await ws_communicator(user=user) recipient = await ws_communicator(user=user2) assert (await sender.connect())[0] assert (await recipient.connect())[0] await sender.send_json_to( {"action": "open_chat", "chat_id": test_chat_with_users.id} ) await sender.receive_json_from(timeout=5) await recipient.send_json_to( {"action": "open_chat", "chat_id": test_chat_with_users.id} ) await recipient.receive_json_from(timeout=5) await sender.send_json_to({"action": "typing"}) typing_started = await recipient.receive_json_from(timeout=5) assert typing_started["type"] == "typing_start" assert typing_started["user_id"] == user.id await sender.disconnect() typing_stopped = await recipient.receive_json_from(timeout=5) assert typing_stopped == { "type": "typing_stop", "chat_id": test_chat_with_users.id, "user_id": user.id, } await recipient.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()