| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890 |
- # -*- 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
|