test(podcasts): cover lifecycle state machine

This commit is contained in:
CREDO23 2026-06-10 18:44:25 +02:00
parent f61e8af8c0
commit 9d8e4e4f9d

View file

@ -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")