"""Layer 3: Platform client tests using HTTP-level mocks. Deezer and SoundCloud use httpx — mocked with respx. Spotify uses spotipy — mocked with unittest.mock. YouTube uses google-api-python-client — mocked with unittest.mock. No real network calls are made. """ from unittest.mock import MagicMock, patch import httpx import respx # =========================================================================== # Deezer client # =========================================================================== from playlist_sync.clients import deezer_client DEEZER_BASE = "https://api.deezer.com" class TestDeezerClient: @respx.mock async def test_get_playlist_success(self): payload = {"id": 42, "title": "My Playlist", "tracks": {"data": []}} respx.get(f"{DEEZER_BASE}/playlist/42").mock( return_value=httpx.Response(200, json=payload) ) result = await deezer_client.get_playlist(42, "tok") assert result["id"] == 42 @respx.mock async def test_get_playlist_returns_none_when_no_id(self): """Empty/error response without 'id' field → None.""" respx.get(f"{DEEZER_BASE}/playlist/99").mock( return_value=httpx.Response(200, json={"error": "not found"}) ) result = await deezer_client.get_playlist(99, "tok") assert result is None @respx.mock async def test_get_playlist_http_error_returns_none(self): respx.get(f"{DEEZER_BASE}/playlist/1").mock(return_value=httpx.Response(404)) result = await deezer_client.get_playlist(1, "tok") assert result is None @respx.mock async def test_get_track_by_isrc_found(self): payload = {"id": 111, "title": "Cool Track", "isrc": "US-X1Y-23-45678"} respx.get(f"{DEEZER_BASE}/2.0/track/isrc:US-X1Y-23-45678").mock( return_value=httpx.Response(200, json=payload) ) result = await deezer_client.get_track_by_isrc("tok", "US-X1Y-23-45678") assert result is not None assert result["id"] == 111 @respx.mock async def test_get_track_by_isrc_not_found(self): respx.get(f"{DEEZER_BASE}/2.0/track/isrc:MISS-ISRC").mock( return_value=httpx.Response(200, json={}) ) result = await deezer_client.get_track_by_isrc("tok", "MISS-ISRC") assert result is None @respx.mock async def test_get_track_by_isrc_http_error_returns_none(self): respx.get(f"{DEEZER_BASE}/2.0/track/isrc:ERR").mock( return_value=httpx.Response(400) ) result = await deezer_client.get_track_by_isrc("tok", "ERR") assert result is None @respx.mock async def test_add_tracks_sends_correct_request(self): route = respx.post(f"{DEEZER_BASE}/playlist/5/tracks").mock( return_value=httpx.Response(200, json=True) ) await deezer_client.add_tracks("tok", 5, [1, 2, 3]) assert route.called request = route.calls.last.request body = request.content.decode() assert "songs=1%2C2%2C3" in body or "1,2,3" in body @respx.mock async def test_delete_playlist_tracks(self): route = respx.delete(f"{DEEZER_BASE}/playlist/5/tracks").mock( return_value=httpx.Response(200, json=True) ) await deezer_client.delete_playlist_tracks("tok", 5, [10, 20]) assert route.called @respx.mock async def test_order_playlist_tracks(self): route = respx.post(f"{DEEZER_BASE}/playlist/5/tracks").mock( return_value=httpx.Response(200, json=True) ) await deezer_client.order_playlist_tracks("tok", 5, [1, 2, 3]) assert route.called # =========================================================================== # SoundCloud client # =========================================================================== from playlist_sync.clients import soundcloud_client SC_BASE = "https://api.soundcloud.com" class TestSoundCloudClient: @respx.mock async def test_get_playlist_by_id_success(self): payload = {"id": 77, "title": "SC Playlist", "tracks": []} respx.get(f"{SC_BASE}/playlists/77").mock( return_value=httpx.Response(200, json=payload) ) result = await soundcloud_client.get_playlist_by_id(77, "sc-tok") assert result["id"] == 77 @respx.mock async def test_get_playlist_by_id_404_returns_none(self): respx.get(f"{SC_BASE}/playlists/9999").mock(return_value=httpx.Response(404)) result = await soundcloud_client.get_playlist_by_id(9999, "sc-tok") assert result is None @respx.mock async def test_get_playlist_by_url(self): payload = {"id": 88, "permalink_url": "https://soundcloud.com/user/playlist"} respx.get(f"{SC_BASE}/resolve").mock( return_value=httpx.Response(200, json=payload) ) result = await soundcloud_client.get_playlist_by_url( "https://soundcloud.com/user/playlist", "tok" ) assert result["id"] == 88 @respx.mock async def test_search_tracks_returns_list(self): payload = { "collection": [{"id": 1, "title": "Song A"}, {"id": 2, "title": "Song B"}] } respx.get(f"{SC_BASE}/tracks").mock( return_value=httpx.Response(200, json=payload) ) results = await soundcloud_client.search_tracks("Song A", None, "tok") assert len(results) == 2 @respx.mock async def test_search_tracks_empty_result(self): respx.get(f"{SC_BASE}/tracks").mock( return_value=httpx.Response(200, json={"collection": []}) ) results = await soundcloud_client.search_tracks("Unknown", None, "tok") assert results == [] @respx.mock async def test_search_tracks_http_error_returns_empty(self): respx.get(f"{SC_BASE}/tracks").mock(return_value=httpx.Response(500)) results = await soundcloud_client.search_tracks("Err", None, "tok") assert results == [] # =========================================================================== # Spotify client (uses spotipy — mock at the spotipy.Spotify level) # =========================================================================== from playlist_sync.clients import spotify_client class TestSpotifyClient: def _mock_sp(self) -> MagicMock: sp = MagicMock() sp.playlist.return_value = { "id": "pl-123", "name": "Test Playlist", "snapshot_id": "snap1", "tracks": { "items": [{"track": {"uri": "spotify:track:aaa"}, "is_local": False}], "next": None, }, } sp.next.return_value = {"items": [], "next": None} sp.playlist_items.return_value = {"items": [], "next": None} return sp def test_get_playlist_pagination(self): """get_playlist_with_all_tracks should collect all pages.""" sp = MagicMock() page1 = { "id": "pl", "name": "P", "snapshot_id": "s1", "tracks": { "items": [{"track": {"uri": "a"}}], "next": "http://next", }, } page2 = {"items": [{"track": {"uri": "b"}}], "next": None} sp.playlist.return_value = page1 sp.next.return_value = page2 with patch.object(spotify_client, "get_app_spotify_client", return_value=sp): result = spotify_client.get_playlist_with_all_tracks("pl") assert len(result["tracks"]["items"]) == 2 def test_get_playlist_returns_none_on_exception(self): import spotipy sp = MagicMock() sp.playlist.side_effect = spotipy.SpotifyException(400, -1, "bad") with patch.object(spotify_client, "get_app_spotify_client", return_value=sp): result = spotify_client.get_playlist_with_all_tracks("bad-id") assert result is None def test_add_all_tracks_chunks_at_100(self): sp = MagicMock() sp.playlist_add_items.return_value = {"snapshot_id": "s2"} uris = [f"spotify:track:{i}" for i in range(250)] spotify_client.add_all_tracks(sp, "user", "pl", uris) # 250 tracks → 3 calls (100 + 100 + 50) assert sp.playlist_add_items.call_count == 3 def test_delete_multiple_chunks_at_100(self): sp = MagicMock() sp.playlist_remove_all_occurrences_of_items.return_value = {"snapshot_id": "s3"} uris = [f"spotify:track:{i}" for i in range(150)] spotify_client.delete_multiple_playlist_tracks(sp, "user", "pl", uris) assert sp.playlist_remove_all_occurrences_of_items.call_count == 2 def test_get_all_playlist_track_items_paginates(self): sp = MagicMock() sp.playlist_items.return_value = { "items": [{"track": {"uri": "x"}}], "next": "http://next", } sp.next.return_value = { "items": [{"track": {"uri": "y"}}], "next": None, } with patch.object(spotify_client, "get_app_spotify_client", return_value=sp): items = spotify_client.get_all_playlist_track_items("pl") assert len(items) == 2 def test_delete_tracks_by_position_calls_api(self): sp = MagicMock() sp.playlist_remove_specific_occurrences_of_items.return_value = { "snapshot_id": "s4" } items = [ {"track": {"uri": "spotify:track:a"}}, {"track": {"uri": "spotify:track:b"}}, {"track": {"uri": "spotify:track:c"}}, {"track": {"uri": "spotify:track:d"}}, {"track": {"uri": "spotify:track:e"}}, ] result = spotify_client.delete_playlist_tracks_by_position( sp, "user", "pl", [0, 2, 4], items, "snap" ) assert sp.playlist_remove_specific_occurrences_of_items.called def test_order_playlist_tracks_calls_reorder(self): sp = MagicMock() sp.playlist_reorder_items.return_value = {"snapshot_id": "s5"} result = spotify_client.order_playlist_tracks(sp, "user", "pl", 2, 1, 0, "snap") sp.playlist_reorder_items.assert_called_once_with( "pl", range_start=2, range_length=1, insert_before=0, snapshot_id="snap" ) assert result == {"snapshot_id": "s5"}