491 lines
18 KiB
Python
491 lines
18 KiB
Python
# 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()
|