"""Тесты Celery-задачи суммаризации (`workers.tasks.summarize.summarize_session_async`). Как и `test_pipeline.py`, не использует savepoint-фикстуру `db_session`: `summarize_session_async` открывает собственную сессию с отдельным engine (`workers/db.py::open_session`), которая не видит незакоммиченные изменения другой сессии. Тестовые данные заводятся и коммитятся напрямую через `core.db.engine`, LLM — фейковый `Summarizer` за контрактом `create_summarizer` (без реального `QwenLocal`/HTTP). """ import uuid from collections.abc import AsyncGenerator from datetime import UTC, datetime, timedelta from pathlib import Path from typing import cast 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 core.plugins.transcriber import Segment from core.summarization.llm_client import LlmUnavailableError from services.conference_ids import generate_number, generate_slug from workers.tasks import pipeline as pipeline_module from workers.tasks import summarize as summarize_module from workers.tasks.pipeline import run_pipeline_async from workers.tasks.summarize import summarize_session_async NOW = datetime.now(UTC) class _Request: """Заглушка `celery.Task.request`: нужен только счётчик уже сделанных попыток.""" def __init__(self, retries: int = 0) -> None: self.retries = retries class _FakeTask: """Заглушка bound-задачи Celery: фиксирует вызовы `retry`, не бросает исключение.""" def __init__(self, retries: int = 0) -> None: self.request = _Request(retries) self.retry = MagicMock() class _ExhaustedRetryTask: """Заглушка bound-задачи: `retry` всегда бросает `MaxRetriesExceededError`.""" def __init__(self, retries: int = 0) -> None: self.request = _Request(retries) self.retry_calls = 0 def retry(self, countdown: int | None = None) -> None: self.retry_calls += 1 raise MaxRetriesExceededError("исчерпаны попытки суммаризации") class _FakeSummarizer: """Фейковый `Summarizer`: запоминает транскрипт, опционально падает N раз подряд.""" provider = "fake" def __init__(self, result: str = "Итоговое саммари", fail_times: int = 0) -> None: self.result = result self._fail_times = fail_times self.calls = 0 self.transcript: str | None = None def summarize(self, transcript: str) -> str: self.calls += 1 self.transcript = transcript if self._fail_times > 0: self._fail_times -= 1 raise LlmUnavailableError("LLM недоступен (тест)") return self.result class _Fixture: """Id тестовой конференции/сеанса/участников с уже вставленными фразами.""" def __init__(self) -> None: self.conference_id = uuid.uuid4() self.session_id = uuid.uuid4() self.user_id = uuid.uuid4() self.guest_id = uuid.uuid4() self.user_participant_id = uuid.uuid4() self.guest_participant_id = uuid.uuid4() self.number = generate_number() self.slug = generate_slug() self.t_start = NOW - timedelta(minutes=30) self.t_end = NOW @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, 'Иван Тестов', 'x')" ), {"id": f.user_id, "email": f"summarize-{f.user_id}@example.com"}, ) await conn.execute( text( "INSERT INTO conferences (id, number, slug, title, status, is_pinned) " "VALUES (:id, :number, :slug, 'Summarize test', 'ended', false)" ), {"id": f.conference_id, "number": f.number, "slug": f.slug}, ) await conn.execute( text( "INSERT INTO guest_access (id, conference_id, display_name) " "VALUES (:id, :conference_id, 'Гость Тестов')" ), {"id": f.guest_id, "conference_id": f.conference_id}, ) await conn.execute( text( "INSERT INTO conference_sessions " "(id, conference_id, title, t_start, t_end, pipeline_status) " "VALUES (:id, :conference_id, 'Summarize session', :t_start, :t_end, " "'summarizing')" ), { "id": f.session_id, "conference_id": f.conference_id, "t_start": f.t_start, "t_end": f.t_end, }, ) await conn.execute( text( "INSERT INTO conference_participants (id, session_id, user_id, joined_at, left_at) " "VALUES (:id, :session_id, :user_id, :joined_at, :left_at)" ), { "id": f.user_participant_id, "session_id": f.session_id, "user_id": f.user_id, "joined_at": f.t_start, "left_at": f.t_end, }, ) await conn.execute( text( "INSERT INTO conference_participants " "(id, session_id, guest_id, joined_at, left_at) " "VALUES (:id, :session_id, :guest_id, :joined_at, :left_at)" ), { "id": f.guest_participant_id, "session_id": f.session_id, "guest_id": f.guest_id, "joined_at": f.t_start, "left_at": f.t_end, }, ) await conn.execute( text( "INSERT INTO phrases (participant_id, session_id, data, t_start, t_end) " "VALUES (:participant_id, :session_id, 'Обсудили бюджет', :t_start, :t_end)" ), { "participant_id": f.user_participant_id, "session_id": f.session_id, "t_start": f.t_start + timedelta(seconds=5), "t_end": f.t_start + timedelta(seconds=7), }, ) await conn.execute( text( "INSERT INTO phrases (participant_id, session_id, data, t_start, t_end) " "VALUES (:participant_id, :session_id, 'Утвердили сроки', :t_start, :t_end)" ), { "participant_id": f.guest_participant_id, "session_id": f.session_id, "t_start": f.t_start + timedelta(seconds=10), "t_end": f.t_start + timedelta(seconds=12), }, ) await conn.commit() yield f async with engine.connect() as conn: await conn.execute(text("DELETE FROM phrases WHERE session_id = :id"), {"id": f.session_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 = :id"), {"id": f.user_id}) await conn.commit() def _cfg(*, enabled: bool = True) -> InstanceConfig: """Конфиг для тестов: реальный `Summarizer` не создаётся — провайдер подменяется через monkeypatch `summarize_module.create_summarizer`.""" return InstanceConfig( transcriber=TranscriberConfig(), summarizer=SummarizerConfig(enabled=enabled, provider="fake"), chat=ChatConfig(), ) async def _fetch_session(session_id: uuid.UUID) -> tuple[str, str | None]: async with engine.connect() as conn: result = await conn.execute( text("SELECT pipeline_status, summary_data FROM conference_sessions WHERE id = :id"), {"id": session_id}, ) row = result.one() return cast("str", row[0]), cast("str | None", row[1]) async def test_summarize_session_fills_summary_data_and_keeps_summarizing( fx: _Fixture, monkeypatch: pytest.MonkeyPatch ) -> None: """(a) phrases → summary_data заполнен, статус — 'summarizing', 'notified' не ставится (его ставит отдельная задача `notify_session`, см. её постановку ниже).""" fake = _FakeSummarizer(result="Итоговое саммари встречи") monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: fake) mock_send_task = MagicMock() monkeypatch.setattr(summarize_module.app, "send_task", mock_send_task) await summarize_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg()) status, summary = await _fetch_session(fx.session_id) assert status == "summarizing" assert summary == "Итоговое саммари встречи" assert fake.calls == 1 mock_send_task.assert_called_once_with( "workers.tasks.notify.notify_session", args=[str(fx.session_id)] ) async def test_summarize_session_noop_when_summary_already_present( fx: _Fixture, monkeypatch: pytest.MonkeyPatch ) -> None: """(b) повторный вызов при уже заполненном summary_data — no-op.""" monkeypatch.setattr(summarize_module.app, "send_task", MagicMock()) first = _FakeSummarizer(result="Первое саммари") monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: first) await summarize_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg()) second = _FakeSummarizer(result="Второе саммари (не должно попасть в БД)") monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: second) await summarize_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg()) status, summary = await _fetch_session(fx.session_id) assert status == "summarizing" assert summary == "Первое саммари" assert second.calls == 0 async def test_summarize_session_noop_when_disabled( fx: _Fixture, monkeypatch: pytest.MonkeyPatch ) -> None: """(c) summarizer.enabled=false → выход, summary_data NULL, статус не тронут.""" mock_factory = MagicMock() monkeypatch.setattr(summarize_module, "create_summarizer", mock_factory) await summarize_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg(enabled=False)) mock_factory.assert_not_called() status, summary = await _fetch_session(fx.session_id) assert status == "summarizing" assert summary is None async def test_summarize_session_retries_on_llm_unavailable_then_succeeds( fx: _Fixture, monkeypatch: pytest.MonkeyPatch ) -> None: """(d) LlmUnavailableError на первом вызове → retry без потери шага → успех со второй попытки.""" monkeypatch.setattr(summarize_module.app, "send_task", MagicMock()) fake = _FakeSummarizer(result="Саммари после повтора", fail_times=1) monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: fake) first_task = _FakeTask(retries=0) await summarize_session_async(first_task, fx.session_id, plugins_config=_cfg()) first_task.retry.assert_called_once_with(countdown=60) status, summary = await _fetch_session(fx.session_id) assert status == "summarizing" assert summary is None assert fake.calls == 1 # Повторная доставка задачи celery-ретраем (acks_late) — шаг не потерян. second_task = _FakeTask(retries=1) await summarize_session_async(second_task, fx.session_id, plugins_config=_cfg()) second_task.retry.assert_not_called() status, summary = await _fetch_session(fx.session_id) assert status == "summarizing" assert summary == "Саммари после повтора" assert fake.calls == 2 async def test_summarize_session_marks_failed_when_retries_exhausted( fx: _Fixture, monkeypatch: pytest.MonkeyPatch ) -> None: """(e) исчерпание retry → pipeline_status='failed'.""" fake = _FakeSummarizer(result="не важно", fail_times=999) monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: fake) task = _ExhaustedRetryTask(retries=5) await summarize_session_async(task, fx.session_id, plugins_config=_cfg()) assert task.retry_calls == 1 status, summary = await _fetch_session(fx.session_id) assert status == "failed" assert summary is None async def test_summarize_session_transcript_includes_guest_display_name( fx: _Fixture, monkeypatch: pytest.MonkeyPatch ) -> None: """(f) имена гостей (guest_access.display_name) попадают в транскрипт.""" monkeypatch.setattr(summarize_module.app, "send_task", MagicMock()) fake = _FakeSummarizer() monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: fake) await summarize_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg()) assert fake.transcript is not None assert "Гость Тестов" in fake.transcript assert "Иван Тестов" in fake.transcript async def test_summarize_session_noop_when_no_phrases(monkeypatch: pytest.MonkeyPatch) -> None: """Guard №5: у сеанса нет фраз → warning-выход, summary_data остаётся NULL.""" f = _Fixture() 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 phrases', 'ended', false)" ), {"id": f.conference_id, "number": f.number, "slug": f.slug}, ) await conn.execute( text( "INSERT INTO conference_sessions " "(id, conference_id, title, t_start, t_end, pipeline_status) " "VALUES (:id, :conference_id, 'No phrases session', :t_start, :t_end, " "'summarizing')" ), { "id": f.session_id, "conference_id": f.conference_id, "t_start": f.t_start, "t_end": f.t_end, }, ) await conn.commit() mock_factory = MagicMock() monkeypatch.setattr(summarize_module, "create_summarizer", mock_factory) try: await summarize_session_async(_FakeTask(), f.session_id, plugins_config=_cfg()) mock_factory.assert_not_called() status, summary = await _fetch_session(f.session_id) assert status == "summarizing" assert summary is None finally: async with engine.connect() as conn: await conn.execute( text("DELETE FROM conference_sessions WHERE conference_id = :id"), {"id": f.conference_id}, ) await conn.execute( text("DELETE FROM conferences WHERE id = :id"), {"id": f.conference_id} ) await conn.commit() class _FakeTranscriber: """Заглушка `Transcriber`: канонические сегменты по пути файла (см. `test_pipeline.py`).""" def __init__(self, canned: dict[str, list[Segment]]) -> None: self._canned = canned self.calls: list[str] = [] def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]: self.calls.append(audio_path) return self._canned[audio_path] def _touch(path: str) -> None: """Создать пустой файл трека (синхронно, вне тела async-теста — ASYNC240).""" Path(path).write_bytes(b"") async def test_run_pipeline_sends_summarize_task_after_summarizing( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """(g) run_pipeline отправляет `summarize_session` по имени после перехода в 'summarizing'.""" f = _Fixture() track_path = str(tmp_path / "track.ogg") _touch(track_path) async with engine.connect() as conn: await conn.execute( text( "INSERT INTO users (id, email, name_user, password_hash) " "VALUES (:id, :email, 'Иван Тестов', 'x')" ), {"id": f.user_id, "email": f"pipeline-send-{f.user_id}@example.com"}, ) await conn.execute( text( "INSERT INTO conferences (id, number, slug, title, status, is_pinned) " "VALUES (:id, :number, :slug, 'Send task test', 'ended', false)" ), {"id": f.conference_id, "number": f.number, "slug": f.slug}, ) await conn.execute( text( "INSERT INTO conference_sessions (id, conference_id, title, t_start, t_end) " "VALUES (:id, :conference_id, 'Send task session', :t_start, :t_end)" ), { "id": f.session_id, "conference_id": f.conference_id, "t_start": f.t_start, "t_end": f.t_end, }, ) await conn.execute( text( "INSERT INTO conference_participants (id, session_id, user_id, joined_at, left_at) " "VALUES (:id, :session_id, :user_id, :joined_at, :left_at)" ), { "id": f.user_participant_id, "session_id": f.session_id, "user_id": f.user_id, "joined_at": f.t_start, "left_at": f.t_end, }, ) await conn.execute( text( "INSERT INTO session_audio_tracks " "(id, session_id, participant_id, track_sid, egress_id, " "file_path, status, started_at) " "VALUES (:id, :session_id, :participant_id, 'TR_USER', 'EG_TR_USER', " ":file_path, 'recorded', :started_at)" ), { "id": uuid.uuid4(), "session_id": f.session_id, "participant_id": f.user_participant_id, "file_path": track_path, "started_at": f.t_start + timedelta(seconds=5), }, ) await conn.commit() fake_transcriber = _FakeTranscriber(canned={track_path: [Segment(0.0, 2.0, "привет")]}) monkeypatch.setattr(pipeline_module, "create_transcriber", lambda cfg: fake_transcriber) mock_send_task = MagicMock() monkeypatch.setattr(pipeline_module.app, "send_task", mock_send_task) try: await run_pipeline_async( _FakeTask(), f.session_id, plugins_config=InstanceConfig( transcriber=TranscriberConfig(enabled=True, provider="fake", language="ru"), summarizer=SummarizerConfig(), chat=ChatConfig(), ), ) mock_send_task.assert_called_once_with( "workers.tasks.summarize.summarize_session", args=[str(f.session_id)] ) status, _ = await _fetch_session(f.session_id) assert status == "summarizing" finally: async with engine.connect() as conn: await conn.execute( text("DELETE FROM phrases WHERE session_id = :id"), {"id": f.session_id} ) await conn.execute( text("DELETE FROM session_audio_tracks WHERE session_id = :id"), {"id": f.session_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 conferences WHERE id = :id"), {"id": f.conference_id} ) await conn.execute(text("DELETE FROM users WHERE id = :id"), {"id": f.user_id}) await conn.commit()