From e0a455466d2e0063581e654aeaf2b86dd6f5284d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 16 May 2026 13:35:26 -0400 Subject: [PATCH] feat(tags): TagService.merge guards + MergeResult + repoint stubs Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/tag_service.py | 91 +++++++++++++++++++++++++++++ tests/test_tag_merge.py | 53 +++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index 441b9b5..794b0f8 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -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 diff --git a/tests/test_tag_merge.py b/tests/test_tag_merge.py index 679bb98..6835b12 100644 --- a/tests/test_tag_merge.py +++ b/tests/test_tag_merge.py @@ -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