mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-12 20:45:20 +02:00
test(podcasts): cover lifecycle state machine
This commit is contained in:
parent
f61e8af8c0
commit
9d8e4e4f9d
1 changed files with 163 additions and 0 deletions
163
surfsense_backend/tests/unit/podcasts/test_lifecycle.py
Normal file
163
surfsense_backend/tests/unit/podcasts/test_lifecycle.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue