"""Layer 12: Resilience tests. Verifies that the application degrades gracefully when dependencies fail: - DB unavailable -> API returns 500, no crash - Spotify API 429/404 -> synchronizer records SyncError, does not propagate - Deezer/YouTube/SoundCloud API timeout or connection error -> SyncError recorded - ApplicationService cache warm-up (cold start) completes within 1 second - Cache is reused on subsequent calls (no repeated DB queries) """ import time from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest_asyncio import spotipy from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker import playlist_sync.synchronizers.deezer as deezer_mod import playlist_sync.synchronizers.soundcloud as sc_mod import playlist_sync.synchronizers.spotify as spotify_mod import playlist_sync.synchronizers.youtube as yt_mod from playlist_sync.api import app from playlist_sync.models.application import Application from playlist_sync.models.generic_playlist import GenericPlaylist, GenericTrack from playlist_sync.models.service_account import ServiceAccount from playlist_sync.models.sync_task import PlaylistSynchronization from playlist_sync.services.application_service import ApplicationService from playlist_sync.services.database import get_session from playlist_sync.synchronizers.deezer import DeezerSynchronizer from playlist_sync.synchronizers.soundcloud import SoundCloudSynchronizer from playlist_sync.synchronizers.spotify import SpotifySynchronizer from playlist_sync.synchronizers.youtube import YoutubeSynchronizer # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _generic_playlist(*uris): tracks = [ GenericTrack(name="Track", artists=["Artist"], isrc=f"ISRC-{i}", spotify_uri=u) for i, u in enumerate(uris) ] return GenericPlaylist(name="Test", description=None, image=None, tracks=tracks) def _make_sync( id_: int, from_pl="spotify:playlist:src", to_pl="target-pl", account_id=1 ): return PlaylistSynchronization( id=id_, application_id=1, from_playlist_id=from_pl, from_service_type=0, from_music_service_id=1, to_playlist_id=to_pl, to_service_account_id=account_id, active=True, ) def _make_account(id_: int, music_service_id: int): return ServiceAccount( id=id_, application_id=1, service_type=0, music_service_id=music_service_id, user_identifier="user1", access_token="tok", ) def _mock_session(): """Return an AsyncMock session for tests where the DB is not reached.""" return AsyncMock() # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest_asyncio.fixture(scope="function") async def session(db_engine): Session = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) async with Session() as s: yield s @pytest_asyncio.fixture(scope="function") async def res_client(db_engine): Session = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) async with Session() as s: s.add( Application( id=1, name="US", spotify_region_code="US", active=True, fallback_application=False, global_push_application=False, workout_market=False, ) ) await s.commit() ApplicationService.invalidate_cache() async def override_get_session(): async with Session() as s: yield s app.dependency_overrides[get_session] = override_get_session async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as ac: yield ac, Session app.dependency_overrides.clear() ApplicationService.invalidate_cache() # --------------------------------------------------------------------------- # DB unavailability # --------------------------------------------------------------------------- class TestDatabaseUnavailable: async def test_db_exception_on_list_returns_500(self): """When DB session raises on execute, the list endpoint returns 500.""" async def broken_session(): session = AsyncMock() session.execute.side_effect = Exception("MySQL connection refused") session.close = AsyncMock() yield session app.dependency_overrides[get_session] = broken_session try: async with AsyncClient( transport=ASGITransport(app=app, raise_app_exceptions=False), base_url="http://test", ) as ac: resp = await ac.get("/PlaylistSync/playlists") assert resp.status_code == 500 finally: app.dependency_overrides.clear() async def test_db_exception_on_create_returns_500(self): """DB failure during sync creation -> 500.""" ApplicationService.invalidate_cache() async def broken_session(): session = AsyncMock() session.execute.side_effect = Exception("Deadlock") session.close = AsyncMock() yield session app.dependency_overrides[get_session] = broken_session try: async with AsyncClient( transport=ASGITransport(app=app, raise_app_exceptions=False), base_url="http://test", ) as ac: resp = await ac.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "src", "to_playlist_id": "tgt", "to_service_account_id": 1, }, ) assert resp.status_code == 500 finally: app.dependency_overrides.clear() async def test_health_endpoint_survives_db_unavailability(self): """When the DB is down, /health returns 503 (degraded) instead of crashing.""" async def broken_session(): session = AsyncMock() session.execute.side_effect = Exception("DB is down") session.close = AsyncMock() yield session app.dependency_overrides[get_session] = broken_session try: async with AsyncClient( transport=ASGITransport(app=app, raise_app_exceptions=False), base_url="http://test", ) as ac: resp = await ac.get("/health") assert resp.status_code == 503 data = resp.json() assert data["status"] == "degraded" assert "error" in data.get("database", "") finally: app.dependency_overrides.clear() # --------------------------------------------------------------------------- # Spotify platform API failures # --------------------------------------------------------------------------- class TestSpotifyApiFailures: async def test_spotify_rate_limit_results_in_sync_error(self): """Spotify 429 -> synchronizer records SyncError, does not propagate.""" mock_sp = MagicMock(spec=spotipy.Spotify) exc = spotipy.SpotifyException( http_status=429, code=-1, msg="Too many requests" ) with ( patch.object( spotify_mod.spotify_client, "get_playlist_with_all_tracks", side_effect=exc, ), patch.object( spotify_mod.spotify_client, "get_authenticated_client", return_value=mock_sp, ), patch( "playlist_sync.synchronizers.spotify.ServiceAccountService.get_spotify_api_keys", new=AsyncMock(return_value=None), ), ): result = await SpotifySynchronizer().copy_to_playlist( _generic_playlist("spotify:track:aaa"), _make_sync(1), _make_account(1, 1), _mock_session(), ) assert result.error is not None assert result.made_changes is False async def test_spotify_not_found_results_in_sync_error(self): """Spotify playlist not found (None target) -> SyncError.NoTargetPlaylist.""" mock_sp = MagicMock(spec=spotipy.Spotify) with ( patch.object( spotify_mod.spotify_client, "get_playlist_with_all_tracks", return_value=None, ), patch.object( spotify_mod.spotify_client, "get_authenticated_client", return_value=mock_sp, ), patch( "playlist_sync.synchronizers.spotify.ServiceAccountService.get_spotify_api_keys", new=AsyncMock(return_value=None), ), ): result = await SpotifySynchronizer().copy_to_playlist( _generic_playlist("spotify:track:bbb"), _make_sync(2, to_pl="tgt-sp"), _make_account(1, 1), _mock_session(), ) assert result.error is not None async def test_spotify_exception_does_not_propagate(self): """Synchronizer catches RuntimeError from Spotify - never raises to caller.""" mock_sp = MagicMock(spec=spotipy.Spotify) with ( patch.object( spotify_mod.spotify_client, "get_playlist_with_all_tracks", side_effect=RuntimeError("Unexpected crash"), ), patch.object( spotify_mod.spotify_client, "get_authenticated_client", return_value=mock_sp, ), patch( "playlist_sync.synchronizers.spotify.ServiceAccountService.get_spotify_api_keys", new=AsyncMock(return_value=None), ), ): result = await SpotifySynchronizer().copy_to_playlist( _generic_playlist("spotify:track:ccc"), _make_sync(3, to_pl="tgt-crash"), _make_account(1, 1), _mock_session(), ) assert result is not None assert result.error is not None async def test_spotify_unauthorized_results_in_sync_error(self): """Spotify 401 -> synchronizer records SyncError, does not propagate.""" mock_sp = MagicMock(spec=spotipy.Spotify) exc = spotipy.SpotifyException(http_status=401, code=-1, msg="Unauthorized") with ( patch.object( spotify_mod.spotify_client, "get_playlist_with_all_tracks", side_effect=exc, ), patch.object( spotify_mod.spotify_client, "get_authenticated_client", return_value=mock_sp, ), patch( "playlist_sync.synchronizers.spotify.ServiceAccountService.get_spotify_api_keys", new=AsyncMock(return_value=None), ), ): result = await SpotifySynchronizer().copy_to_playlist( _generic_playlist("spotify:track:ddd"), _make_sync(4, to_pl="tgt-unauth"), _make_account(1, 1), _mock_session(), ) assert result.error is not None assert result.made_changes is False # --------------------------------------------------------------------------- # Deezer platform API failures # --------------------------------------------------------------------------- class TestDeezerApiFailures: async def test_deezer_timeout_results_in_sync_error(self): """Deezer API timeout -> synchronizer records SyncError, does not propagate.""" with patch.object( deezer_mod.deezer_client, "get_playlist", new=AsyncMock(side_effect=httpx.TimeoutException("Timed out")), ): result = await DeezerSynchronizer().copy_to_playlist( _generic_playlist("spotify:track:aaa"), _make_sync(10, to_pl="123456"), _make_account(2, 2), _mock_session(), ) assert result.error is not None async def test_deezer_5xx_results_in_sync_error(self): """Deezer API 5xx error -> synchronizer records SyncError, does not raise.""" mock_response = MagicMock() mock_response.status_code = 503 mock_request = MagicMock() http_err = httpx.HTTPStatusError( "503 Server Error", request=mock_request, response=mock_response ) with patch.object( deezer_mod.deezer_client, "get_playlist", new=AsyncMock(side_effect=http_err), ): result = await DeezerSynchronizer().copy_to_playlist( _generic_playlist("spotify:track:bbb"), _make_sync(11, to_pl="234567"), _make_account(2, 2), _mock_session(), ) assert result.error is not None async def test_deezer_connection_refused_results_in_sync_error(self): """Deezer connection refused -> synchronizer records SyncError, no raise.""" with patch.object( deezer_mod.deezer_client, "get_playlist", new=AsyncMock(side_effect=httpx.ConnectError("Connection refused")), ): result = await DeezerSynchronizer().copy_to_playlist( _generic_playlist("spotify:track:ccc"), _make_sync(12, to_pl="345678"), _make_account(2, 2), _mock_session(), ) assert result.error is not None # --------------------------------------------------------------------------- # YouTube platform API failures # --------------------------------------------------------------------------- class TestYoutubeApiFailures: async def test_youtube_no_target_playlist_results_in_sync_error(self): """YouTube playlist not found (None) -> SyncError recorded, no propagation.""" mock_creds = MagicMock() with ( patch.object( yt_mod.youtube_client, "build_credentials", return_value=mock_creds ), patch.object( yt_mod.youtube_client, "get_all_playlist_items", return_value=None ), ): result = await YoutubeSynchronizer().copy_to_playlist( _generic_playlist("spotify:track:aaa"), _make_sync(20, to_pl="PLtarget123"), _make_account(3, 3), _mock_session(), ) assert result.error is not None async def test_youtube_build_error_does_not_propagate(self): """YouTube API build failure -> synchronizer records error, does not raise.""" mock_creds = MagicMock() with ( patch.object( yt_mod.youtube_client, "build_credentials", return_value=mock_creds ), patch.object( yt_mod.youtube_client, "build", side_effect=Exception("API key invalid") ), ): result = await YoutubeSynchronizer().copy_to_playlist( _generic_playlist("spotify:track:bbb"), _make_sync(21, to_pl="PLtarget456"), _make_account(3, 3), _mock_session(), ) assert result is not None assert result.error is not None # --------------------------------------------------------------------------- # SoundCloud platform API failures # --------------------------------------------------------------------------- class TestSoundCloudApiFailures: async def test_soundcloud_connection_error_results_in_sync_error(self): """SoundCloud connection refused -> records SyncError, does not raise.""" with patch.object( sc_mod.soundcloud_client, "get_playlist_by_id", new=AsyncMock(side_effect=httpx.ConnectError("Connection refused")), ): result = await SoundCloudSynchronizer().copy_to_playlist( _generic_playlist("spotify:track:aaa"), _make_sync(30, to_pl="987654"), _make_account(4, 4), _mock_session(), ) assert result is not None assert result.error is not None async def test_soundcloud_timeout_results_in_sync_error(self): """SoundCloud timeout -> synchronizer records SyncError, does not propagate.""" with patch.object( sc_mod.soundcloud_client, "get_playlist_by_id", new=AsyncMock(side_effect=httpx.TimeoutException("Timed out")), ): result = await SoundCloudSynchronizer().copy_to_playlist( _generic_playlist("spotify:track:bbb"), _make_sync(31, to_pl="876543"), _make_account(4, 4), _mock_session(), ) assert result is not None assert result.error is not None # --------------------------------------------------------------------------- # ApplicationService cache warm-up (cold start) # --------------------------------------------------------------------------- class TestApplicationServiceColdStart: async def test_cache_warm_up_is_fast(self, db_engine): """Cold-start cache population should complete well within 1 second.""" Session = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) async with Session() as session: for i in range(1, 21): session.add( Application( id=i + 100, name=f"Market{i}", spotify_region_code=f"MC{i:02d}", active=True, fallback_application=False, global_push_application=False, workout_market=False, ) ) await session.commit() ApplicationService.invalidate_cache() start = time.monotonic() async with Session() as session: svc = ApplicationService(session) apps = await svc.get_all() elapsed = time.monotonic() - start assert len(apps) >= 20 assert elapsed < 1.0, f"Cache warm-up took {elapsed:.3f}s (should be < 1s)" async def test_cache_is_used_on_second_call(self, db_engine): """Second call to get_all() hits cache and is faster than the first.""" Session = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) ApplicationService.invalidate_cache() async with Session() as session: svc = ApplicationService(session) t0 = time.monotonic() await svc.get_all() first_elapsed = time.monotonic() - t0 async with Session() as session: svc = ApplicationService(session) t1 = time.monotonic() await svc.get_all() second_elapsed = time.monotonic() - t1 assert second_elapsed < first_elapsed + 0.1, ( f"Cache hit ({second_elapsed:.4f}s) was not faster than" f" cold load ({first_elapsed:.4f}s)" ) async def test_invalidate_cache_forces_reload(self, db_engine): """invalidate_cache() clears the cache so next call hits DB.""" Session = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) async with Session() as session: svc = ApplicationService(session) await svc.get_all() # warm up ApplicationService.invalidate_cache() assert ApplicationService._cache == [] assert ApplicationService._cache_ts == 0.0 async def test_country_code_resolution_uses_cache(self, db_engine): """get_by_country_code() uses the in-memory cache, not a per-call DB query.""" Session = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) async with Session() as session: session.add( Application( id=1, name="US", spotify_region_code="US", active=True, fallback_application=False, global_push_application=False, workout_market=False, ) ) await session.commit() ApplicationService.invalidate_cache() async with Session() as session: svc = ApplicationService(session) app_obj = await svc.get_by_country_code("US") assert len(ApplicationService._cache) >= 1 assert app_obj is not None assert app_obj.spotify_region_code == "US"