diff --git a/backend/app/api/artists.py b/backend/app/api/artists.py index f4f5e26..fa6da74 100644 --- a/backend/app/api/artists.py +++ b/backend/app/api/artists.py @@ -1,11 +1,14 @@ """FC-3a: plural /api/artists endpoints — POST (find-or-create) + -autocomplete. The slug-routed singular /api/artist/ blueprint +autocomplete. FC-3f: GET /api/artists/directory (cursor-paginated +browse grid). 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_directory_service import ArtistDirectoryService from ..services.artist_service import ArtistService +from ..services.source_service import KNOWN_PLATFORMS artists_bp = Blueprint("artists", __name__, url_prefix="/api/artists") @@ -42,3 +45,39 @@ async def autocomplete(): return jsonify([ {"id": a.id, "name": a.name, "slug": a.slug} for a in rows ]) + + +@artists_bp.route("/directory", methods=["GET"]) +async def directory(): + """FC-3f: cursor-paginated artists directory. + + Mirrors /api/tags/directory shape: { cards: [...], next_cursor }. + """ + args = request.args + + cursor = args.get("cursor") or None + q = args.get("q") or None + platform = args.get("platform") or None + limit_raw = args.get("limit", "60") + + try: + limit = int(limit_raw) + except ValueError: + return jsonify({"error": "invalid_limit"}), 400 + if limit < 1 or limit > 200: + return jsonify({"error": "invalid_limit"}), 400 + + if platform is not None and platform not in KNOWN_PLATFORMS: + return jsonify({"error": "unknown_platform"}), 400 + + async with get_session() as session: + svc = ArtistDirectoryService(session) + try: + page = await svc.list_artists( + q=q, platform=platform, cursor=cursor, limit=limit, + ) + except ValueError: + # Service raises only on bad cursor (limit was validated above). + return jsonify({"error": "invalid_cursor"}), 400 + + return jsonify({"cards": page.cards, "next_cursor": page.next_cursor}) diff --git a/tests/test_api_artists_directory.py b/tests/test_api_artists_directory.py new file mode 100644 index 0000000..37459fc --- /dev/null +++ b/tests/test_api_artists_directory.py @@ -0,0 +1,104 @@ +"""FC-3f: /api/artists/directory API tests.""" + +import pytest + +from backend.app import create_app +from backend.app.models import Artist, ImageRecord, 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 seeded(db): + alice = Artist(name="alice-api", slug="alice-api", is_subscription=True) + bob = Artist(name="bob-api", slug="bob-api", is_subscription=False) + db.add(alice) + db.add(bob) + await db.flush() + db.add(Source( + artist_id=alice.id, platform="patreon", + url="https://p/alice-api", enabled=True, + )) + db.add(ImageRecord( + path="/images/api/alice.jpg", + sha256=("a" * 60) + "0001", + size_bytes=10, mime="image/jpeg", width=10, height=10, + origin="downloaded", artist_id=alice.id, + )) + await db.commit() + return alice, bob + + +@pytest.mark.asyncio +async def test_directory_returns_cards_and_cursor_keys(client, seeded): + resp = await client.get("/api/artists/directory") + assert resp.status_code == 200 + body = await resp.get_json() + assert set(body.keys()) == {"cards", "next_cursor"} + assert isinstance(body["cards"], list) + + +@pytest.mark.asyncio +async def test_directory_card_shape(client, seeded): + resp = await client.get("/api/artists/directory?q=alice-api") + body = await resp.get_json() + card = next(c for c in body["cards"] if c["name"] == "alice-api") + assert set(card.keys()) == { + "id", "name", "slug", "is_subscription", "image_count", "preview_thumbnails", + } + assert card["is_subscription"] is True + assert card["image_count"] == 1 + assert len(card["preview_thumbnails"]) == 1 + + +@pytest.mark.asyncio +async def test_directory_rejects_malformed_cursor(client): + resp = await client.get("/api/artists/directory?cursor=garbage!!!") + assert resp.status_code == 400 + body = await resp.get_json() + assert body["error"] == "invalid_cursor" + + +@pytest.mark.asyncio +async def test_directory_rejects_unknown_platform(client): + resp = await client.get("/api/artists/directory?platform=myspace") + assert resp.status_code == 400 + body = await resp.get_json() + assert body["error"] == "unknown_platform" + + +@pytest.mark.asyncio +async def test_directory_rejects_limit_out_of_range(client): + resp = await client.get("/api/artists/directory?limit=0") + assert resp.status_code == 400 + resp = await client.get("/api/artists/directory?limit=500") + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_directory_q_propagates(client, seeded): + resp = await client.get("/api/artists/directory?q=alice-api") + body = await resp.get_json() + names = [c["name"] for c in body["cards"]] + assert "alice-api" in names + assert "bob-api" not in names + + +@pytest.mark.asyncio +async def test_directory_platform_propagates(client, seeded): + resp = await client.get("/api/artists/directory?platform=patreon") + body = await resp.get_json() + names = [c["name"] for c in body["cards"]] + assert "alice-api" in names + assert "bob-api" not in names # no patreon source