"""Unit tests for the Apple Music connector.""" from datetime import datetime, timedelta from unittest.mock import MagicMock import jwt as pyjwt import pytest from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.serialization import ( Encoding, NoEncryption, PrivateFormat, PublicFormat, load_pem_private_key, ) from pytest_mock import MockerFixture from contributor.connectors.apple_music import ( AppleArtist, AppleMusicClient, AppleMusicClientCredentialsManager, ) @pytest.fixture def private_key() -> str: """An EC P-256 private key for testing.""" key = ec.generate_private_key(ec.SECP256R1()) return key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode() @pytest.fixture def credentials_manager(private_key) -> AppleMusicClientCredentialsManager: return AppleMusicClientCredentialsManager( client_id="ABCDE12345", team_id="TEAM123456", private_key=private_key, token_expires_in=3600, ) class TestInit: def test_stores_credentials(self, private_key) -> None: credentials_manager = AppleMusicClientCredentialsManager( client_id="KEY_ID", team_id="TEAM_ID", private_key=private_key, token_expires_in=7200, ) assert credentials_manager._client_id == "KEY_ID" assert credentials_manager._team_id == "TEAM_ID" assert credentials_manager._private_key == private_key assert credentials_manager._expires_in == 7200 assert credentials_manager._token_info is None assert credentials_manager._alg == "ES256" class TestGetAccessToken: def test_generates_token_on_first_call( self, credentials_manager: AppleMusicClientCredentialsManager ) -> None: token = credentials_manager.get_access_token() assert isinstance(token, str) assert len(token) > 0 def test_returns_cached_token_when_not_expired( self, credentials_manager: AppleMusicClientCredentialsManager ) -> None: first_token = credentials_manager.get_access_token() second_token = credentials_manager.get_access_token() assert first_token == second_token def test_generates_new_token_when_expired( self, credentials_manager: AppleMusicClientCredentialsManager, mocker: MockerFixture, ) -> None: first_token = credentials_manager.get_access_token() # Simulate token expiration assert credentials_manager._token_info is not None credentials_manager._token_info["token_expiration_time"] = ( datetime.now() - timedelta(seconds=1) ) second_token = credentials_manager.get_access_token() assert second_token != first_token class TestIsTokenExpired: def test_returns_true_when_no_token_info( self, credentials_manager: AppleMusicClientCredentialsManager ) -> None: assert credentials_manager._is_token_expired() is True def test_returns_false_when_token_valid( self, credentials_manager: AppleMusicClientCredentialsManager ) -> None: credentials_manager._token_info = { "token_expiration_time": datetime.now() + timedelta(seconds=3600) } assert credentials_manager._is_token_expired() is False def test_returns_true_when_token_past_expiration( self, credentials_manager: AppleMusicClientCredentialsManager ) -> None: credentials_manager._token_info = { "token_expiration_time": datetime.now() - timedelta(seconds=1) } assert credentials_manager._is_token_expired() is True class TestRequestAccessToken: def test_produces_valid_jwt( self, credentials_manager: AppleMusicClientCredentialsManager, private_key ) -> None: expiration = datetime.now() + timedelta(seconds=3600) token = credentials_manager._request_access_token(expiration) priv_key = load_pem_private_key(private_key.encode(), password=None) pub_key_pem = priv_key.public_key().public_bytes( # type: ignore[union-attr] encoding=Encoding.PEM, format=PublicFormat.SubjectPublicKeyInfo, ) decoded = pyjwt.decode( token, pub_key_pem, algorithms=["ES256"], options={"verify_exp": False} ) assert decoded["iss"] == "TEAM123456" assert "iat" in decoded assert "exp" in decoded def test_jwt_header_contains_key_id( self, credentials_manager: AppleMusicClientCredentialsManager ) -> None: expiration = datetime.now() + timedelta(seconds=3600) token = credentials_manager._request_access_token(expiration) header = pyjwt.get_unverified_header(token) assert header["kid"] == "ABCDE12345" assert header["alg"] == "ES256" class TestAppleMusicClientInit: def test_stores_configuration(self) -> None: creds_manager = MagicMock() client = AppleMusicClient( client_credentials_manager=creds_manager, requests_timeout=10, delay_step=1.0, ) assert client._client_credentials_manager is creds_manager assert client._requests_timeout == 10 assert client._delay_step == 1.0 def test_default_timeout_and_delay(self) -> None: client = AppleMusicClient(client_credentials_manager=MagicMock()) assert client._requests_timeout == 5 assert client._delay_step == 0.5 class TestAppleMusicClientAuthHeaders: def test_returns_bearer_token(self) -> None: creds_manager = MagicMock() creds_manager.get_access_token.return_value = "test-token" client = AppleMusicClient(client_credentials_manager=creds_manager) headers = client._auth_headers() assert headers == {"Authorization": "Bearer test-token"} class TestAppleMusicClientSearch: @pytest.fixture def client(self) -> AppleMusicClient: creds_manager = MagicMock() creds_manager.get_access_token.return_value = "test-token" return AppleMusicClient(client_credentials_manager=creds_manager) @pytest.fixture def raw_search_response(self): return { "results": { "artists": { "data": [ { "id": "123456", "attributes": { "name": "Artist One", "genreNames": ["Pop", "Rock"], "url": "https://music.apple.com/artist/123456", }, }, { "id": "789012", "attributes": { "name": "Artist Two", "genreNames": [], "url": "https://music.apple.com/artist/789012", }, }, ] } } } def test_returns_list_of_apple_artists( self, client: AppleMusicClient, mocker: MockerFixture, raw_search_response: dict, ) -> None: mock_response = MagicMock() mock_response.json.return_value = raw_search_response mocker.patch( "contributor.connectors.apple_music.requests.get", return_value=mock_response, ) result = client.search("test query") assert result == [ AppleArtist( identifier="123456", name="Artist One", genres=["Pop", "Rock"], url="https://music.apple.com/artist/123456", ), AppleArtist( identifier="789012", name="Artist Two", genres=[], url="https://music.apple.com/artist/789012", ), ] def test_passes_correct_params( self, client: AppleMusicClient, mocker: MockerFixture, raw_search_response: dict, ) -> None: mock_response = MagicMock() mock_response.json.return_value = raw_search_response mock_get = mocker.patch( "contributor.connectors.apple_music.requests.get", return_value=mock_response, ) client.search("test query", limit=5, offset=10, localization="fr") mock_get.assert_called_once_with( "https://api.music.apple.com/v1/catalog/us/search", headers={ "Authorization": "Bearer test-token", "Content-Type": "application/json", }, params={ "term": "test query", "limit": 5, "offset": 10, "types": "artists", "l": "fr", }, timeout=5, ) def test_calls_raise_for_status( self, client: AppleMusicClient, mocker: MockerFixture, raw_search_response: dict, ) -> None: mock_response = MagicMock() mock_response.json.return_value = raw_search_response mocker.patch( "contributor.connectors.apple_music.requests.get", return_value=mock_response, ) client.search("test") mock_response.raise_for_status.assert_called_once() def test_returns_empty_list_when_no_results( self, client: AppleMusicClient, mocker: MockerFixture ) -> None: mock_response = MagicMock() mock_response.json.return_value = {"results": {}} mocker.patch( "contributor.connectors.apple_music.requests.get", return_value=mock_response, ) result = client.search("nonexistent") assert result == [] def test_returns_empty_list_for_unexpected_payload( self, client: AppleMusicClient, mocker: MockerFixture ) -> None: mock_response = MagicMock() mock_response.json.return_value = "not a dict" mocker.patch( "contributor.connectors.apple_music.requests.get", return_value=mock_response, ) result = client.search("test") assert result == [] class TestAppleMusicClientGetArtistById: @pytest.fixture def client(self) -> AppleMusicClient: creds_manager = MagicMock() creds_manager.get_access_token.return_value = "test-token" return AppleMusicClient(client_credentials_manager=creds_manager) @pytest.fixture def raw_artist_response(self): return { "data": [ { "id": "123456", "attributes": { "name": "Test Artist", "genreNames": ["Pop"], "url": "https://music.apple.com/artist/123456", }, } ] } def test_returns_apple_artist( self, client: AppleMusicClient, mocker: MockerFixture, raw_artist_response: dict, ) -> None: mock_response = MagicMock() mock_response.json.return_value = raw_artist_response mocker.patch( "contributor.connectors.apple_music.requests.get", return_value=mock_response, ) result = client.get_artist_by_id("123456") assert result == AppleArtist( identifier="123456", name="Test Artist", genres=["Pop"], url="https://music.apple.com/artist/123456", ) def test_calls_correct_url( self, client: AppleMusicClient, mocker: MockerFixture, raw_artist_response: dict, ) -> None: mock_response = MagicMock() mock_response.json.return_value = raw_artist_response mock_get = mocker.patch( "contributor.connectors.apple_music.requests.get", return_value=mock_response, ) client.get_artist_by_id("123456") mock_get.assert_called_once_with( "https://api.music.apple.com/v1/catalog/us/artists/123456", headers={ "Authorization": "Bearer test-token", "Content-Type": "application/json", }, timeout=5, ) def test_calls_raise_for_status( self, client: AppleMusicClient, mocker: MockerFixture, raw_artist_response: dict, ) -> None: mock_response = MagicMock() mock_response.json.return_value = raw_artist_response mocker.patch( "contributor.connectors.apple_music.requests.get", return_value=mock_response, ) client.get_artist_by_id("123456") mock_response.raise_for_status.assert_called_once() def test_raises_index_error_when_data_is_empty( self, client: AppleMusicClient, mocker: MockerFixture, ) -> None: mock_response = MagicMock() mock_response.json.return_value = {"data": []} mocker.patch( "contributor.connectors.apple_music.requests.get", return_value=mock_response, ) with pytest.raises(IndexError): client.get_artist_by_id("123456") def test_raises_key_error_when_data_key_missing( self, client: AppleMusicClient, mocker: MockerFixture, ) -> None: mock_response = MagicMock() mock_response.json.return_value = {} mocker.patch( "contributor.connectors.apple_music.requests.get", return_value=mock_response, ) with pytest.raises(KeyError): client.get_artist_by_id("123456")