- Исправлен PollSerializer для создания опросов с опциями - Добавлено поле read_only_fields для корректной валидации - Реализована отправка WebSocket уведомлений при создании опроса - Исправлен импорт serialize_message из communications.serialization - Добавлен обработчик poll_update в UserConsumer для WebSocket событий - Исправлен эндпоинт /results/ для возврата user_voted_option_ids - Добавлено поле voters в PollOptionSerializer для списка проголосовавших - Реализована инициализация poll виджетов после рендера сообщений - Добавлено игнорирование poll_update в notification-manager - Исправлены стили для request list badges (height, word-break, flex) - Восстановлены стили team-wheel (.wheel-focus, .scroll-arc) из git истории - Реализовано отображение forwarded message info в messageRendererV2 - Множественные улучшения UI и UX для опросов Fixes: создание опросов, голосование, отображение результатов, список проголосовавших
547 lines
21 KiB
Python
547 lines
21 KiB
Python
# tests/realtime/test_websocket_events.py
|
||
"""
|
||
Тесты для WebSocket событий из API.
|
||
|
||
Проверяем что Consumer правильно обрабатывает события от channel_layer:
|
||
- Создание сообщения
|
||
- Редактирование сообщения
|
||
- Удаление сообщения
|
||
- Добавление/удаление реакций
|
||
- Голосование в опросе
|
||
- Отметка прочтения
|
||
|
||
Тесты не создают реальные Django объекты, а проверяют только
|
||
правильность обработки событий Consumer'ом.
|
||
"""
|
||
import pytest
|
||
from channels.layers import get_channel_layer
|
||
|
||
pytestmark = pytest.mark.django_db(transaction=True)
|
||
|
||
|
||
class TestMessageWebSocketEvents:
|
||
"""Тесты WebSocket событий для сообщений."""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_new_message_broadcast(self, ws_communicator, user, user2, test_chat_with_users):
|
||
"""При создании нового сообщения оба участника получают событие."""
|
||
# Подключаем обоих пользователей
|
||
comm1 = await ws_communicator(user=user)
|
||
comm2 = await ws_communicator(user=user2)
|
||
|
||
connected1, _ = await comm1.connect()
|
||
connected2, _ = await comm2.connect()
|
||
assert connected1 and connected2
|
||
|
||
# User1 открывает чат
|
||
await comm1.send_json_to({
|
||
"action": "open_chat",
|
||
"chat_id": test_chat_with_users.id,
|
||
"load_history": False
|
||
})
|
||
|
||
# User2 тоже открывает чат
|
||
await comm2.send_json_to({
|
||
"action": "open_chat",
|
||
"chat_id": test_chat_with_users.id,
|
||
"load_history": False
|
||
})
|
||
|
||
# Подтверждения открытия
|
||
await comm1.receive_json_from(timeout=5)
|
||
await comm2.receive_json_from(timeout=5)
|
||
|
||
# Отправляем событие через channel layer (имитируем API)
|
||
channel_layer = get_channel_layer()
|
||
mock_payload = {
|
||
'id': 999,
|
||
'content': 'Test message',
|
||
'author_id': user.id,
|
||
'author_name': user.get_full_name(),
|
||
'chat_id': test_chat_with_users.id,
|
||
'created_at': '2026-01-21T10:00:00Z'
|
||
}
|
||
|
||
await channel_layer.group_send(
|
||
f'chat_{test_chat_with_users.id}',
|
||
{
|
||
'type': 'chat.message',
|
||
'chat_id': test_chat_with_users.id,
|
||
'payload': mock_payload
|
||
}
|
||
)
|
||
|
||
# User1 должен получить new_message (активный чат)
|
||
response1 = await comm1.receive_json_from(timeout=5)
|
||
assert response1["type"] == "new_message"
|
||
assert response1["chat_id"] == test_chat_with_users.id
|
||
assert response1["message"]["content"] == "Test message"
|
||
|
||
# И list_update для списка чатов
|
||
response1_list = await comm1.receive_json_from(timeout=5)
|
||
assert response1_list["type"] == "list_update"
|
||
|
||
# User2 тоже должен получить оба события
|
||
response2 = await comm2.receive_json_from(timeout=5)
|
||
assert response2["type"] == "new_message"
|
||
|
||
response2_list = await comm2.receive_json_from(timeout=5)
|
||
assert response2_list["type"] == "list_update"
|
||
|
||
await comm1.disconnect()
|
||
await comm2.disconnect()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_new_message_list_update_only(self, ws_communicator, user, user2, test_chat_with_users):
|
||
"""Если чат неактивен, пользователь получает только list_update."""
|
||
comm = await ws_communicator(user=user)
|
||
connected, _ = await comm.connect()
|
||
assert connected
|
||
|
||
# НЕ открываем чат (чтобы он был неактивным)
|
||
|
||
# Отправляем событие (имитируем API)
|
||
channel_layer = get_channel_layer()
|
||
mock_payload = {
|
||
'id': 998,
|
||
'content': 'Message when chat is inactive',
|
||
'author_id': user2.id,
|
||
'author_name': user2.get_full_name(),
|
||
'chat_id': test_chat_with_users.id
|
||
}
|
||
|
||
await channel_layer.group_send(
|
||
f'chat_{test_chat_with_users.id}',
|
||
{
|
||
'type': 'chat.message',
|
||
'chat_id': test_chat_with_users.id,
|
||
'payload': mock_payload
|
||
}
|
||
)
|
||
|
||
# Когда чат неактивен, Consumer отправляет только list_update
|
||
response = await comm.receive_json_from(timeout=5)
|
||
assert response["type"] == "list_update"
|
||
assert response["chat_id"] == test_chat_with_users.id
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_message_edited_broadcast(self, ws_communicator, user, test_chat):
|
||
"""При редактировании сообщения участники получают событие."""
|
||
comm = await ws_communicator(user=user)
|
||
connected, _ = await comm.connect()
|
||
assert connected
|
||
|
||
# Открываем чат
|
||
await comm.send_json_to({
|
||
"action": "open_chat",
|
||
"chat_id": test_chat.id,
|
||
"load_history": False
|
||
})
|
||
await comm.receive_json_from(timeout=5) # chat_opened
|
||
|
||
# Отправляем событие редактирования (имитируем API)
|
||
channel_layer = get_channel_layer()
|
||
mock_payload = {
|
||
'id': 997,
|
||
'content': 'Edited content',
|
||
'is_edited': True,
|
||
'author_id': user.id,
|
||
'chat_id': test_chat.id
|
||
}
|
||
|
||
await channel_layer.group_send(
|
||
f'chat_{test_chat.id}',
|
||
{
|
||
'type': 'chat.message_edited',
|
||
'chat_id': test_chat.id,
|
||
'payload': mock_payload
|
||
}
|
||
)
|
||
|
||
# Должны получить message_updated для активного чата
|
||
response1 = await comm.receive_json_from(timeout=5)
|
||
assert response1["type"] == "message_updated"
|
||
assert response1["message"]["content"] == "Edited content"
|
||
assert response1["message"]["is_edited"] is True
|
||
|
||
# И message_edited для списка
|
||
response2 = await comm.receive_json_from(timeout=5)
|
||
assert response2["type"] == "message_edited"
|
||
|
||
await comm.disconnect()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_message_deleted_broadcast(self, ws_communicator, user, test_chat):
|
||
"""При удалении сообщения участники получают событие."""
|
||
comm = await ws_communicator(user=user)
|
||
connected, _ = await comm.connect()
|
||
assert connected
|
||
|
||
# Открываем чат
|
||
await comm.send_json_to({
|
||
"action": "open_chat",
|
||
"chat_id": test_chat.id,
|
||
"load_history": False
|
||
})
|
||
await comm.receive_json_from(timeout=5) # chat_opened
|
||
|
||
# Отправляем событие удаления (имитируем API)
|
||
channel_layer = get_channel_layer()
|
||
mock_message_id = 996
|
||
|
||
await channel_layer.group_send(
|
||
f'chat_{test_chat.id}',
|
||
{
|
||
'type': 'chat.message_deleted',
|
||
'chat_id': test_chat.id,
|
||
'message_id': mock_message_id
|
||
}
|
||
)
|
||
|
||
# Должны получить message_deleted
|
||
response = await comm.receive_json_from(timeout=5)
|
||
assert response["type"] == "message_deleted"
|
||
assert response["chat_id"] == test_chat.id
|
||
assert response["message_id"] == mock_message_id
|
||
|
||
await comm.disconnect()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_message_deleted_inactive_chat(self, ws_communicator, user, test_chat):
|
||
"""Если чат неактивен, событие удаления НЕ отправляется клиенту."""
|
||
comm = await ws_communicator(user=user)
|
||
connected, _ = await comm.connect()
|
||
assert connected
|
||
|
||
# НЕ открываем чат
|
||
|
||
# Отправляем событие удаления (имитируем API)
|
||
channel_layer = get_channel_layer()
|
||
mock_message_id = 995
|
||
|
||
await channel_layer.group_send(
|
||
f'chat_{test_chat.id}',
|
||
{
|
||
'type': 'chat.message_deleted',
|
||
'chat_id': test_chat.id,
|
||
'message_id': mock_message_id
|
||
}
|
||
)
|
||
|
||
# НЕ должны получить событие (чат неактивен)
|
||
# Проверяем что не получим ничего в течение короткого таймаута
|
||
import asyncio
|
||
with pytest.raises(asyncio.TimeoutError):
|
||
await comm.receive_json_from(timeout=2)
|
||
|
||
|
||
class TestReactionWebSocketEvents:
|
||
"""Тесты WebSocket событий для реакций."""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_reaction_added_broadcast(self, ws_communicator, user, user2, test_chat_with_users):
|
||
"""При добавлении реакции участники получают событие."""
|
||
# Подключаем обоих пользователей
|
||
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,
|
||
"load_history": False
|
||
})
|
||
await comm2.send_json_to({
|
||
"action": "open_chat",
|
||
"chat_id": test_chat_with_users.id,
|
||
"load_history": False
|
||
})
|
||
|
||
await comm1.receive_json_from(timeout=5) # chat_opened
|
||
await comm2.receive_json_from(timeout=5) # chat_opened
|
||
|
||
# Отправляем событие реакции (имитируем API)
|
||
channel_layer = get_channel_layer()
|
||
mock_message_id = 994
|
||
|
||
reactions_summary = {
|
||
'👍': {
|
||
'count': 1,
|
||
'users': [user2.id],
|
||
'user_names': [user2.get_full_name()]
|
||
}
|
||
}
|
||
|
||
await channel_layer.group_send(
|
||
f'chat_{test_chat_with_users.id}',
|
||
{
|
||
'type': 'chat.reaction_added',
|
||
'chat_id': test_chat_with_users.id,
|
||
'message_id': mock_message_id,
|
||
'user_id': user2.id,
|
||
'emoji': '👍',
|
||
'reactions_summary': reactions_summary
|
||
}
|
||
)
|
||
|
||
# User1 должен получить reaction_added
|
||
response1 = await comm1.receive_json_from(timeout=5)
|
||
assert response1["type"] == "reaction_added"
|
||
assert response1["message_id"] == mock_message_id
|
||
assert response1["emoji"] == '👍'
|
||
assert response1["user_id"] == user2.id
|
||
assert '👍' in response1["reactions_summary"]
|
||
|
||
# User2 тоже должен получить
|
||
response2 = await comm2.receive_json_from(timeout=5)
|
||
assert response2["type"] == "reaction_added"
|
||
assert response2["emoji"] == '👍'
|
||
|
||
await comm1.disconnect()
|
||
await comm2.disconnect()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_reaction_removed_broadcast(self, ws_communicator, user, test_chat):
|
||
"""При удалении реакции участники получают событие."""
|
||
comm = await ws_communicator(user=user)
|
||
connected, _ = await comm.connect()
|
||
assert connected
|
||
|
||
# Открываем чат
|
||
await comm.send_json_to({
|
||
"action": "open_chat",
|
||
"chat_id": test_chat.id,
|
||
"load_history": False
|
||
})
|
||
await comm.receive_json_from(timeout=5) # chat_opened
|
||
|
||
# Отправляем событие удаления реакции (имитируем API)
|
||
channel_layer = get_channel_layer()
|
||
mock_message_id = 993
|
||
|
||
reactions_summary = {} # Пустой - все реакции удалены
|
||
|
||
await channel_layer.group_send(
|
||
f'chat_{test_chat.id}',
|
||
{
|
||
'type': 'chat.reaction_removed',
|
||
'chat_id': test_chat.id,
|
||
'message_id': mock_message_id,
|
||
'user_id': user.id,
|
||
'emoji': '👍',
|
||
'reactions_summary': reactions_summary
|
||
}
|
||
)
|
||
|
||
# Должны получить reaction_removed
|
||
response = await comm.receive_json_from(timeout=5)
|
||
assert response["type"] == "reaction_removed"
|
||
assert response["message_id"] == mock_message_id
|
||
assert response["emoji"] == '👍'
|
||
assert response["reactions_summary"] == {}
|
||
|
||
await comm.disconnect()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_reaction_inactive_chat_no_event(self, ws_communicator, user, test_chat):
|
||
"""Если чат неактивен, событие реакции НЕ отправляется."""
|
||
comm = await ws_communicator(user=user)
|
||
connected, _ = await comm.connect()
|
||
assert connected
|
||
|
||
# НЕ открываем чат
|
||
|
||
# Отправляем событие реакции (имитируем API)
|
||
channel_layer = get_channel_layer()
|
||
mock_message_id = 992
|
||
|
||
await channel_layer.group_send(
|
||
f'chat_{test_chat.id}',
|
||
{
|
||
'type': 'chat.reaction_added',
|
||
'chat_id': test_chat.id,
|
||
'message_id': mock_message_id,
|
||
'user_id': user.id,
|
||
'emoji': '👍',
|
||
'reactions_summary': {}
|
||
}
|
||
)
|
||
|
||
# НЕ должны получить событие
|
||
import asyncio
|
||
with pytest.raises(asyncio.TimeoutError):
|
||
await comm.receive_json_from(timeout=2)
|
||
|
||
|
||
class TestPollWebSocketEvents:
|
||
"""Тесты WebSocket событий для голосований."""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_poll_vote_broadcast(self, ws_communicator, user, user2, test_chat_with_users):
|
||
"""При голосовании участники получают обновление."""
|
||
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,
|
||
"load_history": False
|
||
})
|
||
await comm2.send_json_to({
|
||
"action": "open_chat",
|
||
"chat_id": test_chat_with_users.id,
|
||
"load_history": False
|
||
})
|
||
|
||
await comm1.receive_json_from(timeout=5)
|
||
await comm2.receive_json_from(timeout=5)
|
||
|
||
# Отправляем событие обновления опроса (имитируем API)
|
||
channel_layer = get_channel_layer()
|
||
mock_poll_id = 991
|
||
mock_message_id = 990
|
||
|
||
await channel_layer.group_send(
|
||
f'chat_{test_chat_with_users.id}',
|
||
{
|
||
'type': 'chat.poll_update',
|
||
'chat_id': test_chat_with_users.id,
|
||
'payload': {
|
||
'poll_id': mock_poll_id,
|
||
'message_id': mock_message_id,
|
||
'total_voters': 1,
|
||
'results': {
|
||
'options': [
|
||
{'id': 1, 'vote_count': 1}
|
||
]
|
||
}
|
||
}
|
||
}
|
||
)
|
||
|
||
# User1 должен получить poll_update
|
||
response1 = await comm1.receive_json_from(timeout=5)
|
||
assert response1["type"] == "poll_update"
|
||
assert response1["poll_id"] == mock_poll_id
|
||
assert response1["message_id"] == mock_message_id
|
||
|
||
# User2 тоже
|
||
response2 = await comm2.receive_json_from(timeout=5)
|
||
assert response2["type"] == "poll_update"
|
||
|
||
await comm1.disconnect()
|
||
await comm2.disconnect()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_poll_update_inactive_chat(self, ws_communicator, user, test_chat):
|
||
"""Если чат неактивен, событие опроса НЕ отправляется."""
|
||
comm = await ws_communicator(user=user)
|
||
connected, _ = await comm.connect()
|
||
assert connected
|
||
|
||
# НЕ открываем чат
|
||
|
||
# Отправляем событие (имитируем API)
|
||
channel_layer = get_channel_layer()
|
||
mock_poll_id = 989
|
||
|
||
await channel_layer.group_send(
|
||
f'chat_{test_chat.id}',
|
||
{
|
||
'type': 'chat.poll_update',
|
||
'chat_id': test_chat.id,
|
||
'payload': {
|
||
'poll_id': mock_poll_id
|
||
}
|
||
}
|
||
)
|
||
|
||
# НЕ должны получить
|
||
import asyncio
|
||
with pytest.raises(asyncio.TimeoutError):
|
||
await comm.receive_json_from(timeout=2)
|
||
|
||
|
||
class TestChatMarkedReadEvent:
|
||
"""Тесты WebSocket события отметки прочтения."""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_marked_read_sync_between_tabs(self, ws_communicator, user, test_chat):
|
||
"""Отметка прочтения синхронизируется между вкладками пользователя."""
|
||
# Имитируем две вкладки одного пользователя
|
||
comm1 = await ws_communicator(user=user)
|
||
comm2 = await ws_communicator(user=user)
|
||
|
||
connected1, _ = await comm1.connect()
|
||
connected2, _ = await comm2.connect()
|
||
assert connected1 and connected2
|
||
|
||
# Отправляем событие marked_read в канал пользователя (имитируем API)
|
||
channel_layer = get_channel_layer()
|
||
mock_message_id = 988
|
||
|
||
await channel_layer.group_send(
|
||
f'user_{user.id}',
|
||
{
|
||
'type': 'chat_marked_read',
|
||
'chat_id': test_chat.id,
|
||
'last_read_at': '2026-01-21T10:00:00Z',
|
||
'last_read_message_id': mock_message_id
|
||
}
|
||
)
|
||
|
||
# Обе вкладки должны получить событие
|
||
response1 = await comm1.receive_json_from(timeout=5)
|
||
assert response1["type"] == "marked_read"
|
||
assert response1["chat_id"] == test_chat.id
|
||
assert response1["last_read_message_id"] == mock_message_id
|
||
|
||
response2 = await comm2.receive_json_from(timeout=5)
|
||
assert response2["type"] == "marked_read"
|
||
assert response2["chat_id"] == test_chat.id
|
||
|
||
await comm1.disconnect()
|
||
await comm2.disconnect()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_marked_read_not_received_by_other_users(self, ws_communicator, user, user2, test_chat_with_users):
|
||
"""Событие marked_read получает только тот пользователь, который отметил."""
|
||
comm1 = await ws_communicator(user=user)
|
||
comm2 = await ws_communicator(user=user2)
|
||
|
||
connected1, _ = await comm1.connect()
|
||
connected2, _ = await comm2.connect()
|
||
assert connected1 and connected2
|
||
|
||
# User1 отмечает как прочитанное (имитируем API)
|
||
channel_layer = get_channel_layer()
|
||
mock_message_id = 987
|
||
|
||
await channel_layer.group_send(
|
||
f'user_{user.id}', # ТОЛЬКО user1
|
||
{
|
||
'type': 'chat_marked_read',
|
||
'chat_id': test_chat_with_users.id,
|
||
'last_read_at': '2026-01-21T10:00:00Z',
|
||
'last_read_message_id': mock_message_id
|
||
}
|
||
)
|
||
|
||
# User1 должен получить
|
||
response1 = await comm1.receive_json_from(timeout=5)
|
||
assert response1["type"] == "marked_read"
|
||
|
||
# User2 НЕ должен получить
|
||
import asyncio
|
||
with pytest.raises(asyncio.TimeoutError):
|
||
await comm2.receive_json_from(timeout=2)
|