feat(tags): system-tag UI markers + full protection sweep (step 4 of #128)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Failing after 3m33s

UI: shield marker + tooltip on TagChip and TagCard; system tags hide
rename/merge/delete affordances (chip kebab entirely — set-fandom never
applies to their general kind; remove stays, un-tagging is normal use).
Aliases stay available: mapping model outputs ONTO a system tag is
useful. Directory cards carry is_system.

Every destructive path that could take out a system row is now guarded,
found by sweeping run 1891s off-by-three failures — each one was a
surface that would have eaten the seeded tags:
- prune-unused: predicate exempts is_system (they ship with zero
  applications and matched every unused condition)
- reset-content: predicate exempts is_system AND keeps their
  applications — hygiene flags describe the file, not content tagging
- admin tag DELETE: refused with system_tag error
- normalize_existing_tags: scan excludes is_system — canonicalization
  would recase wip -> Wip behind TagService.rename's guard, breaking
  the name-keyed presentation lookup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-03 08:34:16 -04:00
parent 723f023e6a
commit f77e75147d
7 changed files with 121 additions and 8 deletions
+73 -2
View File
@@ -7,9 +7,14 @@ mechanism keys on these exact rows. Merge-INTO stays allowed: adopting an
operator's old hygiene tag into the system row is the intended move.
"""
import pytest
from sqlalchemy import select
from sqlalchemy import insert, select
from backend.app.models import Tag, TagKind
from backend.app.models import ImageRecord, Tag, TagKind
from backend.app.models.tag import image_tag
from backend.app.services.cleanup_service import (
prune_unused_tags,
reset_content_tagging,
)
pytestmark = pytest.mark.integration
@@ -65,6 +70,72 @@ async def test_merge_away_system_tag_refused(client, db):
assert "system tag" in body["error"]
@pytest.mark.asyncio
async def test_admin_delete_system_tag_refused(client, db):
wip = await _system_tag(db, "wip")
resp = await client.delete(f"/api/admin/tags/{wip.id}")
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "system_tag"
still = (await db.execute(
select(Tag.id).where(Tag.id == wip.id)
)).scalar_one_or_none()
assert still == wip.id
@pytest.mark.asyncio
async def test_prune_unused_skips_system_tags(db):
"""The seeded system tags have zero applications and would match every
'unused' condition — the shared predicate must exempt them (preview AND
live delete, same conditions)."""
preview = await db.run_sync(
lambda s: prune_unused_tags(s, dry_run=True)
)
assert preview["count"] == 0
assert preview["sample_names"] == []
await db.run_sync(lambda s: prune_unused_tags(s, dry_run=False))
remaining = (await db.execute(
select(Tag.name).where(Tag.is_system.is_(True))
)).scalars().all()
assert set(remaining) == SYSTEM_NAMES
@pytest.mark.asyncio
async def test_reset_content_preserves_system_tags_and_applications(db):
"""Reset re-tags CONTENT concepts; hygiene flags describe the file itself
and survive with their applications."""
wip = await _system_tag(db, "wip")
plain = Tag(name="doomed concept", kind=TagKind.general)
db.add(plain)
img = ImageRecord(
path="/images/r.jpg", sha256="f" * 64, size_bytes=1, mime="image/jpeg",
width=1, height=1, origin="imported_filesystem",
integrity_status="unknown",
)
db.add(img)
await db.flush()
for tid in (wip.id, plain.id):
await db.execute(insert(image_tag).values(
image_record_id=img.id, tag_id=tid, source="manual",
))
await db.commit()
result = await db.run_sync(
lambda s: reset_content_tagging(s, dry_run=False)
)
assert result["deleted"] == 1 # only the plain general tag
names_left = (await db.execute(
select(Tag.name).where(Tag.kind == TagKind.general)
)).scalars().all()
assert "doomed concept" not in names_left
assert "wip" in names_left
wip_apps = (await db.execute(
select(image_tag.c.image_record_id)
.where(image_tag.c.tag_id == wip.id)
)).scalars().all()
assert wip_apps == [img.id]
@pytest.mark.asyncio
async def test_merge_into_system_tag_allowed(client, db):
wip = await _system_tag(db, "wip")