feat(tags): system-tag UI markers + full protection sweep (step 4 of #128)
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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user