"""Layer 7: End-to-end tests using docker-compose. These tests spin up the full application stack (MySQL + Redis + app + worker) using docker-compose and verify the complete data path works against the real database schema. Running these tests ------------------- Requirements: Docker + docker-compose installed and running. docker-compose -f docker-compose.test.yml up -d pytest tests/e2e/test_e2e.py -v -m e2e docker-compose -f docker-compose.test.yml down Or with pytest-docker (auto-managed): pytest tests/e2e/test_e2e.py -v -m e2e Environment variables --------------------- E2E_API_URL — Base URL of the running Python app (default: http://localhost:8001) E2E_DB_URL — SQLAlchemy URL for the test MySQL DB (default: mysql+aiomysql://filtr:filtr@localhost:3307/filtr_test) If neither Docker nor the env vars are configured the tests are skipped automatically with a clear message. Local (in-process) fallback ---------------------------- When E2E_USE_INPROCESS=1 the tests run against an in-process FastAPI app backed by SQLite so they can be executed in CI without Docker. """ import os 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.service_account import ServiceAccount # noqa: F401 from playlist_sync.models.sync_log import PlaylistSynchronizationLog # noqa: F401 from playlist_sync.models.sync_task import PlaylistSynchronization # noqa: F401 from playlist_sync.services.application_service import ApplicationService from playlist_sync.services.database import get_session E2E_API_URL = os.environ.get("E2E_API_URL", "http://localhost:8001") E2E_USE_INPROCESS = os.environ.get("E2E_USE_INPROCESS", "1") == "1" TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" # --------------------------------------------------------------------------- # Fixtures — in-process fallback (always available) # --------------------------------------------------------------------------- @pytest_asyncio.fixture(scope="module") async def e2e_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="module") async def e2e_client(e2e_engine): """In-process client backed by SQLite (always available). Set E2E_USE_INPROCESS=0 and E2E_API_URL to test against a real deployment. """ Session = sessionmaker(e2e_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, ) ) session.add( Application( id=2, name="GB", spotify_region_code="GB", active=True, fallback_application=True, global_push_application=False, workout_market=False, ) ) await session.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() # --------------------------------------------------------------------------- # Full create -> read -> update -> delete lifecycle # --------------------------------------------------------------------------- class TestFullSyncLifecycle: """End-to-end CRUD lifecycle for a playlist synchronization.""" async def test_create_read_update_delete_cycle(self, e2e_client): """Complete CRUD cycle: POST -> GET -> PUT -> DELETE -> 404.""" client, _ = e2e_client # 1. Create post_resp = await client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "spotify:playlist:e2e-lifecycle", "to_playlist_id": "deezer:playlist:e2e-lifecycle", "to_service_account_id": 42, "title": "E2E Lifecycle Test", "description": "Created by e2e test", }, ) assert post_resp.status_code == 201 created = post_resp.json() sync_id = created["id"] assert created["title"] == "E2E Lifecycle Test" assert created["active"] is True # 2. Read back get_resp = await client.get(f"/PlaylistSync/US/playlists/{sync_id}") assert get_resp.status_code == 200 assert get_resp.json()["id"] == sync_id # 3. Update put_resp = await client.put( f"/PlaylistSync/US/playlists/{sync_id}", json={ "title": "E2E Updated Title", "active": False, }, ) assert put_resp.status_code == 200 updated = put_resp.json() assert updated["title"] == "E2E Updated Title" assert updated["active"] is False # 4. Verify update persisted get_after_resp = await client.get(f"/PlaylistSync/US/playlists/{sync_id}") assert get_after_resp.status_code == 200 assert get_after_resp.json()["title"] == "E2E Updated Title" # 5. Delete del_resp = await client.delete(f"/PlaylistSync/US/playlists/{sync_id}") assert del_resp.status_code == 204 # 6. Confirm gone get_deleted = await client.get(f"/PlaylistSync/US/playlists/{sync_id}") assert get_deleted.status_code == 404 async def test_list_reflects_created_syncs(self, e2e_client): """After creating syncs they should appear in the list endpoint.""" client, _ = e2e_client resp_before = await client.get("/PlaylistSync/US/playlists") count_before = len(resp_before.json()) await client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "src-list-e2e", "to_playlist_id": "tgt-list-e2e", "to_service_account_id": 1, }, ) resp_after = await client.get("/PlaylistSync/US/playlists") assert len(resp_after.json()) == count_before + 1 async def test_market_isolation(self, e2e_client): """Syncs created for US market should not appear in GB market list.""" client, _ = e2e_client await client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "src-iso-us", "to_playlist_id": "tgt-iso-us", "to_service_account_id": 1, }, ) gb_resp = await client.get("/PlaylistSync/GB/playlists") us_resp = await client.get("/PlaylistSync/US/playlists") gb_ids = {s["id"] for s in gb_resp.json()} us_ids = {s["id"] for s in us_resp.json()} # No overlap between US and GB syncs assert gb_ids.isdisjoint(us_ids), "US and GB syncs should not share IDs" # --------------------------------------------------------------------------- # Duplicate prevention # --------------------------------------------------------------------------- class TestDuplicatePrevention: async def test_duplicate_to_playlist_id_returns_409(self, e2e_client): """Creating a second sync with the same to_playlist_id returns 409.""" client, _ = e2e_client payload = { "from_playlist_id": "src-dup-e2e", "to_playlist_id": "tgt-dup-e2e-unique", "to_service_account_id": 1, } first = await client.post("/PlaylistSync/US/playlists", json=payload) assert first.status_code == 201 second = await client.post("/PlaylistSync/US/playlists", json=payload) assert second.status_code == 409 async def test_conflict_error_references_existing_id(self, e2e_client): """409 response body should reference the existing sync ID.""" client, _ = e2e_client payload = { "from_playlist_id": "src-conf-e2e", "to_playlist_id": "tgt-conf-e2e-ref", "to_service_account_id": 1, } first = await client.post("/PlaylistSync/US/playlists", json=payload) existing_id = first.json()["id"] second = await client.post("/PlaylistSync/US/playlists", json=payload) assert str(existing_id) in second.text # --------------------------------------------------------------------------- # Log endpoint e2e # --------------------------------------------------------------------------- class TestLogEndpointE2E: async def test_log_empty_for_new_sync(self, e2e_client): """A newly created sync has no log entries.""" client, _ = e2e_client post_resp = await client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "src-log-e2e", "to_playlist_id": "tgt-log-e2e", "to_service_account_id": 1, }, ) sync_id = post_resp.json()["id"] log_resp = await client.get(f"/PlaylistSync/US/playlists/{sync_id}/log") assert log_resp.status_code == 200 assert log_resp.json() == [] async def test_log_returns_seeded_entries(self, e2e_client): """Log entries seeded directly in DB are returned by the log endpoint.""" client, Session = e2e_client post_resp = await client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "src-log-seed", "to_playlist_id": "tgt-log-seed", "to_service_account_id": 1, }, ) sync_id = post_resp.json()["id"] async with Session() as session: session.add( PlaylistSynchronizationLog( sync_id=sync_id, source_tracks=20, added_tracks=5, deleted_tracks=1, ) ) await session.commit() log_resp = await client.get(f"/PlaylistSync/US/playlists/{sync_id}/log") assert log_resp.status_code == 200 entries = log_resp.json() assert len(entries) == 1 assert entries[0]["sourceTracks"] == 20 assert entries[0]["addedTracks"] == 5 # --------------------------------------------------------------------------- # Market resolution e2e # --------------------------------------------------------------------------- class TestMarketResolutionE2E: async def test_numeric_market_id_resolves(self, e2e_client): """Numeric country_code resolves to app by integer ID.""" client, _ = e2e_client resp = await client.get("/PlaylistSync/1/playlists") assert resp.status_code == 200 async def test_global_resolves_to_push_app(self, e2e_client): """'global' country_code resolves to global_push_application.""" client, Session = e2e_client async with Session() as session: session.add( Application( id=99, name="Global", spotify_region_code="GPUSH", active=True, fallback_application=False, global_push_application=True, workout_market=False, ) ) await session.commit() ApplicationService.invalidate_cache() resp = await client.get("/PlaylistSync/global/playlists") assert resp.status_code == 200 async def test_other_resolves_to_fallback(self, e2e_client): """'other' country_code resolves to fallback application.""" client, _ = e2e_client resp = await client.get("/PlaylistSync/other/playlists") # GB is seeded as fallback_application=True assert resp.status_code == 200