From f61e8af8c0bc3a7d8541140e3a3b9a5a7b05acf8 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 10 Jun 2026 18:44:25 +0200 Subject: [PATCH] test(podcasts): add shared test fixtures --- .../tests/unit/podcasts/conftest.py | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 surfsense_backend/tests/unit/podcasts/conftest.py diff --git a/surfsense_backend/tests/unit/podcasts/conftest.py b/surfsense_backend/tests/unit/podcasts/conftest.py new file mode 100644 index 000000000..446982904 --- /dev/null +++ b/surfsense_backend/tests/unit/podcasts/conftest.py @@ -0,0 +1,142 @@ +"""Shared builders for podcast unit tests. + +These tests exercise the podcast domain through its public interfaces. The only +test double is a minimal stand-in for the SQLAlchemy ``AsyncSession`` — a real +system boundary — so the service's own repository and state machine run for +real. Briefs and transcripts are built with valid factories so each test states +just the fields it cares about. +""" + +from __future__ import annotations + +import pytest + +from app.podcasts.schemas import ( + DurationTarget, + PodcastSpec, + PodcastStyle, + SpeakerRole, + SpeakerSpec, + Transcript, + TranscriptTurn, +) + + +class FakeAsyncSession: + """A no-op stand-in for ``AsyncSession`` at the persistence boundary. + + The service flushes to assign state within a unit of work; in a unit test + there is no database, so ``add``/``flush`` simply do nothing. Behavior is + observed through the returned aggregate, never through this double. + """ + + def add(self, _obj: object) -> None: + return None + + async def flush(self) -> None: + return None + + +class FakeCeleryDbSession(FakeAsyncSession): + """An async-context session double for Celery task bodies. + + Task bodies open ``get_celery_session_maker()()`` as an async context, + ``get`` the row, then ``commit``. This holds one preloaded podcast and + records whether the body committed, so tests assert on the row's final + state — not on the calls made to get there. + """ + + def __init__(self, podcast: object | None = None) -> None: + self._podcast = podcast + self.committed = False + + async def get(self, _model: object, _id: object) -> object | None: + return self._podcast + + async def commit(self) -> None: + self.committed = True + + async def __aenter__(self) -> FakeCeleryDbSession: + return self + + async def __aexit__(self, *_exc: object) -> None: + return None + + +@pytest.fixture +def fake_session() -> FakeAsyncSession: + return FakeAsyncSession() + + +@pytest.fixture +def make_celery_session(): + """Factory for a Celery-style session double holding one podcast.""" + + def _make(podcast: object | None = None) -> FakeCeleryDbSession: + return FakeCeleryDbSession(podcast) + + return _make + + +@pytest.fixture +def session_maker_for(): + """Build a ``get_celery_session_maker`` replacement bound to one session. + + ``get_celery_session_maker()()`` must yield the session, so the replacement + is a zero-arg callable returning a maker that returns the session. + """ + + def _make(session: object): + return lambda: (lambda: session) + + return _make + + +@pytest.fixture +def make_spec(): + """Factory for a valid :class:`PodcastSpec`; override only what matters.""" + + def _make( + *, + language: str = "en", + style: PodcastStyle = PodcastStyle.CONVERSATIONAL, + speakers: list[SpeakerSpec] | None = None, + min_minutes: int = 10, + max_minutes: int = 20, + focus: str | None = None, + ) -> PodcastSpec: + if speakers is None: + speakers = [ + SpeakerSpec( + slot=0, name="Host", role=SpeakerRole.HOST, voice_id="kokoro:am_adam" + ), + SpeakerSpec( + slot=1, + name="Guest", + role=SpeakerRole.GUEST, + voice_id="kokoro:af_bella", + ), + ] + return PodcastSpec( + language=language, + style=style, + speakers=speakers, + duration=DurationTarget(min_minutes=min_minutes, max_minutes=max_minutes), + focus=focus, + ) + + return _make + + +@pytest.fixture +def make_transcript(): + """Factory for a valid :class:`Transcript`.""" + + def _make(turns: list[tuple[int, str]] | None = None) -> Transcript: + if turns is None: + turns = [(0, "Welcome to the show."), (1, "Glad to be here.")] + return Transcript( + turns=[TranscriptTurn(speaker=slot, text=text) for slot, text in turns] + ) + + return _make