"""HTTP contract tests for recommendation and playlist routes.""" import time from collections.abc import AsyncGenerator import pytest from fastapi.testclient import TestClient from pydantic import TypeAdapter from app.adapters.spotify.auth import TokenSet from app.adapters.spotify.errors import SpotifyAuthenticationError from app.adapters.spotify.session import SessionStore, SpotifySession from app.api.routes import SESSION_COOKIE_NAME from app.api.schemas import StreamEvent from app.config import AppMode, Settings from app.domain.models import ConversationTurn, CreatedPlaylist, PreviousRecommendation, Track from app.main import create_app from app.pipeline.event import PipelineDoneEvent, PipelineMetadataEvent, PipelineTrackEvent from app.ports.protocols import MusicCatalog class FakePipeline: """Emit one complete deterministic stream.""" async def stream( self, session_id: str, request_id: str, catalog: MusicCatalog, query: str, history: tuple[ConversationTurn, ...], previous_recommendations: tuple[PreviousRecommendation, ...], ) -> AsyncGenerator[PipelineMetadataEvent | PipelineTrackEvent | PipelineDoneEvent]: """Yield metadata, one track, and completion.""" yield PipelineMetadataEvent(request_id, "A focused test request.", 35) yield PipelineTrackEvent(1, _track(), "It fits the requested focus.") yield PipelineDoneEvent(1, 4) class FakePlaylistWriter: """Capture playlist writes without external calls.""" def __init__(self, error: Exception | None = None) -> None: """Create an empty write trace.""" self.error = error self.name: str | None = None self.track_uris: list[str] = [] async def create_playlist(self, name: str, description: str) -> CreatedPlaylist: """Record the prefixed name and return a stable playlist.""" if self.error is not None: raise self.error self.name = name return CreatedPlaylist("playlist", "https://open.spotify.com/playlist/playlist") async def add_tracks_to_playlist(self, playlist_id: str, track_uris: list[str]) -> None: """Record the ordered track URIs.""" self.track_uris = track_uris def test_recommendations_stream_lines_validate_against_frozen_schemas( monkeypatch: pytest.MonkeyPatch, ) -> None: async def fail_if_polled(request: object) -> bool: raise AssertionError("Request disconnect state must not be polled") monkeypatch.setattr("starlette.requests.Request.is_disconnected", fail_if_polled) app = create_app() with TestClient(app) as client: _authenticate(client, session_store=app.state.session_store) app.state.recommendation_pipeline = FakePipeline() response = client.post( "/api/recommendations", json={"schema_version": 1, "query": "focused electronic music"}, ) adapter: TypeAdapter[StreamEvent] = TypeAdapter(StreamEvent) events = [adapter.validate_json(line) for line in response.text.splitlines()] assert response.status_code == 200 assert response.headers["content-type"] == "application/x-ndjson" assert [event.type for event in events] == ["metadata", "track", "done"] def test_live_recommendations_require_a_valid_session_without_seed() -> None: application_settings = Settings( app_mode=AppMode.LIVE, spotify_client_id="client-id", anthropic_api_key="test-key", ) with TestClient(create_app(application_settings)) as client: response = client.post( "/api/recommendations", json={"schema_version": 1, "query": "focused electronic music"}, ) assert response.status_code == 401 assert response.json() == {"detail": "Not authenticated"} def test_playlist_endpoint_prefixes_name_and_adds_tracks() -> None: app = create_app() writer = FakePlaylistWriter() with TestClient(app) as client: _authenticate(client, session_store=app.state.session_store) app.state.spotify_client_factory = lambda session: writer response = client.post( "/api/playlists", json={ "schema_version": 1, "name": "Night drive", "track_uris": ["spotify:track:track"], }, ) assert response.status_code == 200 assert response.json() == {"url": "https://open.spotify.com/playlist/playlist"} assert writer.name == "discovery-by-llm Night drive" assert writer.track_uris == ["spotify:track:track"] def test_playlist_authentication_failure_signals_relogin() -> None: app = create_app() writer = FakePlaylistWriter(SpotifyAuthenticationError("expired")) with TestClient(app) as client: _authenticate(client, session_store=app.state.session_store) app.state.spotify_client_factory = lambda session: writer response = client.post( "/api/playlists", json={ "schema_version": 1, "name": "Night drive", "track_uris": ["spotify:track:track"], }, ) assert response.status_code == 401 assert response.json() == {"detail": "Spotify authentication expired"} def _authenticate(client: TestClient, session_store: SessionStore) -> None: session_id = session_store.create( SpotifySession( tokens=TokenSet("access", "refresh", time.monotonic() + 3600), account_id="account", display_name="Listener", ) ) client.cookies.set(SESSION_COOKIE_NAME, session_id) def _track() -> Track: return Track( id="track", uri="spotify:track:track", title="Test Track", artists=("Test Artist",), album_name="Test Album", album_art_url=None, external_url="https://open.spotify.com/track/track", )