444 lines
17 KiB
Python
444 lines
17 KiB
Python
"""Тесты 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 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, ...] = (),
|
||
) -> None:
|
||
if to in self._fail_for:
|
||
raise EmailSendError(f"сбой отправки {to}", retryable=self._retryable)
|
||
self.sent.append({"to": to, "subject": subject, "attachments": attachments})
|
||
|
||
|
||
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
|
||
|
||
|
||
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) # не должно бросить
|