diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 10bf684..a642298 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -39,6 +39,23 @@ def _bulk_image_confirm_token(image_ids: list[int]) -> str: return digest[:8] +async def _run_dry_run_op(service_fn, **service_kwargs): + """Shared body for the Tier-A dry-run/apply endpoints: read the `dry_run` + flag, run the cleanup_service predicate under `run_sync`, and return its + result dict. The SAME `service_fn` drives both preview and apply (the flag + just toggles), so a handler physically can't let its preview diverge from + its delete (rule 93). Default False preserves the existing contract — the UI + always passes `dry_run` explicitly (true to preview, false to apply). Extra + service kwargs (e.g. `source_id`) pass straight through.""" + body = await request.get_json(silent=True) or {} + dry_run = bool(body.get("dry_run", False)) + async with get_session() as session: + result = await session.run_sync( + lambda sync_sess: service_fn(sync_sess, dry_run=dry_run, **service_kwargs) + ) + return jsonify(result) + + @admin_bp.route("/artists//cascade-delete", methods=["POST"]) async def artist_cascade_delete(slug: str): body = await request.get_json(silent=True) or {} @@ -193,16 +210,7 @@ async def tags_prune_unused(): re-call with dry_run=false.""" from ..services.cleanup_service import prune_unused_tags - body = await request.get_json(silent=True) or {} - dry_run = bool(body.get("dry_run", False)) - - async with get_session() as session: - result = await session.run_sync( - lambda sync_sess: prune_unused_tags( - sync_sess, dry_run=dry_run, - ) - ) - return jsonify(result) + return await _run_dry_run_op(prune_unused_tags) @admin_bp.route("/posts/prune-bare", methods=["POST"]) @@ -214,16 +222,7 @@ async def posts_prune_bare(): prune itself, so the preview can't diverge from the delete.""" from ..services.cleanup_service import prune_bare_posts - body = await request.get_json(silent=True) or {} - dry_run = bool(body.get("dry_run", False)) - - async with get_session() as session: - result = await session.run_sync( - lambda sync_sess: prune_bare_posts( - sync_sess, dry_run=dry_run, - ) - ) - return jsonify(result) + return await _run_dry_run_op(prune_bare_posts) @admin_bp.route("/posts/reconcile-duplicates", methods=["POST"]) @@ -237,20 +236,13 @@ async def posts_reconcile_duplicates(): from ..services.cleanup_service import reconcile_duplicate_posts body = await request.get_json(silent=True) or {} - dry_run = bool(body.get("dry_run", False)) raw_source = body.get("source_id") try: source_id = int(raw_source) if raw_source is not None else None except (TypeError, ValueError): return _bad("invalid_source_id", detail="source_id must be an integer") - async with get_session() as session: - result = await session.run_sync( - lambda sync_sess: reconcile_duplicate_posts( - sync_sess, source_id=source_id, dry_run=dry_run, - ) - ) - return jsonify(result) + return await _run_dry_run_op(reconcile_duplicate_posts, source_id=source_id) @admin_bp.route("/tags/purge-legacy", methods=["POST"]) @@ -263,14 +255,7 @@ async def tags_purge_legacy(): operator confirms with dry_run=false.""" from ..services.cleanup_service import purge_legacy_tags - body = await request.get_json(silent=True) or {} - dry_run = bool(body.get("dry_run", False)) - - async with get_session() as session: - result = await session.run_sync( - lambda sync_sess: purge_legacy_tags(sync_sess, dry_run=dry_run) - ) - return jsonify(result) + return await _run_dry_run_op(purge_legacy_tags) @admin_bp.route("/tags/reset-content", methods=["POST"]) @@ -284,14 +269,7 @@ async def tags_reset_content(): Irreversible except via DB backup restore.""" from ..services.cleanup_service import reset_content_tagging - body = await request.get_json(silent=True) or {} - dry_run = bool(body.get("dry_run", False)) - - async with get_session() as session: - result = await session.run_sync( - lambda sync_sess: reset_content_tagging(sync_sess, dry_run=dry_run) - ) - return jsonify(result) + return await _run_dry_run_op(reset_content_tagging) @admin_bp.route("/tags/normalize", methods=["POST"]) diff --git a/tests/test_api_admin.py b/tests/test_api_admin.py index 6bf34ad..f9b27ea 100644 --- a/tests/test_api_admin.py +++ b/tests/test_api_admin.py @@ -8,7 +8,7 @@ import hashlib import pytest -from backend.app.models import Artist, ImageRecord, Tag, TagKind +from backend.app.models import Artist, ImageRecord, Post, Tag, TagKind from backend.app.models.tag import image_tag pytestmark = pytest.mark.integration @@ -411,6 +411,67 @@ async def test_purge_legacy_commit_deletes_only_legacy(client, db): assert gone == 0 +# --- Tier-A: POST /posts/prune-bare + /posts/reconcile-duplicates --- +# These two routes share _run_dry_run_op with the tag prunes (DRY pass, +# task #753); cover their apply path + reconcile's source_id passthrough so +# every helper consumer is exercised at the route level, not just the service. + + +@pytest.mark.asyncio +async def test_prune_bare_commit_deletes_bare_posts(client, db): + from sqlalchemy import func, select + + a = Artist(name="BP", slug="bp-api") + db.add(a) + await db.flush() + db.add(Post(artist_id=a.id, external_post_id="bare1")) # no images/attachments + await db.commit() + + resp = await client.post( + "/api/admin/posts/prune-bare", json={"dry_run": False}, + ) + body = await resp.get_json() + assert body["deleted"] >= 1 + remaining = (await db.execute( + select(func.count()).select_from(Post).where(Post.external_post_id == "bare1") + )).scalar_one() + assert remaining == 0 + + +@pytest.mark.asyncio +async def test_reconcile_duplicates_commit_merges_via_endpoint(client, db): + from sqlalchemy import select + + a = Artist(name="RC", slug="rc-api") + db.add(a) + await db.flush() + db.add_all([ + Post(artist_id=a.id, external_post_id="711509", + raw_metadata={"post_id": 1923726, "category": "subscribestar"}, + description="body"), + Post(artist_id=a.id, external_post_id="1923726", + raw_metadata={"post_id": 1923726, "category": "subscribestar"}), + ]) + await db.commit() + + resp = await client.post( + "/api/admin/posts/reconcile-duplicates", json={"dry_run": False}, + ) + body = await resp.get_json() + assert body["merged"] == 1 + rows = (await db.execute(select(Post.external_post_id))).scalars().all() + assert rows == ["1923726"] # one post-id-keyed keeper survives + + +@pytest.mark.asyncio +async def test_reconcile_duplicates_rejects_bad_source_id(client): + resp = await client.post( + "/api/admin/posts/reconcile-duplicates", + json={"dry_run": True, "source_id": "abc"}, + ) + assert resp.status_code == 400 + + # --- DB maintenance: bloat readout + manual VACUUM trigger ----------