"""Тесты Celery-задачи рассылки .ics-приглашений (`workers.tasks.invitations`). Как и `test_pipeline.py`/`test_summarize_task.py`, не использует savepoint-фикстуру `db_session`: `send_invitations_async` открывает собственную сессию с отдельным engine (`workers/db.py::open_session`), которая не видит незакоммиченные изменения другой сессии. Тестовые данные заводятся и коммитятся напрямую через `core.db.engine`; email-бэкенд — фейковый (без реального SMTP/console-лога). """ import uuid from collections.abc import AsyncGenerator from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import MagicMock import pytest from celery.exceptions import MaxRetriesExceededError from sqlalchemy import text from core.db import engine from core.plugins.config import ChatConfig, InstanceConfig, SummarizerConfig, TranscriberConfig from services.conference_ids import generate_number, generate_slug from services.email import EmailAttachment, EmailSendError from workers.tasks import invitations as invitations_module from workers.tasks.invitations import send_invitations_async NOW = datetime.now(UTC) FUTURE = NOW + timedelta(days=3) class _Request: def __init__(self, retries: int = 0) -> None: self.retries = retries class _FakeTask: def __init__(self, retries: int = 0) -> None: self.request = _Request(retries) self.retry = MagicMock() class _ExhaustedRetryTask: def __init__(self) -> None: self.request = _Request(0) self.retry_calls = 0 def retry(self, countdown: int | None = None) -> None: self.retry_calls += 1 raise MaxRetriesExceededError("исчерпаны попытки рассылки приглашений") class _FakeEmailBackend: """Фейковый `EmailBackend`: запоминает вызовы, опционально бросает `EmailSendError`.""" def __init__(self, *, fail_for: set[str] | None = None, retryable: bool = True) -> None: self.sent: list[dict[str, Any]] = [] self._fail_for = fail_for or set() self._retryable = retryable async def send( self, *, to: str, subject: str, body: str, html_body: str | None = None, attachments: tuple[EmailAttachment, ...] = (), reply_to: str | None = None, ) -> None: if to in self._fail_for: raise EmailSendError(f"сбой отправки {to}", retryable=self._retryable) self.sent.append( {"to": to, "subject": subject, "attachments": attachments, "reply_to": reply_to} ) class _Fixture: """Id тестовой конференции/владельца/сеанса/участников.""" def __init__(self) -> None: self.owner_id = uuid.uuid4() self.conference_id = uuid.uuid4() self.session_id = uuid.uuid4() self.user_id = uuid.uuid4() self.guest_with_email_id = uuid.uuid4() self.guest_without_email_id = uuid.uuid4() self.number = generate_number() self.slug = generate_slug() @pytest.fixture async def fx() -> AsyncGenerator[_Fixture, None]: f = _Fixture() async with engine.connect() as conn: await conn.execute( text( "INSERT INTO users (id, email, name_user, password_hash) " "VALUES (:id, :email, 'Owner Tester', 'x')" ), {"id": f.owner_id, "email": f"invitations-owner-{f.owner_id}@example.com"}, ) await conn.execute( text( "INSERT INTO users (id, email, name_user, password_hash) " "VALUES (:id, :email, 'Participant Tester', 'x')" ), {"id": f.user_id, "email": f"invitations-participant-{f.user_id}@example.com"}, ) await conn.execute( text( "INSERT INTO conferences " "(id, number, slug, title, status, is_pinned, owner_id, scheduled_at, " "duration_minutes) " "VALUES (:id, :number, :slug, 'Invitations test', 'scheduled', true, " ":owner_id, :scheduled_at, 30)" ), { "id": f.conference_id, "number": f.number, "slug": f.slug, "owner_id": f.owner_id, "scheduled_at": FUTURE, }, ) await conn.execute( text( "INSERT INTO guest_access (id, conference_id, display_name, email) " "VALUES (:id, :conference_id, 'Guest With Email', :email)" ), { "id": f.guest_with_email_id, "conference_id": f.conference_id, "email": f"invitations-guest-{f.guest_with_email_id}@example.com", }, ) await conn.execute( text( "INSERT INTO guest_access (id, conference_id, display_name) " "VALUES (:id, :conference_id, 'Guest Without Email')" ), {"id": f.guest_without_email_id, "conference_id": f.conference_id}, ) await conn.execute( text( "INSERT INTO conference_sessions (id, conference_id, title, t_start, t_end) " "VALUES (:id, :conference_id, 'Past session', :t_start, :t_end)" ), { "id": f.session_id, "conference_id": f.conference_id, "t_start": NOW - timedelta(days=7), "t_end": NOW - timedelta(days=7) + timedelta(minutes=30), }, ) await conn.execute( text( "INSERT INTO conference_participants (id, session_id, user_id, joined_at) " "VALUES (:id, :session_id, :user_id, :joined_at)" ), { "id": uuid.uuid4(), "session_id": f.session_id, "user_id": f.user_id, "joined_at": NOW, }, ) await conn.execute( text( "INSERT INTO conference_participants (id, session_id, guest_id, joined_at) " "VALUES (:id, :session_id, :guest_id, :joined_at)" ), { "id": uuid.uuid4(), "session_id": f.session_id, "guest_id": f.guest_with_email_id, "joined_at": NOW, }, ) await conn.execute( text( "INSERT INTO conference_participants (id, session_id, guest_id, joined_at) " "VALUES (:id, :session_id, :guest_id, :joined_at)" ), { "id": uuid.uuid4(), "session_id": f.session_id, "guest_id": f.guest_without_email_id, "joined_at": NOW, }, ) await conn.commit() yield f async with engine.connect() as conn: await conn.execute( text("DELETE FROM email_deliveries WHERE conference_id = :id"), {"id": f.conference_id}, ) await conn.execute( text("DELETE FROM conference_participants WHERE session_id = :id"), {"id": f.session_id}, ) await conn.execute( text("DELETE FROM conference_sessions WHERE conference_id = :id"), {"id": f.conference_id}, ) await conn.execute( text("DELETE FROM guest_access WHERE conference_id = :id"), {"id": f.conference_id} ) await conn.execute(text("DELETE FROM conferences WHERE id = :id"), {"id": f.conference_id}) await conn.execute( text("DELETE FROM users WHERE id IN (:owner_id, :user_id)"), {"owner_id": f.owner_id, "user_id": f.user_id}, ) await conn.commit() async def _deliveries(conference_id: uuid.UUID) -> list[str]: async with engine.connect() as conn: result = await conn.execute( text( "SELECT recipient_email FROM email_deliveries " "WHERE conference_id = :id AND kind = 'invitation'" ), {"id": conference_id}, ) return [row[0] for row in result.all()] async def test_unknown_conference_is_noop(monkeypatch: pytest.MonkeyPatch) -> None: mock_backend = _FakeEmailBackend() monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend) await send_invitations_async(_FakeTask(), uuid.uuid4()) assert mock_backend.sent == [] async def test_conference_without_schedule_is_noop(monkeypatch: pytest.MonkeyPatch) -> None: conference_id = uuid.uuid4() async with engine.connect() as conn: await conn.execute( text( "INSERT INTO conferences (id, number, slug, title, status, is_pinned) " "VALUES (:id, :number, :slug, 'No schedule', 'active', true)" ), {"id": conference_id, "number": generate_number(), "slug": generate_slug()}, ) await conn.commit() mock_backend = _FakeEmailBackend() monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend) try: await send_invitations_async(_FakeTask(), conference_id) assert mock_backend.sent == [] finally: async with engine.connect() as conn: await conn.execute( text("DELETE FROM conferences WHERE id = :id"), {"id": conference_id} ) await conn.commit() async def test_non_pinned_conference_sends_to_owner_and_invitees( monkeypatch: pytest.MonkeyPatch, ) -> None: """Приглашённые (`conference_invitees`, ADR-003) получают письмо независимо от `is_pinned`.""" conference_id = uuid.uuid4() owner_id = uuid.uuid4() invitee_user_id = uuid.uuid4() external_email = f"external-invitee-{uuid.uuid4()}@example.com" async with engine.connect() as conn: await conn.execute( text( "INSERT INTO users (id, email, name_user, password_hash) " "VALUES (:id, :email, 'Owner Tester', 'x')" ), {"id": owner_id, "email": f"invitations-owner-{owner_id}@example.com"}, ) await conn.execute( text( "INSERT INTO users (id, email, name_user, password_hash) " "VALUES (:id, :email, 'Invitee Tester', 'x')" ), {"id": invitee_user_id, "email": f"invitations-invitee-{invitee_user_id}@example.com"}, ) await conn.execute( text( "INSERT INTO conferences " "(id, number, slug, title, status, is_pinned, owner_id, scheduled_at, " "duration_minutes) " "VALUES (:id, :number, :slug, 'Roster test', 'scheduled', false, :owner_id, " ":scheduled_at, 30)" ), { "id": conference_id, "number": generate_number(), "slug": generate_slug(), "owner_id": owner_id, "scheduled_at": FUTURE, }, ) await conn.execute( text( "INSERT INTO conference_invitees (id, conference_id, user_id) " "VALUES (:id, :conference_id, :user_id)" ), {"id": uuid.uuid4(), "conference_id": conference_id, "user_id": invitee_user_id}, ) await conn.execute( text( "INSERT INTO conference_invitees (id, conference_id, email) " "VALUES (:id, :conference_id, :email)" ), {"id": uuid.uuid4(), "conference_id": conference_id, "email": external_email}, ) await conn.commit() mock_backend = _FakeEmailBackend() monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend) try: await send_invitations_async(_FakeTask(), conference_id) sent_to = {call["to"] for call in mock_backend.sent} assert sent_to == { f"invitations-owner-{owner_id}@example.com".lower(), f"invitations-invitee-{invitee_user_id}@example.com".lower(), external_email.lower(), } finally: async with engine.connect() as conn: await conn.execute( text("DELETE FROM email_deliveries WHERE conference_id = :id"), {"id": conference_id}, ) await conn.execute( text("DELETE FROM conference_invitees WHERE conference_id = :id"), {"id": conference_id}, ) await conn.execute( text("DELETE FROM conferences WHERE id = :id"), {"id": conference_id} ) await conn.execute( text("DELETE FROM users WHERE id IN (:owner_id, :invitee_user_id)"), {"owner_id": owner_id, "invitee_user_id": invitee_user_id}, ) await conn.commit() async def test_pinned_conference_sends_to_owner_and_past_participants_with_email( fx: _Fixture, monkeypatch: pytest.MonkeyPatch ) -> None: mock_backend = _FakeEmailBackend() monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend) await send_invitations_async(_FakeTask(), fx.conference_id) sent_to = {call["to"] for call in mock_backend.sent} assert sent_to == { f"invitations-owner-{fx.owner_id}@example.com".lower(), f"invitations-participant-{fx.user_id}@example.com".lower(), f"invitations-guest-{fx.guest_with_email_id}@example.com".lower(), } # Каждое отправленное письмо несёт вложение `invite.ics`. assert all(call["attachments"][0].filename == "invite.ics" for call in mock_backend.sent) delivered = set(await _deliveries(fx.conference_id)) assert delivered == sent_to def _cfg( *, contact_email_enabled: bool = False, contact_email: str | None = None ) -> InstanceConfig: return InstanceConfig( transcriber=TranscriberConfig(), summarizer=SummarizerConfig(), chat=ChatConfig(), contact_email_enabled=contact_email_enabled, contact_email=contact_email, ) async def test_invitations_set_reply_to_when_contact_email_enabled( fx: _Fixture, monkeypatch: pytest.MonkeyPatch ) -> None: """Включённый контактный адрес инстанса — приглашение несёт `Reply-To`.""" mock_backend = _FakeEmailBackend() monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend) await send_invitations_async( _FakeTask(), fx.conference_id, plugins_config=_cfg(contact_email_enabled=True, contact_email="contact@vidconf.example"), ) assert mock_backend.sent assert all(call["reply_to"] == "contact@vidconf.example" for call in mock_backend.sent) async def test_invitations_no_reply_to_when_contact_email_disabled( fx: _Fixture, monkeypatch: pytest.MonkeyPatch ) -> None: """Контактный адрес выключен (дефолт) — `Reply-To` не проставляется.""" mock_backend = _FakeEmailBackend() monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend) await send_invitations_async(_FakeTask(), fx.conference_id, plugins_config=_cfg()) assert mock_backend.sent assert all(call["reply_to"] is None for call in mock_backend.sent) async def test_explicit_emails_override_default_recipients( fx: _Fixture, monkeypatch: pytest.MonkeyPatch ) -> None: mock_backend = _FakeEmailBackend() monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend) await send_invitations_async(_FakeTask(), fx.conference_id, emails=["Custom@Example.com"]) assert [call["to"] for call in mock_backend.sent] == ["custom@example.com"] async def test_no_recipients_is_noop(monkeypatch: pytest.MonkeyPatch) -> None: conference_id = uuid.uuid4() async with engine.connect() as conn: await conn.execute( text( "INSERT INTO conferences (id, number, slug, title, status, is_pinned, " "scheduled_at) VALUES (:id, :number, :slug, 'No owner', 'scheduled', false, " ":scheduled_at)" ), { "id": conference_id, "number": generate_number(), "slug": generate_slug(), "scheduled_at": FUTURE, }, ) await conn.commit() mock_backend = _FakeEmailBackend() monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend) try: await send_invitations_async(_FakeTask(), conference_id) assert mock_backend.sent == [] finally: async with engine.connect() as conn: await conn.execute( text("DELETE FROM conferences WHERE id = :id"), {"id": conference_id} ) await conn.commit() async def test_non_retryable_failure_skips_recipient_but_delivers_others( fx: _Fixture, monkeypatch: pytest.MonkeyPatch ) -> None: failing = f"invitations-owner-{fx.owner_id}@example.com".lower() mock_backend = _FakeEmailBackend(fail_for={failing}, retryable=False) monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend) await send_invitations_async(_FakeTask(), fx.conference_id) delivered = set(await _deliveries(fx.conference_id)) assert failing not in delivered assert len(delivered) == 2 async def test_retryable_failure_calls_task_retry( fx: _Fixture, monkeypatch: pytest.MonkeyPatch ) -> None: failing = f"invitations-owner-{fx.owner_id}@example.com".lower() mock_backend = _FakeEmailBackend(fail_for={failing}, retryable=True) monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend) task = _FakeTask() await send_invitations_async(task, fx.conference_id) task.retry.assert_called_once() async def test_retryable_failure_exhausted_logs_and_returns( fx: _Fixture, monkeypatch: pytest.MonkeyPatch ) -> None: failing = f"invitations-owner-{fx.owner_id}@example.com".lower() mock_backend = _FakeEmailBackend(fail_for={failing}, retryable=True) monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend) await send_invitations_async(_ExhaustedRetryTask(), fx.conference_id) # не должно бросить