feat(tags): non-mutating merge preview + admin dry_run (#8a)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Failing after 3m17s

Cluster B, milestone #99. TagService.merge_preview(source, target) computes the
same counts the apply produces (rule 93 parity) without mutating: images_moving
(source links the apply UPDATEs), images_already_on_target (links it drops),
source_total, series_pages, will_alias (_keep_as_alias), a kind/fandom
compatible flag (surfaced, not raised, so the UI can warn), and up to 6
thumbnails of the moving images. The admin /tags/<dest>/merge route gains a
dry_run flag returning the preview JSON.

Tests: preview moving-count == apply merged_count (parity), incompatible flagged
without raising, self/missing raise, admin dry_run returns preview + no mutation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
This commit is contained in:
2026-06-23 01:37:11 -04:00
parent e206778a5c
commit 7127714316
4 changed files with 208 additions and 0 deletions
+35
View File
@@ -270,6 +270,41 @@ async def test_tag_merge_succeeds_for_same_kind(client, db):
assert body["result"]["target_id"] == dest_id
@pytest.mark.asyncio
async def test_tag_merge_dry_run_previews_without_mutating(client, db):
from backend.app.models import ImageRecord
from backend.app.models.tag import image_tag
src = Tag(name="dry-src", kind=TagKind.general)
dest = Tag(name="dry-dest", kind=TagKind.general)
db.add_all([src, dest])
await db.flush()
img = ImageRecord(
path="/images/dry.jpg", sha256="dry" + "0" * 61, size_bytes=1,
mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
)
db.add(img)
await db.flush()
await db.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=src.id, source="manual",
))
src_id, dest_id = src.id, dest.id
await db.commit()
resp = await client.post(
f"/api/admin/tags/{dest_id}/merge",
json={"source_id": src_id, "dry_run": True},
)
assert resp.status_code == 200
p = (await resp.get_json())["preview"]
assert p["compatible"] is True
assert p["images_moving"] == 1
assert p["source_total"] == 1
# No mutation: source still exists.
assert await db.get(Tag, src_id) is not None
@pytest.mark.asyncio
async def test_tag_merge_rejects_self_merge(client, db):
t = Tag(name="x", kind=TagKind.general)