feat(tags): edit a character tag's fandom (backend)
No way existed to change which fandom a character tag belongs to after creation — PATCH /tags/<id> only renamed. - TagService.set_fandom(tag_id, fandom_id, merge=False): set / change / clear (fandom_id=None) a character's fandom, with the same validation as find_or_create. On a name collision in the target fandom it raises TagMergeConflict (→ 409, same shape as rename); merge=True resolves it by merging this tag INTO the existing character. - Extract _do_merge(source, target) from merge() so set_fandom can perform the deliberate CROSS-fandom merge the public merge() validation forbids. - PATCH /tags/<id> now accepts optional fandom_id (+ merge flag) alongside name, and returns fandom_id. Tests: set/change/clear, non-character + bad-ref rejection, collision raises, merge resolves; API set/clear + collision→merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,44 @@ import pytest
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def _mk(client, name, kind):
|
||||
r = await client.post("/api/tags", json={"name": name, "kind": kind})
|
||||
async def _mk(client, name, kind, fandom_id=None):
|
||||
body = {"name": name, "kind": kind}
|
||||
if fandom_id is not None:
|
||||
body["fandom_id"] = fandom_id
|
||||
r = await client.post("/api/tags", json=body)
|
||||
return (await r.get_json())["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_sets_and_clears_character_fandom(client):
|
||||
fandom = await _mk(client, "Bleach", "fandom")
|
||||
char = await _mk(client, "Ichigo", "character")
|
||||
resp = await client.patch(f"/api/tags/{char}", json={"fandom_id": fandom})
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["fandom_id"] == fandom
|
||||
resp = await client.patch(f"/api/tags/{char}", json={"fandom_id": None})
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["fandom_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_fandom_collision_then_merge(client):
|
||||
f1 = await _mk(client, "Bleach", "fandom")
|
||||
f2 = await _mk(client, "Naruto", "fandom")
|
||||
c1 = await _mk(client, "Renji", "character", fandom_id=f1)
|
||||
c2 = await _mk(client, "Renji", "character", fandom_id=f2)
|
||||
# Moving c1 into f2 collides with c2 → rich 409 (same shape as rename).
|
||||
resp = await client.patch(f"/api/tags/{c1}", json={"fandom_id": f2})
|
||||
assert resp.status_code == 409
|
||||
assert (await resp.get_json())["target"]["id"] == c2
|
||||
# Confirm → merge c1 into c2, c2 survives.
|
||||
resp = await client.patch(
|
||||
f"/api/tags/{c1}", json={"fandom_id": f2, "merge": True}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["id"] == c2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_collision_returns_rich_409(client):
|
||||
tgt = await _mk(client, "Canonical", "general")
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import pytest
|
||||
|
||||
from backend.app.models import TagKind
|
||||
from backend.app.services.tag_service import TagService, TagValidationError
|
||||
from backend.app.services.tag_service import (
|
||||
TagMergeConflict,
|
||||
TagService,
|
||||
TagValidationError,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
@@ -101,3 +105,64 @@ async def test_autocomplete_empty_query_returns_nothing(db):
|
||||
svc = TagService(db)
|
||||
await svc.find_or_create("Foo", TagKind.artist)
|
||||
assert await svc.autocomplete("") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_fandom_assigns_changes_and_clears(db):
|
||||
svc = TagService(db)
|
||||
f1 = await svc.find_or_create("Bleach", TagKind.fandom)
|
||||
f2 = await svc.find_or_create("Naruto", TagKind.fandom)
|
||||
char = await svc.find_or_create("Ichigo", TagKind.character)
|
||||
assert char.fandom_id is None
|
||||
assert (await svc.set_fandom(char.id, f1.id)).fandom_id == f1.id
|
||||
assert (await svc.set_fandom(char.id, f2.id)).fandom_id == f2.id
|
||||
assert (await svc.set_fandom(char.id, None)).fandom_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_fandom_rejects_non_character(db):
|
||||
svc = TagService(db)
|
||||
fandom = await svc.find_or_create("Naruto", TagKind.fandom)
|
||||
series = await svc.find_or_create("Arc 1", TagKind.series)
|
||||
with pytest.raises(TagValidationError, match="character"):
|
||||
await svc.set_fandom(series.id, fandom.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_fandom_rejects_non_fandom_reference(db):
|
||||
svc = TagService(db)
|
||||
general = await svc.find_or_create("misc", TagKind.general)
|
||||
char = await svc.find_or_create("Rukia", TagKind.character)
|
||||
with pytest.raises(TagValidationError, match="fandom"):
|
||||
await svc.set_fandom(char.id, general.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_fandom_collision_raises_merge_conflict(db):
|
||||
svc = TagService(db)
|
||||
f1 = await svc.find_or_create("Bleach", TagKind.fandom)
|
||||
f2 = await svc.find_or_create("Naruto", TagKind.fandom)
|
||||
c1 = await svc.find_or_create("Renji", TagKind.character, fandom_id=f1.id)
|
||||
c2 = await svc.find_or_create("Renji", TagKind.character, fandom_id=f2.id)
|
||||
with pytest.raises(TagMergeConflict) as ei:
|
||||
await svc.set_fandom(c1.id, f2.id) # collides with c2 in f2
|
||||
assert ei.value.target_id == c2.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_fandom_merge_resolves_collision(db):
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models import Tag
|
||||
|
||||
svc = TagService(db)
|
||||
f1 = await svc.find_or_create("Bleach", TagKind.fandom)
|
||||
f2 = await svc.find_or_create("Naruto", TagKind.fandom)
|
||||
c1 = await svc.find_or_create("Renji", TagKind.character, fandom_id=f1.id)
|
||||
c2 = await svc.find_or_create("Renji", TagKind.character, fandom_id=f2.id)
|
||||
survivor = await svc.set_fandom(c1.id, f2.id, merge=True)
|
||||
assert survivor.id == c2.id # merged INTO the existing character
|
||||
gone = (
|
||||
await db.execute(select(Tag).where(Tag.id == c1.id))
|
||||
).scalar_one_or_none()
|
||||
assert gone is None
|
||||
|
||||
Reference in New Issue
Block a user