# -*- coding: utf-8 -*- """ Tests fuer die Spotify-Quelle (SpotifySource). Testet die Spotify Web API Integration via spotipy sowie den Audio-Download via spotdl. """ from __future__ import annotations import asyncio import os import subprocess from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock import pytest from trixy_core.music.sources.base import SourceState, SourceType from trixy_core.music.track import Track, TrackMetadata, TrackState # ============================================================================= # Hilfsfunktionen und Fixtures # ============================================================================= def _spotify_api_response(items: list[dict] | None = None) -> dict: """Erzeugt eine Spotify-API-Antwort fuer search().""" if items is None: items = [_spotify_track_item()] return {"tracks": {"items": items}} def _spotify_track_item( track_id: str = "abc123", name: str = "Testlied", artist_name: str = "Testkuenstler", album_name: str = "Testalbum", duration_ms: int = 210000, popularity: int = 75, explicit: bool = False, release_date: str = "2024-03-15", track_number: int = 3, disc_number: int = 1, artwork_url: str = "https://img.spotify.com/cover.jpg", external_url: str = "https://open.spotify.com/track/abc123", uri: str = "spotify:track:abc123", preview_url: str = "https://p.scdn.co/preview.mp3", extra_artists: list[dict] | None = None, ) -> dict: """Erzeugt ein einzelnes Spotify-Track-Item.""" artists = extra_artists or [{"name": artist_name}] return { "id": track_id, "name": name, "duration_ms": duration_ms, "popularity": popularity, "explicit": explicit, "track_number": track_number, "disc_number": disc_number, "uri": uri, "external_urls": {"spotify": external_url}, "preview_url": preview_url, "artists": artists, "album": { "name": album_name, "release_date": release_date, "images": [{"url": artwork_url}], }, } @pytest.fixture def spotify_source(tmp_path: Path): """Erstellt eine SpotifySource-Instanz fuer Tests.""" with patch("plugins.music.sources.spotify._SPOTIPY_AVAILABLE", True): from plugins.music.sources.spotify import SpotifySource return SpotifySource( client_id="test_id", client_secret="test_secret", cache_dir=str(tmp_path / "spotify_cache"), max_cache_size_mb=50, max_results=10, market="DE", ) @pytest.fixture def connected_source(tmp_path: Path): """Erstellt eine verbundene SpotifySource mit gemocktem API-Client.""" with patch("plugins.music.sources.spotify._SPOTIPY_AVAILABLE", True): from plugins.music.sources.spotify import SpotifySource source = SpotifySource( client_id="test_id", client_secret="test_secret", cache_dir=str(tmp_path / "spotify_cache"), max_cache_size_mb=50, ) # Zustand manuell setzen fuer Unit-Tests ohne echte Verbindung mock_sp = MagicMock() source._sp = mock_sp source._state = SourceState.AVAILABLE source._spotdl_available = True source._cache_dir.mkdir(parents=True, exist_ok=True) return source, mock_sp # ============================================================================= # Konstruktor-Tests # ============================================================================= class TestSpotifySourceKonstruktor: """Tests fuer den SpotifySource-Konstruktor.""" def test_standardwerte(self, spotify_source): """Konstruktor setzt korrekte Standardwerte.""" assert spotify_source.source_type == SourceType.SPOTIFY assert spotify_source.name == "Spotify" assert spotify_source._market == "DE" assert spotify_source._max_results == 10 assert spotify_source._max_cache_size_mb == 50 def test_quelltyp_ist_spotify(self, spotify_source): """source_type ist SourceType.SPOTIFY.""" assert spotify_source.source_type == SourceType.SPOTIFY def test_cache_verzeichnis_als_path(self, tmp_path): """cache_dir wird als Path gespeichert.""" with patch("plugins.music.sources.spotify._SPOTIPY_AVAILABLE", True): from plugins.music.sources.spotify import SpotifySource source = SpotifySource(cache_dir=str(tmp_path / "sp_cache")) assert isinstance(source._cache_dir, Path) def test_anfangszustand_unbekannt(self, spotify_source): """Anfangszustand ist UNKNOWN (von MusicSource geerbt).""" assert spotify_source.state == SourceState.UNKNOWN def test_kein_client_initial(self, spotify_source): """Kein Spotify-Client vor connect().""" assert spotify_source._sp is None assert spotify_source._spotdl_available is False # ============================================================================= # connect()-Tests # ============================================================================= class TestSpotifySourceConnect: """Tests fuer die connect()-Methode.""" @pytest.mark.asyncio async def test_spotipy_nicht_verfuegbar(self, tmp_path): """connect() gibt False zurueck wenn spotipy nicht installiert.""" with patch("plugins.music.sources.spotify._SPOTIPY_AVAILABLE", False): from plugins.music.sources.spotify import SpotifySource source = SpotifySource( client_id="test", client_secret="test", cache_dir=str(tmp_path / "cache"), ) result = await source.connect() assert result is False assert source.state == SourceState.UNAVAILABLE @pytest.mark.asyncio async def test_client_id_fehlt(self, tmp_path): """connect() gibt False zurueck bei fehlender client_id.""" with patch("plugins.music.sources.spotify._SPOTIPY_AVAILABLE", True): from plugins.music.sources.spotify import SpotifySource source = SpotifySource( client_id="", client_secret="test_secret", cache_dir=str(tmp_path / "cache"), ) result = await source.connect() assert result is False assert source.state == SourceState.UNAVAILABLE @pytest.mark.asyncio async def test_client_secret_fehlt(self, tmp_path): """connect() gibt False zurueck bei fehlendem client_secret.""" with patch("plugins.music.sources.spotify._SPOTIPY_AVAILABLE", True): from plugins.music.sources.spotify import SpotifySource source = SpotifySource( client_id="test_id", client_secret="", cache_dir=str(tmp_path / "cache"), ) result = await source.connect() assert result is False assert source.state == SourceState.UNAVAILABLE @pytest.mark.asyncio async def test_api_test_fehlgeschlagen(self, tmp_path): """connect() gibt False zurueck wenn API-Test fehlschlaegt.""" import plugins.music.sources.spotify as spotify_mod mock_sp_instance = MagicMock() mock_sp_instance.search.side_effect = Exception("Ungueltige Credentials") mock_spotipy = MagicMock() mock_spotipy.Spotify.return_value = mock_sp_instance # spotipy und SpotifyClientCredentials muessen als Modul-Attribute existieren with patch.object(spotify_mod, "_SPOTIPY_AVAILABLE", True), \ patch.object(spotify_mod, "spotipy", mock_spotipy, create=True), \ patch.object(spotify_mod, "SpotifyClientCredentials", MagicMock(), create=True): source = spotify_mod.SpotifySource( client_id="bad_id", client_secret="bad_secret", cache_dir=str(tmp_path / "cache"), ) result = await source.connect() assert result is False assert source.state == SourceState.ERROR assert source._sp is None @pytest.mark.asyncio async def test_erfolgreiche_verbindung_mit_spotdl(self, tmp_path): """connect() erfolgreich mit spotdl verfuegbar.""" import plugins.music.sources.spotify as spotify_mod mock_sp_instance = MagicMock() mock_sp_instance.search.return_value = {"tracks": {"items": []}} mock_spotipy = MagicMock() mock_spotipy.Spotify.return_value = mock_sp_instance with patch.object(spotify_mod, "_SPOTIPY_AVAILABLE", True), \ patch.object(spotify_mod, "spotipy", mock_spotipy, create=True), \ patch.object(spotify_mod, "SpotifyClientCredentials", MagicMock(), create=True), \ patch("plugins.music.sources.spotify.shutil.which", return_value="/usr/bin/spotdl"): source = spotify_mod.SpotifySource( client_id="valid_id", client_secret="valid_secret", cache_dir=str(tmp_path / "cache"), ) result = await source.connect() assert result is True assert source.state == SourceState.AVAILABLE assert source._spotdl_available is True @pytest.mark.asyncio async def test_erfolgreiche_verbindung_ohne_spotdl(self, tmp_path): """connect() erfolgreich — Suche funktioniert, Download nicht.""" import plugins.music.sources.spotify as spotify_mod mock_sp_instance = MagicMock() mock_sp_instance.search.return_value = {"tracks": {"items": []}} mock_spotipy = MagicMock() mock_spotipy.Spotify.return_value = mock_sp_instance with patch.object(spotify_mod, "_SPOTIPY_AVAILABLE", True), \ patch.object(spotify_mod, "spotipy", mock_spotipy, create=True), \ patch.object(spotify_mod, "SpotifyClientCredentials", MagicMock(), create=True), \ patch("plugins.music.sources.spotify.shutil.which", return_value=None): source = spotify_mod.SpotifySource( client_id="valid_id", client_secret="valid_secret", cache_dir=str(tmp_path / "cache"), ) result = await source.connect() assert result is True assert source.state == SourceState.AVAILABLE assert source._spotdl_available is False # ============================================================================= # disconnect()-Tests # ============================================================================= class TestSpotifySourceDisconnect: """Tests fuer die disconnect()-Methode.""" @pytest.mark.asyncio async def test_disconnect_loescht_zustand(self, connected_source): """disconnect() setzt Client auf None und State auf UNAVAILABLE.""" source, _ = connected_source assert source._sp is not None await source.disconnect() assert source._sp is None assert source.state == SourceState.UNAVAILABLE # ============================================================================= # Properties-Tests # ============================================================================= class TestSpotifySourceProperties: """Tests fuer has_api und has_download Properties.""" def test_has_api_mit_client(self, connected_source): """has_api gibt True zurueck wenn Client vorhanden.""" source, _ = connected_source assert source.has_api is True def test_has_api_ohne_client(self, spotify_source): """has_api gibt False zurueck ohne Client.""" assert spotify_source.has_api is False def test_has_download_mit_spotdl(self, connected_source): """has_download gibt True zurueck wenn spotdl verfuegbar.""" source, _ = connected_source assert source.has_download is True def test_has_download_ohne_spotdl(self, connected_source): """has_download gibt False zurueck ohne spotdl.""" source, _ = connected_source source._spotdl_available = False assert source.has_download is False # ============================================================================= # search()-Tests # ============================================================================= class TestSpotifySourceSearch: """Tests fuer die search()-Methode.""" @pytest.mark.asyncio async def test_suche_ohne_verbindung(self, spotify_source): """search() gibt leere Liste zurueck ohne Verbindung.""" result = await spotify_source.search("Test") assert result == [] @pytest.mark.asyncio async def test_suche_parst_api_antwort_korrekt(self, connected_source): """search() parst Spotify-API-Antwort mit allen Metadaten.""" source, mock_sp = connected_source mock_sp.search.return_value = _spotify_api_response([ _spotify_track_item( track_id="xyz789", name="Nachtlied", artist_name="Mondband", album_name="Sternenlicht", duration_ms=195000, artwork_url="https://img.spotify.com/mond.jpg", release_date="2023-07-20", track_number=5, popularity=88, explicit=True, ), ]) results = await source.search("Nachtlied") assert len(results) == 1 track = results[0] assert track.metadata.title == "Nachtlied" assert track.metadata.artist == "Mondband" assert track.metadata.album == "Sternenlicht" assert track.metadata.artwork_url == "https://img.spotify.com/mond.jpg" assert track.metadata.duration_ms == 195000 assert track.metadata.year == 2023 assert track.metadata.track_number == 5 assert track.source_type == "spotify" assert track.source_id == "xyz789" assert track.extra["popularity"] == 88 assert track.extra["explicit"] is True @pytest.mark.asyncio async def test_suche_mehrere_kuenstler(self, connected_source): """search() verbindet mehrere Kuenstler mit Komma.""" source, mock_sp = connected_source mock_sp.search.return_value = _spotify_api_response([ _spotify_track_item( extra_artists=[ {"name": "Kuenstler A"}, {"name": "Kuenstler B"}, {"name": "Kuenstler C"}, ], ), ]) results = await source.search("Multi") assert len(results) == 1 assert results[0].metadata.artist == "Kuenstler A, Kuenstler B, Kuenstler C" @pytest.mark.asyncio async def test_suche_leere_ergebnisse(self, connected_source): """search() gibt leere Liste bei keine Treffer zurueck.""" source, mock_sp = connected_source mock_sp.search.return_value = {"tracks": {"items": []}} results = await source.search("GibtEsNicht") assert results == [] @pytest.mark.asyncio async def test_suche_respektiert_limit(self, connected_source): """search() begrenzt Ergebnisse auf limit.""" source, mock_sp = connected_source items = [_spotify_track_item(track_id=f"id_{i}", name=f"Track {i}") for i in range(10)] mock_sp.search.return_value = _spotify_api_response(items) results = await source.search("Track", limit=3) # Pruefe dass der API-Aufruf mit dem Limit erfolgt mock_sp.search.assert_called_once() call_kwargs = mock_sp.search.call_args assert call_kwargs[1].get("limit", call_kwargs[0][1] if len(call_kwargs[0]) > 1 else None) is not None @pytest.mark.asyncio async def test_suche_fehlerbehandlung(self, connected_source): """search() gibt leere Liste bei Fehler zurueck.""" source, mock_sp = connected_source mock_sp.search.side_effect = Exception("API-Fehler") results = await source.search("Test") assert results == [] @pytest.mark.asyncio async def test_suche_ohne_tracks_key(self, connected_source): """search() behandelt fehlenden 'tracks' Key korrekt.""" source, mock_sp = connected_source mock_sp.search.return_value = {} results = await source.search("Test") assert results == [] @pytest.mark.asyncio async def test_suche_none_item_wird_uebersprungen(self, connected_source): """search() ueberspringt None-Items in der Ergebnisliste.""" source, mock_sp = connected_source mock_sp.search.return_value = _spotify_api_response([ None, _spotify_track_item(name="Gueltiger Track"), None, ]) results = await source.search("Test") assert len(results) == 1 assert results[0].metadata.title == "Gueltiger Track" @pytest.mark.asyncio async def test_suche_album_ohne_bilder(self, connected_source): """search() setzt leere artwork_url bei fehlendem Album-Bild.""" source, mock_sp = connected_source item = _spotify_track_item() item["album"]["images"] = [] mock_sp.search.return_value = _spotify_api_response([item]) results = await source.search("Test") assert results[0].metadata.artwork_url == "" @pytest.mark.asyncio async def test_suche_release_date_parsing(self, connected_source): """search() parst Release-Datum korrekt (nur Jahr).""" source, mock_sp = connected_source item = _spotify_track_item(release_date="2020") mock_sp.search.return_value = _spotify_api_response([item]) results = await source.search("Test") assert results[0].metadata.year == 2020 @pytest.mark.asyncio async def test_suche_extra_felder(self, connected_source): """search() speichert spotify_uri, spotify_url und preview_url in extra.""" source, mock_sp = connected_source mock_sp.search.return_value = _spotify_api_response([ _spotify_track_item( uri="spotify:track:abc123", external_url="https://open.spotify.com/track/abc123", preview_url="https://p.scdn.co/preview.mp3", ), ]) results = await source.search("Test") track = results[0] assert track.extra["spotify_uri"] == "spotify:track:abc123" assert track.extra["spotify_url"] == "https://open.spotify.com/track/abc123" assert track.extra["preview_url"] == "https://p.scdn.co/preview.mp3" @pytest.mark.asyncio async def test_suche_effektives_limit(self, connected_source): """search() nutzt min(limit, max_results) als effektives Limit.""" source, mock_sp = connected_source source._max_results = 3 mock_sp.search.return_value = _spotify_api_response([]) await source.search("Test", limit=10) call_args = mock_sp.search.call_args # Pruefe dass limit=3 uebergeben wurde (min(10, 3) = 3) assert call_args[1].get("limit") == 3 or (len(call_args[0]) > 1 and call_args[0][1] == 3) # ============================================================================= # prepare_track()-Tests # ============================================================================= class TestSpotifySourcePrepareTrack: """Tests fuer die prepare_track()-Methode.""" @pytest.mark.asyncio async def test_cache_treffer(self, connected_source): """prepare_track() gibt True zurueck bei Cache-Treffer.""" source, _ = connected_source # Cache-Datei erstellen cache_file = source._cache_dir / "track_001.mp3" cache_file.write_bytes(b"\xff\xfb\x90\x00" * 100) track = Track( source_type="spotify", source_id="track_001", extra={"spotify_url": "https://open.spotify.com/track/track_001"}, ) result = await source.prepare_track(track) assert result is True assert track.path == cache_file assert track.state == TrackState.AVAILABLE @pytest.mark.asyncio async def test_spotdl_nicht_verfuegbar(self, connected_source): """prepare_track() gibt False zurueck wenn spotdl fehlt.""" source, _ = connected_source source._spotdl_available = False track = Track( source_type="spotify", source_id="track_001", extra={"spotify_url": "https://open.spotify.com/track/track_001"}, ) result = await source.prepare_track(track) assert result is False @pytest.mark.asyncio async def test_erfolgreicher_download(self, connected_source): """prepare_track() laedt Track via spotdl herunter.""" source, _ = connected_source output_path = source._cache_dir / "dl_track.mp3" def fake_download(url, track_id): output_path.parent.mkdir(parents=True, exist_ok=True) target = source._cache_dir / f"{track_id}.mp3" target.write_bytes(b"\xff\xfb\x90\x00" * 100) return target with patch.object(source, "_download_sync", side_effect=fake_download): track = Track( source_type="spotify", source_id="dl_track", extra={"spotify_url": "https://open.spotify.com/track/dl_track"}, ) result = await source.prepare_track(track) assert result is True assert track.state == TrackState.AVAILABLE assert track.path is not None assert track.path.exists() @pytest.mark.asyncio async def test_download_fehlgeschlagen(self, connected_source): """prepare_track() setzt ERROR wenn Download fehlschlaegt.""" source, _ = connected_source with patch.object(source, "_download_sync", return_value=None): track = Track( source_type="spotify", source_id="fail_track", extra={"spotify_url": "https://open.spotify.com/track/fail_track"}, ) result = await source.prepare_track(track) assert result is False assert track.state == TrackState.ERROR @pytest.mark.asyncio async def test_download_exception(self, connected_source): """prepare_track() behandelt Exceptions korrekt.""" source, _ = connected_source with patch.object(source, "_download_sync", side_effect=Exception("Netzwerkfehler")): track = Track( source_type="spotify", source_id="exc_track", extra={"spotify_url": "https://open.spotify.com/track/exc_track"}, ) result = await source.prepare_track(track) assert result is False assert track.state == TrackState.ERROR assert "Netzwerkfehler" in track.error_message @pytest.mark.asyncio async def test_spotify_url_aus_extra(self, connected_source): """prepare_track() nutzt spotify_url aus extra-Feld.""" source, _ = connected_source def fake_download(url, track_id): assert url == "https://open.spotify.com/track/url_track" target = source._cache_dir / f"{track_id}.mp3" target.write_bytes(b"\xff\xfb" * 50) return target with patch.object(source, "_download_sync", side_effect=fake_download): track = Track( source_type="spotify", source_id="url_track", extra={"spotify_url": "https://open.spotify.com/track/url_track"}, ) result = await source.prepare_track(track) assert result is True @pytest.mark.asyncio async def test_spotify_uri_konvertierung_zu_url(self, connected_source): """prepare_track() konvertiert spotify:track:ID zu URL.""" source, _ = connected_source captured_url = {} def fake_download(url, track_id): captured_url["url"] = url target = source._cache_dir / f"{track_id}.mp3" target.write_bytes(b"\xff\xfb" * 50) return target with patch.object(source, "_download_sync", side_effect=fake_download): track = Track( source_type="spotify", source_id="uri_track", extra={ "spotify_url": "", "spotify_uri": "spotify:track:URI_ABC123", }, ) result = await source.prepare_track(track) assert result is True assert captured_url["url"] == "https://open.spotify.com/track/URI_ABC123" @pytest.mark.asyncio async def test_keine_url_und_kein_uri(self, connected_source): """prepare_track() gibt False zurueck ohne URL/URI.""" source, _ = connected_source track = Track( source_type="spotify", source_id="no_url_track", extra={}, ) result = await source.prepare_track(track) assert result is False # ============================================================================= # _get_cached_path()-Tests # ============================================================================= class TestSpotifySourceCachePath: """Tests fuer _get_cached_path().""" def test_cache_datei_gefunden(self, connected_source): """_get_cached_path() gibt Pfad zurueck wenn Datei existiert.""" source, _ = connected_source cache_file = source._cache_dir / "found_track.mp3" cache_file.write_bytes(b"\xff\xfb" * 50) result = source._get_cached_path("found_track") assert result == cache_file def test_cache_datei_nicht_gefunden(self, connected_source): """_get_cached_path() gibt None zurueck wenn Datei fehlt.""" source, _ = connected_source result = source._get_cached_path("missing_track") assert result is None def test_cache_verzeichnis_existiert_nicht(self, tmp_path): """_get_cached_path() gibt None zurueck wenn Verzeichnis fehlt.""" with patch("plugins.music.sources.spotify._SPOTIPY_AVAILABLE", True): from plugins.music.sources.spotify import SpotifySource source = SpotifySource(cache_dir=str(tmp_path / "nicht_vorhanden")) result = source._get_cached_path("test") assert result is None # ============================================================================= # cleanup_cache()-Tests # ============================================================================= class TestSpotifySourceCleanupCache: """Tests fuer die cleanup_cache()-Methode.""" @pytest.mark.asyncio async def test_unter_limit_keine_entfernung(self, connected_source): """cleanup_cache() entfernt nichts wenn Cache unter Limit.""" source, _ = connected_source source._max_cache_size_mb = 100 # 100 MB Limit # Kleine Datei erstellen (weit unter 100 MB) (source._cache_dir / "small.mp3").write_bytes(b"\x00" * 1024) removed = await source.cleanup_cache() assert removed == 0 assert (source._cache_dir / "small.mp3").exists() @pytest.mark.asyncio async def test_ueber_limit_entfernt_aelteste(self, connected_source): """cleanup_cache() entfernt aelteste Dateien bei Ueberschreitung.""" source, _ = connected_source # Minimales Limit: 1 Byte (wird immer ueberschritten) source._max_cache_size_mb = 0 import time f1 = source._cache_dir / "alt.mp3" f1.write_bytes(b"\x00" * 512) # Aeltere Modifikationszeit erzwingen os.utime(f1, (1000000, 1000000)) f2 = source._cache_dir / "neu.mp3" f2.write_bytes(b"\x00" * 512) removed = await source.cleanup_cache() assert removed >= 1 # Die aelteste Datei sollte entfernt werden assert not f1.exists() @pytest.mark.asyncio async def test_cache_verzeichnis_fehlt(self, tmp_path): """cleanup_cache() gibt 0 zurueck bei fehlendem Verzeichnis.""" with patch("plugins.music.sources.spotify._SPOTIPY_AVAILABLE", True): from plugins.music.sources.spotify import SpotifySource source = SpotifySource(cache_dir=str(tmp_path / "nope")) removed = await source.cleanup_cache() assert removed == 0 # ============================================================================= # scan()-Tests # ============================================================================= class TestSpotifySourceScan: """Tests fuer die scan()-Methode.""" @pytest.mark.asyncio async def test_scan_gibt_null_zurueck(self, spotify_source): """scan() gibt immer 0 zurueck (kein Scanning bei Spotify).""" result = await spotify_source.scan() assert result == 0 # ============================================================================= # get_track()-Tests # ============================================================================= class TestSpotifySourceGetTrack: """Tests fuer die get_track()-Methode.""" @pytest.mark.asyncio async def test_track_aus_internem_cache(self, connected_source): """get_track() findet Track im internen _tracks-Dict.""" source, _ = connected_source track = Track( id="test_id_123", source_type="spotify", source_id="sp_123", ) source._tracks["test_id_123"] = track result = await source.get_track("test_id_123") assert result is track @pytest.mark.asyncio async def test_track_nicht_gefunden(self, connected_source): """get_track() gibt None zurueck bei unbekannter ID.""" source, _ = connected_source result = await source.get_track("unbekannt") assert result is None # ============================================================================= # get_metadata()-Tests # ============================================================================= class TestSpotifySourceGetMetadata: """Tests fuer die get_metadata()-Methode.""" @pytest.mark.asyncio async def test_metadaten_zurueckgeben(self, connected_source): """get_metadata() gibt Track-Metadaten zurueck.""" source, _ = connected_source meta = TrackMetadata(title="Testlied", artist="Testband", album="Testalbum") track = Track(source_type="spotify", metadata=meta) result = await source.get_metadata(track) assert result is meta assert result.title == "Testlied" assert result.artist == "Testband" # ============================================================================= # get_audio_stream()-Tests # ============================================================================= class TestSpotifySourceAudioStream: """Tests fuer die get_audio_stream()-Methode.""" @pytest.mark.asyncio async def test_stream_liefert_chunks(self, connected_source): """get_audio_stream() liefert Datei-Inhalt als Chunks.""" source, _ = connected_source audio_data = b"\xff\xfb\x90\x00" * 20000 # ~80 KB audio_file = source._cache_dir / "stream_test.mp3" audio_file.write_bytes(audio_data) track = Track(source_type="spotify", path=audio_file) chunks = [] async for chunk in source.get_audio_stream(track): chunks.append(chunk) combined = b"".join(chunks) assert combined == audio_data @pytest.mark.asyncio async def test_stream_ohne_pfad(self, connected_source): """get_audio_stream() liefert nichts ohne gueltige Datei.""" source, _ = connected_source track = Track(source_type="spotify", path=None) chunks = [] async for chunk in source.get_audio_stream(track): chunks.append(chunk) assert chunks == [] @pytest.mark.asyncio async def test_stream_datei_nicht_vorhanden(self, connected_source): """get_audio_stream() liefert nichts bei nicht existierender Datei.""" source, _ = connected_source track = Track( source_type="spotify", path=source._cache_dir / "nicht_da.mp3", ) chunks = [] async for chunk in source.get_audio_stream(track): chunks.append(chunk) assert chunks == [] # ============================================================================= # _download_sync()-Tests # ============================================================================= class TestSpotifySourceDownloadSync: """Tests fuer die synchrone Download-Methode.""" def test_erfolgreicher_download_direkt(self, connected_source): """_download_sync() gibt Pfad zurueck bei erfolgreichem spotdl-Lauf.""" source, _ = connected_source output_path = source._cache_dir / "dl_123.mp3" def fake_run(cmd, **kwargs): # Simuliere spotdl: Datei anlegen output_path.write_bytes(b"\xff\xfb" * 100) return MagicMock(returncode=0, stderr=b"") with patch("subprocess.run", side_effect=fake_run): result = source._download_sync( "https://open.spotify.com/track/dl_123", "dl_123" ) assert result == output_path def test_download_timeout(self, connected_source): """_download_sync() gibt None zurueck bei Timeout.""" source, _ = connected_source with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("spotdl", 120)): result = source._download_sync( "https://open.spotify.com/track/timeout", "timeout" ) assert result is None def test_download_spotdl_nicht_gefunden(self, connected_source): """_download_sync() gibt None zurueck wenn spotdl Binary fehlt.""" source, _ = connected_source with patch("subprocess.run", side_effect=FileNotFoundError()): result = source._download_sync( "https://open.spotify.com/track/nope", "nope" ) assert result is None def test_download_fallback_dateisuche(self, connected_source): """_download_sync() findet umbenannte Datei als Fallback.""" source, _ = connected_source # spotdl legt Datei unter anderem Namen an renamed_file = source._cache_dir / "Artist - Title.mp3" renamed_file.write_bytes(b"\xff\xfb" * 100) def fake_run(cmd, **kwargs): # returncode 0 aber Datei unter erwartetem Namen existiert nicht return MagicMock(returncode=0, stderr=b"") with patch("subprocess.run", side_effect=fake_run): result = source._download_sync( "https://open.spotify.com/track/renamed", "renamed" ) # Sollte die umbenannte Datei finden und umbenennen assert result is not None