feat(tags): system tags — is_system column, seeded hygiene tags, protection guards
Training hygiene step 1 (milestone #128). Migration 0075 adds tag.is_system and seeds wip / banner / editor screenshot (kind=general), ADOPTING an existing same-(name,kind) tag case-insensitively instead of duplicating. These rows drive the upcoming training exclusions, so they are protected: rename and merge-away refuse system tags (merge-INTO stays allowed — folding an operator's old hygiene tag into the system row is the intended move; merge is the only tag-delete path, so that guard covers deletion). is_system rides every tag serialization. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
"""System tags (milestone #128): seeded rows, protection guards, serialization.
|
||||
|
||||
The three hygiene tags (wip / banner / editor screenshot) ship via migration
|
||||
0075 and drive the training-hygiene exclusions. They must exist after
|
||||
migration, serialize with is_system, and refuse rename/merge-away — the
|
||||
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 backend.app.models import Tag, TagKind
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
SYSTEM_NAMES = {"wip", "banner", "editor screenshot"}
|
||||
|
||||
|
||||
async def _system_tag(db, name):
|
||||
return (await db.execute(
|
||||
select(Tag).where(Tag.is_system.is_(True), Tag.name == name)
|
||||
)).scalar_one()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seeded_system_tags_exist(db):
|
||||
rows = (await db.execute(
|
||||
select(Tag.name, Tag.kind).where(Tag.is_system.is_(True))
|
||||
)).all()
|
||||
assert {r.name for r in rows} == SYSTEM_NAMES
|
||||
assert all(r.kind == TagKind.general for r in rows)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tag_serializes_is_system(client, db):
|
||||
wip = await _system_tag(db, "wip")
|
||||
resp = await client.get(f"/api/tags/{wip.id}")
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["is_system"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_system_tag_refused(client, db):
|
||||
wip = await _system_tag(db, "wip")
|
||||
resp = await client.patch(
|
||||
f"/api/tags/{wip.id}", json={"name": "work in progress"}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
body = await resp.get_json()
|
||||
assert "system tag" in body["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_away_system_tag_refused(client, db):
|
||||
banner = await _system_tag(db, "banner")
|
||||
created = await client.post(
|
||||
"/api/tags", json={"name": "ad banner", "kind": "general"}
|
||||
)
|
||||
target_id = (await created.get_json())["id"]
|
||||
resp = await client.post(
|
||||
f"/api/tags/{banner.id}/merge", json={"target_id": target_id}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
body = await resp.get_json()
|
||||
assert "system tag" in body["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_into_system_tag_allowed(client, db):
|
||||
wip = await _system_tag(db, "wip")
|
||||
created = await client.post(
|
||||
"/api/tags", json={"name": "old wip", "kind": "general"}
|
||||
)
|
||||
source_id = (await created.get_json())["id"]
|
||||
resp = await client.post(
|
||||
f"/api/tags/{source_id}/merge", json={"target_id": wip.id}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["target"]["id"] == wip.id
|
||||
assert body["source_deleted"] is True
|
||||
+13
-4
@@ -10,20 +10,29 @@ def test_serialize_tag_with_enum_kind():
|
||||
# ORM/column rows carry kind as a TagKind enum.
|
||||
row = SimpleNamespace(
|
||||
id=1, name="Sasuke Uchiha", kind=TagKind.character,
|
||||
fandom_id=5, fandom_name="Naruto",
|
||||
fandom_id=5, fandom_name="Naruto", is_system=False,
|
||||
)
|
||||
assert serialize_tag(row) == {
|
||||
"id": 1, "name": "Sasuke Uchiha", "kind": "character",
|
||||
"fandom_id": 5, "fandom_name": "Naruto",
|
||||
"fandom_id": 5, "fandom_name": "Naruto", "is_system": False,
|
||||
}
|
||||
|
||||
|
||||
def test_serialize_tag_with_string_kind_and_no_fandom():
|
||||
# autocomplete hits already store kind as a plain string.
|
||||
# autocomplete hits already store kind as a plain string. A row from a
|
||||
# select that predates the is_system column serializes as non-system.
|
||||
row = SimpleNamespace(
|
||||
id=2, name="solo", kind="general", fandom_id=None, fandom_name=None,
|
||||
)
|
||||
assert serialize_tag(row) == {
|
||||
"id": 2, "name": "solo", "kind": "general",
|
||||
"fandom_id": None, "fandom_name": None,
|
||||
"fandom_id": None, "fandom_name": None, "is_system": False,
|
||||
}
|
||||
|
||||
|
||||
def test_serialize_tag_system_row():
|
||||
row = SimpleNamespace(
|
||||
id=3, name="wip", kind=TagKind.general,
|
||||
fandom_id=None, fandom_name=None, is_system=True,
|
||||
)
|
||||
assert serialize_tag(row)["is_system"] is True
|
||||
|
||||
Reference in New Issue
Block a user