Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View File

@@ -0,0 +1,450 @@
"""Юнит-тесты `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=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