feat(tags): TagService.merge guards + MergeResult + repoint stubs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 13:35:26 -04:00
parent 96e6003b66
commit e0a455466d
2 changed files with 144 additions and 0 deletions
+91
View File
@@ -46,6 +46,16 @@ class TagAutocompleteHit:
image_count: int
@dataclass(frozen=True)
class MergeResult:
target_id: int
target_name: str
target_kind: str
merged_count: int
alias_created: bool
source_deleted: bool
class TagService:
def __init__(self, session: AsyncSession):
self.session = session
@@ -259,3 +269,84 @@ class TagService:
tag.name = new_name
await self.session.flush()
return tag
async def merge(self, source_id: int, target_id: int) -> MergeResult:
"""Transactionally repoint every FK from source→target, optionally
keep source's name as a tagger alias, delete source. Atomic: any
exception rolls back via the caller's session."""
if source_id == target_id:
raise TagValidationError("Cannot merge a tag into itself")
source = await self.session.get(Tag, source_id)
if source is None:
raise TagValidationError(f"Tag {source_id} not found")
target = await self.session.get(Tag, target_id)
if target is None:
raise TagValidationError(f"Tag {target_id} not found")
if source.kind != target.kind or (source.fandom_id or 0) != (
target.fandom_id or 0
):
raise TagValidationError(
"Tags must be the same kind and fandom to merge"
)
keep_as_alias = await self._keep_as_alias(source_id)
source_name = source.name
source_kind = source.kind
target_name = target.name
target_kind = (
target.kind.value
if hasattr(target.kind, "value")
else str(target.kind)
)
merged_count = await self._repoint_image_tags(source_id, target_id)
await self._repoint_rejections(source_id, target_id)
await self._repoint_allowlist(source_id, target_id)
await self._repoint_embedding(source_id)
await self._repoint_aliases(source_id, target_id)
await self._repoint_fandom_children(
source_id, target_id, source_kind
)
alias_created = False
if keep_as_alias:
alias_created = await self._create_protective_aliases(
source_name, source_kind, target_id
)
await self.session.delete(source)
await self.session.flush()
return MergeResult(
target_id=target_id,
target_name=target_name,
target_kind=target_kind,
merged_count=merged_count,
alias_created=alias_created,
source_deleted=True,
)
async def _repoint_image_tags(self, src: int, tgt: int) -> int:
return 0
async def _repoint_rejections(self, src: int, tgt: int) -> None:
pass
async def _repoint_allowlist(self, src: int, tgt: int) -> None:
pass
async def _repoint_embedding(self, src: int) -> None:
pass
async def _repoint_aliases(self, src: int, tgt: int) -> None:
pass
async def _repoint_fandom_children(
self, src: int, tgt: int, src_kind: TagKind
) -> None:
pass
async def _create_protective_aliases(
self, src_name: str, src_kind: TagKind, tgt: int
) -> bool:
return False
+53
View File
@@ -3,6 +3,7 @@ import pytest
from backend.app.models import Tag, TagKind, image_tag
from backend.app.models.tag_allowlist import TagAllowlist
from backend.app.services.tag_service import (
MergeResult,
TagMergeConflict,
TagService,
TagValidationError,
@@ -76,3 +77,55 @@ async def test_will_alias_true_when_allowlisted(db):
with pytest.raises(TagMergeConflict) as ei:
await svc.rename(source.id, "Canon2")
assert ei.value.will_alias is True
@pytest.mark.asyncio
async def test_merge_rejects_self_merge(db):
svc = TagService(db)
t = await svc.find_or_create("Solo", TagKind.general)
with pytest.raises(TagValidationError, match="into itself"):
await svc.merge(t.id, t.id)
@pytest.mark.asyncio
async def test_merge_rejects_missing_tag(db):
svc = TagService(db)
t = await svc.find_or_create("Real", TagKind.general)
with pytest.raises(TagValidationError, match="not found"):
await svc.merge(t.id, 999999)
@pytest.mark.asyncio
async def test_merge_rejects_cross_kind(db):
svc = TagService(db)
a = await svc.find_or_create("NameA", TagKind.general)
b = await svc.find_or_create("NameB", TagKind.artist)
with pytest.raises(TagValidationError, match="same kind"):
await svc.merge(a.id, b.id)
@pytest.mark.asyncio
async def test_merge_rejects_cross_fandom(db):
svc = TagService(db)
f1 = await svc.find_or_create("Fandom1", TagKind.fandom)
f2 = await svc.find_or_create("Fandom2", TagKind.fandom)
c1 = await svc.find_or_create("Char", TagKind.character, fandom_id=f1.id)
c2 = await svc.find_or_create("Char2", TagKind.character, fandom_id=f2.id)
with pytest.raises(TagValidationError, match="same kind and fandom"):
await svc.merge(c1.id, c2.id)
@pytest.mark.asyncio
async def test_merge_empty_tags_returns_result(db):
svc = TagService(db)
a = await svc.find_or_create("EmptyA", TagKind.general)
b = await svc.find_or_create("EmptyB", TagKind.general)
result = await svc.merge(a.id, b.id)
assert isinstance(result, MergeResult)
assert result.target_id == b.id
assert result.target_name == "EmptyB"
assert result.target_kind == "general"
assert result.merged_count == 0
assert result.alias_created is False
assert result.source_deleted is True
assert await db.get(Tag, a.id) is None