На нагрузочном тесте 31.07.2026 около 70 человек заходили одновременно. Вход развалился: p95 `/api/v1/auth/token` — 7.28 с, p95 `guest-join` — 7.06 с, в БД 33 соединения `idle in transaction` при ОДНОМ активном запросе. Люди попадали внутрь с пятой-десятой попытки, часть не попала вовсе. Медиа при этом работало штатно: 30 участников с 27 камерами в следующем окне прошли без единого лага. Причина — argon2 считался синхронно внутри async-обработчика. Замер на боевом сервере: 95–155 мс на одну проверку, и всё это время event loop процесса стоит целиком. Транзакция БД к тому моменту уже открыта (`get_by_email` сделал SELECT), поэтому соединение висело без работы, пул из 40 выбирался, и отказы получали совершенно посторонние ручки — включая вход в конференцию, где никакого пароля не проверялось. Что изменилось: - `hash_password`/`verify_password` стали асинхронными и считаются в пуле потоков (`asyncio.to_thread`). argon2-cffi освобождает GIL, поэтому проверки идут по-настоящему параллельно; - параметры argon2id заменены с дефолтов библиотеки (t=3, m=64 МБ, p=4) на рекомендацию OWASP (t=2, m=19 МБ, p=1): 95 мс → 42 мс. Отдельно важен `parallelism`: при p=4 одна проверка пароля занимала все четыре ядра сервера — те же, на которых работает LiveKit; - добавлен `needs_rehash`: существующие хэши проверяются как прежде (параметры зашиты в саму строку) и лениво перевыпускаются при первом успешном входе. Расчёт по замерам: пачка из 70 логинов — 6.7–10.9 с блокировки против ~0.36 с без неё. Тесты: event loop продолжает тикать во время проверки; 8 параллельных проверок укладываются заметно быстрее восьми последовательных; хэш со старыми параметрами принимается и перевыпускается при входе.
451 lines
17 KiB
Python
451 lines
17 KiB
Python
"""Юнит-тесты `services/conferences.py`: retry при коллизии номера/slug, резолв,
|
||
постановка рассылки .ics-приглашений и инкремент `ics_sequence`.
|
||
"""
|
||
|
||
import uuid
|
||
from datetime import UTC, datetime, timedelta
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
from sqlalchemy.exc import IntegrityError
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
import services.conferences as conferences_module
|
||
from core.security import hash_password
|
||
from models.conference import Conference
|
||
from models.user import User
|
||
from schemas.conferences import ConferenceCreateIn, ConferenceUpdateIn, InviteeIn
|
||
from services.conference_ids import generate_number, generate_slug
|
||
from services.conferences import ConferenceService, InviteeUserNotFoundError
|
||
|
||
FUTURE = datetime.now(UTC) + timedelta(days=3)
|
||
|
||
|
||
async def _make_user(session: AsyncSession, *, name: str = "Service Tester") -> User:
|
||
user = User(
|
||
email=f"{uuid.uuid4()}@example.com",
|
||
name_user=name,
|
||
password_hash=await hash_password("password123"),
|
||
email_verified=True,
|
||
)
|
||
session.add(user)
|
||
await session.flush()
|
||
return user
|
||
|
||
|
||
async def test_create_retries_on_number_collision_and_succeeds(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""Первая попытка сталкивается с занятым номером, вторая — успевает с новым."""
|
||
owner = await _make_user(db_session)
|
||
existing = Conference(number="123456789", slug=generate_slug(), status="active")
|
||
db_session.add(existing)
|
||
await db_session.commit()
|
||
|
||
fresh_number = generate_number()
|
||
while fresh_number == "123456789":
|
||
fresh_number = generate_number()
|
||
attempts = iter(["123456789", fresh_number])
|
||
monkeypatch.setattr(conferences_module, "generate_number", lambda: next(attempts))
|
||
|
||
service = ConferenceService(db_session)
|
||
conference, join = await service.create(owner=owner, data=ConferenceCreateIn())
|
||
|
||
assert conference.number == fresh_number
|
||
assert join is not None
|
||
|
||
|
||
async def test_create_gives_up_after_max_attempts(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""При постоянной коллизии номера и slug после 5 попыток исключение всплывает."""
|
||
owner = await _make_user(db_session)
|
||
existing = Conference(number="223456789", slug="always-colliding-slug", status="active")
|
||
db_session.add(existing)
|
||
await db_session.commit()
|
||
|
||
attempt_count = 0
|
||
|
||
def _always_colliding_number() -> str:
|
||
nonlocal attempt_count
|
||
attempt_count += 1
|
||
return "223456789"
|
||
|
||
monkeypatch.setattr(conferences_module, "generate_number", _always_colliding_number)
|
||
monkeypatch.setattr(conferences_module, "generate_slug", lambda: "always-colliding-slug")
|
||
|
||
service = ConferenceService(db_session)
|
||
with pytest.raises(IntegrityError):
|
||
await service.create(owner=owner, data=ConferenceCreateIn())
|
||
|
||
assert attempt_count == conferences_module.MAX_ID_GENERATION_ATTEMPTS
|
||
|
||
|
||
async def test_resolve_returns_none_for_unknown_query(db_session: AsyncSession) -> None:
|
||
service = ConferenceService(db_session)
|
||
assert await service.resolve("nothing-like-this-exists") is None
|
||
|
||
|
||
async def test_resolve_by_number_strips_internal_spaces(db_session: AsyncSession) -> None:
|
||
conference = Conference(number=generate_number(), slug=generate_slug(), status="active")
|
||
db_session.add(conference)
|
||
await db_session.commit()
|
||
|
||
service = ConferenceService(db_session)
|
||
number = conference.number
|
||
grouped = f"{number[:3]} {number[3:6]} {number[6:]}"
|
||
|
||
resolved = await service.resolve(grouped)
|
||
assert resolved is not None
|
||
assert resolved.id == conference.id
|
||
|
||
|
||
# --- Постановка `send_invitations` + `ics_sequence` ----------
|
||
|
||
|
||
async def test_create_instant_conference_does_not_enqueue_invitations(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
owner = await _make_user(db_session)
|
||
await db_session.commit()
|
||
mock_enqueue = MagicMock()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
|
||
|
||
service = ConferenceService(db_session)
|
||
await service.create(owner=owner, data=ConferenceCreateIn())
|
||
|
||
mock_enqueue.assert_not_called()
|
||
|
||
|
||
async def test_create_scheduled_conference_enqueues_invitations(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
owner = await _make_user(db_session)
|
||
await db_session.commit()
|
||
mock_enqueue = MagicMock()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
|
||
|
||
service = ConferenceService(db_session)
|
||
conference, _ = await service.create(owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE))
|
||
|
||
mock_enqueue.assert_called_once_with(conference.id)
|
||
|
||
|
||
async def test_create_with_summary_recipients_override_is_persisted(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
owner = await _make_user(db_session)
|
||
await db_session.commit()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
|
||
|
||
service = ConferenceService(db_session)
|
||
conference, _ = await service.create(
|
||
owner=owner,
|
||
data=ConferenceCreateIn(scheduled_at=FUTURE, summary_recipients="owner"),
|
||
)
|
||
|
||
assert conference.summary_recipients == "owner"
|
||
assert service.to_out(conference).summary_recipients == "owner"
|
||
|
||
|
||
async def test_update_schedule_change_increments_sequence_and_enqueues(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
owner = await _make_user(db_session)
|
||
await db_session.commit()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
|
||
|
||
service = ConferenceService(db_session)
|
||
conference, _ = await service.create(
|
||
owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE, duration_minutes=30)
|
||
)
|
||
assert conference.ics_sequence == 0
|
||
|
||
mock_enqueue = MagicMock()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
|
||
new_time = FUTURE + timedelta(hours=1)
|
||
updated = await service.update(
|
||
conference.id, actor=owner, data=ConferenceUpdateIn(scheduled_at=new_time)
|
||
)
|
||
|
||
assert updated.ics_sequence == 1
|
||
mock_enqueue.assert_called_once_with(conference.id)
|
||
|
||
|
||
async def test_update_unrelated_field_does_not_touch_sequence(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
owner = await _make_user(db_session)
|
||
await db_session.commit()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
|
||
|
||
service = ConferenceService(db_session)
|
||
conference, _ = await service.create(
|
||
owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE, password=None)
|
||
)
|
||
|
||
mock_enqueue = MagicMock()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
|
||
updated = await service.update(
|
||
conference.id, actor=owner, data=ConferenceUpdateIn(is_closed=True, password="secret1")
|
||
)
|
||
|
||
assert updated.ics_sequence == 0
|
||
mock_enqueue.assert_not_called()
|
||
|
||
|
||
async def test_update_summary_recipients_explicit_null_resets_override(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
owner = await _make_user(db_session)
|
||
await db_session.commit()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
|
||
|
||
service = ConferenceService(db_session)
|
||
conference, _ = await service.create(
|
||
owner=owner,
|
||
data=ConferenceCreateIn(scheduled_at=FUTURE, summary_recipients="owner"),
|
||
)
|
||
assert conference.summary_recipients == "owner"
|
||
|
||
updated = await service.update(
|
||
conference.id, actor=owner, data=ConferenceUpdateIn(summary_recipients=None)
|
||
)
|
||
|
||
assert updated.summary_recipients is None
|
||
|
||
|
||
# --- Постановка рассылки не должна ронять запрос при недоступности брокера ------------
|
||
|
||
|
||
async def test_create_survives_enqueue_invitations_failure(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""Конференция уже закоммичена — сбой постановки .ics-рассылки (Redis недоступен)
|
||
не должен ронять `create` 500-й; компенсация — ручная рассылка из админки."""
|
||
owner = await _make_user(db_session)
|
||
await db_session.commit()
|
||
monkeypatch.setattr(
|
||
conferences_module,
|
||
"enqueue_invitations",
|
||
MagicMock(side_effect=ConnectionError("брокер недоступен")),
|
||
)
|
||
|
||
service = ConferenceService(db_session)
|
||
conference, join = await service.create(
|
||
owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE)
|
||
)
|
||
|
||
assert join is None
|
||
assert conference.id is not None
|
||
reloaded = await ConferenceService(db_session).resolve(conference.number)
|
||
assert reloaded is not None
|
||
assert reloaded.id == conference.id
|
||
|
||
|
||
async def test_update_survives_enqueue_invitations_failure(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""Аналогично для `update`: правка расписания сохраняется и `ics_sequence`
|
||
растёт, даже если постановку рассылки не удалось поставить в очередь."""
|
||
owner = await _make_user(db_session)
|
||
await db_session.commit()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
|
||
|
||
service = ConferenceService(db_session)
|
||
conference, _ = await service.create(
|
||
owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE, duration_minutes=30)
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
conferences_module,
|
||
"enqueue_invitations",
|
||
MagicMock(side_effect=ConnectionError("брокер недоступен")),
|
||
)
|
||
new_time = FUTURE + timedelta(hours=1)
|
||
updated = await service.update(
|
||
conference.id, actor=owner, data=ConferenceUpdateIn(scheduled_at=new_time)
|
||
)
|
||
|
||
assert updated.ics_sequence == 1
|
||
assert updated.scheduled_at == new_time
|
||
|
||
|
||
# --- Участники (ADR-003) ---------------------------------------
|
||
|
||
|
||
async def test_create_participants_dedupes_owner_and_lists_organizer_first(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""Организатор всегда первый в `participants` и неудаляем — передача его же
|
||
(по `user_id` и по email) в списке молча дедуплицируется (ADR-003, п.2)."""
|
||
owner = await _make_user(db_session, name="Owner Tester")
|
||
external_invitee = await _make_user(db_session, name="Invited Colleague")
|
||
await db_session.commit()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
|
||
|
||
service = ConferenceService(db_session)
|
||
conference, _ = await service.create(
|
||
owner=owner,
|
||
data=ConferenceCreateIn(
|
||
scheduled_at=FUTURE,
|
||
participants=[
|
||
InviteeIn(user_id=owner.id), # дубль владельца — дедуп
|
||
InviteeIn(email=owner.email.upper()), # email владельца — тоже дедуп
|
||
InviteeIn(user_id=external_invitee.id),
|
||
],
|
||
),
|
||
)
|
||
|
||
out = await service.to_detail_out(conference, viewer=owner)
|
||
assert [p.user_id for p in out.participants] == [owner.id, external_invitee.id]
|
||
assert out.participants[0].is_organizer is True
|
||
assert out.participants[1].is_organizer is False
|
||
assert out.participants[1].name == "Invited Colleague"
|
||
|
||
|
||
async def test_create_with_unknown_participant_user_id_raises(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
owner = await _make_user(db_session)
|
||
await db_session.commit()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
|
||
unknown_user_id = uuid.uuid4()
|
||
|
||
service = ConferenceService(db_session)
|
||
with pytest.raises(InviteeUserNotFoundError):
|
||
await service.create(
|
||
owner=owner,
|
||
data=ConferenceCreateIn(
|
||
scheduled_at=FUTURE, participants=[InviteeIn(user_id=unknown_user_id)]
|
||
),
|
||
)
|
||
|
||
|
||
async def test_update_replaces_participant_set(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
owner = await _make_user(db_session)
|
||
first_invitee = await _make_user(db_session, name="First Invitee")
|
||
second_invitee = await _make_user(db_session, name="Second Invitee")
|
||
await db_session.commit()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
|
||
|
||
service = ConferenceService(db_session)
|
||
conference, _ = await service.create(
|
||
owner=owner,
|
||
data=ConferenceCreateIn(
|
||
scheduled_at=FUTURE, participants=[InviteeIn(user_id=first_invitee.id)]
|
||
),
|
||
)
|
||
|
||
updated = await service.update(
|
||
conference.id,
|
||
actor=owner,
|
||
data=ConferenceUpdateIn(participants=[InviteeIn(user_id=second_invitee.id)]),
|
||
)
|
||
|
||
out = await service.to_detail_out(updated, viewer=owner)
|
||
participant_ids = {p.user_id for p in out.participants}
|
||
assert participant_ids == {owner.id, second_invitee.id}
|
||
assert first_invitee.id not in participant_ids
|
||
|
||
|
||
async def test_update_participants_none_does_not_change_existing_set(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""`participants=None` в PATCH — «не менять» (ADR-003, п.3)."""
|
||
owner = await _make_user(db_session)
|
||
invitee = await _make_user(db_session, name="Untouched Invitee")
|
||
await db_session.commit()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
|
||
|
||
service = ConferenceService(db_session)
|
||
conference, _ = await service.create(
|
||
owner=owner,
|
||
data=ConferenceCreateIn(scheduled_at=FUTURE, participants=[InviteeIn(user_id=invitee.id)]),
|
||
)
|
||
|
||
updated = await service.update(
|
||
conference.id, actor=owner, data=ConferenceUpdateIn(title="Renamed")
|
||
)
|
||
|
||
out = await service.to_detail_out(updated, viewer=owner)
|
||
assert invitee.id in {p.user_id for p in out.participants}
|
||
|
||
|
||
async def test_update_participants_only_change_enqueues_without_incrementing_sequence(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""Изменение ТОЛЬКО состава: рассылка ставится, `ics_sequence` не растёт (анти-спам)."""
|
||
owner = await _make_user(db_session)
|
||
invitee = await _make_user(db_session, name="New Invitee")
|
||
await db_session.commit()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
|
||
|
||
service = ConferenceService(db_session)
|
||
conference, _ = await service.create(
|
||
owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE, duration_minutes=30)
|
||
)
|
||
assert conference.ics_sequence == 0
|
||
|
||
mock_enqueue = MagicMock()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
|
||
updated = await service.update(
|
||
conference.id,
|
||
actor=owner,
|
||
data=ConferenceUpdateIn(participants=[InviteeIn(user_id=invitee.id)]),
|
||
)
|
||
|
||
assert updated.ics_sequence == 0
|
||
mock_enqueue.assert_called_once_with(conference.id)
|
||
|
||
|
||
async def test_update_same_participant_set_does_not_enqueue(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""Повторная передача ТОГО ЖЕ состава — фактических изменений нет, рассылка не ставится."""
|
||
owner = await _make_user(db_session)
|
||
invitee = await _make_user(db_session, name="Same Invitee")
|
||
await db_session.commit()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
|
||
|
||
service = ConferenceService(db_session)
|
||
conference, _ = await service.create(
|
||
owner=owner,
|
||
data=ConferenceCreateIn(scheduled_at=FUTURE, participants=[InviteeIn(user_id=invitee.id)]),
|
||
)
|
||
|
||
mock_enqueue = MagicMock()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
|
||
await service.update(
|
||
conference.id,
|
||
actor=owner,
|
||
data=ConferenceUpdateIn(participants=[InviteeIn(user_id=invitee.id)]),
|
||
)
|
||
|
||
mock_enqueue.assert_not_called()
|
||
|
||
|
||
async def test_list_my_does_not_populate_participants(
|
||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""`/my` не раздувает состав — `participants` пуст (ADR-003, п.5)."""
|
||
owner = await _make_user(db_session)
|
||
invitee = await _make_user(db_session, name="Should Not Appear")
|
||
await db_session.commit()
|
||
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
|
||
|
||
service = ConferenceService(db_session)
|
||
conference, _ = await service.create(
|
||
owner=owner,
|
||
data=ConferenceCreateIn(is_pinned=True, participants=[InviteeIn(user_id=invitee.id)]),
|
||
)
|
||
|
||
my_list = await service.list_my(owner=owner)
|
||
item = next(c for c in my_list if c.id == conference.id)
|
||
assert item.participants == []
|
||
assert item.owner_id == owner.id
|
||
assert item.is_owner is True
|
||
assert item.organizer_name == owner.name_user
|