527 lines
20 KiB
Python
527 lines
20 KiB
Python
"""Тесты 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"
|