refactor(admin): consolidate Tier-A dry-run/apply handlers onto one helper (#753)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m18s

DRY pass on the cleanup/admin destructive-ops surface (task #753, hardened
process #594). Five Tier-A endpoints repeated the same get_json -> dry_run ->
run_sync(service_fn) -> jsonify block verbatim. Extract _run_dry_run_op(service_fn,
**kwargs); the five route handlers now delegate. reconcile keeps its source_id
validation and passes it through **kwargs.

The cleanup_service predicates were already shared between preview and apply
(find_*_conditions / find_duplicate_post_groups) — the post-data-loss fix — so no
backend-logic change; this is purely the HTTP-handler boilerplate.

Consumers (all routed through the helper, verified no copy left behind):
  prune_unused_tags, prune_bare_posts, reconcile_duplicate_posts (+source_id),
  purge_legacy_tags, reset_content_tagging.

Added route-level tests for prune-bare (apply) and reconcile (apply + source_id
passthrough + invalid-source_id 400) — the two helper consumers that previously
had only service-level coverage, so every consumer is exercised at the route.

Findings B (queued-response helper) and C (store dry-run POST helper) identified
but not applied this pass (operator scoped to A). The card preview->commit state
machine is deferred to a frontend pattern-consistency sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:53:04 -04:00
parent 77d02f57ae
commit 6281cb1e66
2 changed files with 84 additions and 45 deletions
+62 -1
View File
@@ -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 ----------