"""Layer 5: Worker task integration tests. Tasks are invoked synchronously via task.apply() — no Celery broker required. Each test: 1. Creates a temporary SQLite file-based DB (using tmp_path) 2. Patches settings.DATABASE_URL to point at it 3. Seeds the required rows 4. Patches external dependencies (Spotify API, synchronizers) 5. Calls task.apply() and inspects DB side-effects Tests are synchronous (no @pytest.mark.asyncio) because task.apply() is synchronous and calls asyncio.run() internally — nesting event loops would fail. """ import asyncio from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from sqlmodel import SQLModel, select from playlist_sync.models.application import Application # noqa: F401 from playlist_sync.models.service_account import ServiceAccount # noqa: F401 from playlist_sync.models.sync_log import PlaylistSynchronizationLog # noqa: F401 # Import all models so SQLModel.metadata knows their tables before create_all runs from playlist_sync.models.sync_task import PlaylistSynchronization # noqa: F401 # --------------------------------------------------------------------------- # Helpers — run DB setup/queries in isolated event loops # --------------------------------------------------------------------------- def _run(coro): return asyncio.run(coro) def _db_url(tmp_path: Path) -> str: return f"sqlite+aiosqlite:///{tmp_path / 'worker_test.db'}" async def _setup_tables(url: str) -> None: engine = create_async_engine(url, connect_args={"check_same_thread": False}) async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) await engine.dispose() async def _seed( url: str, *, application_id: int = 1, account_app_id: int = 1, active: bool = True, music_service_id: int = 1, # 1=Spotify, 2=Deezer, 3=YouTube, 4=SoundCloud ) -> tuple[int, int]: """Insert ServiceAccount + PlaylistSynchronization, return (sync_id, account_id).""" engine = create_async_engine(url, connect_args={"check_same_thread": False}) Session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with Session() as session: acct = ServiceAccount( application_id=account_app_id, service_type=0, music_service_id=music_service_id, user_identifier="test_user", access_token="test_tok", ) session.add(acct) await session.commit() await session.refresh(acct) sync = PlaylistSynchronization( application_id=application_id, from_playlist_id="spotify:playlist:abc123", from_service_type=0, to_playlist_id="target-001", to_service_account_id=acct.id, title="Test Sync", active=active, ) session.add(sync) await session.commit() await session.refresh(sync) return sync.id, acct.id await engine.dispose() async def _get_last_log(url: str, sync_id: int): engine = create_async_engine(url, connect_args={"check_same_thread": False}) Session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with Session() as session: result = await session.execute( select(PlaylistSynchronizationLog) .where(PlaylistSynchronizationLog.sync_id == sync_id) .order_by(PlaylistSynchronizationLog.time.desc()) ) # type: ignore[attr-defined] return result.scalars().first() await engine.dispose() async def _get_sync(url: str, sync_id: int): engine = create_async_engine(url, connect_args={"check_same_thread": False}) Session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with Session() as session: result = await session.execute( select(PlaylistSynchronization).where(PlaylistSynchronization.id == sync_id) ) return result.scalars().first() await engine.dispose() async def _count_logs(url: str, sync_id: int) -> int: engine = create_async_engine(url, connect_args={"check_same_thread": False}) Session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with Session() as session: result = await session.execute( select(PlaylistSynchronizationLog).where( PlaylistSynchronizationLog.sync_id == sync_id ) ) return len(result.scalars().all()) await engine.dispose() # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def db_url(tmp_path): url = _db_url(tmp_path) _run(_setup_tables(url)) return url def _make_spotify_playlist(track_count: int = 2) -> dict: """Build a minimal fake Spotify playlist API response.""" tracks = [ { "track": { "name": f"Track {i}", "uri": f"spotify:track:uri{i}", "artists": [{"name": "Artist"}], "external_ids": {"isrc": f"ISRC000{i}"}, }, "is_local": False, } for i in range(track_count) ] return { "name": "My Source Playlist", "description": "A test playlist", "images": [{"url": "https://example.com/img.jpg"}], "owner": {"id": "testuser"}, "tracks": {"items": tracks}, } def _make_sync_result(added: int = 2, deleted: int = 0, error=None): from playlist_sync.models.playlist_sync_result import PlaylistSynchronizationResult if error: return PlaylistSynchronizationResult(error=error, error_text=str(error)) return PlaylistSynchronizationResult( synced_track_count=added, added_tracks=added, deleted_tracks=deleted, made_changes=added > 0 or deleted > 0, ) # --------------------------------------------------------------------------- # Helper: run the task synchronously with patched DB URL # --------------------------------------------------------------------------- def _apply_task(sync_id: int, db_url: str, extra_patches=None): """Run execute_single_sync_task synchronously with the test DB.""" import contextlib patches = [] if extra_patches: patches.extend(extra_patches) with contextlib.ExitStack() as stack: for p in patches: stack.enter_context(p) # Import fresh each time to pick up patches import worker.tasks as wt with patch.object(wt, "settings") as ms: ms.DATABASE_URL = db_url ms.SYNC_ACCOUNT_FILTER_FILE = "/nonexistent/filter.txt" return wt.execute_single_sync_task.apply(args=[sync_id]) def _apply_sweep(db_url: str): import worker.tasks as wt with patch.object(wt, "settings") as ms: ms.DATABASE_URL = db_url ms.SYNC_ACCOUNT_FILTER_FILE = "/nonexistent/filter.txt" return wt.periodic_sync_sweep.apply() # --------------------------------------------------------------------------- # Tests: execute_single_sync_task # --------------------------------------------------------------------------- class TestExecuteSingleSyncTask: def test_skip_nonexistent_sync(self, db_url): """Task returns skipped when sync_id does not exist.""" result = _apply_task(sync_id=99999, db_url=db_url) assert result.successful() data = result.get() assert data["status"] == "skipped" assert data["reason"] == "not_found" def test_skip_inactive_sync(self, db_url): """Task returns skipped when sync is inactive.""" sync_id, _ = _run(_seed(db_url, active=False)) result = _apply_task(sync_id=sync_id, db_url=db_url) assert result.successful() data = result.get() assert data["status"] == "skipped" assert data["reason"] == "inactive" def test_application_id_mismatch_writes_error_log(self, db_url): """account.application_id != sync.application_id logs ApplicationMismatch.""" sync_id, _ = _run(_seed(db_url, application_id=1, account_app_id=2)) result = _apply_task(sync_id=sync_id, db_url=db_url) assert result.successful() log = _run(_get_last_log(db_url, sync_id)) assert log is not None assert log.error == "ApplicationMismatch" def test_no_source_playlist_writes_error_log(self, db_url): """Spotify returns None for source playlist → NoSourcePlaylist error log.""" sync_id, _ = _run(_seed(db_url)) with patch( "playlist_sync.clients.spotify_client.get_playlist_with_all_tracks", return_value=None, ): result = _apply_task(sync_id=sync_id, db_url=db_url) assert result.successful() log = _run(_get_last_log(db_url, sync_id)) assert log is not None assert log.error == "NoSourcePlaylist" def test_successful_spotify_sync_writes_log(self, db_url): """Happy path with SpotifySynchronizer: log row created, count updated.""" sync_id, _ = _run(_seed(db_url, music_service_id=1)) fake_result = _make_sync_result(added=3, deleted=1) mock_sync = MagicMock() mock_sync.copy_to_playlist = AsyncMock(return_value=fake_result) with ( patch( "playlist_sync.clients.spotify_client.get_playlist_with_all_tracks", return_value=_make_spotify_playlist(2), ), patch( "playlist_sync.synchronizers.spotify.SpotifySynchronizer", return_value=mock_sync, ), ): result = _apply_task(sync_id=sync_id, db_url=db_url) assert result.successful() data = result.get() assert data["status"] == "success" assert data["added"] == 3 assert data["removed"] == 1 log = _run(_get_last_log(db_url, sync_id)) assert log is not None assert log.error is None assert log.added_tracks == 3 assert log.deleted_tracks == 1 sync = _run(_get_sync(db_url, sync_id)) assert sync.synchronized_track_count == 3 def test_successful_deezer_sync_routes_to_deezer_synchronizer(self, db_url): """Account with music_service_id=2 routes to DeezerSynchronizer.""" sync_id, _ = _run(_seed(db_url, music_service_id=2)) fake_result = _make_sync_result(added=1) mock_sync = MagicMock() mock_sync.copy_to_playlist = AsyncMock(return_value=fake_result) patched_cls = MagicMock(return_value=mock_sync) with ( patch( "playlist_sync.clients.spotify_client.get_playlist_with_all_tracks", return_value=_make_spotify_playlist(1), ), patch("playlist_sync.synchronizers.deezer.DeezerSynchronizer", patched_cls), ): _apply_task(sync_id=sync_id, db_url=db_url) patched_cls.assert_called_once() def test_synchronizer_exception_writes_error_log_and_sets_error_flag(self, db_url): """When synchronizer raises, error is recorded and task still succeeds.""" sync_id, _ = _run(_seed(db_url, music_service_id=1)) mock_sync = MagicMock() mock_sync.copy_to_playlist = AsyncMock( side_effect=RuntimeError("Spotify exploded") ) with ( patch( "playlist_sync.clients.spotify_client.get_playlist_with_all_tracks", return_value=_make_spotify_playlist(2), ), patch( "playlist_sync.synchronizers.spotify.SpotifySynchronizer", return_value=mock_sync, ), ): result = _apply_task(sync_id=sync_id, db_url=db_url) # Task itself should not raise (error is recorded internally) assert result.successful() data = result.get() assert data["status"] == "error" log = _run(_get_last_log(db_url, sync_id)) assert log is not None assert log.error is not None # VendorSpecific or similar sync = _run(_get_sync(db_url, sync_id)) assert sync.error is True def test_empty_source_playlist_writes_vendor_specific_error(self, db_url): """Source playlist with no tracks → VendorSpecific error log.""" sync_id, _ = _run(_seed(db_url)) empty_playlist = _make_spotify_playlist(0) with patch( "playlist_sync.clients.spotify_client.get_playlist_with_all_tracks", return_value=empty_playlist, ): result = _apply_task(sync_id=sync_id, db_url=db_url) assert result.successful() log = _run(_get_last_log(db_url, sync_id)) assert log is not None assert log.error == "VendorSpecific" def test_triggered_manually_flag_propagates_to_log(self, db_url): """triggered_manually=True should appear in the log row.""" sync_id, _ = _run(_seed(db_url, music_service_id=1)) fake_result = _make_sync_result(added=1) mock_sync = MagicMock() mock_sync.copy_to_playlist = AsyncMock(return_value=fake_result) with ( patch( "playlist_sync.clients.spotify_client.get_playlist_with_all_tracks", return_value=_make_spotify_playlist(1), ), patch( "playlist_sync.synchronizers.spotify.SpotifySynchronizer", return_value=mock_sync, ), ): import worker.tasks as wt with patch.object(wt, "settings") as ms: ms.DATABASE_URL = db_url ms.SYNC_ACCOUNT_FILTER_FILE = "/nonexistent/filter.txt" wt.execute_single_sync_task.apply( args=[sync_id, True] ) # triggered_manually=True log = _run(_get_last_log(db_url, sync_id)) assert log is not None assert log.triggered_manually is True def test_source_metadata_saved_on_sync_row(self, db_url): """source_title and source_image are populated from the Spotify response.""" sync_id, _ = _run(_seed(db_url, music_service_id=1)) fake_result = _make_sync_result(added=2) mock_sync = MagicMock() mock_sync.copy_to_playlist = AsyncMock(return_value=fake_result) spotify_payload = _make_spotify_playlist(2) spotify_payload["name"] = "Expected Title" spotify_payload["images"] = [{"url": "https://example.com/cover.jpg"}] with ( patch( "playlist_sync.clients.spotify_client.get_playlist_with_all_tracks", return_value=spotify_payload, ), patch( "playlist_sync.synchronizers.spotify.SpotifySynchronizer", return_value=mock_sync, ), ): _apply_task(sync_id=sync_id, db_url=db_url) sync = _run(_get_sync(db_url, sync_id)) assert sync.source_title == "Expected Title" assert sync.source_image == "https://example.com/cover.jpg" assert sync.source_track_count == 2 # --------------------------------------------------------------------------- # Tests: periodic_sync_sweep # --------------------------------------------------------------------------- class TestPeriodicSyncSweep: def test_sweep_dispatches_active_syncs(self, db_url): """Sweep dispatches execute_single_sync_task for each active sync.""" sync_id_1, _ = _run(_seed(db_url, active=True)) sync_id_2, _ = _run(_seed(db_url, active=True)) dispatched = [] import worker.tasks as wt original_delay = wt.execute_single_sync_task.delay def capture_delay(sid): dispatched.append(sid) with ( patch.object(wt, "settings") as ms, patch.object( wt.execute_single_sync_task, "delay", side_effect=capture_delay ), ): ms.DATABASE_URL = db_url ms.SYNC_ACCOUNT_FILTER_FILE = "/nonexistent/filter.txt" wt.periodic_sync_sweep.apply() assert sync_id_1 in dispatched assert sync_id_2 in dispatched def test_sweep_skips_inactive_syncs(self, db_url): """Inactive syncs are NOT dispatched by the sweep.""" _run(_seed(db_url, active=False)) dispatched = [] import worker.tasks as wt with ( patch.object(wt, "settings") as ms, patch.object( wt.execute_single_sync_task, "delay", side_effect=dispatched.append ), ): ms.DATABASE_URL = db_url ms.SYNC_ACCOUNT_FILTER_FILE = "/nonexistent/filter.txt" wt.periodic_sync_sweep.apply() assert dispatched == [] def test_sweep_returns_dispatch_count(self, db_url): """Sweep result includes how many tasks were dispatched.""" _run(_seed(db_url, active=True)) import worker.tasks as wt with ( patch.object(wt, "settings") as ms, patch.object(wt.execute_single_sync_task, "delay"), ): ms.DATABASE_URL = db_url ms.SYNC_ACCOUNT_FILTER_FILE = "/nonexistent/filter.txt" result = wt.periodic_sync_sweep.apply() data = result.get() assert data["status"] == "success" assert data["dispatched"] >= 1 def test_sweep_respects_account_filter_file(self, db_url, tmp_path): """When a filter file exists, only syncs for listed account IDs are run.""" sync_id_allowed, account_id = _run(_seed(db_url, active=True)) sync_id_blocked, _ = _run(_seed(db_url, active=True)) # different account # Write filter file with only the first account filter_file = tmp_path / "filter.txt" filter_file.write_text(str(account_id)) dispatched = [] import worker.tasks as wt with ( patch.object(wt, "settings") as ms, patch.object( wt.execute_single_sync_task, "delay", side_effect=dispatched.append ), ): ms.DATABASE_URL = db_url ms.SYNC_ACCOUNT_FILTER_FILE = str(filter_file) wt.periodic_sync_sweep.apply() assert sync_id_allowed in dispatched assert sync_id_blocked not in dispatched