diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index c44cdd9..64f16bf 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -8,6 +8,7 @@ from ..extensions import get_session from ..models import Tag, TagKind from ..models.tag_allowlist import TagAllowlist from ..services.bulk_tag_service import BulkTagService +from ..services.series_service import SeriesError, SeriesService from ..services.tag_directory_service import TagDirectoryService from ..services.tag_service import ( TagMergeConflict, @@ -27,7 +28,9 @@ def _coerce_kind(raw: str | None) -> TagKind | None: return None -def _parse_bulk_ids(body) -> tuple[list[int] | None, tuple | None]: +def _parse_bulk_ids( + body, *, max_ids: int = 200 +) -> tuple[list[int] | None, tuple | None]: """Returns (image_ids, error). error is (json, status) or None.""" if not body or "image_ids" not in body: return None, (jsonify({"error": "image_ids required"}), 400) @@ -44,9 +47,9 @@ def _parse_bulk_ids(body) -> tuple[list[int] | None, tuple | None]: jsonify({"error": "image_ids must be integers"}), 400, ) - if len(ids) > 200: + if len(ids) > max_ids: return None, ( - jsonify({"error": "selection too large (max 200)"}), + jsonify({"error": f"selection too large (max {max_ids})"}), 400, ) return ids, None @@ -282,3 +285,82 @@ async def bulk_remove_tag(): ) await session.commit() return jsonify({"removed_count": removed}) + + +def _series_err(exc: SeriesError): + msg = str(exc) + status = 404 if ("not found" in msg or "not a series" in msg) else 400 + return jsonify({"error": msg}), status + + +@tags_bp.route("/series//pages", methods=["GET"]) +async def series_pages(tag_id: int): + async with get_session() as session: + try: + data = await SeriesService(session).list_pages(tag_id) + except SeriesError as exc: + return _series_err(exc) + return jsonify(data) + + +@tags_bp.route("/series//pages", methods=["POST"]) +async def series_add(tag_id: int): + body = await request.get_json() + ids, err = _parse_bulk_ids(body, max_ids=500) + if err: + return err + async with get_session() as session: + try: + n = await SeriesService(session).add_images(tag_id, ids) + except SeriesError as exc: + return _series_err(exc) + await session.commit() + return jsonify({"added_count": n}) + + +@tags_bp.route("/series//pages/remove", methods=["POST"]) +async def series_remove(tag_id: int): + body = await request.get_json() + ids, err = _parse_bulk_ids(body, max_ids=500) + if err: + return err + async with get_session() as session: + try: + n = await SeriesService(session).remove_images(tag_id, ids) + except SeriesError as exc: + return _series_err(exc) + await session.commit() + return jsonify({"removed_count": n}) + + +@tags_bp.route("/series//reorder", methods=["POST"]) +async def series_reorder(tag_id: int): + body = await request.get_json() + ids, err = _parse_bulk_ids(body, max_ids=500) + if err: + return err + async with get_session() as session: + try: + await SeriesService(session).reorder(tag_id, ids) + except SeriesError as exc: + return _series_err(exc) + await session.commit() + return jsonify({"ok": True}) + + +@tags_bp.route("/series//cover", methods=["POST"]) +async def series_cover(tag_id: int): + body = await request.get_json() + if not body or "image_id" not in body: + return jsonify({"error": "image_id required"}), 400 + try: + image_id = int(body["image_id"]) + except (TypeError, ValueError): + return jsonify({"error": "image_id must be an integer"}), 400 + async with get_session() as session: + try: + await SeriesService(session).set_cover(tag_id, image_id) + except SeriesError as exc: + return _series_err(exc) + await session.commit() + return jsonify({"ok": True}) diff --git a/tests/test_api_series.py b/tests/test_api_series.py new file mode 100644 index 0000000..5f70f6b --- /dev/null +++ b/tests/test_api_series.py @@ -0,0 +1,72 @@ +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 + + +async def _series(client, name="Vol"): + r = await client.post("/api/tags", json={"name": name, "kind": "series"}) + return (await r.get_json())["id"] + + +@pytest.mark.asyncio +async def test_pages_404_for_non_series(client): + r = await client.post("/api/tags", json={"name": "g", "kind": "general"}) + gid = (await r.get_json())["id"] + resp = await client.get(f"/api/series/{gid}/pages") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_pages_empty_shape(client): + sid = await _series(client, "Empty") + resp = await client.get(f"/api/series/{sid}/pages") + assert resp.status_code == 200 + body = await resp.get_json() + assert body["series"]["id"] == sid + assert body["pages"] == [] + + +@pytest.mark.asyncio +async def test_add_requires_ids(client): + sid = await _series(client, "NoIds") + resp = await client.post(f"/api/series/{sid}/pages", json={}) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_add_over_cap_400(client): + sid = await _series(client, "Cap") + resp = await client.post( + f"/api/series/{sid}/pages", + json={"image_ids": list(range(1, 502))}, + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_reorder_mismatch_400(client): + sid = await _series(client, "Re") + resp = await client.post( + f"/api/series/{sid}/reorder", json={"image_ids": [1, 2, 3]} + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_cover_requires_image_id(client): + sid = await _series(client, "Cov") + resp = await client.post(f"/api/series/{sid}/cover", json={}) + assert resp.status_code == 400