"""Integration tests for the user-settings + scrobbling endpoints. Drives ``/api/v1/settings`` end to end: lazy defaults, partial update, enum validation, and scrobbling config (write-only session key). Requires a reachable Postgres; skips otherwise. """ import asyncio import os from collections.abc import AsyncIterator from pathlib import Path import pytest from app.core.config import get_settings from app.infrastructure.db import Base, dispose_engine, get_engine, session_scope from app.infrastructure.db.repositories import ( SqlAlchemyRefreshTokenRepository, SqlAlchemyUserRepository, ) from asgi_lifespan import LifespanManager from httpx import ASGITransport, AsyncClient pytestmark = pytest.mark.asyncio _db_reachable_cache: bool | None = None async def _db_reachable() -> bool: global _db_reachable_cache if _db_reachable_cache is not None: return _db_reachable_cache from sqlalchemy import text try: async with asyncio.timeout(3): async with get_engine().connect() as conn: await conn.execute(text("SELECT 1")) _db_reachable_cache = True except Exception: _db_reachable_cache = False return _db_reachable_cache @pytest.fixture async def api(tmp_path: Path) -> AsyncIterator[AsyncClient]: if not await _db_reachable(): pytest.skip("Postgres not reachable — integration test skipped.") os.environ["MEDIA_PATH"] = str(tmp_path) get_settings.cache_clear() try: async with get_engine().begin() as conn: await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.create_all) from app.application.user_service import UserService from app.core.security import Argon2PasswordHasher async with session_scope() as session: await UserService( users=SqlAlchemyUserRepository(session), refresh_tokens=SqlAlchemyRefreshTokenRepository(session), hasher=Argon2PasswordHasher(), ).create_user(username="setuser", password="testpass1", is_superuser=False) from app.main import create_app app = create_app() async with LifespanManager(app): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: yield client async with get_engine().begin() as conn: await conn.run_sync(Base.metadata.drop_all) await dispose_engine() finally: os.environ.pop("MEDIA_PATH", None) get_settings.cache_clear() async def _auth(api: AsyncClient) -> dict[str, str]: resp = await api.post( "/api/v1/auth/login", json={"username": "setuser", "password": "testpass1"} ) assert resp.status_code == 200, resp.text return {"Authorization": f"Bearer {resp.json()['access_token']}"} async def test_get_settings_returns_defaults(api: AsyncClient) -> None: headers = await _auth(api) resp = await api.get("/api/v1/settings", headers=headers) assert resp.status_code == 200, resp.text assert resp.json() == {"theme": "system", "stream_quality": "original"} async def test_patch_settings_persists(api: AsyncClient) -> None: headers = await _auth(api) resp = await api.patch("/api/v1/settings", json={"theme": "dark"}, headers=headers) assert resp.status_code == 200, resp.text assert resp.json() == {"theme": "dark", "stream_quality": "original"} # Persisted + partial update leaves the untouched field alone. again = await api.patch("/api/v1/settings", json={"stream_quality": "low"}, headers=headers) assert again.json() == {"theme": "dark", "stream_quality": "low"} async def test_invalid_theme_is_422(api: AsyncClient) -> None: headers = await _auth(api) resp = await api.patch("/api/v1/settings", json={"theme": "neon"}, headers=headers) assert resp.status_code == 422 async def test_scrobbling_defaults_and_enable(api: AsyncClient) -> None: headers = await _auth(api) resp = await api.get("/api/v1/settings/scrobbling", headers=headers) assert resp.status_code == 200, resp.text assert resp.json() == { "enabled": False, "provider": None, "username": None, "configured": False, } # Enabling without a provider is rejected. bad = await api.put("/api/v1/settings/scrobbling", json={"enabled": True}, headers=headers) assert bad.status_code == 422 # Enable with a provider + secret token; the token is never echoed back. ok = await api.put( "/api/v1/settings/scrobbling", json={ "enabled": True, "provider": "listenbrainz", "username": "me", "session_key": "super-secret-token", }, headers=headers, ) assert ok.status_code == 200, ok.text body = ok.json() assert body == { "enabled": True, "provider": "listenbrainz", "username": "me", "configured": True, } assert "session_key" not in body assert "super-secret-token" not in ok.text async def test_scrobbling_keeps_stored_key_when_omitted(api: AsyncClient) -> None: headers = await _auth(api) await api.put( "/api/v1/settings/scrobbling", json={"enabled": True, "provider": "lastfm", "session_key": "k"}, headers=headers, ) # A later update without a session_key must keep the stored one. resp = await api.put( "/api/v1/settings/scrobbling", json={"enabled": True, "provider": "lastfm", "username": "changed"}, headers=headers, ) assert resp.status_code == 200, resp.text assert resp.json()["configured"] is True assert resp.json()["username"] == "changed" async def test_settings_require_auth(api: AsyncClient) -> None: resp = await api.get("/api/v1/settings") assert resp.status_code == 401