From 1e834c01395eb18f0a3762a79d5425dc25f29d9a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 20 May 2026 12:55:27 -0400 Subject: [PATCH] feat(fc3a): /api/sources blueprint (CRUD + platforms endpoint) Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/__init__.py | 2 + backend/app/api/sources.py | 121 +++++++++++++++++++++++++++++++ tests/test_api_sources.py | 140 ++++++++++++++++++++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 backend/app/api/sources.py create mode 100644 tests/test_api_sources.py diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 6f0085d..959506b 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -24,6 +24,7 @@ def all_blueprints() -> list[Blueprint]: from .provenance import provenance_bp from .settings import settings_bp from .showcase import showcase_bp + from .sources import sources_bp from .suggestions import suggestions_bp from .tags import tags_bp return [ @@ -40,4 +41,5 @@ def all_blueprints() -> list[Blueprint]: allowlist_bp, aliases_bp, ml_admin_bp, + sources_bp, ] diff --git a/backend/app/api/sources.py b/backend/app/api/sources.py new file mode 100644 index 0000000..a9c314b --- /dev/null +++ b/backend/app/api/sources.py @@ -0,0 +1,121 @@ +"""FC-3a: CRUD over Source rows.""" + +from quart import Blueprint, jsonify, request + +from ..extensions import get_session +from ..services.source_service import ( + ArtistNotFoundError, + DuplicateSourceError, + EmptyUrlError, + InvalidConfigError, + KNOWN_PLATFORMS, + SourceService, + UnknownPlatformError, +) + +sources_bp = Blueprint("sources", __name__, url_prefix="/api/sources") + + +def _bad(error: str, *, status: int = 400, detail: str | None = None, **extra): + body = {"error": error} + if detail is not None: + body["detail"] = detail + body.update(extra) + return jsonify(body), status + + +@sources_bp.route("/platforms", methods=["GET"]) +async def platforms(): + return jsonify({"platforms": sorted(KNOWN_PLATFORMS)}) + + +@sources_bp.route("", methods=["GET"]) +async def list_sources(): + artist_id_raw = request.args.get("artist_id") + artist_id = None + if artist_id_raw is not None: + try: + artist_id = int(artist_id_raw) + except ValueError: + return _bad("invalid_artist_id", detail="artist_id must be an integer") + async with get_session() as session: + records = await SourceService(session).list(artist_id=artist_id) + return jsonify([r.to_dict() for r in records]) + + +@sources_bp.route("/", methods=["GET"]) +async def get_source(source_id: int): + async with get_session() as session: + record = await SourceService(session).get(source_id) + if record is None: + return _bad("not_found", status=404, detail=f"source id={source_id}") + return jsonify(record.to_dict()) + + +@sources_bp.route("", methods=["POST"]) +async def create_source(): + body = await request.get_json() + if not isinstance(body, dict): + return _bad("invalid_body", detail="body must be a JSON object") + try: + artist_id = int(body["artist_id"]) + platform = body["platform"] + url = body["url"] + except (KeyError, TypeError, ValueError): + return _bad("invalid_body", detail="artist_id, platform, url are required") + + optional = { + k: body[k] + for k in ("enabled", "config_overrides", "check_interval_override") + if k in body + } + + async with get_session() as session: + svc = SourceService(session) + try: + record = await svc.create( + artist_id=artist_id, platform=platform, url=url, **optional, + ) + except ArtistNotFoundError: + return _bad("artist_not_found", status=404, detail=f"artist id={artist_id}") + except UnknownPlatformError as exc: + return _bad("unknown_platform", detail=str(exc), known=sorted(KNOWN_PLATFORMS)) + except InvalidConfigError as exc: + return _bad("invalid_config", detail=str(exc)) + except EmptyUrlError as exc: + return _bad("empty_url", detail=str(exc)) + except DuplicateSourceError as exc: + return _bad("duplicate", status=409, existing_id=exc.existing_id) + return jsonify(record.to_dict()), 201 + + +@sources_bp.route("/", methods=["PATCH"]) +async def patch_source(source_id: int): + body = await request.get_json() + if not isinstance(body, dict): + return _bad("invalid_body", detail="body must be a JSON object") + async with get_session() as session: + svc = SourceService(session) + try: + record = await svc.update(source_id, **body) + except LookupError: + return _bad("not_found", status=404) + except UnknownPlatformError as exc: + return _bad("unknown_platform", detail=str(exc), known=sorted(KNOWN_PLATFORMS)) + except InvalidConfigError as exc: + return _bad("invalid_config", detail=str(exc)) + except EmptyUrlError as exc: + return _bad("empty_url", detail=str(exc)) + except DuplicateSourceError as exc: + return _bad("duplicate", status=409, existing_id=exc.existing_id) + return jsonify(record.to_dict()) + + +@sources_bp.route("/", methods=["DELETE"]) +async def delete_source(source_id: int): + async with get_session() as session: + try: + await SourceService(session).delete(source_id) + except LookupError: + return _bad("not_found", status=404) + return "", 204 diff --git a/tests/test_api_sources.py b/tests/test_api_sources.py new file mode 100644 index 0000000..2979230 --- /dev/null +++ b/tests/test_api_sources.py @@ -0,0 +1,140 @@ +import pytest + +from backend.app import create_app +from backend.app.models import Artist + +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 artist(db): + a = Artist(name="Alice", slug="alice") + db.add(a) + await db.commit() + return a + + +@pytest.mark.asyncio +async def test_platforms_endpoint(client): + resp = await client.get("/api/sources/platforms") + assert resp.status_code == 200 + body = await resp.get_json() + assert "patreon" in body["platforms"] + + +@pytest.mark.asyncio +async def test_create_list_get_delete(client, artist): + create = await client.post("/api/sources", json={ + "artist_id": artist.id, "platform": "patreon", + "url": "https://patreon.com/alice", + }) + assert create.status_code == 201 + created = await create.get_json() + assert created["artist_name"] == "Alice" + sid = created["id"] + + listing = await client.get("/api/sources") + assert listing.status_code == 200 + assert len(await listing.get_json()) == 1 + + filtered = await client.get(f"/api/sources?artist_id={artist.id}") + assert filtered.status_code == 200 + assert len(await filtered.get_json()) == 1 + + one = await client.get(f"/api/sources/{sid}") + assert one.status_code == 200 + assert (await one.get_json())["id"] == sid + + deleted = await client.delete(f"/api/sources/{sid}") + assert deleted.status_code == 204 + assert (await client.get(f"/api/sources/{sid}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_create_rejects_unknown_platform(client, artist): + resp = await client.post("/api/sources", json={ + "artist_id": artist.id, "platform": "myspace", "url": "https://m/x", + }) + assert resp.status_code == 400 + body = await resp.get_json() + assert body["error"] == "unknown_platform" + assert "known" in body + + +@pytest.mark.asyncio +async def test_create_rejects_bad_config(client, artist): + resp = await client.post("/api/sources", json={ + "artist_id": artist.id, "platform": "patreon", + "url": "https://p/a", "config_overrides": [1, 2, 3], + }) + assert resp.status_code == 400 + assert (await resp.get_json())["error"] == "invalid_config" + + +@pytest.mark.asyncio +async def test_create_rejects_empty_url(client, artist): + resp = await client.post("/api/sources", json={ + "artist_id": artist.id, "platform": "patreon", "url": " ", + }) + assert resp.status_code == 400 + assert (await resp.get_json())["error"] == "empty_url" + + +@pytest.mark.asyncio +async def test_create_rejects_unknown_artist(client): + resp = await client.post("/api/sources", json={ + "artist_id": 99999, "platform": "patreon", "url": "https://p/a", + }) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_create_duplicate_returns_409_with_existing_id(client, artist): + a = await client.post("/api/sources", json={ + "artist_id": artist.id, "platform": "patreon", "url": "https://p/a", + }) + first = (await a.get_json())["id"] + b = await client.post("/api/sources", json={ + "artist_id": artist.id, "platform": "patreon", "url": "https://p/a", + }) + assert b.status_code == 409 + body = await b.get_json() + assert body["error"] == "duplicate" + assert body["existing_id"] == first + + +@pytest.mark.asyncio +async def test_patch_updates_fields(client, artist): + create = await client.post("/api/sources", json={ + "artist_id": artist.id, "platform": "patreon", "url": "https://p/a", + }) + sid = (await create.get_json())["id"] + patch = await client.patch(f"/api/sources/{sid}", json={"enabled": False}) + assert patch.status_code == 200 + assert (await patch.get_json())["enabled"] is False + + +@pytest.mark.asyncio +async def test_get_404(client): + assert (await client.get("/api/sources/99999")).status_code == 404 + + +@pytest.mark.asyncio +async def test_patch_404(client): + assert (await client.patch("/api/sources/99999", json={"enabled": False})).status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_404(client): + assert (await client.delete("/api/sources/99999")).status_code == 404