"""Extended integration tests for all API routes. Each test uses a per-function SQLite in-memory database, a real FastAPI app wired via ASGITransport, and seeded data added directly through SQLAlchemy sessions (bypassing the API where needed to keep tests focused). Celery tasks are patched so no broker is required. """ from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock, patch import pytest_asyncio from httpx import ASGITransport, AsyncClient 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.api import app from playlist_sync.models.application import Application from playlist_sync.models.sync_log import PlaylistSynchronizationLog from playlist_sync.models.sync_task import PlaylistSynchronization from playlist_sync.services.application_service import ApplicationService from playlist_sync.services.database import get_session TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" # --------------------------------------------------------------------------- # Shared fixtures # --------------------------------------------------------------------------- @pytest_asyncio.fixture(scope="function") async def client(db_engine): """HTTP test client with two pre-seeded applications (us → id=1, gb → id=2).""" TestSession = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) async with TestSession() 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, ) ) session.add( Application( id=2, name="GB", spotify_region_code="GB", active=True, fallback_application=True, global_push_application=False, workout_market=False, ) ) session.add( Application( id=3, name="Global", spotify_region_code="GPUSH", active=True, fallback_application=False, global_push_application=True, workout_market=False, ) ) await session.commit() ApplicationService.invalidate_cache() async def override_get_session(): async with TestSession() as session: yield session app.dependency_overrides[get_session] = override_get_session async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as ac: yield ac, TestSession app.dependency_overrides.clear() ApplicationService.invalidate_cache() async def _seed_sync( TestSession, application_id=1, to_playlist_id="target-001", from_playlist_id="source-001", title="Seeded Sync", active=True, ) -> int: """Helper: insert a PlaylistSynchronization row and return its id.""" async with TestSession() as session: sync = PlaylistSynchronization( application_id=application_id, from_playlist_id=from_playlist_id, from_service_type=0, to_playlist_id=to_playlist_id, to_service_account_id=99, title=title, active=active, ) session.add(sync) await session.commit() await session.refresh(sync) return sync.id _log_counter = 0 async def _seed_log( TestSession, sync_id: int, added: int = 0, deleted: int = 0, **extra ) -> None: """Helper: insert a PlaylistSynchronizationLog row.""" global _log_counter _log_counter += 1 defaults = dict( sync_id=sync_id, time=datetime.now(timezone.utc) - timedelta(seconds=_log_counter), added_tracks=added, deleted_tracks=deleted, made_changes=(added + deleted) > 0, ) defaults.update(extra) async with TestSession() as session: log = PlaylistSynchronizationLog(**defaults) session.add(log) await session.commit() # --------------------------------------------------------------------------- # Health check # --------------------------------------------------------------------------- async def test_health_check(client): ac, _ = client response = await ac.get("/health") assert response.status_code in (200, 503) # 503 when Redis not available in CI data = response.json() assert "status" in data assert data["status"] in ("healthy", "degraded") assert "database" in data assert "redis" in data # --------------------------------------------------------------------------- # GET /PlaylistSync/playlists (global list — no market filter) # --------------------------------------------------------------------------- async def test_list_all_syncs_empty(client): ac, _ = client response = await ac.get("/PlaylistSync/playlists") assert response.status_code == 200 assert response.json() == [] async def test_list_all_syncs_returns_all_including_inactive(client): ac, TestSession = client await _seed_sync( TestSession, application_id=1, to_playlist_id="t-active", active=True ) await _seed_sync( TestSession, application_id=1, to_playlist_id="t-inactive", active=False ) response = await ac.get("/PlaylistSync/playlists") assert response.status_code == 200 data = response.json() assert len(data) == 2 playlist_ids = {item["toPlaylistId"] for item in data} assert "t-active" in playlist_ids assert "t-inactive" in playlist_ids # --------------------------------------------------------------------------- # GET /PlaylistSync/{country_code}/playlists/ (market-filtered list) # --------------------------------------------------------------------------- async def test_get_syncs_by_market_returns_only_that_market(client): ac, TestSession = client await _seed_sync(TestSession, application_id=1, to_playlist_id="us-sync") await _seed_sync(TestSession, application_id=2, to_playlist_id="gb-sync") response = await ac.get("/PlaylistSync/US/playlists") assert response.status_code == 200 data = response.json() assert len(data) == 1 assert data[0]["toPlaylistId"] == "us-sync" async def test_get_syncs_by_market_invalid_country(client): ac, _ = client response = await ac.get("/PlaylistSync/UNKNOWN/playlists") assert response.status_code == 400 async def test_get_syncs_by_market_global_keyword(client): ac, TestSession = client await _seed_sync(TestSession, application_id=3, to_playlist_id="global-sync") response = await ac.get("/PlaylistSync/global/playlists") assert response.status_code == 200 data = response.json() assert len(data) == 1 assert data[0]["toPlaylistId"] == "global-sync" async def test_get_syncs_by_market_other_keyword(client): ac, TestSession = client # Application id=2 is the fallback (non-workout) await _seed_sync(TestSession, application_id=2, to_playlist_id="fallback-sync") response = await ac.get("/PlaylistSync/other/playlists") assert response.status_code == 200 data = response.json() assert len(data) == 1 assert data[0]["toPlaylistId"] == "fallback-sync" async def test_get_syncs_by_market_numeric_id(client): ac, TestSession = client await _seed_sync(TestSession, application_id=2, to_playlist_id="gb-by-id") response = await ac.get("/PlaylistSync/2/playlists") assert response.status_code == 200 data = response.json() assert len(data) == 1 assert data[0]["toPlaylistId"] == "gb-by-id" # --------------------------------------------------------------------------- # GET /PlaylistSync/{country_code}/playlists/{sync_id} # --------------------------------------------------------------------------- async def test_get_single_sync_success(client): ac, TestSession = client sync_id = await _seed_sync(TestSession, application_id=1, to_playlist_id="single-1") response = await ac.get(f"/PlaylistSync/US/playlists/{sync_id}") assert response.status_code == 200 assert response.json()["id"] == sync_id async def test_get_single_sync_not_found(client): ac, _ = client response = await ac.get("/PlaylistSync/US/playlists/9999") assert response.status_code == 404 async def test_get_single_sync_invalid_country(client): ac, TestSession = client sync_id = await _seed_sync(TestSession) response = await ac.get(f"/PlaylistSync/UNKNOWN/playlists/{sync_id}") assert response.status_code == 400 async def test_get_single_sync_wrong_application(client): """A sync that belongs to app 2 should not be accessible under 'US' (app 1) code.""" ac, TestSession = client sync_id = await _seed_sync(TestSession, application_id=2, to_playlist_id="gb-only") response = await ac.get(f"/PlaylistSync/US/playlists/{sync_id}") assert response.status_code == 400 # --------------------------------------------------------------------------- # POST /PlaylistSync/{country_code}/playlists # --------------------------------------------------------------------------- async def test_create_sync_success(client): ac, _ = client payload = { "from_playlist_id": "from-x", "to_playlist_id": "to-x", "to_service_account_id": 10, "title": "New Sync", } response = await ac.post("/PlaylistSync/US/playlists", json=payload) assert response.status_code == 201 data = response.json() assert data["title"] == "New Sync" # application_id must be the resolved id for "US" (1) assert data["applicationId"] == 1 async def test_create_sync_sets_correct_application_id(client): """When using 'GB' the created sync must carry application_id=2.""" ac, _ = client payload = { "from_playlist_id": "gb-from", "to_playlist_id": "gb-to", "to_service_account_id": 20, } response = await ac.post("/PlaylistSync/GB/playlists", json=payload) assert response.status_code == 201 assert response.json()["applicationId"] == 2 async def test_create_sync_invalid_country(client): ac, _ = client payload = { "from_playlist_id": "f", "to_playlist_id": "t", "to_service_account_id": 1, } response = await ac.post("/PlaylistSync/UNKNOWN/playlists", json=payload) assert response.status_code == 400 async def test_create_sync_duplicate_to_playlist(client): ac, TestSession = client await _seed_sync(TestSession, to_playlist_id="dup-target") payload = { "from_playlist_id": "any-source", "to_playlist_id": "dup-target", "to_service_account_id": 1, } response = await ac.post("/PlaylistSync/US/playlists", json=payload) assert response.status_code == 409 async def test_create_sync_defaults(client): """Verify automatically-set fields on create.""" ac, _ = client payload = { "from_playlist_id": "src", "to_playlist_id": "dst", "to_service_account_id": 5, } response = await ac.post("/PlaylistSync/US/playlists", json=payload) assert response.status_code == 201 data = response.json() assert data["active"] is True assert data["fromServiceType"] == 0 # Spotify assert data["fromMusicServiceId"] == 1 # Spotify async def test_create_sync_inactive_copy_once(client): """Creating a sync with active=False ('copy once') persists as inactive.""" ac, _ = client payload = { "from_playlist_id": "src-once", "to_playlist_id": "dst-once", "to_service_account_id": 5, "active": False, } response = await ac.post("/PlaylistSync/US/playlists", json=payload) assert response.status_code == 201 assert response.json()["active"] is False async def test_create_sync_use_setting_prepopulates_to_playlist_title(client): """When titleCopyMode=1 and title is set, toPlaylistTitle is pre-populated.""" ac, _ = client payload = { "from_playlist_id": "src-custom", "to_playlist_id": "dst-custom", "to_service_account_id": 5, "titleCopyMode": 1, "title": "my awesome playlist", "active": False, } response = await ac.post("/PlaylistSync/US/playlists", json=payload) assert response.status_code == 201 data = response.json() assert data["title"] == "my awesome playlist" assert data["toPlaylistTitle"] == "my awesome playlist" async def test_get_sync_response_falls_back_to_title_when_to_playlist_title_null( client, ): """GET response falls back to title for toPlaylistTitle when null (mode=1).""" ac, TestSession = client # Seed a sync with title but no toPlaylistTitle (simulates old records before fix) async with TestSession() as session: sync = PlaylistSynchronization( application_id=1, from_playlist_id="src-fallback", from_service_type=0, to_playlist_id="fallback-target", to_service_account_id=99, title="fallback name", title_copy_mode=1, to_playlist_title=None, active=False, ) session.add(sync) await session.commit() await session.refresh(sync) sync_id = sync.id response = await ac.get(f"/PlaylistSync/US/playlists/{sync_id}") assert response.status_code == 200 data = response.json() assert data["toPlaylistTitle"] == "fallback name" async def test_execute_sync_dispatches_triggered_manually(client): """The execute endpoint must dispatch the task with triggered_manually=True.""" ac, TestSession = client sync_id = await _seed_sync(TestSession, to_playlist_id="exec-manual-target") mock_task = MagicMock() with patch("worker.tasks.execute_single_sync_task", mock_task): response = await ac.post(f"/PlaylistSync/US/playlists/{sync_id}/execute") assert response.status_code == 200 mock_task.delay.assert_called_once_with(sync_id, triggered_manually=True) # --------------------------------------------------------------------------- # PUT /PlaylistSync/{country_code}/playlists/{sync_id} # --------------------------------------------------------------------------- async def test_update_sync_success(client): ac, TestSession = client sync_id = await _seed_sync(TestSession, to_playlist_id="upd-orig") response = await ac.put( f"/PlaylistSync/US/playlists/{sync_id}", json={"title": "Updated Title"} ) assert response.status_code == 200 assert response.json()["title"] == "Updated Title" async def test_update_sync_active_flag(client): ac, TestSession = client sync_id = await _seed_sync(TestSession, to_playlist_id="disable-me") response = await ac.put( f"/PlaylistSync/US/playlists/{sync_id}", json={"active": False} ) assert response.status_code == 200 assert response.json()["active"] is False async def test_update_sync_not_found(client): ac, _ = client response = await ac.put("/PlaylistSync/US/playlists/9999", json={"title": "X"}) assert response.status_code == 404 async def test_update_sync_conflict_to_playlist(client): """Changing to_playlist_id to one used by another active sync should yield 409.""" ac, TestSession = client await _seed_sync(TestSession, to_playlist_id="existing-target") sync_id = await _seed_sync(TestSession, to_playlist_id="my-current-target") response = await ac.put( f"/PlaylistSync/US/playlists/{sync_id}", json={"to_playlist_id": "existing-target"}, ) assert response.status_code == 409 async def test_update_sync_same_to_playlist_allowed(client): """Updating a sync with its own to_playlist_id should not trigger a conflict.""" ac, TestSession = client sync_id = await _seed_sync(TestSession, to_playlist_id="my-own-target") response = await ac.put( f"/PlaylistSync/US/playlists/{sync_id}", json={"to_playlist_id": "my-own-target", "title": "Renamed"}, ) assert response.status_code == 200 # --------------------------------------------------------------------------- # DELETE /PlaylistSync/{country_code}/playlists/{sync_id} # --------------------------------------------------------------------------- async def test_delete_sync_success(client): ac, TestSession = client sync_id = await _seed_sync(TestSession, to_playlist_id="del-me") response = await ac.delete(f"/PlaylistSync/US/playlists/{sync_id}") assert response.status_code == 204 # Verify it's gone response = await ac.get(f"/PlaylistSync/US/playlists/{sync_id}") assert response.status_code == 404 async def test_delete_sync_not_found(client): ac, _ = client response = await ac.delete("/PlaylistSync/US/playlists/9999") assert response.status_code == 404 # --------------------------------------------------------------------------- # POST /PlaylistSync/{country_code}/playlists/{sync_id}/execute # --------------------------------------------------------------------------- async def test_execute_sync_success(client): ac, TestSession = client sync_id = await _seed_sync(TestSession) mock_task = MagicMock() with patch("worker.tasks.execute_single_sync_task", mock_task): response = await ac.post(f"/PlaylistSync/US/playlists/{sync_id}/execute") assert response.status_code == 200 mock_task.delay.assert_called_once_with(sync_id, triggered_manually=True) assert str(sync_id) in response.json()["message"] async def test_execute_sync_not_found(client): ac, _ = client mock_task = MagicMock() with patch("worker.tasks.execute_single_sync_task", mock_task): response = await ac.post("/PlaylistSync/US/playlists/9999/execute") assert response.status_code == 404 mock_task.delay.assert_not_called() async def test_execute_force_all_sources_dispatches_siblings(client): """forceAllSources=true dispatches tasks for all active syncs sharing the source.""" ac, TestSession = client shared_source = "shared-source-fas-1" id1 = await _seed_sync( TestSession, from_playlist_id=shared_source, to_playlist_id="fas-target-1" ) id2 = await _seed_sync( TestSession, from_playlist_id=shared_source, to_playlist_id="fas-target-2" ) id3 = await _seed_sync( TestSession, from_playlist_id=shared_source, to_playlist_id="fas-target-3" ) mock_task = MagicMock() with patch("worker.tasks.execute_single_sync_task", mock_task): response = await ac.post( f"/PlaylistSync/US/playlists/{id1}/execute?forceAllSources=true" ) assert response.status_code == 200 dispatched_ids = {call.args[0] for call in mock_task.delay.call_args_list} assert dispatched_ids == {id1, id2, id3} assert mock_task.delay.call_count == 3 # All dispatches must carry triggered_manually=True for call in mock_task.delay.call_args_list: assert call.kwargs.get("triggered_manually") is True async def test_execute_force_all_sources_false_dispatches_single(client): """forceAllSources=false (default) still dispatches only the targeted sync.""" ac, TestSession = client shared_source = "shared-source-fas-2" id1 = await _seed_sync( TestSession, from_playlist_id=shared_source, to_playlist_id="fas2-target-1" ) await _seed_sync( TestSession, from_playlist_id=shared_source, to_playlist_id="fas2-target-2" ) mock_task = MagicMock() with patch("worker.tasks.execute_single_sync_task", mock_task): response = await ac.post( f"/PlaylistSync/US/playlists/{id1}/execute?forceAllSources=false" ) assert response.status_code == 200 mock_task.delay.assert_called_once_with(id1, triggered_manually=True) async def test_execute_force_all_sources_excludes_inactive(client): """forceAllSources=true must NOT dispatch tasks for inactive sibling syncs.""" ac, TestSession = client shared_source = "shared-source-fas-3" active_id = await _seed_sync( TestSession, from_playlist_id=shared_source, to_playlist_id="fas3-active", active=True, ) await _seed_sync( TestSession, from_playlist_id=shared_source, to_playlist_id="fas3-inactive", active=False, ) mock_task = MagicMock() with patch("worker.tasks.execute_single_sync_task", mock_task): response = await ac.post( f"/PlaylistSync/US/playlists/{active_id}/execute?forceAllSources=true" ) assert response.status_code == 200 mock_task.delay.assert_called_once_with(active_id, triggered_manually=True) # --------------------------------------------------------------------------- # GET /PlaylistSync/{country_code}/playlists/{sync_id}/log # --------------------------------------------------------------------------- async def test_get_sync_logs_empty(client): ac, TestSession = client sync_id = await _seed_sync(TestSession) response = await ac.get(f"/PlaylistSync/US/playlists/{sync_id}/log") assert response.status_code == 200 assert response.json() == [] async def test_get_sync_logs_returns_entries(client): ac, TestSession = client sync_id = await _seed_sync(TestSession) await _seed_log(TestSession, sync_id, added=3, deleted=1) await _seed_log(TestSession, sync_id, added=0, deleted=0) response = await ac.get(f"/PlaylistSync/US/playlists/{sync_id}/log") assert response.status_code == 200 data = response.json() assert len(data) == 2 async def test_get_sync_logs_only_for_requested_sync(client): ac, TestSession = client sync_id_a = await _seed_sync(TestSession, to_playlist_id="log-a") sync_id_b = await _seed_sync(TestSession, to_playlist_id="log-b") await _seed_log(TestSession, sync_id_a) await _seed_log(TestSession, sync_id_b) await _seed_log(TestSession, sync_id_b) response = await ac.get(f"/PlaylistSync/US/playlists/{sync_id_b}/log") data = response.json() assert len(data) == 2 # All entries belong to sync_id_b assert all(entry["playlistSynchronizationId"] == sync_id_b for entry in data) async def test_get_sync_logs_limit(client): ac, TestSession = client sync_id = await _seed_sync(TestSession) for _ in range(5): await _seed_log(TestSession, sync_id) response = await ac.get(f"/PlaylistSync/US/playlists/{sync_id}/log?limit=3") assert response.status_code == 200 assert len(response.json()) == 3 async def test_get_sync_logs_offset(client): ac, TestSession = client sync_id = await _seed_sync(TestSession) for _ in range(4): await _seed_log(TestSession, sync_id) response = await ac.get(f"/PlaylistSync/US/playlists/{sync_id}/log?offset=3") assert response.status_code == 200 assert len(response.json()) == 1 async def test_get_sync_logs_ordered_desc(client): """Logs should be returned newest-first.""" from datetime import datetime, timedelta, timezone ac, TestSession = client sync_id = await _seed_sync(TestSession) base = datetime.now(timezone.utc) async with TestSession() as session: for i in range(3): session.add( PlaylistSynchronizationLog( sync_id=sync_id, added_tracks=i, time=base - timedelta(seconds=i), ) ) await session.commit() response = await ac.get(f"/PlaylistSync/US/playlists/{sync_id}/log") data = response.json() times = [entry["time"] for entry in data] assert times == sorted(times, reverse=True) # --------------------------------------------------------------------------- # PlaylistSyncResponse enrichment — log fields, Z datetimes, toServiceType # --------------------------------------------------------------------------- async def test_playlist_sync_response_log_fields_null_without_log(client): """Log-derived fields must be null when no sync run has been recorded yet.""" ac, TestSession = client sync_id = await _seed_sync(TestSession) response = await ac.get(f"/PlaylistSync/US/playlists/{sync_id}") assert response.status_code == 200 data = response.json() assert data["time"] is None assert data["madeChange"] is None assert data["addedTracks"] is None assert data["deletedTracks"] is None assert data["deletedDuplicates"] is None assert data["syncedTrackCount"] is None assert data["triggeredManually"] is None assert data["errorText"] is None assert data["errorType"] is None async def test_playlist_sync_response_log_fields_populated_after_log(client): """Log-derived fields must reflect the latest log entry.""" from playlist_sync.models.sync_log import PlaylistSynchronizationLog ac, TestSession = client sync_id = await _seed_sync(TestSession) async with TestSession() as session: session.add( PlaylistSynchronizationLog( sync_id=sync_id, added_tracks=5, deleted_tracks=2, deleted_duplicates=1, made_changes=True, triggered_manually=True, error_message="something went wrong", error="NotSupported", ) ) await session.commit() response = await ac.get(f"/PlaylistSync/US/playlists/{sync_id}") assert response.status_code == 200 data = response.json() assert data["addedTracks"] == 5 assert data["deletedTracks"] == 2 assert data["deletedDuplicates"] == 1 assert data["madeChange"] is True assert data["triggeredManually"] is True assert data["errorText"] == "something went wrong" assert data["errorType"] == "NotSupported" assert data["time"] is not None async def test_playlist_sync_response_latest_log_only(client): """Only the most recent log entry should populate the enrichment fields.""" from datetime import datetime, timedelta, timezone from playlist_sync.models.sync_log import PlaylistSynchronizationLog ac, TestSession = client sync_id = await _seed_sync(TestSession) now = datetime.now(timezone.utc) async with TestSession() as session: session.add( PlaylistSynchronizationLog( sync_id=sync_id, added_tracks=1, time=now - timedelta(hours=1) ) ) session.add( PlaylistSynchronizationLog(sync_id=sync_id, added_tracks=99, time=now) ) await session.commit() response = await ac.get(f"/PlaylistSync/US/playlists/{sync_id}") data = response.json() assert data["addedTracks"] == 99 async def test_playlist_sync_response_created_at_has_z_suffix(client): """CreatedAt must include a Z UTC suffix in the response.""" ac, _ = client resp = await ac.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "src", "to_playlist_id": "dst-z", "to_service_account_id": 1, }, ) sync_id = resp.json()["id"] response = await ac.get(f"/PlaylistSync/US/playlists/{sync_id}") data = response.json() assert data["createdAt"] is not None assert data["createdAt"].endswith("Z"), ( f"createdAt missing Z suffix: {data['createdAt']}" ) async def test_playlist_sync_response_synchronized_track_count_defaults_to_zero(client): """SynchronizedTrackCount must be 0 (not null) even for freshly created syncs.""" ac, _ = client resp = await ac.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "src2", "to_playlist_id": "dst-stc", "to_service_account_id": 1, }, ) sync_id = resp.json()["id"] response = await ac.get(f"/PlaylistSync/US/playlists/{sync_id}") data = response.json() assert data["synchronizedTrackCount"] == 0 assert isinstance(data["synchronizedTrackCount"], int) async def test_get_syncs_by_market_includes_inactive(client): """Market list must include inactive syncs, not filter them out.""" ac, TestSession = client await _seed_sync( TestSession, application_id=1, to_playlist_id="active-one", active=True ) await _seed_sync( TestSession, application_id=1, to_playlist_id="inactive-one", active=False ) response = await ac.get("/PlaylistSync/US/playlists") assert response.status_code == 200 data = response.json() assert len(data) == 2 playlist_ids = {item["toPlaylistId"] for item in data} assert "active-one" in playlist_ids assert "inactive-one" in playlist_ids async def test_playlist_sync_response_insert_media_empty_by_default(client): """InsertMedia must be an empty list when no insert-media rows exist.""" ac, TestSession = client sync_id = await _seed_sync(TestSession) response = await ac.get(f"/PlaylistSync/US/playlists/{sync_id}") data = response.json() assert data["insertMedia"] == [] # --------------------------------------------------------------------------- # GET /apollo-api/app-markets/ # --------------------------------------------------------------------------- async def test_get_app_markets_returns_all(client): """GET /apollo-api/app-markets/ returns all seeded application rows.""" ac, _ = client response = await ac.get("/apollo-api/app-markets/") assert response.status_code == 200 data = response.json() # The client fixture seeds 3 applications (US, GB, Global) assert len(data) == 3 async def test_get_app_markets_field_names(client): """Response uses camelCase field names matching the apollo-api contract.""" ac, _ = client response = await ac.get("/apollo-api/app-markets/") assert response.status_code == 200 item = response.json()[0] expected_keys = { "id", "name", "cultureInfo", "spotifyRegionCode", "active", "gaCountryName", "defaultService", "services", "workoutMarket", "includeOtherPlaylists", } assert expected_keys.issubset(item.keys()) async def test_get_app_markets_services_is_list(client): """Services field must always be a list, never a raw string.""" ac, TestSession = client ApplicationService.invalidate_cache() async with TestSession() as session: session.add( Application( id=10, name="ServiceTest", spotify_region_code="ST", active=True, service_list="spotify,deezer", fallback_application=False, global_push_application=False, workout_market=False, ) ) await session.commit() ApplicationService.invalidate_cache() response = await ac.get("/apollo-api/app-markets/") assert response.status_code == 200 data = response.json() market = next(m for m in data if m["id"] == 10) assert isinstance(market["services"], list) assert market["services"] == ["spotify", "deezer"] async def test_get_app_markets_empty_services(client): """A null/empty strServiceList becomes an empty list.""" ac, _ = client response = await ac.get("/apollo-api/app-markets/") assert response.status_code == 200 data = response.json() # The seeded apps have no service_list set → should be [] for item in data: if item["id"] in (1, 2, 3): assert item["services"] == [] async def test_get_app_markets_boolean_fields(client): """WorkoutMarket and includeOtherPlaylists must be booleans.""" ac, _ = client response = await ac.get("/apollo-api/app-markets/") assert response.status_code == 200 for item in response.json(): assert isinstance(item["workoutMarket"], bool) assert isinstance(item["includeOtherPlaylists"], bool) assert isinstance(item["active"], bool) async def test_get_app_markets_empty_db(): """Returns [] when no application rows exist.""" 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) TestSession = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) ApplicationService.invalidate_cache() async def override_get_session(): async with TestSession() as session: yield session app.dependency_overrides[get_session] = override_get_session async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as ac: response = await ac.get("/apollo-api/app-markets/") app.dependency_overrides.clear() ApplicationService.invalidate_cache() async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.drop_all) await engine.dispose() assert response.status_code == 200 assert response.json() == []