from unittest.mock import MagicMock import httpx import pytest from fansifter_common.httpclient import HTTPClientError from pydantic import BaseModel, SecretStr, ValidationError from pytest_mock import MockerFixture from resonance_engine.dsp.backends.base import DSPBackend from resonance_engine.dsp.enums import DSPClientName, DSPResource from resonance_engine.dsp.exceptions import ( DSPForbiddenError, DSPNotRegisteredError, InvalidResponseError, RateLimitError, StreamingAPIError, TokenRevokedError, ) from resonance_engine.dsp.gateway import RETRY_AFTER_THRESHOLD, DSPGateway def _validation_error() -> ValidationError: class _M(BaseModel): scope: str try: _M() # ty:ignore[missing-argument] except ValidationError as exc: return exc raise AssertionError("expected ValidationError") class TestDSPGateway: @pytest.fixture def gateway(self) -> DSPGateway: return DSPGateway() @pytest.fixture def backend_mock(self, mocker: MockerFixture) -> MagicMock: return mocker.MagicMock(spec=DSPBackend) def test_is_configured_false_before_register(self, gateway: DSPGateway) -> None: assert not gateway.is_configured(DSPClientName.spotify_songwhip) def test_is_configured_true_after_register( self, gateway: DSPGateway, backend_mock: MagicMock ) -> None: gateway.register(DSPClientName.spotify_songwhip, backend_mock) assert gateway.is_configured(DSPClientName.spotify_songwhip) def test_get_returns_registered_backend( self, gateway: DSPGateway, backend_mock: MagicMock ) -> None: gateway.register(DSPClientName.spotify_songwhip, backend_mock) assert gateway.get(DSPClientName.spotify_songwhip) is backend_mock def test_get_raises_for_unknown_backend(self, gateway: DSPGateway) -> None: with pytest.raises(DSPNotRegisteredError) as exc_info: gateway.get("unknown") # ty:ignore[invalid-argument-type] assert exc_info.value.name == "unknown" def test_refresh_token_delegates_to_backend( self, gateway: DSPGateway, backend_mock: MagicMock ) -> None: backend_mock.refresh_token.return_value = { "access_token": "new", "refresh_token": "ref", } gateway.register(DSPClientName.spotify_songwhip, backend_mock) result = gateway.refresh_token( DSPClientName.spotify_songwhip, SecretStr("old_ref") ) backend_mock.refresh_token.assert_called_once_with(SecretStr("old_ref")) assert result["access_token"] == "new" def test_skips_request_when_token_lacks_required_scope( self, gateway: DSPGateway, backend_mock: MagicMock ) -> None: backend_mock.has_required_scope.return_value = False backend_mock.required_scope.return_value = "user-top-read" gateway.register(DSPClientName.spotify_songwhip, backend_mock) with pytest.raises(DSPForbiddenError): gateway.get_top_artists( DSPClientName.spotify_songwhip, SecretStr("tok"), scope="user-read-email", ) backend_mock.has_required_scope.assert_called_once_with( DSPResource.top_artists, "user-read-email" ) backend_mock.get_top_artists.assert_not_called() def test_makes_request_when_scope_satisfied( self, gateway: DSPGateway, backend_mock: MagicMock ) -> None: backend_mock.has_required_scope.return_value = True backend_mock.get_top_artists.return_value = [{"id": "a"}] gateway.register(DSPClientName.spotify_songwhip, backend_mock) result = gateway.get_top_artists( DSPClientName.spotify_songwhip, SecretStr("tok"), scope="user-top-read" ) assert result == [{"id": "a"}] backend_mock.get_top_artists.assert_called_once() def test_call_wraps_validation_error_with_readable_fields( self, gateway: DSPGateway, backend_mock: MagicMock ) -> None: backend_mock.refresh_token.side_effect = _validation_error() gateway.register(DSPClientName.spotify_songwhip, backend_mock) with pytest.raises(InvalidResponseError) as exc_info: gateway.refresh_token(DSPClientName.spotify_songwhip, SecretStr("ref")) msg = str(exc_info.value) assert "scope" in msg and "Field required" in msg # readable, not a traceback def test_get_profile_delegates_to_backend( self, gateway: DSPGateway, backend_mock: MagicMock ) -> None: backend_mock.get_profile.return_value = {"email": "user@example.com"} gateway.register(DSPClientName.spotify_songwhip, backend_mock) result = gateway.get_profile(DSPClientName.spotify_songwhip, SecretStr("acc")) backend_mock.get_profile.assert_called_once_with(SecretStr("acc")) assert result["email"] == "user@example.com" def test_get_top_artists_delegates_to_backend( self, gateway: DSPGateway, backend_mock: MagicMock ) -> None: backend_mock.get_top_artists.return_value = [] gateway.register(DSPClientName.spotify_songwhip, backend_mock) result = gateway.get_top_artists( DSPClientName.spotify_songwhip, SecretStr("acc") ) backend_mock.get_top_artists.assert_called_once_with(SecretStr("acc")) assert result == [] def test_get_recently_played_delegates_to_backend( self, gateway: DSPGateway, backend_mock: MagicMock ) -> None: backend_mock.get_recently_played.return_value = [] gateway.register(DSPClientName.spotify_songwhip, backend_mock) result = gateway.get_recently_played( DSPClientName.spotify_songwhip, SecretStr("acc") ) backend_mock.get_recently_played.assert_called_once_with( SecretStr("acc"), after=None ) assert result == [] def test_stats_returns_per_client_snapshot( self, gateway: DSPGateway, backend_mock: MagicMock ) -> None: backend_mock.get_profile.return_value = {} gateway.register(DSPClientName.spotify_songwhip, backend_mock) gateway.get_profile(DSPClientName.spotify_songwhip, SecretStr("acc")) assert gateway.stats(DSPClientName.spotify_songwhip).requests == 1 def test_close_calls_close_on_all_backends( self, gateway: DSPGateway, mocker: MockerFixture ) -> None: backend_1_mock = mocker.MagicMock(spec=DSPBackend) backend_2_mock = mocker.MagicMock(spec=DSPBackend) gateway.register(DSPClientName.spotify_songwhip, backend_1_mock) gateway.register(DSPClientName.spotify_smf_sme, backend_2_mock) gateway.close() backend_1_mock.close.assert_called_once() backend_2_mock.close.assert_called_once() class TestDSPGatewayRetry: @pytest.fixture def backend_mock(self, mocker: MockerFixture) -> MagicMock: return mocker.MagicMock(spec=DSPBackend) def test_translates_401_to_token_revoked(self, backend_mock: MagicMock) -> None: backend_mock.get_profile.side_effect = HTTPClientError( "unauthorized", response=httpx.Response(401) ) gateway = DSPGateway(stats_backend="memory") gateway.register(DSPClientName.spotify_songwhip, backend_mock) with pytest.raises(TokenRevokedError): gateway.get_profile(DSPClientName.spotify_songwhip, SecretStr("acc")) def test_propagates_non_429_error( self, backend_mock: MagicMock, mocker: MockerFixture ) -> None: backend_mock.get_profile.side_effect = StreamingAPIError( "server error", status_code=500 ) gateway = DSPGateway(stats_backend="memory") gateway.register(DSPClientName.spotify_songwhip, backend_mock) mocker.patch("resonance_engine.dsp.gateway.time.sleep") with pytest.raises(StreamingAPIError, match="server error"): gateway.get_profile(DSPClientName.spotify_songwhip, SecretStr("acc")) snapshot = gateway.stats(DSPClientName.spotify_songwhip) assert snapshot.requests == 1 assert snapshot.rate_limited == 0 assert snapshot.retries == 0 def test_retries_on_429_below_threshold( self, backend_mock: MagicMock, mocker: MockerFixture ) -> None: rate_limit = StreamingAPIError("rate limited", status_code=429, retry_after=1) backend_mock.get_profile.side_effect = [rate_limit, rate_limit, {"id": "ok"}] gateway = DSPGateway(max_retries=2, stats_backend="memory") gateway.register(DSPClientName.spotify_songwhip, backend_mock) mock_sleep = mocker.patch("resonance_engine.dsp.gateway.time.sleep") result = gateway.get_profile(DSPClientName.spotify_songwhip, SecretStr("acc")) snapshot = gateway.stats(DSPClientName.spotify_songwhip) assert result == {"id": "ok"} assert snapshot.requests == 1 assert snapshot.retries == 2 assert snapshot.rate_limited == 2 assert mock_sleep.call_count == 2 def test_raises_immediately_when_retry_after_exceeds_threshold( self, backend_mock: MagicMock, mocker: MockerFixture ) -> None: backend_mock.get_profile.side_effect = StreamingAPIError( "rate limited", status_code=429, retry_after=RETRY_AFTER_THRESHOLD + 1 ) gateway = DSPGateway(stats_backend="memory") gateway.register(DSPClientName.spotify_songwhip, backend_mock) mock_sleep = mocker.patch("resonance_engine.dsp.gateway.time.sleep") with pytest.raises(RateLimitError) as exc_info: gateway.get_profile(DSPClientName.spotify_songwhip, SecretStr("acc")) snapshot = gateway.stats(DSPClientName.spotify_songwhip) assert exc_info.value.retry_after == RETRY_AFTER_THRESHOLD + 1 assert snapshot.rate_limited == 1 assert snapshot.retries == 0 mock_sleep.assert_not_called() def test_raises_after_max_retries_exhausted( self, backend_mock: MagicMock, mocker: MockerFixture ) -> None: backend_mock.get_profile.side_effect = StreamingAPIError( "rate limited", status_code=429, retry_after=1 ) gateway = DSPGateway(max_retries=2, stats_backend="memory") gateway.register(DSPClientName.spotify_songwhip, backend_mock) mocker.patch("resonance_engine.dsp.gateway.time.sleep") with pytest.raises(RateLimitError, match="retries exhausted"): gateway.get_profile(DSPClientName.spotify_songwhip, SecretStr("acc")) snapshot = gateway.stats(DSPClientName.spotify_songwhip) assert snapshot.requests == 1 assert snapshot.retries == 2 assert snapshot.rate_limited == 3 def test_uses_default_retry_after_when_not_set( self, backend_mock: MagicMock, mocker: MockerFixture ) -> None: backend_mock.get_profile.side_effect = [ StreamingAPIError("rate limited", status_code=429, retry_after=None), {"id": "ok"}, ] gateway = DSPGateway( max_retries=1, default_retry_after=3, stats_backend="memory" ) gateway.register(DSPClientName.spotify_songwhip, backend_mock) mock_sleep = mocker.patch("resonance_engine.dsp.gateway.time.sleep") mocker.patch("resonance_engine.dsp.gateway.random.uniform", return_value=0.0) gateway.get_profile(DSPClientName.spotify_songwhip, SecretStr("acc")) mock_sleep.assert_called_once_with(3.0) def test_retries_on_transient_5xx( self, backend_mock: MagicMock, mocker: MockerFixture ) -> None: backend_mock.get_profile.side_effect = [ StreamingAPIError("bad gateway", status_code=502), StreamingAPIError("bad gateway", status_code=502), {"id": "ok"}, ] gateway = DSPGateway(max_retries=2, stats_backend="memory") gateway.register(DSPClientName.spotify_songwhip, backend_mock) mock_sleep = mocker.patch("resonance_engine.dsp.gateway.time.sleep") result = gateway.get_profile(DSPClientName.spotify_songwhip, SecretStr("acc")) snapshot = gateway.stats(DSPClientName.spotify_songwhip) assert result == {"id": "ok"} assert snapshot.retries == 2 assert snapshot.rate_limited == 0 assert mock_sleep.call_count == 2