From ad3d34a1fc1ade98d29c4b91d245c2721c689454 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 00:45:31 -0400 Subject: [PATCH] =?UTF-8?q?fc3k:=20/api/admin=20endpoint=20integration=20t?= =?UTF-8?q?ests=20=E2=80=94=20dry-run,=20confirm-mismatch,=20dispatch,=20T?= =?UTF-8?q?ier-A/B/C=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_api_admin.py | 356 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 tests/test_api_admin.py diff --git a/tests/test_api_admin.py b/tests/test_api_admin.py new file mode 100644 index 0000000..4869e22 --- /dev/null +++ b/tests/test_api_admin.py @@ -0,0 +1,356 @@ +"""FC-3k: /api/admin/* endpoint integration tests. + +Monkeypatch `.delay()` for Tier-C tasks to capture dispatch payloads +without actually queuing. Tier-B/A endpoints run synchronously +through the real service. +""" +import hashlib + +import pytest + +from backend.app import create_app +from backend.app.models import Artist, ImageRecord, Tag, TagKind +from backend.app.models.tag import image_tag + +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 + + +# --- Tier-C: POST /artists//cascade-delete -------------------- + + +@pytest.mark.asyncio +async def test_artist_cascade_dry_run_returns_projection(client, db): + a = Artist(name="Aria", slug="aria") + db.add(a) + await db.commit() + + resp = await client.post( + "/api/admin/artists/aria/cascade-delete", + json={"dry_run": True}, + ) + assert resp.status_code == 200 + body = await resp.get_json() + assert body["artist"]["slug"] == "aria" + assert "projected" in body + assert body["projected"]["images"] == 0 + + +@pytest.mark.asyncio +async def test_artist_cascade_unknown_slug_404(client): + resp = await client.post( + "/api/admin/artists/no-such/cascade-delete", + json={"dry_run": True}, + ) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_artist_cascade_wrong_confirm_400(client, db): + a = Artist(name="Aria", slug="aria") + db.add(a) + await db.commit() + artist_id = a.id + + resp = await client.post( + "/api/admin/artists/aria/cascade-delete", + json={"dry_run": False, "confirm": "wrong"}, + ) + assert resp.status_code == 400 + body = await resp.get_json() + assert body["error"] == "confirm_mismatch" + assert body["expected"] == f"delete-artist-{artist_id}" + + +@pytest.mark.asyncio +async def test_artist_cascade_correct_confirm_dispatches( + client, db, monkeypatch, +): + a = Artist(name="Aria", slug="aria") + db.add(a) + await db.commit() + artist_id = a.id + + dispatched = [] + + class _Result: + id = "fake-task-id" + + def _delay(**kw): + dispatched.append(kw) + return _Result() + + monkeypatch.setattr( + "backend.app.tasks.admin.delete_artist_cascade_task.delay", _delay, + ) + + resp = await client.post( + "/api/admin/artists/aria/cascade-delete", + json={ + "dry_run": False, + "confirm": f"delete-artist-{artist_id}", + }, + ) + assert resp.status_code == 202 + body = await resp.get_json() + assert body["task_id"] == "fake-task-id" + assert dispatched == [{"artist_id": artist_id}] + + +# --- Tier-C: POST /images/bulk-delete ------------------------------- + + +@pytest.mark.asyncio +async def test_bulk_delete_dry_run_returns_counts(client, db, tmp_path): + a = Artist(name="B", slug="b") + db.add(a) + await db.flush() + i1 = ImageRecord( + artist_id=a.id, path=str(tmp_path / "1.jpg"), + sha256="1" * 64, size_bytes=10, + ) + i2 = ImageRecord( + artist_id=a.id, path=str(tmp_path / "2.jpg"), + sha256="2" * 64, size_bytes=20, + ) + db.add_all([i1, i2]) + await db.commit() + + resp = await client.post( + "/api/admin/images/bulk-delete", + json={ + "image_ids": [i1.id, i2.id, 9_999_999], + "dry_run": True, + }, + ) + assert resp.status_code == 200 + body = await resp.get_json() + assert body["images_found"] == 2 + assert body["bytes_on_disk"] == 30 + assert body["missing_ids"] == [9_999_999] + + +@pytest.mark.asyncio +async def test_bulk_delete_empty_list_400(client): + resp = await client.post( + "/api/admin/images/bulk-delete", + json={"image_ids": [], "dry_run": True}, + ) + assert resp.status_code == 400 + body = await resp.get_json() + assert body["error"] == "invalid_image_ids" + + +@pytest.mark.asyncio +async def test_bulk_delete_non_int_400(client): + resp = await client.post( + "/api/admin/images/bulk-delete", + json={"image_ids": ["foo", 2], "dry_run": True}, + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_bulk_delete_wrong_confirm_400_with_expected_token( + client, db, tmp_path, +): + a = Artist(name="B", slug="b") + db.add(a) + await db.flush() + img = ImageRecord( + artist_id=a.id, path=str(tmp_path / "x.jpg"), + sha256="c" * 64, size_bytes=10, + ) + db.add(img) + await db.commit() + img_id = img.id + + resp = await client.post( + "/api/admin/images/bulk-delete", + json={ + "image_ids": [img_id], "dry_run": False, "confirm": "nope", + }, + ) + assert resp.status_code == 400 + body = await resp.get_json() + assert body["error"] == "confirm_mismatch" + assert body["expected"].startswith("delete-images-") + + +@pytest.mark.asyncio +async def test_bulk_delete_correct_confirm_dispatches( + client, db, tmp_path, monkeypatch, +): + a = Artist(name="B", slug="b") + db.add(a) + await db.flush() + img = ImageRecord( + artist_id=a.id, path=str(tmp_path / "x.jpg"), + sha256="d" * 64, size_bytes=10, + ) + db.add(img) + await db.commit() + img_id = img.id + + sha8 = hashlib.sha256(str(img_id).encode("utf-8")).hexdigest()[:8] + + dispatched = [] + + class _Result: + id = "fake-bulk-id" + + def _delay(**kw): + dispatched.append(kw) + return _Result() + + monkeypatch.setattr( + "backend.app.tasks.admin.bulk_delete_images_task.delay", _delay, + ) + + resp = await client.post( + "/api/admin/images/bulk-delete", + json={ + "image_ids": [img_id], + "dry_run": False, + "confirm": f"delete-images-{sha8}", + }, + ) + assert resp.status_code == 202 + body = await resp.get_json() + assert body["task_id"] == "fake-bulk-id" + assert dispatched == [{"image_ids": [img_id]}] + + +# --- Tier-B: DELETE /tags/ + POST /tags//merge ----------- + + +@pytest.mark.asyncio +async def test_tag_delete_200_after_cascade(client, db): + t = Tag(name="doomed-tag", kind=TagKind.general) + db.add(t) + await db.commit() + tag_id = t.id + + resp = await client.delete(f"/api/admin/tags/{tag_id}") + assert resp.status_code == 200 + body = await resp.get_json() + assert body["deleted"]["id"] == tag_id + assert body["associations_removed"] == 0 + + +@pytest.mark.asyncio +async def test_tag_delete_404_unknown(client): + resp = await client.delete("/api/admin/tags/9999999") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_tag_merge_succeeds_for_same_kind(client, db): + src = Tag(name="src-tag", kind=TagKind.general) + dest = Tag(name="dest-tag", kind=TagKind.general) + db.add_all([src, dest]) + await db.commit() + src_id = src.id + dest_id = dest.id + + resp = await client.post( + f"/api/admin/tags/{dest_id}/merge", + json={"source_id": src_id}, + ) + assert resp.status_code == 200 + body = await resp.get_json() + assert body["result"]["target_id"] == dest_id + + +@pytest.mark.asyncio +async def test_tag_merge_rejects_self_merge(client, db): + t = Tag(name="x", kind=TagKind.general) + db.add(t) + await db.commit() + resp = await client.post( + f"/api/admin/tags/{t.id}/merge", + json={"source_id": t.id}, + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_tag_merge_returns_400_on_kind_mismatch(client, db): + src = Tag(name="src", kind=TagKind.general) + dest = Tag(name="dest", kind=TagKind.artist) + db.add_all([src, dest]) + await db.commit() + resp = await client.post( + f"/api/admin/tags/{dest.id}/merge", + json={"source_id": src.id}, + ) + assert resp.status_code == 400 + body = await resp.get_json() + assert body["error"] == "tag_kind_mismatch" + + +# --- Tier-B helper: GET /tags//usage-count ---------------------- + + +@pytest.mark.asyncio +async def test_tag_usage_count_returns_zero_for_unused(client, db): + t = Tag(name="lonely", kind=TagKind.general) + db.add(t) + await db.commit() + resp = await client.get(f"/api/admin/tags/{t.id}/usage-count") + assert resp.status_code == 200 + body = await resp.get_json() + assert body["count"] == 0 + + +# --- Tier-A: POST /tags/prune-unused -------------------------------- + + +@pytest.mark.asyncio +async def test_prune_unused_dry_run_lists_unused(client, db, tmp_path): + a = Artist(name="P", slug="p") + db.add(a) + await db.flush() + img = ImageRecord( + artist_id=a.id, path=str(tmp_path / "p.jpg"), + sha256="e" * 64, size_bytes=10, + ) + db.add(img) + used = Tag(name="kept", kind=TagKind.general) + unused = Tag(name="prune", kind=TagKind.general) + db.add_all([used, unused]) + await db.flush() + await db.execute(image_tag.insert().values( + image_record_id=img.id, tag_id=used.id, + )) + await db.commit() + + resp = await client.post( + "/api/admin/tags/prune-unused", json={"dry_run": True}, + ) + body = await resp.get_json() + assert body["count"] == 1 + assert "prune" in body["sample_names"] + + +@pytest.mark.asyncio +async def test_prune_unused_commit_deletes_and_returns_count(client, db): + db.add(Tag(name="byebye", kind=TagKind.general)) + await db.commit() + + resp = await client.post( + "/api/admin/tags/prune-unused", json={"dry_run": False}, + ) + body = await resp.get_json() + assert body["deleted"] >= 1 + assert "byebye" in body["sample_names"]