From c415cb45d9e571dafa60f8dbc62554d103f129d4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 20 May 2026 12:55:51 -0400 Subject: [PATCH] feat(fc3a): /api/artists POST + autocomplete Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/__init__.py | 2 ++ backend/app/api/artists.py | 44 +++++++++++++++++++++++ tests/test_api_artists_create.py | 62 ++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 backend/app/api/artists.py create mode 100644 tests/test_api_artists_create.py diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 959506b..90b7468 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -17,6 +17,7 @@ def all_blueprints() -> list[Blueprint]: from .aliases import aliases_bp from .allowlist import allowlist_bp from .artist import artist_bp + from .artists import artists_bp from .attachments import attachments_bp from .gallery import gallery_bp from .import_admin import import_admin_bp @@ -34,6 +35,7 @@ def all_blueprints() -> list[Blueprint]: provenance_bp, tags_bp, artist_bp, + artists_bp, showcase_bp, settings_bp, import_admin_bp, diff --git a/backend/app/api/artists.py b/backend/app/api/artists.py new file mode 100644 index 0000000..f4f5e26 --- /dev/null +++ b/backend/app/api/artists.py @@ -0,0 +1,44 @@ +"""FC-3a: plural /api/artists endpoints — POST (find-or-create) + +autocomplete. The slug-routed singular /api/artist/ blueprint +stays separate and unmodified.""" + +from quart import Blueprint, jsonify, request + +from ..extensions import get_session +from ..services.artist_service import ArtistService + +artists_bp = Blueprint("artists", __name__, url_prefix="/api/artists") + + +@artists_bp.route("", methods=["POST"]) +async def create_or_get(): + body = await request.get_json() + if not isinstance(body, dict): + return jsonify({"error": "invalid_body"}), 400 + name = body.get("name", "") + async with get_session() as session: + svc = ArtistService(session) + try: + artist, created = await svc.find_or_create(name) + except ValueError as exc: + return jsonify({"error": "empty_name", "detail": str(exc)}), 400 + return jsonify({ + "id": artist.id, "name": artist.name, "slug": artist.slug, + "created": created, + }), 201 + + +@artists_bp.route("/autocomplete", methods=["GET"]) +async def autocomplete(): + q = request.args.get("q") or "" + try: + limit = int(request.args.get("limit", "20")) + except ValueError: + return jsonify({"error": "invalid_limit"}), 400 + if limit < 1 or limit > 100: + return jsonify({"error": "invalid_limit"}), 400 + async with get_session() as session: + rows = await ArtistService(session).autocomplete(q, limit=limit) + return jsonify([ + {"id": a.id, "name": a.name, "slug": a.slug} for a in rows + ]) diff --git a/tests/test_api_artists_create.py b/tests/test_api_artists_create.py new file mode 100644 index 0000000..ccf1030 --- /dev/null +++ b/tests/test_api_artists_create.py @@ -0,0 +1,62 @@ +import pytest + +from backend.app import create_app + +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.mark.asyncio +async def test_create_artist_returns_201(client): + resp = await client.post("/api/artists", json={"name": "Alice"}) + assert resp.status_code == 201 + body = await resp.get_json() + assert body["name"] == "Alice" + assert body["slug"] == "alice" + assert body["created"] is True + + +@pytest.mark.asyncio +async def test_create_artist_idempotent(client): + a = await client.post("/api/artists", json={"name": "Bob"}) + b = await client.post("/api/artists", json={"name": "Bob"}) + assert (await a.get_json())["created"] is True + body_b = await b.get_json() + assert body_b["created"] is False + assert body_b["id"] == (await a.get_json())["id"] + + +@pytest.mark.asyncio +async def test_create_artist_rejects_empty(client): + resp = await client.post("/api/artists", json={"name": " "}) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_autocomplete_returns_matches(client): + await client.post("/api/artists", json={"name": "Alice"}) + await client.post("/api/artists", json={"name": "Alice Cooper"}) + await client.post("/api/artists", json={"name": "Bob"}) + resp = await client.get("/api/artists/autocomplete?q=alic") + assert resp.status_code == 200 + names = [a["name"] for a in await resp.get_json()] + assert "Alice" in names + assert "Alice Cooper" in names + assert "Bob" not in names + + +@pytest.mark.asyncio +async def test_autocomplete_empty_query(client): + resp = await client.get("/api/artists/autocomplete?q=") + assert resp.status_code == 200 + assert await resp.get_json() == []