Первоначальная версия VidConf
This commit is contained in:
526
backend/tests/test_chat_ws.py
Normal file
526
backend/tests/test_chat_ws.py
Normal file
@@ -0,0 +1,526 @@
|
||||
"""Тесты WS-чата конференции (`WS /api/v1/conferences/{id}/chat`).
|
||||
|
||||
Интеграционные тесты используют `ws_client` — in-process ASGI websocket
|
||||
клиент (см. `tests/conftest.py::ASGIWebSocketSession`), т.к. ни
|
||||
`httpx.AsyncClient`, ни `starlette.testclient.TestClient` не подходят для
|
||||
websocket-тестов поверх нашей savepoint-сессии БД (см. докстринг класса).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.redis import redis_client
|
||||
from core.security import create_access_token, hash_password
|
||||
from models.chat import ChatMessage
|
||||
from models.conference import Conference
|
||||
from models.guest import GuestAccess
|
||||
from models.session import ConferenceSession
|
||||
from models.user import User
|
||||
from repositories.chat import ChatMessageRepository
|
||||
from repositories.conferences import ConferenceSessionRepository
|
||||
from schemas.chat import ChatMessageIn, ChatMessageOut
|
||||
from services.chat import ChatService, chat_channel
|
||||
from services.conference_ids import generate_number, generate_slug
|
||||
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
|
||||
from services.livekit_tokens import create_room_access_token
|
||||
from tests.conftest import ASGIWebSocketSession
|
||||
|
||||
WSFactory = Callable[[str], ASGIWebSocketSession]
|
||||
|
||||
|
||||
# --- Хелперы ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def _make_user(session: AsyncSession, *, name: str = "Chat Tester") -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user=name,
|
||||
password_hash=hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
return user
|
||||
|
||||
|
||||
async def _make_conference(session: AsyncSession, *, status: str = "active") -> Conference:
|
||||
conference = Conference(
|
||||
number=generate_number(), slug=generate_slug(), title="Chat Test", status=status
|
||||
)
|
||||
session.add(conference)
|
||||
await session.flush()
|
||||
return conference
|
||||
|
||||
|
||||
async def _make_guest(session: AsyncSession, conference: Conference, *, name: str) -> GuestAccess:
|
||||
guest = GuestAccess(conference_id=conference.id, display_name=name)
|
||||
session.add(guest)
|
||||
await session.flush()
|
||||
return guest
|
||||
|
||||
|
||||
def _chat_path(conference_id: uuid.UUID) -> str:
|
||||
return f"/api/v1/conferences/{conference_id}/chat"
|
||||
|
||||
|
||||
def _user_token(conference: Conference, user: User) -> str:
|
||||
return create_room_access_token(
|
||||
room_name=conference.slug, identity=str(user.id), name=user.name_user
|
||||
)
|
||||
|
||||
|
||||
def _guest_token(conference: Conference, guest: GuestAccess) -> str:
|
||||
return create_room_access_token(
|
||||
room_name=conference.slug, identity=f"guest:{guest.id}", name=guest.display_name
|
||||
)
|
||||
|
||||
|
||||
async def _connect_and_auth(session: ASGIWebSocketSession, token: str) -> dict[str, Any]:
|
||||
"""Подключиться, аутентифицироваться и вернуть первое сообщение (`history`)."""
|
||||
accept = await session.connect()
|
||||
assert accept["type"] == "websocket.accept"
|
||||
await session.send_json({"type": "auth", "token": token})
|
||||
return await session.receive_json()
|
||||
|
||||
|
||||
# --- Основной сценарий: обмен сообщениями + история -------------------------
|
||||
|
||||
|
||||
async def test_two_clients_exchange_messages_and_history_on_reconnect(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
conference = await _make_conference(db_session)
|
||||
user1 = await _make_user(db_session, name="Alice")
|
||||
user2 = await _make_user(db_session, name="Bob")
|
||||
await db_session.commit()
|
||||
|
||||
token1 = _user_token(conference, user1)
|
||||
token2 = _user_token(conference, user2)
|
||||
path = _chat_path(conference.id)
|
||||
|
||||
ws1 = ws_client(path)
|
||||
history1 = await _connect_and_auth(ws1, token1)
|
||||
assert history1 == {"type": "history", "messages": []}
|
||||
|
||||
ws2 = ws_client(path)
|
||||
history2 = await _connect_and_auth(ws2, token2)
|
||||
assert history2 == {"type": "history", "messages": []}
|
||||
|
||||
await ws1.send_json({"type": "message", "text": "hello from alice"})
|
||||
|
||||
echo = await ws1.receive_json()
|
||||
assert echo["type"] == "message"
|
||||
assert echo["message"]["text"] == "hello from alice"
|
||||
assert echo["message"]["author_name"] == "Alice"
|
||||
assert echo["message"]["is_guest"] is False
|
||||
assert echo["message"]["author_id"] == str(user1.id)
|
||||
assert echo["message"]["created_at"].endswith("Z")
|
||||
|
||||
broadcast = await ws2.receive_json()
|
||||
assert broadcast == echo
|
||||
|
||||
await ws1.aclose()
|
||||
await ws2.aclose()
|
||||
|
||||
ws3 = ws_client(path)
|
||||
history3 = await _connect_and_auth(ws3, _user_token(conference, user1))
|
||||
assert len(history3["messages"]) == 1
|
||||
assert history3["messages"][0]["text"] == "hello from alice"
|
||||
|
||||
|
||||
# --- Гонка history vs pubsub subscribe --------------------------------------
|
||||
|
||||
|
||||
async def test_subscribe_happens_before_history_query(
|
||||
db_session: AsyncSession, ws_client: WSFactory, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Regression: подписка на pub/sub должна происходить ДО SELECT истории.
|
||||
|
||||
Иначе сообщение, опубликованное другим клиентом в этом окне, теряется
|
||||
для подключающегося клиента.
|
||||
"""
|
||||
conference = await _make_conference(db_session)
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
call_order: list[str] = []
|
||||
|
||||
original_history = ChatService.history
|
||||
|
||||
async def _tracked_history(self: ChatService, conference: Conference) -> list[ChatMessageOut]:
|
||||
call_order.append("history")
|
||||
return await original_history(self, conference)
|
||||
|
||||
monkeypatch.setattr(ChatService, "history", _tracked_history)
|
||||
|
||||
import redis.asyncio.client as redis_client_module
|
||||
|
||||
original_subscribe = redis_client_module.PubSub.subscribe
|
||||
|
||||
async def _tracked_subscribe(self: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
call_order.append("subscribe")
|
||||
return await original_subscribe(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(redis_client_module.PubSub, "subscribe", _tracked_subscribe)
|
||||
|
||||
token = _user_token(conference, user)
|
||||
ws = ws_client(_chat_path(conference.id))
|
||||
await _connect_and_auth(ws, token)
|
||||
|
||||
assert call_order == ["subscribe", "history"]
|
||||
|
||||
|
||||
async def test_no_duplicate_when_message_already_in_history(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
"""Сообщение, уже попавшее в history, не должно продублироваться через pub/sub.
|
||||
|
||||
Имитирует стык гонки: то же сообщение (тот же `id`) публикуется в канал
|
||||
уже ПОСЛЕ того, как клиент получил его в `history` — дедуп по `id`
|
||||
(`_pump_pubsub_to_websocket`) должен его отфильтровать.
|
||||
"""
|
||||
conference = await _make_conference(db_session)
|
||||
user = await _make_user(db_session, name="Eve")
|
||||
await db_session.commit()
|
||||
|
||||
session_record = await ConferenceSessionRepository(db_session).create(
|
||||
conference_id=conference.id, title=conference.title, t_start=datetime.now(UTC)
|
||||
)
|
||||
existing = await ChatMessageRepository(db_session).add(
|
||||
session_id=session_record.id,
|
||||
user_id=user.id,
|
||||
guest_access_id=None,
|
||||
author_name="Eve",
|
||||
text="already there",
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
token = _user_token(conference, user)
|
||||
ws = ws_client(_chat_path(conference.id))
|
||||
history = await _connect_and_auth(ws, token)
|
||||
assert len(history["messages"]) == 1
|
||||
assert history["messages"][0]["id"] == existing.id
|
||||
|
||||
duplicate_payload = ChatMessageOut(
|
||||
id=existing.id,
|
||||
author_id=str(user.id),
|
||||
author_name="Eve",
|
||||
is_guest=False,
|
||||
text="already there",
|
||||
created_at=existing.created_at,
|
||||
)
|
||||
await redis_client.publish(chat_channel(conference.id), duplicate_payload.model_dump_json())
|
||||
|
||||
# Настоящее новое сообщение отправляем следом — оно должно дойти БЕЗ
|
||||
# опережающего дубликата (если бы дедуп не работал, первым пришёл бы
|
||||
# повтор "already there").
|
||||
await ws.send_json({"type": "message", "text": "genuinely new"})
|
||||
received = await ws.receive_json()
|
||||
assert received["type"] == "message"
|
||||
assert received["message"]["text"] == "genuinely new"
|
||||
|
||||
|
||||
# --- Auth: коды закрытия ----------------------------------------------------
|
||||
|
||||
|
||||
async def test_no_auth_message_closes_4401(db_session: AsyncSession, ws_client: WSFactory) -> None:
|
||||
conference = await _make_conference(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
ws = ws_client(_chat_path(conference.id))
|
||||
accept = await ws.connect()
|
||||
assert accept["type"] == "websocket.accept"
|
||||
# Первым сообщением шлём НЕ auth (нарушение протокола) — трактуется как отсутствие auth.
|
||||
await ws.send_json({"type": "message", "text": "too early"})
|
||||
code = await ws.receive_close()
|
||||
assert code == 4401
|
||||
|
||||
|
||||
async def test_garbage_token_closes_4401(db_session: AsyncSession, ws_client: WSFactory) -> None:
|
||||
conference = await _make_conference(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
ws = ws_client(_chat_path(conference.id))
|
||||
await ws.connect()
|
||||
await ws.send_json({"type": "auth", "token": "not-a-jwt-at-all"})
|
||||
code = await ws.receive_close()
|
||||
assert code == 4401
|
||||
|
||||
|
||||
async def test_non_livekit_jwt_closes_4401(db_session: AsyncSession, ws_client: WSFactory) -> None:
|
||||
conference = await _make_conference(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
# Синтаксически валидный JWT, но подписан не тем секретом (не LiveKit).
|
||||
foreign_token = jwt.encode(
|
||||
{"sub": "someone"}, "wrong-secret-not-livekit-at-all-32-bytes", algorithm="HS256"
|
||||
)
|
||||
|
||||
ws = ws_client(_chat_path(conference.id))
|
||||
await ws.connect()
|
||||
await ws.send_json({"type": "auth", "token": foreign_token})
|
||||
code = await ws.receive_close()
|
||||
assert code == 4401
|
||||
|
||||
|
||||
async def test_wrong_room_token_closes_4403(db_session: AsyncSession, ws_client: WSFactory) -> None:
|
||||
conference_a = await _make_conference(db_session)
|
||||
conference_b = await _make_conference(db_session)
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
# Токен выдан для конференции A, подключаемся к B.
|
||||
token_for_a = _user_token(conference_a, user)
|
||||
|
||||
ws = ws_client(_chat_path(conference_b.id))
|
||||
await ws.connect()
|
||||
await ws.send_json({"type": "auth", "token": token_for_a})
|
||||
code = await ws.receive_close()
|
||||
assert code == 4403
|
||||
|
||||
|
||||
async def test_chat_disabled_closes_4404_without_restart(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
conference = await _make_conference(db_session)
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
token = _user_token(conference, user)
|
||||
path = _chat_path(conference.id)
|
||||
|
||||
# До выключения тоггла подключение работает штатно.
|
||||
ws1 = ws_client(path)
|
||||
history = await _connect_and_auth(ws1, token)
|
||||
assert history["type"] == "history"
|
||||
await ws1.aclose()
|
||||
|
||||
await InstanceSettingsService(db_session).update(SettingsUpdateIn(chat_enabled=False))
|
||||
|
||||
# Тоггл действует немедленно, без рестарта backend — следующее подключение отклоняется.
|
||||
ws2 = ws_client(path)
|
||||
await ws2.connect()
|
||||
await ws2.send_json({"type": "auth", "token": token})
|
||||
code = await ws2.receive_close()
|
||||
assert code == 4404
|
||||
|
||||
|
||||
async def test_conference_not_found_closes_4404(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
conference = await _make_conference(db_session)
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
# Токен валиден (комната существует), но подключаемся по чужому/несуществующему id.
|
||||
token = _user_token(conference, user)
|
||||
ws = ws_client(_chat_path(uuid.uuid4()))
|
||||
await ws.connect()
|
||||
await ws.send_json({"type": "auth", "token": token})
|
||||
code = await ws.receive_close()
|
||||
assert code == 4404
|
||||
|
||||
|
||||
# --- Сообщение после room_finished ------------------------------------------
|
||||
|
||||
|
||||
async def test_message_after_conference_ended_rejected_without_phantom_session(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
"""LiveKit-токен (TTL 6 часов) может пережить конференцию — сообщение отклоняется.
|
||||
|
||||
Сценарий: клиент подключился, пока конференция была активна; сессия
|
||||
пайплайна закрывается (`t_end` проставлен, как это делает webhook
|
||||
`room_finished`), а статус конференции переводится в `ended` СЫРЫМ SQL
|
||||
(в обход ORM identity map — имитация того, что вебхук работает в ДРУГОЙ
|
||||
сессии/процессе и `expire_on_commit=False` не даёт уже загруженному
|
||||
объекту `conference` увидеть новый статус). Отправленное после этого
|
||||
сообщение должно быть отклонено (close 4404), новая "фантомная" открытая
|
||||
сессия — НЕ создана, сообщение — НЕ сохранено.
|
||||
"""
|
||||
conference = await _make_conference(db_session, status="active")
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
token = _user_token(conference, user)
|
||||
ws = ws_client(_chat_path(conference.id))
|
||||
await _connect_and_auth(ws, token)
|
||||
|
||||
sessions_repo = ConferenceSessionRepository(db_session)
|
||||
session_record = await sessions_repo.get_open_by_conference(conference.id)
|
||||
if session_record is None:
|
||||
session_record = await sessions_repo.create(
|
||||
conference_id=conference.id, title=conference.title, t_start=datetime.now(UTC)
|
||||
)
|
||||
await sessions_repo.close(session_record, t_end=datetime.now(UTC))
|
||||
|
||||
# Сырой UPDATE — намеренно в обход ORM, чтобы не обновить закэшированный
|
||||
# в текущей сессии Python-объект `conference` (имитация другой сессии/процесса).
|
||||
await db_session.execute(
|
||||
text("UPDATE conferences SET status = 'ended' WHERE id = :id"), {"id": conference.id}
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
await ws.send_json({"type": "message", "text": "too late"})
|
||||
code = await ws.receive_close()
|
||||
assert code == 4404
|
||||
|
||||
sessions = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0].id == session_record.id
|
||||
assert sessions[0].t_end is not None
|
||||
|
||||
stray = (
|
||||
await db_session.execute(select(ChatMessage).where(ChatMessage.text == "too late"))
|
||||
).scalar_one_or_none()
|
||||
assert stray is None
|
||||
|
||||
|
||||
async def test_message_before_room_started_creates_session_for_active_conference(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
"""Легитимный случай: первое сообщение до webhook `room_started`.
|
||||
|
||||
Открытой сессии ещё нет, но конференция активна (не `ended`) — создание
|
||||
новой сессии допустимо (в отличие от случая, когда конференция уже
|
||||
завершена).
|
||||
"""
|
||||
conference = await _make_conference(db_session, status="active")
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
token = _user_token(conference, user)
|
||||
ws = ws_client(_chat_path(conference.id))
|
||||
await _connect_and_auth(ws, token)
|
||||
|
||||
await ws.send_json({"type": "message", "text": "before room_started"})
|
||||
echo = await ws.receive_json()
|
||||
assert echo["message"]["text"] == "before room_started"
|
||||
|
||||
sessions = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0].t_end is None
|
||||
|
||||
|
||||
# --- Гость -------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_guest_message_persists_guest_access_id_and_author_name(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
conference = await _make_conference(db_session)
|
||||
guest = await _make_guest(db_session, conference, name="Guest Carol")
|
||||
await db_session.commit()
|
||||
|
||||
token = _guest_token(conference, guest)
|
||||
ws = ws_client(_chat_path(conference.id))
|
||||
await _connect_and_auth(ws, token)
|
||||
|
||||
await ws.send_json({"type": "message", "text": "hi from guest"})
|
||||
echo = await ws.receive_json()
|
||||
assert echo["message"]["author_name"] == "Guest Carol"
|
||||
assert echo["message"]["is_guest"] is True
|
||||
assert echo["message"]["author_id"] == str(guest.id)
|
||||
|
||||
await ws.aclose()
|
||||
|
||||
row = (
|
||||
await db_session.execute(select(ChatMessage).where(ChatMessage.text == "hi from guest"))
|
||||
).scalar_one()
|
||||
assert row.guest_access_id == guest.id
|
||||
assert row.user_id is None
|
||||
assert row.author_name == "Guest Carol"
|
||||
|
||||
|
||||
# --- REST: JoinOut.chat_enabled ----------------------------------------------
|
||||
|
||||
|
||||
async def test_join_out_reflects_chat_enabled_toggle(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
headers = {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"}
|
||||
|
||||
response = await client.post("/api/v1/conferences", json={"title": "Standup"}, headers=headers)
|
||||
assert response.status_code == 201, response.text
|
||||
assert response.json()["join"]["chat_enabled"] is True
|
||||
|
||||
await InstanceSettingsService(db_session).update(SettingsUpdateIn(chat_enabled=False))
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/conferences", json={"title": "Standup 2"}, headers=headers
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
assert response.json()["join"]["chat_enabled"] is False
|
||||
|
||||
|
||||
async def test_guest_join_out_reflects_chat_enabled(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
conference = await _make_conference(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
await InstanceSettingsService(db_session).update(SettingsUpdateIn(chat_enabled=False))
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Dave"}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["chat_enabled"] is False
|
||||
|
||||
|
||||
# --- Юнит: схема протокола ----------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_message_in_rejects_empty_text_after_strip() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ChatMessageIn(type="message", text=" ")
|
||||
|
||||
|
||||
def test_chat_message_in_rejects_too_long_text() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ChatMessageIn(type="message", text="a" * 2001)
|
||||
|
||||
|
||||
def test_chat_message_in_strips_surrounding_whitespace() -> None:
|
||||
parsed = ChatMessageIn(type="message", text=" hello ")
|
||||
assert parsed.text == "hello"
|
||||
|
||||
|
||||
def test_chat_message_out_serializes_created_at_as_utc_z() -> None:
|
||||
out = ChatMessageOut(
|
||||
id=1,
|
||||
author_id="abc",
|
||||
author_name="Alice",
|
||||
is_guest=False,
|
||||
text="hi",
|
||||
created_at=datetime(2026, 7, 18, 12, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
dumped = out.model_dump(mode="json")
|
||||
assert dumped["created_at"] == "2026-07-18T12:00:00Z"
|
||||
Reference in New Issue
Block a user