"""Integration tests for downloads + external search. Requires a reachable Postgres; skips otherwise. The download worker task is invoked directly (no Redis needed) against a fake fetch source, so the full DB + storage import path is covered without touching the network. """ import asyncio import os from collections.abc import AsyncIterator from pathlib import Path from typing import Any import pytest from app.core.config import get_settings from app.domain.sources import KIND_FETCH, DownloadResult, SearchResult, SourceInfo from app.infrastructure.db import Base, dispose_engine, get_engine, session_scope from app.infrastructure.db.repositories import ( SqlAlchemyRefreshTokenRepository, SqlAlchemyUserRepository, ) from app.infrastructure.sources.registry import SourceRegistry 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 class FakeFetchSource: """A searchable + fetchable source that writes a local file (no network).""" name = "youtube" def __init__(self, tmp_dir: Path) -> None: self._tmp_dir = tmp_dir def info(self) -> SourceInfo: return SourceInfo(name=self.name, label="YouTube Music", kind=KIND_FETCH, available=True) def is_available(self) -> bool: return True async def search(self, query: str, *, limit: int) -> list[SearchResult]: return [ SearchResult( source=self.name, source_id="vid-1", title=f"{query} song", artist="Some Artist", album="Some Album", duration_seconds=200, thumbnail_url="http://img/large.jpg", ) ] async def fetch(self, source_id: str, *, on_progress: Any = None) -> DownloadResult: path = self._tmp_dir / f"{source_id}.m4a" path.write_bytes(b"downloaded audio bytes" * 8) if on_progress is not None: await on_progress(0.5) return DownloadResult( source_id=source_id, path=path, file_format="webm", file_size=path.stat().st_size, bitrate=160, suggested_title=f"Title for {source_id}", ) async def get_metadata(self, source_id: str) -> None: return None @pytest.fixture async def api(tmp_path: Path) -> AsyncIterator[AsyncClient]: if not await _db_reachable(): pytest.skip("Postgres not reachable — integration test skipped.") media = tmp_path / "media" media.mkdir() os.environ["MEDIA_PATH"] = str(media) 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="admin", password="adminpass1", is_superuser=True) from app.api.deps import get_source_registry from app.main import create_app app = create_app() # Inject a fake fetch source so search/download never hit the network. fake_registry = SourceRegistry([FakeFetchSource(tmp_path / "dl")]) # type: ignore[list-item] (tmp_path / "dl").mkdir() app.dependency_overrides[get_source_registry] = lambda: fake_registry 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": "admin", "password": "adminpass1"} ) assert resp.status_code == 200 return str(resp.json()["access_token"]) async def test_search_aggregates_fetch_sources(api: AsyncClient) -> None: token = await _login(api) headers = {"Authorization": f"Bearer {token}"} resp = await api.get("/api/v1/search", params={"q": "queen"}, headers=headers) assert resp.status_code == 200 body = resp.json() assert body["searched_sources"] == ["youtube"] assert len(body["results"]) == 1 hit = body["results"][0] assert hit["source"] == "youtube" assert hit["source_id"] == "vid-1" assert hit["title"] == "queen song" async def test_source_scoped_search(api: AsyncClient) -> None: token = await _login(api) headers = {"Authorization": f"Bearer {token}"} resp = await api.get("/api/v1/sources/youtube/search", params={"q": "abba"}, headers=headers) assert resp.status_code == 200 assert resp.json()["results"][0]["title"] == "abba song" async def test_download_create_list_and_complete( api: AsyncClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: token = await _login(api) headers = {"Authorization": f"Bearer {token}"} # Request a download — Redis is absent, so enqueue degrades but the job persists. create = await api.post( "/api/v1/downloads", json={"source": "youtube", "source_id": "vid-1", "query": "queen"}, headers=headers, ) assert create.status_code == 202 body = create.json() assert body["already_in_library"] is False job_id = body["job"]["id"] assert body["job"]["status"] == "queued" # It shows up in the listing. listing = await api.get("/api/v1/downloads", headers=headers) assert listing.status_code == 200 assert any(j["id"] == job_id for j in listing.json()["items"]) # A duplicate request returns the same in-flight job, not a new one. dup = await api.post( "/api/v1/downloads", json={"source": "youtube", "source_id": "vid-1"}, headers=headers, ) assert dup.json()["job"]["id"] == job_id # Run the worker task directly (bypasses Redis) with the fake fetch source. import app.workers.tasks.download_task as dl_task worker_dl = tmp_path / "worker-dl" worker_dl.mkdir() fake = SourceRegistry([FakeFetchSource(worker_dl)]) # type: ignore[list-item] monkeypatch.setattr(dl_task, "build_source_registry", lambda _settings: fake) result = await dl_task.download_track({}, job_id=job_id) assert result["status"] == "done" track_id = result["track_id"] # The job is now done and linked to the imported track. got = await api.get(f"/api/v1/downloads/{job_id}", headers=headers) assert got.json()["status"] == "done" assert got.json()["track_id"] == track_id # The imported track streams back. stream = await api.get(f"/api/v1/stream/{track_id}", headers=headers) assert stream.status_code == 200 assert len(stream.content) > 0 # A new request for the same item now dedups against the library. again = await api.post( "/api/v1/downloads", json={"source": "youtube", "source_id": "vid-1"}, headers=headers, ) assert again.json()["already_in_library"] is True assert again.json()["track_id"] == track_id async def test_cancel_download(api: AsyncClient) -> None: token = await _login(api) headers = {"Authorization": f"Bearer {token}"} create = await api.post( "/api/v1/downloads", json={"source": "youtube", "source_id": "vid-cancel"}, headers=headers, ) job_id = create.json()["job"]["id"] cancel = await api.delete(f"/api/v1/downloads/{job_id}", headers=headers) assert cancel.status_code == 204 got = await api.get(f"/api/v1/downloads/{job_id}", headers=headers) assert got.status_code == 404