"""Юнит-тесты `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