"""Layer 4: Synchronizer integration tests. Each synchronizer's _copy_internal is exercised with: - mocked platform clients (no real HTTP calls) - a real async SQLite in-memory DB session (for ISRC cache, ServiceAccountService) Scenarios covered per synchronizer: happy path, no-op (nothing changes), add + remove, dedup, ISRC miss, target playlist not found, mid-sync exception → BaseSynchronizer wraps it. """ from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest_asyncio from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from sqlmodel import SQLModel from playlist_sync.models.enums import SyncError 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.synchronizers.deezer import DeezerSynchronizer from playlist_sync.synchronizers.soundcloud import SoundCloudSynchronizer from playlist_sync.synchronizers.spotify import SpotifySynchronizer TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" # --------------------------------------------------------------------------- # DB fixtures # --------------------------------------------------------------------------- @pytest_asyncio.fixture(scope="function") async def db_engine(): engine = create_async_engine( TEST_DATABASE_URL, connect_args={"check_same_thread": False}, poolclass=StaticPool, ) async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) yield engine async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.drop_all) await engine.dispose() @pytest_asyncio.fixture(scope="function") async def session(db_engine): TestSession = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) async with TestSession() as s: yield s # --------------------------------------------------------------------------- # Test data helpers # --------------------------------------------------------------------------- def _generic_track( uri: str, isrc: str = "US-X00-23-00001", name: str = "Track" ) -> GenericTrack: return GenericTrack(name=name, artists=["Artist"], isrc=isrc, spotify_uri=uri) def _generic_playlist(*uris: str, name: str = "Test Playlist") -> GenericPlaylist: tracks = [_generic_track(u, isrc=f"ISRC-{i}") for i, u in enumerate(uris)] return GenericPlaylist(name=name, description=None, image=None, tracks=tracks) def _sync( to_playlist_id: str = "target-001", title_copy_mode: int = 0 ) -> PlaylistSynchronization: return PlaylistSynchronization( id=1, application_id=1, from_playlist_id="source-001", from_service_type=0, to_playlist_id=to_playlist_id, to_service_account_id=10, title="My Playlist", title_copy_mode=title_copy_mode, active=True, ) def _account( user_id: str = "spotify_user", music_service_id: int = 1 ) -> ServiceAccount: return ServiceAccount( id=10, application_id=1, service_type=0, music_service_id=music_service_id, user_identifier=user_id, access_token="fake-token", ) def _spotify_target_playlist(uris: list[str], playlist_id: str = "target-001") -> dict: return { "id": playlist_id, "snapshot_id": "snap1", "tracks": { "items": [ {"track": {"uri": u, "name": "T"}, "is_local": False} for u in uris ] }, } # =========================================================================== # SpotifySynchronizer # =========================================================================== class TestSpotifySynchronizer: def _patched( self, target_uris: list[str], add_return=None, delete_return=None, target_items_after=None, sp_keys=None, ): """Return a list of context managers that patch spotify_client for one test.""" from unittest.mock import patch as _patch import playlist_sync.synchronizers.spotify as mod mock_sp = MagicMock() target_playlist = _spotify_target_playlist(target_uris) after_items = target_items_after or [ {"track": {"uri": u, "name": "T"}, "is_local": False} for u in target_uris ] patches = [ _patch.object( mod.spotify_client, "get_playlist_with_all_tracks", return_value=target_playlist, ), _patch.object( mod.spotify_client, "get_all_playlist_track_items", return_value=after_items, ), _patch.object( mod.spotify_client, "get_authenticated_client", return_value=mock_sp ), _patch.object( mod.spotify_client, "add_all_tracks", return_value=[{"snapshot_id": "s2"}], ), _patch.object( mod.spotify_client, "add_tracks_at_position", return_value=[{"snapshot_id": "s2"}], ), _patch.object( mod.spotify_client, "delete_multiple_playlist_tracks", return_value="s3" ), _patch.object( mod.spotify_client, "delete_playlist_tracks_by_position", return_value="s4", ), _patch.object( mod.spotify_client, "order_playlist_tracks", return_value={"snapshot_id": "s5"}, ), _patch.object( mod.spotify_client, "set_playlist_details", return_value=None ), # tblSpotifyApiKeys doesn't exist in test SQLite DB — mock at service level _patch( "playlist_sync.synchronizers.spotify.ServiceAccountService.get_spotify_api_keys", new=AsyncMock(return_value=None), ), ] return patches def _apply(self, patches): """Context manager that applies all patches in the list.""" from contextlib import ExitStack stack = ExitStack() for p in patches: stack.enter_context(p) return stack async def test_happy_path_adds_new_tracks(self, session): """Source has 3 tracks, target is empty → 3 added.""" source = _generic_playlist("a", "b", "c") sync = _sync() account = _account() with self._apply(self._patched(target_uris=[])): result = await SpotifySynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error is None assert result.added_tracks == 3 assert result.deleted_tracks == 0 async def test_noop_when_source_equals_target(self, session): """Same URIs in source and target → nothing added or removed.""" uris = ["a", "b", "c"] source = _generic_playlist(*uris) sync = _sync() account = _account() with self._apply( self._patched( target_uris=uris, target_items_after=[ {"track": {"uri": u, "name": "T"}, "is_local": False} for u in uris ], ) ): result = await SpotifySynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error is None assert result.added_tracks == 0 assert result.deleted_tracks == 0 assert result.made_changes is False async def test_removes_stale_and_adds_new(self, session): """Source has [a, c], target has [a, b] → remove b, add c.""" source = _generic_playlist("a", "c") sync = _sync() account = _account() with self._apply( self._patched( target_uris=["a", "b"], target_items_after=[ {"track": {"uri": u, "name": "T"}, "is_local": False} for u in ["a", "c"] ], ) ): result = await SpotifySynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error is None assert result.added_tracks == 1 assert result.deleted_tracks == 1 async def test_source_duplicates_are_preserved_in_target(self, session): """Source has [a, a, b] and empty target → target gets [a, a, b].""" source = GenericPlaylist( name="P", description=None, image=None, tracks=[ _generic_track("a"), _generic_track("a"), _generic_track("b"), ], ) sync = _sync() account = _account() added_calls: list[tuple[list[str], int]] = [] import playlist_sync.synchronizers.spotify as mod patches = self._patched(target_uris=[]) orig_add = mod.spotify_client.add_tracks_at_position def capture_add(sp, user, pl, uris, position): added_calls.append((list(uris), position)) return [{"snapshot_id": "s"}] with self._apply(patches): mod.spotify_client.add_tracks_at_position = capture_add try: result = await SpotifySynchronizer().copy_to_playlist( source, sync, account, session ) finally: mod.spotify_client.add_tracks_at_position = orig_add assert result.error is None # All three source tracks (including the duplicate) should have been added # in source order at position 0. all_added = [uri for batch, _pos in added_calls for uri in batch] assert all_added == ["a", "a", "b"] assert result.added_tracks == 3 async def test_source_dupes_with_partial_target(self, session): """Source [a, a, b], target [a, b] → exactly one extra "a" inserted.""" source = GenericPlaylist( name="P", description=None, image=None, tracks=[ _generic_track("a"), _generic_track("a"), _generic_track("b"), ], ) sync = _sync() account = _account() added_calls: list[tuple[list[str], int]] = [] import playlist_sync.synchronizers.spotify as mod patches = self._patched(target_uris=["a", "b"]) orig_add = mod.spotify_client.add_tracks_at_position def capture_add(sp, user, pl, uris, position): added_calls.append((list(uris), position)) return [{"snapshot_id": "s"}] with self._apply(patches): mod.spotify_client.add_tracks_at_position = capture_add try: result = await SpotifySynchronizer().copy_to_playlist( source, sync, account, session ) finally: mod.spotify_client.add_tracks_at_position = orig_add assert result.error is None all_added = [uri for batch, _pos in added_calls for uri in batch] assert all_added == ["a"] assert result.added_tracks == 1 assert result.deleted_tracks == 0 async def test_source_fewer_dupes_than_target_removes_extras(self, session): """Source [a, a, b], target [a, a, a] → one "a" removed, one "b" inserted.""" source = GenericPlaylist( name="P", description=None, image=None, tracks=[ _generic_track("a"), _generic_track("a"), _generic_track("b"), ], ) sync = _sync() account = _account() added_calls: list[tuple[list[str], int]] = [] delete_calls: list[list[int]] = [] import playlist_sync.synchronizers.spotify as mod patches = self._patched(target_uris=["a", "a", "a"]) orig_add = mod.spotify_client.add_tracks_at_position orig_del = mod.spotify_client.delete_playlist_tracks_by_position def capture_add(sp, user, pl, uris, position): added_calls.append((list(uris), position)) return [{"snapshot_id": "s"}] def capture_del(sp, user, pl, positions, items, snapshot_id=None): delete_calls.append(list(positions)) return "s-del" with self._apply(patches): mod.spotify_client.add_tracks_at_position = capture_add mod.spotify_client.delete_playlist_tracks_by_position = capture_del try: result = await SpotifySynchronizer().copy_to_playlist( source, sync, account, session ) finally: mod.spotify_client.add_tracks_at_position = orig_add mod.spotify_client.delete_playlist_tracks_by_position = orig_del assert result.error is None all_added = [uri for batch, _pos in added_calls for uri in batch] assert all_added == ["b"] # one position deleted (a single "a" at the end of the run) assert sum(len(p) for p in delete_calls) == 1 async def test_local_track_is_removed_first(self, session): """Target [a, LOCAL, b], source [a, b] → LOCAL removed, no other changes.""" source = _generic_playlist("a", "b") sync = _sync() account = _account() delete_calls: list[list[int]] = [] import playlist_sync.synchronizers.spotify as mod # Build a custom target with a local track between two normal tracks target_playlist = { "id": "target-001", "snapshot_id": "snap1", "tracks": { "items": [ {"track": {"uri": "a", "name": "T"}, "is_local": False}, {"track": {"uri": "local", "name": "L"}, "is_local": True}, {"track": {"uri": "b", "name": "T"}, "is_local": False}, ] }, } patches = self._patched(target_uris=["a", "b"]) # Override get_playlist_with_all_tracks to inject the local track from unittest.mock import patch as _patch patches.append( _patch.object( mod.spotify_client, "get_playlist_with_all_tracks", return_value=target_playlist, ) ) orig_del = mod.spotify_client.delete_playlist_tracks_by_position def capture_del(sp, user, pl, positions, items, snapshot_id=None): delete_calls.append(list(positions)) return "s-del" with self._apply(patches): mod.spotify_client.delete_playlist_tracks_by_position = capture_del try: result = await SpotifySynchronizer().copy_to_playlist( source, sync, account, session ) finally: mod.spotify_client.delete_playlist_tracks_by_position = orig_del assert result.error is None # Exactly one delete call, removing position 1 (the local track) assert delete_calls == [[1]] # The non-local sequence [a, b] already matches source → no diff ops assert result.added_tracks == 0 assert result.deleted_tracks == 0 async def test_target_not_found_returns_error(self, session): """When the target playlist cannot be fetched, return NoTargetPlaylist error.""" source = _generic_playlist("a") sync = _sync() account = _account() import playlist_sync.synchronizers.spotify as mod with patch( "playlist_sync.synchronizers.spotify.ServiceAccountService.get_spotify_api_keys", new=AsyncMock(return_value=None), ): with patch.object( mod.spotify_client, "get_playlist_with_all_tracks", return_value=None ): with patch.object( mod.spotify_client, "get_authenticated_client", return_value=MagicMock(), ): result = await SpotifySynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error == SyncError.NoTargetPlaylist async def test_exception_is_wrapped_by_base(self, session): """Unhandled exception in _copy_internal must be caught by BaseSynchronizer.""" source = _generic_playlist("a") sync = _sync() account = _account() import playlist_sync.synchronizers.spotify as mod with patch( "playlist_sync.synchronizers.spotify.ServiceAccountService.get_spotify_api_keys", new=AsyncMock(return_value=None), ): with patch.object( mod.spotify_client, "get_playlist_with_all_tracks", side_effect=RuntimeError("boom"), ): with patch.object( mod.spotify_client, "get_authenticated_client", return_value=MagicMock(), ): result = await SpotifySynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error is not None assert "boom" in (result.error_text or "") async def test_title_copy_source_updates_playlist(self, session): """TitleCopyMode=CopySource: set_playlist_details called with source name.""" uris = ["a", "b"] source = _generic_playlist(*uris, name="Source Playlist Name") sync = _sync(title_copy_mode=2) # CopySource account = _account() import playlist_sync.synchronizers.spotify as mod patches = self._patched( target_uris=uris, target_items_after=[ {"track": {"uri": u, "name": "T"}, "is_local": False} for u in uris ], ) mock_set_details = MagicMock() patches.append( patch.object(mod.spotify_client, "set_playlist_details", mock_set_details) ) with self._apply(patches): result = await SpotifySynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error is None mock_set_details.assert_called_once() call_args = mock_set_details.call_args assert call_args.args[3] == "Source Playlist Name" # name positional arg assert sync.to_playlist_title == "Source Playlist Name" async def test_title_use_setting_updates_playlist(self, session): """TitleCopyMode=UseSetting: set_playlist_details is called with sync.title.""" uris = ["a"] source = _generic_playlist(*uris, name="Source Name") sync = _sync(title_copy_mode=1) # UseSetting sync.title = "My Custom Title" account = _account() import playlist_sync.synchronizers.spotify as mod patches = self._patched( target_uris=uris, target_items_after=[ {"track": {"uri": u, "name": "T"}, "is_local": False} for u in uris ], ) mock_set_details = MagicMock() patches.append( patch.object(mod.spotify_client, "set_playlist_details", mock_set_details) ) with self._apply(patches): result = await SpotifySynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error is None mock_set_details.assert_called_once() assert mock_set_details.call_args.args[3] == "My Custom Title" assert sync.to_playlist_title == "My Custom Title" async def test_description_copy_source(self, session): """DescriptionCopyMode=CopySource with title NoUpdate: details are updated.""" uris = ["a"] source = GenericPlaylist( name="P", description="Source description text", image=None, tracks=[_generic_track(u) for u in uris], ) sync = _sync(title_copy_mode=0) # NoUpdate for title sync.description_copy_mode = 2 # CopySource account = _account() import playlist_sync.synchronizers.spotify as mod patches = self._patched( target_uris=uris, target_items_after=[ {"track": {"uri": u, "name": "T"}, "is_local": False} for u in uris ], ) mock_set_details = MagicMock() patches.append( patch.object(mod.spotify_client, "set_playlist_details", mock_set_details) ) with self._apply(patches): result = await SpotifySynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error is None mock_set_details.assert_called_once() # description is the 6th positional arg (index 5) assert mock_set_details.call_args.args[5] == "Source description text" async def test_no_update_skips_set_playlist_details(self, session): """TitleCopyMode=NoUpdate + DescCopyMode=NoUpdate: details not updated.""" uris = ["a"] source = _generic_playlist(*uris) sync = _sync(title_copy_mode=0) # NoUpdate account = _account() import playlist_sync.synchronizers.spotify as mod patches = self._patched( target_uris=uris, target_items_after=[ {"track": {"uri": u, "name": "T"}, "is_local": False} for u in uris ], ) mock_set_details = MagicMock() patches.append( patch.object(mod.spotify_client, "set_playlist_details", mock_set_details) ) with self._apply(patches): result = await SpotifySynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error is None mock_set_details.assert_not_called() # =========================================================================== # DeezerSynchronizer # =========================================================================== class TestDeezerSynchronizer: async def test_happy_path_no_existing_tracks(self, session): """Target is empty, source has 2 tracks with Deezer IDs → 2 added.""" source = GenericPlaylist( name="P", description=None, image=None, tracks=[ GenericTrack(name="A", artists=[], isrc="ISO-1", spotify_uri="s:a"), GenericTrack(name="B", artists=[], isrc="ISO-2", spotify_uri="s:b"), ], ) sync = _sync(to_playlist_id="55") account = _account(music_service_id=2) import playlist_sync.synchronizers.deezer as mod from playlist_sync.services import isrc_cache_service as cache_mod deezer_playlist = {"id": 55, "tracks": {"data": []}, "title": "P"} isrc_lookup = {"ISO-1": {"id": 101}, "ISO-2": {"id": 102}} async def fake_get_track(token, isrc): return isrc_lookup.get(isrc) with ( patch.object( mod.deezer_client, "get_playlist", new=AsyncMock(return_value=deezer_playlist), ), patch.object( mod.deezer_client, "get_track_by_isrc", new=AsyncMock(side_effect=fake_get_track), ), patch.object(mod.deezer_client, "add_tracks", new=AsyncMock()), patch.object(mod.deezer_client, "delete_playlist_tracks", new=AsyncMock()), patch.object(mod.deezer_client, "order_playlist_tracks", new=AsyncMock()), patch.object( cache_mod, "get_synchronized_tracks", new=AsyncMock(return_value=[]) ), patch.object(cache_mod, "save_synchronized_track", new=AsyncMock()), ): result = await DeezerSynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error is None assert result.added_tracks == 2 assert result.deleted_tracks == 0 async def test_isrc_miss_skips_track(self, session): """Tracks with no Deezer match are skipped (not added).""" source = GenericPlaylist( name="P", description=None, image=None, tracks=[ GenericTrack(name="X", artists=[], isrc="MISS-1", spotify_uri="s:x") ], ) sync = _sync(to_playlist_id="66") account = _account(music_service_id=2) import playlist_sync.synchronizers.deezer as mod from playlist_sync.services import isrc_cache_service as cache_mod deezer_playlist = {"id": 66, "tracks": {"data": []}, "title": "P"} add_mock = AsyncMock() with ( patch.object( mod.deezer_client, "get_playlist", new=AsyncMock(return_value=deezer_playlist), ), patch.object( mod.deezer_client, "get_track_by_isrc", new=AsyncMock(return_value=None) ), patch.object(mod.deezer_client, "add_tracks", new=add_mock), patch.object(mod.deezer_client, "order_playlist_tracks", new=AsyncMock()), patch.object( cache_mod, "get_synchronized_tracks", new=AsyncMock(return_value=[]) ), patch.object(cache_mod, "save_synchronized_track", new=AsyncMock()), ): result = await DeezerSynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error is None add_mock.assert_not_called() async def test_target_not_found_returns_error(self, session): source = _generic_playlist("s:a") sync = _sync(to_playlist_id="99") account = _account(music_service_id=2) import playlist_sync.synchronizers.deezer as mod with patch.object( mod.deezer_client, "get_playlist", new=AsyncMock(return_value=None) ): result = await DeezerSynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error == SyncError.NoTargetPlaylist async def test_uses_isrc_cache_on_second_run(self, session): """When cache returns a hit, the Deezer ISRC API should NOT be called.""" from playlist_sync.models.enums import ServiceType from playlist_sync.models.sync_track import SynchronizationTrack cached = SynchronizationTrack( isrc="CACHED-ISRC", service_type=int(ServiceType.Deezer), track_id="999", match_date=datetime.utcnow(), ) # naive, as MySQL returns source = GenericPlaylist( name="P", description=None, image=None, tracks=[ GenericTrack( name="A", artists=[], isrc="CACHED-ISRC", spotify_uri="s:a" ) ], ) sync = _sync(to_playlist_id="77") account = _account(music_service_id=2) import playlist_sync.synchronizers.deezer as mod from playlist_sync.services import isrc_cache_service as cache_mod deezer_playlist = {"id": 77, "tracks": {"data": []}, "title": "P"} api_mock = AsyncMock() with ( patch.object( mod.deezer_client, "get_playlist", new=AsyncMock(return_value=deezer_playlist), ), patch.object(mod.deezer_client, "get_track_by_isrc", new=api_mock), patch.object(mod.deezer_client, "add_tracks", new=AsyncMock()), patch.object(mod.deezer_client, "order_playlist_tracks", new=AsyncMock()), patch.object( cache_mod, "get_synchronized_tracks", new=AsyncMock(return_value=[cached]), ), patch.object(cache_mod, "save_synchronized_track", new=AsyncMock()), ): result = await DeezerSynchronizer().copy_to_playlist( source, sync, account, session ) api_mock.assert_not_called() assert result.error is None async def test_exception_wrapped_by_base(self, session): source = _generic_playlist("s:a") sync = _sync(to_playlist_id="88") account = _account(music_service_id=2) import playlist_sync.synchronizers.deezer as mod with patch.object( mod.deezer_client, "get_playlist", new=AsyncMock(side_effect=RuntimeError("oops")), ): result = await DeezerSynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error is not None assert "oops" in (result.error_text or "") async def test_duplicate_source_logs_warning_but_still_dedupes( self, session, caplog ): """Source has duplicate URIs → Deezer dedupes BUT a warning is logged.""" import logging source = GenericPlaylist( name="P", description=None, image=None, tracks=[ GenericTrack(name="A", artists=[], isrc="ISO-1", spotify_uri="s:a"), GenericTrack(name="A2", artists=[], isrc="ISO-1", spotify_uri="s:a"), GenericTrack(name="B", artists=[], isrc="ISO-2", spotify_uri="s:b"), ], ) sync = _sync(to_playlist_id="155") account = _account(music_service_id=2) import playlist_sync.synchronizers.deezer as mod from playlist_sync.services import isrc_cache_service as cache_mod deezer_playlist = {"id": 155, "tracks": {"data": []}, "title": "P"} isrc_lookup = {"ISO-1": {"id": 101}, "ISO-2": {"id": 102}} async def fake_get_track(token, isrc): return isrc_lookup.get(isrc) add_mock = AsyncMock() with ( caplog.at_level(logging.WARNING, logger="playlist_sync.synchronizer"), patch.object( mod.deezer_client, "get_playlist", new=AsyncMock(return_value=deezer_playlist), ), patch.object( mod.deezer_client, "get_track_by_isrc", new=AsyncMock(side_effect=fake_get_track), ), patch.object(mod.deezer_client, "add_tracks", new=add_mock), patch.object(mod.deezer_client, "delete_playlist_tracks", new=AsyncMock()), patch.object(mod.deezer_client, "order_playlist_tracks", new=AsyncMock()), patch.object( cache_mod, "get_synchronized_tracks", new=AsyncMock(return_value=[]) ), patch.object(cache_mod, "save_synchronized_track", new=AsyncMock()), ): result = await DeezerSynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error is None # Warning was logged messages = [r.getMessage() for r in caplog.records] assert any( "duplicate" in r.getMessage().lower() and r.levelno == logging.WARNING for r in caplog.records ), f"Expected duplicate warning, got: {messages}" # Behaviour unchanged: only 2 unique Deezer IDs added assert result.added_tracks == 2 # =========================================================================== # SoundCloudSynchronizer # =========================================================================== class TestSoundCloudSynchronizer: async def test_target_not_found_returns_error(self, session): source = _generic_playlist("s:a") sync = _sync(to_playlist_id="123") account = _account(music_service_id=4) import playlist_sync.synchronizers.soundcloud as mod with patch.object( mod.soundcloud_client, "get_playlist_by_id", new=AsyncMock(return_value=None), ): with patch.object( mod.soundcloud_client, "get_playlist_by_url", new=AsyncMock(return_value=None), ): result = await SoundCloudSynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error == SyncError.NoTargetPlaylist async def test_exception_wrapped_by_base(self, session): source = _generic_playlist("s:a") sync = _sync(to_playlist_id="456") account = _account(music_service_id=4) import playlist_sync.synchronizers.soundcloud as mod with patch.object( mod.soundcloud_client, "get_playlist_by_id", new=AsyncMock(side_effect=RuntimeError("sc-err")), ): with patch.object( mod.soundcloud_client, "get_playlist_by_url", new=AsyncMock(return_value=None), ): result = await SoundCloudSynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error is not None async def test_happy_path_updates_playlist(self, session): """Tracks resolved via ISRC search → playlist updated.""" source = GenericPlaylist( name="SC", description=None, image=None, tracks=[ GenericTrack(name="T1", artists=[], isrc="SC-001", spotify_uri="s:t1") ], ) sync = _sync(to_playlist_id="789") account = _account(user_id="sc_user", music_service_id=4) import playlist_sync.synchronizers.soundcloud as mod sc_playlist = {"id": 789, "tracks": [], "permalink_url": None} sc_track = {"id": 5001, "title": "T1", "publisher_metadata": {"isrc": "SC-001"}} with ( patch.object( mod.soundcloud_client, "get_playlist_by_id", new=AsyncMock(return_value=sc_playlist), ), patch.object( mod.soundcloud_client, "search_tracks", new=AsyncMock(return_value=[sc_track]), ), patch.object( mod.soundcloud_client, "add_tracks_to_playlist", new=AsyncMock(return_value=sc_playlist), ), patch.object( mod.soundcloud_client, "update_playlist_info", new=AsyncMock(return_value=sc_playlist), ), ): result = await SoundCloudSynchronizer().copy_to_playlist( source, sync, account, session ) assert result.error is None