"""Integration tests for the native cover-art endpoints. Seeds an album with a stored cover, then exercises the ``/api/v1`` album/track cover endpoints (token auth, 404 when absent). Requires a reachable Postgres; skips otherwise. """ import asyncio import os import uuid 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 ( SqlAlchemyAlbumRepository, SqlAlchemyArtistRepository, SqlAlchemyRefreshTokenRepository, SqlAlchemyTrackRepository, SqlAlchemyUserRepository, ) from app.infrastructure.storage.provider import get_file_storage from asgi_lifespan import LifespanManager from httpx import ASGITransport, AsyncClient pytestmark = pytest.mark.asyncio # A minimal valid 1x1 PNG. _PNG_BYTES = bytes.fromhex( "89504e470d0a1a0a0000000d4948445200000001000000010802000000907753" "de0000000c4944415408d763f8cfc0f01f0005000155a2b4f60000000049454e44ae426082" ) _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 async def _seed_album_with_cover(*, with_cover: bool) -> tuple[uuid.UUID, uuid.UUID]: """Create an artist + album (+ optional cover file) + track. Returns ``(album_id, track_id)``.""" async with session_scope() as session: artist = await SqlAlchemyArtistRepository(session).get_or_create("Coverart Artist") album = await SqlAlchemyAlbumRepository(session).get_or_create( title="Coverart Album", artist_id=artist.id, year=2020, musicbrainz_id=None ) track = await SqlAlchemyTrackRepository(session).add( id=uuid.uuid4(), title="Coverart Track", artist_id=artist.id, storage_uri="tracks/zz/cover-track.mp3", file_format="mp3", file_size=10, source="upload", source_id="cover-src", metadata_status="enriched", added_by=None, ) # Link the track to the album (add() doesn't take album_id). await SqlAlchemyTrackRepository(session).apply_enrichment( track.id, title="Coverart Track", artist_id=artist.id, album_id=album.id, genre=None, year=2020, track_number=1, duration_seconds=1, bitrate=None, acoustid_fingerprint=None, musicbrainz_id=None, metadata_status="enriched", ) if with_cover: key = f"covers/{album.id}.png" import tempfile with tempfile.NamedTemporaryFile(suffix=".png") as tmp: tmp.write(_PNG_BYTES) tmp.flush() await get_file_storage().save_file(key, Path(tmp.name)) await SqlAlchemyAlbumRepository(session).set_cover_path(album.id, key) return album.id, track.id @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() import app.infrastructure.storage.provider as _storage_provider _storage_provider._storage = None 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="testuser", 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: _storage_provider._storage = None os.environ.pop("MEDIA_PATH", None) get_settings.cache_clear() async def _login(api: AsyncClient) -> str: resp = await api.post( "/api/v1/auth/login", json={"username": "testuser", "password": "testpass1"} ) assert resp.status_code == 200 return str(resp.json()["access_token"]) async def test_album_cover_served(api: AsyncClient) -> None: token = await _login(api) album_id, _ = await _seed_album_with_cover(with_cover=True) resp = await api.get(f"/api/v1/albums/{album_id}/cover?token={token}") assert resp.status_code == 200, resp.text assert resp.headers["content-type"] == "image/png" assert resp.content == _PNG_BYTES async def test_track_cover_served_from_album(api: AsyncClient) -> None: token = await _login(api) _, track_id = await _seed_album_with_cover(with_cover=True) resp = await api.get(f"/api/v1/tracks/{track_id}/cover?token={token}") assert resp.status_code == 200, resp.text assert resp.headers["content-type"] == "image/png" assert resp.content == _PNG_BYTES async def test_album_without_cover_is_404(api: AsyncClient) -> None: token = await _login(api) album_id, _ = await _seed_album_with_cover(with_cover=False) resp = await api.get(f"/api/v1/albums/{album_id}/cover?token={token}") assert resp.status_code == 404 async def test_cover_requires_auth(api: AsyncClient) -> None: album_id, _ = await _seed_album_with_cover(with_cover=True) resp = await api.get(f"/api/v1/albums/{album_id}/cover") assert resp.status_code == 401 async def test_album_appears_with_has_cover_flag(api: AsyncClient) -> None: token = await _login(api) album_id, _ = await _seed_album_with_cover(with_cover=True) resp = await api.get(f"/api/v1/albums/{album_id}", headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200 assert resp.json()["has_cover"] is True