import pytest from backend.app.models import Artist, DownloadEvent, Source pytestmark = pytest.mark.integration @pytest.fixture async def seed(db): artist = Artist(name="Alice", slug="alice") db.add(artist) await db.flush() source = Source( artist_id=artist.id, platform="patreon", url="https://patreon.com/alice", enabled=True, config_overrides={}, ) db.add(source) await db.flush() events = [ DownloadEvent( source_id=source.id, status="ok", files_count=3, bytes_downloaded=100000, metadata_={ "run_stats": {"downloaded_count": 3, "skipped_count": 1, "quarantined_count": 0}, "duration_seconds": 10.5, "error_type": None, }, ), DownloadEvent( source_id=source.id, status="error", error="auth failed", metadata_={ "run_stats": {"downloaded_count": 0, "skipped_count": 0, "quarantined_count": 0}, "duration_seconds": 2.1, "error_type": "auth_error", }, ), ] db.add_all(events) await db.commit() return artist, source, events @pytest.mark.asyncio async def test_list_returns_newest_first(client, seed): resp = await client.get("/api/downloads") assert resp.status_code == 200 body = await resp.get_json() assert len(body) == 2 assert body[0]["id"] > body[1]["id"] assert body[0]["summary"]["error_type"] == "auth_error" assert body[0]["artist_name"] == "Alice" @pytest.mark.asyncio async def test_list_filter_by_status(client, seed): resp = await client.get("/api/downloads?status=ok") body = await resp.get_json() assert all(r["status"] == "ok" for r in body) @pytest.mark.asyncio async def test_list_filter_by_source_id(client, seed): _, source, _ = seed resp = await client.get(f"/api/downloads?source_id={source.id}") body = await resp.get_json() assert all(r["source_id"] == source.id for r in body) @pytest.mark.asyncio async def test_list_rejects_invalid_status(client): resp = await client.get("/api/downloads?status=bogus") assert resp.status_code == 400 @pytest.mark.asyncio async def test_list_before_keyset(client, seed): _, _, events = seed middle_id = events[0].id resp = await client.get(f"/api/downloads?before={middle_id}") body = await resp.get_json() assert all(r["id"] < middle_id for r in body) @pytest.mark.asyncio async def test_detail_returns_full_metadata(client, seed): _, _, events = seed target = events[1] resp = await client.get(f"/api/downloads/{target.id}") assert resp.status_code == 200 body = await resp.get_json() assert body["metadata"]["error_type"] == "auth_error" assert body["metadata"]["duration_seconds"] == 2.1 @pytest.mark.asyncio async def test_detail_404(client): resp = await client.get("/api/downloads/99999") assert resp.status_code == 404 @pytest.mark.asyncio async def test_stats_returns_full_status_set(client, seed): resp = await client.get("/api/downloads/stats") assert resp.status_code == 200 body = await resp.get_json() assert set(body) == {"pending", "running", "ok", "error", "skipped"} assert body["ok"] == 1 assert body["error"] == 1 assert body["pending"] == 0 @pytest.mark.asyncio async def test_stats_window_hours_rejects_out_of_range(client): resp = await client.get("/api/downloads/stats?window_hours=0") assert resp.status_code == 400 resp = await client.get("/api/downloads/stats?window_hours=bogus") assert resp.status_code == 400 @pytest.mark.asyncio async def test_activity_returns_fixed_bucket_array(client, seed): resp = await client.get("/api/downloads/activity") assert resp.status_code == 200 body = await resp.get_json() assert body["hours"] == 24 assert len(body["buckets"]) == 24 assert sum(b["total"] for b in body["buckets"]) == 2 # Both seed events were created "now" → land in the newest bucket. newest = body["buckets"][-1] assert newest["ok"] == 1 assert newest["error"] == 1 @pytest.mark.asyncio async def test_activity_rejects_bogus_hours(client): resp = await client.get("/api/downloads/activity?hours=bogus") assert resp.status_code == 400 @pytest.mark.asyncio async def test_recover_stalled_dispatches_task(client, monkeypatch): """POST /api/downloads/recover-stalled returns 202 and dispatches the recover_stalled_download_events task. The task body itself is exercised in test_maintenance.py — this just covers the API → Celery hand-off.""" from backend.app.tasks import maintenance called: list[tuple] = [] monkeypatch.setattr( maintenance.recover_stalled_download_events, "delay", lambda *a, **kw: called.append((a, kw)), ) resp = await client.post("/api/downloads/recover-stalled") assert resp.status_code == 202 body = await resp.get_json() assert body == {"queued": True} assert len(called) == 1