"""End-to-end pipeline tests using deterministic service fakes.""" import asyncio from collections.abc import AsyncGenerator from app.adapters.spotify.errors import SpotifyQuotaExhaustedError, SpotifyUnavailableError from app.config import Settings from app.domain.models import ( ConversationTurn, Familiarity, Intent, PreviousRecommendation, RerankSelection, Track, TrackCandidate, ) from app.pipeline.event import PipelineErrorEvent, PipelineEvent, PipelineTrackEvent from app.pipeline.orchestrator import RecommendationPipeline from app.ports.protocols import RecommenderOutputError, TimeRange class FakeCatalog: """Return exact tracks and a configurable known-track sample.""" def __init__(self, tracks: tuple[Track, ...], known_tracks: tuple[Track, ...] = ()) -> None: """Index tracks by title and expose taste-call counters.""" self.tracks_by_title = {track.title: track for track in tracks} self.known_tracks = known_tracks self.search_call_count = 0 self.taste_call_count = 0 async def search_tracks(self, query: str, limit: int = 10) -> list[Track]: """Resolve an exact fielded title and miss bare fallbacks.""" self.search_call_count += 1 if 'track:"' not in query: return [] title = query.split('track:"', 1)[1].split('"', 1)[0] track = self.tracks_by_title.get(title) return [track] if track is not None else [] async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]: """Return one stable taste artist.""" self.taste_call_count += 1 return ["Taste Artist"] async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]: """Return no top tracks.""" self.taste_call_count += 1 return [] async def fetch_saved_tracks(self, limit: int) -> list[Track]: """Return the configured known tracks.""" self.taste_call_count += 1 return list(self.known_tracks) class FakeRecommender: """Return fixed intents and either selections or structured failures.""" def __init__( self, intents: list[Intent], selection_ids: tuple[str, ...] = (), failure_count: int = 0, intent_error: Exception | None = None, intent_delay_seconds: float = 0.0, ) -> None: """Store deterministic outputs for successive calls.""" self.intents = intents self.selection_ids = selection_ids self.failure_count = failure_count self.intent_error = intent_error self.intent_delay_seconds = intent_delay_seconds self.rerank_call_count = 0 async def create_intent( self, query: str, history: tuple[ConversationTurn, ...], previous_recommendations: tuple[PreviousRecommendation, ...], taste_summary: str, candidate_count: int, ) -> Intent: """Return the next fixed intent.""" await asyncio.sleep(self.intent_delay_seconds) if self.intent_error is not None: raise self.intent_error return self.intents.pop(0) async def stream_rerank( self, intent: Intent, grounded_tracks: tuple[Track, ...], taste_summary: str, history: tuple[ConversationTurn, ...], selection_count: int, correction: str | None = None, ) -> AsyncGenerator[RerankSelection]: """Stream configured ids or fail before yielding.""" self.rerank_call_count += 1 if self.failure_count: self.failure_count -= 1 raise RecommenderOutputError("invalid test output") selected_ids = self.selection_ids or tuple(track.id for track in grounded_tracks) for track_id in selected_ids[:selection_count]: yield RerankSelection(track_id, f"Reason for {track_id}") def test_event_order_and_rerank_ids_stay_inside_grounded_pool() -> None: async def run() -> None: first = _track("first", "First Song") second = _track("second", "Second Song") catalog = FakeCatalog((first, second)) recommender = FakeRecommender([_intent(first, second)], ("second", "first")) events = await _run_pipeline(catalog, recommender) assert [event.type for event in events] == ["metadata", "track", "track", "done"] track_events = [event for event in events if isinstance(event, PipelineTrackEvent)] assert [event.track.id for event in track_events] == ["second", "first"] assert {event.track.id for event in track_events} <= {"first", "second"} asyncio.run(run()) def test_intent_stage_failures_yield_one_typed_error_event() -> None: async def run() -> None: cases = ( (RecommenderOutputError("invalid intent"), "intent_failed"), ( SpotifyQuotaExhaustedError(0.0, "QUOTA_EXCEEDED"), "quota_exhausted", ), (SpotifyUnavailableError(503), "spotify_unavailable"), ) for error, expected_code in cases: catalog = FakeCatalog(()) recommender = FakeRecommender([], intent_error=error) events = await _run_pipeline(catalog, recommender) assert len(events) == 1 assert events[0].type == "error" assert events[0].code == expected_code asyncio.run(run()) def test_grounding_quota_yields_typed_error_instead_of_empty_results() -> None: class QuotaCatalog(FakeCatalog): """Exhaust the Spotify quota on the first grounding search.""" async def search_tracks(self, query: str, limit: int = 10) -> list[Track]: self.search_call_count += 1 raise SpotifyQuotaExhaustedError(0.0, "QUOTA_EXCEEDED") async def run() -> None: track = _track("candidate", "Candidate Song") catalog = QuotaCatalog(()) recommender = FakeRecommender([_intent(track)]) events = await _run_pipeline(catalog, recommender) assert [event.type for event in events] == ["metadata", "error"] assert isinstance(events[-1], PipelineErrorEvent) assert events[-1].code == "quota_exhausted" assert catalog.search_call_count == 1 assert recommender.rerank_call_count == 0 asyncio.run(run()) def test_taste_failure_cancels_sibling_fetches() -> None: class FailingTasteCatalog(FakeCatalog): """Fail one taste request after all sibling requests have started.""" def __init__(self) -> None: super().__init__(()) self.started_count = 0 self.cancelled_count = 0 self.all_started = asyncio.Event() self.never_finishes = asyncio.Event() async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]: await self._wait_for_all_fetches() await self._wait_until_cancelled() return [] async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]: await self._wait_for_all_fetches() await self._wait_until_cancelled() return [] async def fetch_saved_tracks(self, limit: int) -> list[Track]: await self._wait_for_all_fetches() raise SpotifyUnavailableError(503) async def _wait_for_all_fetches(self) -> None: self.started_count += 1 if self.started_count == 5: self.all_started.set() await self.all_started.wait() async def _wait_until_cancelled(self) -> None: try: await self.never_finishes.wait() except asyncio.CancelledError: self.cancelled_count += 1 raise async def run() -> None: catalog = FailingTasteCatalog() events = await _run_pipeline(catalog, FakeRecommender([])) assert len(events) == 1 assert events[0].type == "error" assert events[0].code == "spotify_unavailable" assert catalog.cancelled_count == 4 asyncio.run(run()) def test_grounding_deadline_starts_before_intent_generation() -> None: async def run() -> None: track = _track("found", "Found Song") catalog = FakeCatalog((track,)) recommender = FakeRecommender( [_intent(track)], intent_delay_seconds=0.02, ) pipeline = RecommendationPipeline( recommender, Settings( rerank_count=1, rerank_pool_buffer=0, grounding_floor=1, grounding_concurrency=1, request_deadline_seconds=0.01, ), ) events = await _collect(pipeline, catalog, "query") assert [event.type for event in events] == ["metadata", "error"] assert isinstance(events[-1], PipelineErrorEvent) assert events[-1].code == "no_grounded_results" assert catalog.search_call_count == 0 asyncio.run(run()) def test_rerank_fallback_warns_then_streams_grounded_order() -> None: async def run() -> None: first = _track("first", "First Song") second = _track("second", "Second Song") catalog = FakeCatalog((first, second)) recommender = FakeRecommender([_intent(first, second)], failure_count=2) events = await _run_pipeline(catalog, recommender) assert [event.type for event in events] == [ "metadata", "warning", "track", "track", "done", ] assert recommender.rerank_call_count == 2 track_events = [event for event in events if isinstance(event, PipelineTrackEvent)] assert [event.track.id for event in track_events] == ["first", "second"] asyncio.run(run()) def test_new_familiarity_excludes_known_track_ids() -> None: async def run() -> None: known = _track("known", "Known Song") new = _track("new", "New Song") catalog = FakeCatalog((known, new), known_tracks=(known,)) intent = _intent(known, new, familiarity=Familiarity.NEW) recommender = FakeRecommender([intent]) events = await _run_pipeline(catalog, recommender, rerank_count=1) track_events = [event for event in events if isinstance(event, PipelineTrackEvent)] assert [event.track.id for event in track_events] == ["new"] asyncio.run(run()) def test_refinement_reuses_last_grounded_pool_and_cached_taste() -> None: async def run() -> None: track = _track("first", "First Song") initial = _intent(track) refinement = _intent(track, is_refinement=True) catalog = FakeCatalog((track,)) recommender = FakeRecommender([initial, refinement]) pipeline = _pipeline(recommender, rerank_count=1) await _collect(pipeline, catalog, "first request") initial_search_calls = catalog.search_call_count initial_taste_calls = catalog.taste_call_count await _collect(pipeline, catalog, "refine it") assert catalog.search_call_count == initial_search_calls assert catalog.taste_call_count == initial_taste_calls asyncio.run(run()) async def _run_pipeline( catalog: FakeCatalog, recommender: FakeRecommender, rerank_count: int = 2, ) -> list[PipelineEvent]: return await _collect(_pipeline(recommender, rerank_count=rerank_count), catalog, "query") def _pipeline(recommender: FakeRecommender, rerank_count: int) -> RecommendationPipeline: return RecommendationPipeline( recommender, Settings( rerank_count=rerank_count, rerank_pool_buffer=0, grounding_floor=1, grounding_concurrency=2, request_deadline_seconds=1.0, ), ) async def _collect( pipeline: RecommendationPipeline, catalog: FakeCatalog, query: str, ) -> list[PipelineEvent]: return [ event async for event in pipeline.stream( "session", "request", catalog, query, (), (), ) ] def _intent( *tracks: Track, familiarity: Familiarity = Familiarity.MIX, is_refinement: bool = False, ) -> Intent: return Intent( mood=("focused",), activity=None, era=(), languages=(), genres=("electronic",), familiarity=familiarity, is_refinement=is_refinement, intent_summary="Focused electronic discovery.", candidates=tuple( TrackCandidate(title=track.title, artist=track.artists[0]) for track in tracks ), ) def _track(track_id: str, title: str) -> Track: return Track( id=track_id, uri=f"spotify:track:{track_id}", title=title, artists=("Artist",), album_name="Album", album_art_url=None, external_url=None, ) def test_below_floor_pool_streams_partial_results_after_warning() -> None: async def run() -> None: found = _track("found", "Found Song") missing = _track("missing", "Missing Song") catalog = FakeCatalog((found,)) recommender = FakeRecommender([_intent(found, missing)]) pipeline = RecommendationPipeline( recommender, Settings( rerank_count=2, rerank_pool_buffer=0, grounding_floor=2, grounding_concurrency=2, request_deadline_seconds=1.0, ), ) events = await _collect(pipeline, catalog, "query") assert [event.type for event in events] == ["metadata", "warning", "track", "done"] warning = events[1] assert warning.type == "warning" assert warning.code == "partial_results" asyncio.run(run()) def test_empty_pool_is_a_terminal_error() -> None: async def run() -> None: missing = _track("missing", "Missing Song") catalog = FakeCatalog(()) recommender = FakeRecommender([_intent(missing)]) events = await _run_pipeline(catalog, recommender) assert [event.type for event in events] == ["metadata", "error"] error = events[-1] assert error.type == "error" assert error.code == "no_grounded_results" asyncio.run(run())