Files
vidconf/backend/tests/test_invitations_task.py

444 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Тесты 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) # не должно бросить