"""Layer 10: Smoke tests. Two modes of operation ---------------------- 1. In-process (default / CI) — no external services required. Runs against an in-process FastAPI app backed by SQLite. All tests run automatically in CI. 2. Live deployment mode — set SMOKE_BASE_URL to point at a running instance: SMOKE_BASE_URL=http://localhost:8001 pytest tests/smoke/ -v In this mode tests hit the real app (with real MySQL data) and skip the in-process SQLite setup. Tests that write data are skipped automatically to avoid mutating production-like state. """ import os import pytest 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 # noqa: F401 from playlist_sync.models.service_account import ServiceAccount # noqa: F401 from playlist_sync.models.sync_log import PlaylistSynchronizationLog # noqa: F401 # Import models so SQLModel.metadata is fully populated 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 TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" SMOKE_BASE_URL = os.environ.get("SMOKE_BASE_URL", "") # set to use live deployment mode LIVE_MODE = bool(SMOKE_BASE_URL) @pytest_asyncio.fixture(scope="module") async def smoke_engine(): """Create the in-process SQLite engine + seed data once per module. In live mode yields None (no in-process DB needed). """ if LIVE_MODE: yield None return 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) # Seed a market so country_code resolution works for all tests in this module Session = sessionmaker(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() 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 smoke_client(smoke_engine): """HTTP client — in-process SQLite (CI) or real deployment (live mode). Function-scoped so each test gets a fresh client with its own event-loop connection — avoids 'Event loop is closed' errors when running against a live deployment with a real TCP transport. """ if LIVE_MODE: async with AsyncClient(base_url=SMOKE_BASE_URL, timeout=10.0) as ac: yield ac return Session = sessionmaker(smoke_engine, class_=AsyncSession, expire_on_commit=False) async def override_get_session(): async with Session() 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 app.dependency_overrides.clear() ApplicationService.invalidate_cache() # --------------------------------------------------------------------------- # Health / liveness # --------------------------------------------------------------------------- class TestHealthEndpoint: async def test_health_returns_200(self, smoke_client): """GET /health returns 200 when all dependencies are reachable.""" resp = await smoke_client.get("/health") assert resp.status_code in (200, 503), f"Unexpected status {resp.status_code}" async def test_health_body(self, smoke_client): """Health response contains a status field.""" resp = await smoke_client.get("/health") data = resp.json() assert "status" in data assert data["status"] in ("healthy", "degraded") async def test_health_reports_dependency_checks(self, smoke_client): """Health response includes database and redis sub-checks.""" resp = await smoke_client.get("/health") data = resp.json() assert "database" in data, "Health check must report database status" assert "redis" in data, "Health check must report redis status" async def test_health_database_field(self, smoke_client): """In CI (SQLite in-process), the database check must report ok.""" if LIVE_MODE: pytest.skip("Live mode — database connectivity tested by the running app") resp = await smoke_client.get("/health") data = resp.json() assert data.get("database") == "ok", ( f"Database health check failed: {data.get('database')}" ) # --------------------------------------------------------------------------- # Database connectivity (round-trip) # --------------------------------------------------------------------------- class TestDatabaseRoundTrip: @pytest.mark.skipif(LIVE_MODE, reason="Skipping write test in live deployment mode") async def test_create_and_retrieve_sync(self, smoke_client): """POST a sync then GET it back — proves DB reads/writes work.""" payload = { "from_playlist_id": "spotify:playlist:smoke001", "from_service_type": 0, "to_playlist_id": "deezer:playlist:smoke001", "to_service_account_id": 999, "title": "Smoke Test Sync", } post_resp = await smoke_client.post("/PlaylistSync/US/playlists", json=payload) assert post_resp.status_code == 201, post_resp.text created = post_resp.json() sync_id = created["id"] get_resp = await smoke_client.get(f"/PlaylistSync/US/playlists/{sync_id}") assert get_resp.status_code == 200 assert get_resp.json()["id"] == sync_id async def test_list_syncs_returns_array(self, smoke_client): """GET /PlaylistSync/US/playlists/ returns a JSON array.""" resp = await smoke_client.get("/PlaylistSync/US/playlists") assert resp.status_code == 200 assert isinstance(resp.json(), list) @pytest.mark.skipif(LIVE_MODE, reason="Skipping write test in live deployment mode") async def test_delete_sync(self, smoke_client): """DELETE a sync then confirm 404.""" payload = { "from_playlist_id": "spotify:playlist:smoke_del", "from_service_type": 0, "to_playlist_id": "deezer:playlist:smoke_del", "to_service_account_id": 999, "title": "Delete Me", } post_resp = await smoke_client.post("/PlaylistSync/US/playlists", json=payload) sync_id = post_resp.json()["id"] del_resp = await smoke_client.delete(f"/PlaylistSync/US/playlists/{sync_id}") assert del_resp.status_code == 204 get_resp = await smoke_client.get(f"/PlaylistSync/US/playlists/{sync_id}") assert get_resp.status_code == 404 # --------------------------------------------------------------------------- # Country-code / market resolution # --------------------------------------------------------------------------- class TestMarketResolution: @pytest.mark.skipif(LIVE_MODE, reason="Market seeding not applicable in live mode") async def test_unknown_country_code_returns_400(self, smoke_client): """Unknown country code should return 400, not 500.""" resp = await smoke_client.get("/PlaylistSync/XX/playlists") assert resp.status_code == 400 async def test_known_country_code_returns_200(self, smoke_client): """Known country code resolves and returns 200.""" resp = await smoke_client.get("/PlaylistSync/US/playlists") assert resp.status_code == 200 # --------------------------------------------------------------------------- # API schema / content-type # --------------------------------------------------------------------------- class TestApiSchemaSmoke: async def test_openapi_schema_available(self, smoke_client): """OpenAPI schema endpoint returns 200 — confirms FastAPI wiring.""" resp = await smoke_client.get("/openapi.json") assert resp.status_code == 200 schema = resp.json() assert "openapi" in schema assert "paths" in schema async def test_all_responses_are_json(self, smoke_client): """Health and list endpoints respond with application/json content-type.""" for path in ["/health", "/PlaylistSync/US/playlists"]: resp = await smoke_client.get(path) assert "application/json" in resp.headers.get("content-type", ""), path async def test_missing_sync_returns_404_not_500(self, smoke_client): """A missing resource returns 404, not an unhandled 500.""" resp = await smoke_client.get("/PlaylistSync/US/playlists/999999") assert resp.status_code == 404