"""Unit tests for the Spotify connector.""" from unittest.mock import MagicMock import pytest from pytest_mock import MockerFixture from requests.exceptions import ConnectionError as RequestsConnectionError from requests.exceptions import HTTPError from contributor.connectors.spotify import ( CHUNK_SIZE, SpotifyArtist, SpotifyClient, _giveup, chunked, valid_spotify_id, ) class TestValidSpotifyId: def test_valid_22_char_alphanumeric(self): assert valid_spotify_id("0OdUWJ0sBjDrqHygGUXeCF") is True def test_valid_all_digits(self): assert valid_spotify_id("1234567890123456789012") is True def test_valid_all_letters(self): assert valid_spotify_id("abcdefghijABCDEFGHIJkl") is True def test_invalid_too_short(self): assert valid_spotify_id("abc123") is False def test_invalid_too_long(self): assert valid_spotify_id("0OdUWJ0sBjDrqHygGUXeCFx") is False def test_invalid_special_characters(self): assert valid_spotify_id("0OdUWJ0sBjDrqHygGUXe!@") is False def test_invalid_empty_string(self): assert valid_spotify_id("") is False class TestChunked: def test_empty_list(self): assert list(chunked([])) == [] def test_list_smaller_than_chunk_size(self): items = ["a", "b", "c"] result = list(chunked(items, size=5)) assert result == [["a", "b", "c"]] def test_list_equal_to_chunk_size(self): items = ["a", "b", "c"] result = list(chunked(items, size=3)) assert result == [["a", "b", "c"]] def test_list_larger_than_chunk_size(self): items = ["a", "b", "c", "d", "e"] result = list(chunked(items, size=2)) assert result == [["a", "b"], ["c", "d"], ["e"]] def test_default_chunk_size(self): items = [str(i) for i in range(CHUNK_SIZE + 1)] result = list(chunked(items)) assert len(result) == 2 assert len(result[0]) == CHUNK_SIZE assert len(result[1]) == 1 class TestGiveup: def test_does_not_give_up_on_connection_error(self): error = RequestsConnectionError("connection reset") assert _giveup(error) is False def test_does_not_give_up_on_429(self): response = MagicMock() response.status_code = 429 error = HTTPError(response=response) assert _giveup(error) is False def test_does_not_give_up_on_500(self): response = MagicMock() response.status_code = 500 error = HTTPError(response=response) assert _giveup(error) is False def test_does_not_give_up_on_503(self): response = MagicMock() response.status_code = 503 error = HTTPError(response=response) assert _giveup(error) is False def test_gives_up_on_400(self): response = MagicMock() response.status_code = 400 error = HTTPError(response=response) assert _giveup(error) is True def test_gives_up_on_404(self): response = MagicMock() response.status_code = 404 error = HTTPError(response=response) assert _giveup(error) is True def test_gives_up_on_401(self): response = MagicMock() response.status_code = 401 error = HTTPError(response=response) assert _giveup(error) is True class TestSpotifyClientArtist: @pytest.fixture def client(self, mocker: MockerFixture) -> SpotifyClient: mocker.patch("spotipy.Spotify.__init__", return_value=None) return SpotifyClient() @pytest.fixture def raw_artist_response(self): return { "id": "0OdUWJ0sBjDrqHygGUXeCF", "name": "Test Artist", "followers": {"total": 1000}, "genres": ["pop", "rock"], "external_urls": { "spotify": "https://open.spotify.com/artist/0OdUWJ0sBjDrqHygGUXeCF" }, "images": [ {"url": "https://i.scdn.co/image/abc123", "height": 640, "width": 640} ], } def test_returns_spotify_artist_model( self, client: SpotifyClient, mocker: MockerFixture, raw_artist_response: dict ): mocker.patch("spotipy.Spotify.artist", return_value=raw_artist_response) result = client.artist("0OdUWJ0sBjDrqHygGUXeCF") assert result == SpotifyArtist( identifier="0OdUWJ0sBjDrqHygGUXeCF", name="Test Artist", followers=1000, genres=["pop", "rock"], url="https://open.spotify.com/artist/0OdUWJ0sBjDrqHygGUXeCF", image="https://i.scdn.co/image/abc123", ) def test_calls_super_artist_with_id( self, client: SpotifyClient, mocker: MockerFixture, raw_artist_response: dict ): mock_super_artist = mocker.patch( "spotipy.Spotify.artist", return_value=raw_artist_response ) client.artist("0OdUWJ0sBjDrqHygGUXeCF") mock_super_artist.assert_called_once_with("0OdUWJ0sBjDrqHygGUXeCF") def test_image_is_none_when_no_images( self, client: SpotifyClient, mocker: MockerFixture, raw_artist_response: dict ): raw_artist_response["images"] = [] mocker.patch("spotipy.Spotify.artist", return_value=raw_artist_response) result = client.artist("0OdUWJ0sBjDrqHygGUXeCF") assert result == SpotifyArtist( identifier="0OdUWJ0sBjDrqHygGUXeCF", name="Test Artist", followers=1000, genres=["pop", "rock"], url="https://open.spotify.com/artist/0OdUWJ0sBjDrqHygGUXeCF", image=None, ) def test_empty_genres( self, client: SpotifyClient, mocker: MockerFixture, raw_artist_response: dict ): raw_artist_response["genres"] = [] mocker.patch("spotipy.Spotify.artist", return_value=raw_artist_response) result = client.artist("0OdUWJ0sBjDrqHygGUXeCF") assert result == SpotifyArtist( identifier="0OdUWJ0sBjDrqHygGUXeCF", name="Test Artist", followers=1000, genres=[], url="https://open.spotify.com/artist/0OdUWJ0sBjDrqHygGUXeCF", image="https://i.scdn.co/image/abc123", ) class TestSpotifyClientSearch: @pytest.fixture def client(self, mocker: MockerFixture) -> SpotifyClient: mocker.patch("spotipy.Spotify.__init__", return_value=None) return SpotifyClient() @pytest.fixture def raw_search_response(self): return { "artists": { "items": [ { "id": "0OdUWJ0sBjDrqHygGUXeCF", "name": "Artist One", "followers": {"total": 500}, "genres": ["indie"], "external_urls": { "spotify": "https://open.spotify.com/artist/0OdUWJ0sBjDrqHygGUXeCF" }, "images": [ { "url": "https://i.scdn.co/image/one", "height": 640, "width": 640, } ], }, { "id": "1111111111111111111111", "name": "Artist Two", "followers": {"total": 200}, "genres": [], "external_urls": { "spotify": "https://open.spotify.com/artist/1111111111111111111111" }, "images": [], }, ] } } def test_search_returns_list_of_spotify_artists( self, client: SpotifyClient, mocker: MockerFixture, raw_search_response: dict ): mocker.patch.object(client, "_get", return_value=raw_search_response) result = client.search("test query", type="artist") assert result == [ SpotifyArtist( identifier="0OdUWJ0sBjDrqHygGUXeCF", name="Artist One", followers=500, genres=["indie"], url="https://open.spotify.com/artist/0OdUWJ0sBjDrqHygGUXeCF", image="https://i.scdn.co/image/one", ), SpotifyArtist( identifier="1111111111111111111111", name="Artist Two", followers=200, genres=[], url="https://open.spotify.com/artist/1111111111111111111111", image=None, ), ] def test_search_calls_get_with_params( self, client: SpotifyClient, mocker: MockerFixture, raw_search_response: dict ): mock_get = mocker.patch.object(client, "_get", return_value=raw_search_response) client.search("test query", limit=5, offset=0, type="artist") mock_get.assert_called_once_with( "search", q="test query", limit=5, offset=0, type="artist", market=None, locale=None, ) def test_search_with_market_and_locale( self, client: SpotifyClient, mocker: MockerFixture, raw_search_response: dict ): mock_get = mocker.patch.object(client, "_get", return_value=raw_search_response) client.search("q", market="US", locale="en") mock_get.assert_called_once_with( "search", q="q", limit=10, offset=0, type="track", market="US", locale="en", ) def test_search_returns_empty_list_when_no_results( self, client: SpotifyClient, mocker: MockerFixture ): mocker.patch.object(client, "_get", return_value={"artists": {"items": []}}) result = client.search("nonexistent artist", type="artist") assert result == [] class TestSpotifyClientGetArtistsByIds: @pytest.fixture def client(self, mocker: MockerFixture) -> SpotifyClient: mocker.patch("spotipy.Spotify.__init__", return_value=None) return SpotifyClient() def test_returns_artist_map(self, client: SpotifyClient, mocker: MockerFixture): artist_id = "0OdUWJ0sBjDrqHygGUXeCF" mocker.patch.object( client, "artists", return_value={"artists": [{"id": artist_id, "name": "Artist"}]}, ) result = client.get_artists_by_ids(artist_ids=[artist_id]) assert result == {artist_id: {"id": artist_id, "name": "Artist"}} def test_filters_invalid_ids(self, client: SpotifyClient, mocker: MockerFixture): valid_id = "0OdUWJ0sBjDrqHygGUXeCF" invalid_id = "not-valid!" mock_artists = mocker.patch.object( client, "artists", return_value={"artists": [{"id": valid_id, "name": "Artist"}]}, ) result = client.get_artists_by_ids(artist_ids=[valid_id, invalid_id]) # Only valid IDs are sent to the API mock_artists.assert_called_once_with([valid_id]) assert result[valid_id] == {"id": valid_id, "name": "Artist"} assert result[invalid_id] is None def test_returns_none_for_not_found_artists( self, client: SpotifyClient, mocker: MockerFixture ): id_found = "0OdUWJ0sBjDrqHygGUXeCF" id_missing = "1111111111111111111111" mocker.patch.object( client, "artists", return_value={"artists": [{"id": id_found, "name": "Found"}]}, ) result = client.get_artists_by_ids(artist_ids=[id_found, id_missing]) assert result[id_found] == {"id": id_found, "name": "Found"} assert result[id_missing] is None def test_handles_multiple_chunks( self, client: SpotifyClient, mocker: MockerFixture ): ids = [f"{str(i).zfill(22)}" for i in range(CHUNK_SIZE + 5)] # Only ids made of digits and length 22 pass validation artists_chunk1 = [ {"id": id_, "name": f"A{i}"} for i, id_ in enumerate(ids[:CHUNK_SIZE]) ] artists_chunk2 = [ {"id": id_, "name": f"A{i}"} for i, id_ in enumerate(ids[CHUNK_SIZE:]) ] mock_artists = mocker.patch.object( client, "artists", side_effect=[ {"artists": artists_chunk1}, {"artists": artists_chunk2}, ], ) result = client.get_artists_by_ids(artist_ids=ids) assert mock_artists.call_count == 2 for id_ in ids: assert id_ in result assert result[id_] is not None def test_empty_list(self, client: SpotifyClient, mocker: MockerFixture): mock_artists = mocker.patch.object(client, "artists") result = client.get_artists_by_ids(artist_ids=[]) mock_artists.assert_not_called() assert result == {}