Files
vidconf/backend/tests/conftest.py

234 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Общие fixtures для интеграционных тестов, запускаемых против реального экземпляра Postgres
из `deploy/docker-compose.yml`.
Каждый тест запускается внутри внешней транзакции, которая никогда не коммитится; ORM
`AsyncSession` присоединяется к ней с помощью `join_transaction_mode="create_savepoint"` так,
что даже вызовы `session.commit()` (которые вызывают IntegrityError при нарушении
constraint) влияют только на savepoint и полностью отменяются внешним rollback в teardown.
"""
import asyncio
import json
from collections.abc import AsyncGenerator
from typing import Any, cast
import httpx
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession
from starlette.types import Message, Scope
from core.db import engine, get_session
from core.redis import redis_client
from main import create_app
from models.instance_setting import InstanceSetting
@pytest_asyncio.fixture(autouse=True)
async def _reset_rate_limits() -> AsyncGenerator[None, None]:
"""Сбросить счётчики rate limit (Redis реальный, общий на все тесты) до и после теста.
Без этого параллельные/последовательные тесты публичных эндпоинтов
(`resolve`, `guest-join`) делили бы один и тот же счётчик по IP тестового
клиента и мешали друг другу (см. `core/rate_limit.py`).
"""
await _delete_rate_limit_keys()
yield
await _delete_rate_limit_keys()
async def _delete_rate_limit_keys() -> None:
keys = [key async for key in redis_client.scan_iter(match="rate_limit:*")]
if keys:
await redis_client.delete(*keys)
@pytest_asyncio.fixture(autouse=True)
async def _preserve_instance_settings() -> AsyncGenerator[None, None]:
"""Гарантировать, что тест не оставляет следов в `instance_settings` общей dev-БД.
Строки `instance_settings` — живые настройки dev-инстанса (тоггл чата,
домен регистрации и т.п.), а не тестовые данные: закоммиченная тестом
правка молча меняет поведение dev-окружения (реально воспроизводилось —
после прогонов тестов чат в dev-инстансе оказался выключен).
Снимок закоммиченного состояния снимается отдельным подключением (мимо
savepoint-транзакции теста, см. докстринг модуля), после теста таблица
приводится к снимку: появившиеся ключи удаляются, изменённые и пропавшие —
восстанавливаются. Для корректных savepoint-тестов это no-op ценой одного
SELECT — страховка на случай любой записи мимо savepoint-сессии.
"""
before = await _load_committed_instance_settings()
yield
after = await _load_committed_instance_settings()
if after == before:
return
async with engine.connect() as connection:
extra_keys = after.keys() - before.keys()
if extra_keys:
await connection.execute(
delete(InstanceSetting).where(InstanceSetting.key.in_(extra_keys))
)
for key, value in before.items():
if after.get(key) != value:
await connection.execute(
pg_insert(InstanceSetting)
.values(key=key, value=value)
.on_conflict_do_update(index_elements=["key"], set_={"value": value})
)
await connection.commit()
async def _load_committed_instance_settings() -> dict[str, Any]:
"""Прочитать закоммиченные строки `instance_settings` отдельным подключением."""
async with engine.connect() as connection:
result = await connection.execute(select(InstanceSetting.key, InstanceSetting.value))
return {key: value for key, value in result.all()}
@pytest_asyncio.fixture
async def db_connection() -> AsyncGenerator[AsyncConnection, None]:
async with engine.connect() as connection:
trans = await connection.begin()
try:
yield connection
finally:
await trans.rollback()
@pytest_asyncio.fixture
async def db_session(db_connection: AsyncConnection) -> AsyncGenerator[AsyncSession, None]:
session = AsyncSession(
bind=db_connection,
join_transaction_mode="create_savepoint",
expire_on_commit=False,
)
try:
yield session
finally:
await session.close()
@pytest_asyncio.fixture
async def app(db_session: AsyncSession) -> AsyncGenerator[FastAPI, None]:
"""Экземпляр FastAPI-приложения с `get_session`, подменённым на тестовую (savepoint) сессию."""
application = create_app()
async def _override_get_session() -> AsyncGenerator[AsyncSession, None]:
yield db_session
application.dependency_overrides[get_session] = _override_get_session
yield application
@pytest_asyncio.fixture
async def client(app: FastAPI) -> AsyncGenerator[httpx.AsyncClient, None]:
"""Асинхронный HTTP-клиент поверх приложения.
База `https://test` (а не `http://`) нужна, чтобы httpx сохранял в своём
cookie jar httpOnly Secure cookie с refresh-токеном между запросами.
"""
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as ac:
yield ac
# Таймаут ожидания ответа приложения в WS-тестах — на порядок больше
# `AUTH_TIMEOUT_SECONDS` эндпоинта не нужен, реальный ответ приходит мгновенно.
_RECEIVE_TIMEOUT_S = 5.0
class ASGIWebSocketSession:
"""Минимальный in-process ASGI websocket-клиент для тестов.
`httpx.AsyncClient`/`ASGITransport` не поддерживают websocket-соединения,
а `starlette.testclient.TestClient` гоняет ASGI-приложение в отдельном
потоке со своим event loop — тестовая `db_session` (см. выше) привязана к
`AsyncConnection` ТЕКУЩЕГО loop, и обращение к ней из другого loop роняет
asyncpg `RuntimeError: Future attached to a different loop`. Этот класс
драйвит `app(scope, receive, send)` напрямую в текущем event loop через
пару `asyncio.Queue`, эмулируя протокол ASGI websocket (см.
`starlette/websockets.py`).
"""
def __init__(self, app: FastAPI, path: str) -> None:
self._to_app: asyncio.Queue[Message] = asyncio.Queue()
self._from_app: asyncio.Queue[Message] = asyncio.Queue()
scope: Scope = {
"type": "websocket",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"scheme": "ws",
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"headers": [],
"client": ("test-client", 12345),
"server": ("test-server", 80),
"subprotocols": [],
}
self._task = asyncio.create_task(app(scope, self._receive, self._send))
async def _receive(self) -> Message:
return await self._to_app.get()
async def _send(self, message: Message) -> None:
await self._from_app.put(message)
async def connect(self) -> Message:
"""Отправить `websocket.connect` и дождаться ответа (`accept`/`close`)."""
await self._to_app.put({"type": "websocket.connect"})
return await self._receive_from_app()
async def send_json(self, data: dict[str, Any]) -> None:
await self._to_app.put({"type": "websocket.receive", "text": json.dumps(data)})
async def receive_json(self) -> dict[str, Any]:
message = await self._receive_from_app()
assert message["type"] == "websocket.send", message
return cast(dict[str, Any], json.loads(message["text"]))
async def receive_close(self) -> int:
message = await self._receive_from_app()
assert message["type"] == "websocket.close", message
return int(message["code"])
async def _receive_from_app(self) -> Message:
# Фиксированный таймаут ожидания ответа приложения — тестовый
# хелпер не даёт вызывающей стороне переопределить его параметром
# (см. ASYNC109: явный параметр `timeout` у async-функции — плохая
# практика, вместо этого таймаут задаётся здесь одним местом).
async with asyncio.timeout(_RECEIVE_TIMEOUT_S):
return await self._from_app.get()
async def aclose(self) -> None:
"""Эмулировать разрыв соединения клиентом и дождаться завершения ASGI-приложения."""
if not self._task.done():
await self._to_app.put({"type": "websocket.disconnect", "code": 1000})
try:
await asyncio.wait_for(self._task, timeout=5.0)
except Exception: # noqa: BLE001 — best-effort teardown в тестах
self._task.cancel()
@pytest_asyncio.fixture
async def ws_client(app: FastAPI) -> AsyncGenerator[Any, None]:
"""Фабрика in-process WS-клиентов (`ASGIWebSocketSession`) поверх текущего `app`.
Возвращает callable `(path) -> ASGIWebSocketSession`; открытые сессии
закрываются автоматически по завершении теста.
"""
sessions: list[ASGIWebSocketSession] = []
def _factory(path: str) -> ASGIWebSocketSession:
session = ASGIWebSocketSession(app, path)
sessions.append(session)
return session
yield _factory
for session in sessions:
await session.aclose()