feat(fc3a): /api/sources blueprint (CRUD + platforms endpoint)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 12:55:27 -04:00
parent 0270a23c1e
commit 1e834c0139
3 changed files with 263 additions and 0 deletions
+2
View File
@@ -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,
]
+121
View File
@@ -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("/<int:source_id>", 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("/<int:source_id>", 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("/<int:source_id>", 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
+140
View File
@@ -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