diff --git a/surfsense_backend/tests/unit/podcasts/test_lifecycle.py b/surfsense_backend/tests/unit/podcasts/test_lifecycle.py new file mode 100644 index 000000000..5f61c7562 --- /dev/null +++ b/surfsense_backend/tests/unit/podcasts/test_lifecycle.py @@ -0,0 +1,163 @@ +"""The podcast lifecycle: the guarantees the rest of the system relies on. + +These tests drive the aggregate through :class:`PodcastService`'s public +methods and observe the resulting status and stored brief/transcript — the +domain's contract. They say nothing about how the service stores or flushes, +so they survive any refactor that preserves the lifecycle. +""" + +from __future__ import annotations + +import pytest + +from app.podcasts.persistence import PodcastStatus +from app.podcasts.service import ( + InvalidTransition, + PodcastService, + PreconditionFailed, + SpecConflict, + read_spec, + read_transcript, +) + +pytestmark = pytest.mark.unit + + +async def test_a_podcast_progresses_from_creation_to_ready( + fake_session, make_spec, make_transcript +): + """The full happy path: create → brief → draft → review → render → ready.""" + service = PodcastService(fake_session) + + podcast = await service.create(title="Episode 1", search_space_id=7) + assert podcast.status == PodcastStatus.PENDING + + spec = make_spec() + await service.attach_brief(podcast, spec) + assert podcast.status == PodcastStatus.AWAITING_BRIEF + assert read_spec(podcast) == spec + + await service.begin_drafting(podcast) + assert podcast.status == PodcastStatus.DRAFTING + + transcript = make_transcript() + await service.attach_transcript(podcast, transcript) + assert podcast.status == PodcastStatus.AWAITING_REVIEW + assert read_transcript(podcast) == transcript + + await service.approve(podcast) + assert podcast.status == PodcastStatus.RENDERING + + await service.attach_audio( + podcast, storage_backend="local", storage_key="k", duration_seconds=42 + ) + assert podcast.status == PodcastStatus.READY + assert podcast.duration_seconds == 42 + + +async def test_drafting_requires_an_approved_brief(fake_session): + """A brief must exist before drafting can begin.""" + service = PodcastService(fake_session) + podcast = await service.create(title="No brief", search_space_id=1) + + with pytest.raises(PreconditionFailed): + await service.begin_drafting(podcast) + + +async def test_rendering_requires_a_transcript(fake_session, make_spec): + """Approval to render is refused when no transcript has been drafted.""" + service = PodcastService(fake_session) + podcast = await service.create(title="No transcript", search_space_id=1) + await service.attach_brief(podcast, make_spec()) + await service.begin_drafting(podcast) + + with pytest.raises(PreconditionFailed): + await service.approve(podcast) + + +async def test_regenerate_returns_a_reviewed_transcript_to_drafting( + fake_session, make_spec, make_transcript +): + """At the go/no-go gate, rejecting sends the podcast back to drafting.""" + service = PodcastService(fake_session) + podcast = await service.create(title="Redo", search_space_id=1) + await service.attach_brief(podcast, make_spec()) + await service.begin_drafting(podcast) + await service.attach_transcript(podcast, make_transcript()) + + await service.regenerate(podcast) + + assert podcast.status == PodcastStatus.DRAFTING + + +async def test_brief_can_be_edited_at_the_gate_and_bumps_its_version( + fake_session, make_spec +): + """Editing the brief while awaiting review records it and advances version.""" + service = PodcastService(fake_session) + podcast = await service.create(title="Editable", search_space_id=1) + await service.attach_brief(podcast, make_spec(language="en")) + starting_version = podcast.spec_version + + await service.update_spec(podcast, make_spec(language="fr"), starting_version) + + assert read_spec(podcast).language == "fr" + assert podcast.spec_version == starting_version + 1 + + +async def test_editing_a_brief_with_a_stale_version_conflicts( + fake_session, make_spec +): + """A concurrent edit racing on a stale version is rejected, not silently lost.""" + service = PodcastService(fake_session) + podcast = await service.create(title="Raced", search_space_id=1) + await service.attach_brief(podcast, make_spec()) + current = podcast.spec_version + + with pytest.raises(SpecConflict): + await service.update_spec(podcast, make_spec(language="es"), current - 1) + + +async def test_brief_cannot_be_edited_after_the_gate_closes( + fake_session, make_spec +): + """Once drafting starts, the brief is settled and edits are refused.""" + service = PodcastService(fake_session) + podcast = await service.create(title="Locked", search_space_id=1) + await service.attach_brief(podcast, make_spec()) + await service.begin_drafting(podcast) + + with pytest.raises(InvalidTransition): + await service.update_spec(podcast, make_spec(language="es"), podcast.spec_version) + + +async def test_a_podcast_can_be_cancelled_while_in_flight(fake_session, make_spec): + """Cancellation is available from a non-terminal state.""" + service = PodcastService(fake_session) + podcast = await service.create(title="Abort", search_space_id=1) + await service.attach_brief(podcast, make_spec()) + + await service.cancel(podcast) + + assert podcast.status == PodcastStatus.CANCELLED + + +async def test_failure_records_a_reason(fake_session): + """Failing a podcast captures a human-readable reason.""" + service = PodcastService(fake_session) + podcast = await service.create(title="Boom", search_space_id=1) + + await service.fail(podcast, "tts provider unavailable") + + assert podcast.status == PodcastStatus.FAILED + assert podcast.error == "tts provider unavailable" + + +async def test_terminal_podcasts_reject_further_transitions(fake_session): + """A finished podcast cannot be cancelled or otherwise moved.""" + service = PodcastService(fake_session) + podcast = await service.create(title="Done", search_space_id=1) + await service.cancel(podcast) + + with pytest.raises(InvalidTransition): + await service.fail(podcast, "too late")