diff --git a/backend/app/api/sources.py b/backend/app/api/sources.py index d5ef480..fd2b109 100644 --- a/backend/app/api/sources.py +++ b/backend/app/api/sources.py @@ -1,8 +1,10 @@ -"""FC-3a: CRUD over Source rows.""" +"""FC-3a: CRUD over Source rows. FC-3c adds POST //check.""" from quart import Blueprint, jsonify, request +from sqlalchemy import select from ..extensions import get_session +from ..models import DownloadEvent, Source from ..services.source_service import ( KNOWN_PLATFORMS, ArtistNotFoundError, @@ -114,3 +116,41 @@ async def delete_source(source_id: int): except LookupError: return _bad("not_found", status=404) return "", 204 + + +@sources_bp.route("//check", methods=["POST"]) +async def check_source(source_id: int): + """FC-3c: enqueue a download for this source. + + Returns 202 with the new DownloadEvent id. If a pending/running + event already exists for this source, returns 409 with that id.""" + async with get_session() as session: + source = (await session.execute( + select(Source).where(Source.id == source_id) + )).scalar_one_or_none() + if source is None: + return _bad("not_found", status=404, detail=f"source id={source_id}") + if not source.enabled: + return _bad("source_disabled", detail="enable the source first") + + in_flight = (await session.execute( + select(DownloadEvent.id).where( + DownloadEvent.source_id == source_id, + DownloadEvent.status.in_(["pending", "running"]), + ).order_by(DownloadEvent.id.desc()).limit(1) + )).scalar_one_or_none() + if in_flight is not None: + return jsonify( + {"download_event_id": in_flight, "status": "already_running"} + ), 409 + + event = DownloadEvent(source_id=source_id, status="pending") + session.add(event) + await session.commit() + await session.refresh(event) + event_id = event.id + + from ..tasks.download import download_source + download_source.delay(source_id) + + return jsonify({"download_event_id": event_id, "status": "pending"}), 202 diff --git a/tests/test_api_sources_check.py b/tests/test_api_sources_check.py new file mode 100644 index 0000000..c532738 --- /dev/null +++ b/tests/test_api_sources_check.py @@ -0,0 +1,77 @@ +import pytest + +from backend.app import create_app +from backend.app.models import Artist, DownloadEvent, Source + +pytestmark = pytest.mark.integration + + +@pytest.fixture +async def app(): + return create_app() + + +@pytest.fixture +async def client(app): + async with app.test_client() as c: + yield c + + +@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.commit() + return source + + +@pytest.mark.asyncio +async def test_check_202_creates_pending_event(client, seed, monkeypatch): + monkeypatch.setattr( + "backend.app.tasks.download.download_source.delay", + lambda *a, **k: None, + ) + resp = await client.post(f"/api/sources/{seed.id}/check") + assert resp.status_code == 202 + body = await resp.get_json() + assert body["status"] == "pending" + assert isinstance(body["download_event_id"], int) + + +@pytest.mark.asyncio +async def test_check_404_for_unknown_source(client): + resp = await client.post("/api/sources/99999/check") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_check_400_for_disabled_source(client, db, seed): + seed.enabled = False + await db.commit() + resp = await client.post(f"/api/sources/{seed.id}/check") + assert resp.status_code == 400 + body = await resp.get_json() + assert body["error"] == "source_disabled" + + +@pytest.mark.asyncio +async def test_check_409_when_already_running(client, db, seed, monkeypatch): + monkeypatch.setattr( + "backend.app.tasks.download.download_source.delay", + lambda *a, **k: None, + ) + existing = DownloadEvent(source_id=seed.id, status="running") + db.add(existing) + await db.commit() + await db.refresh(existing) + resp = await client.post(f"/api/sources/{seed.id}/check") + assert resp.status_code == 409 + body = await resp.get_json() + assert body["download_event_id"] == existing.id