From f6c71172319bbf2aa31cc8684355fa090dff86af Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 14 May 2026 12:04:48 -0400 Subject: [PATCH] =?UTF-8?q?feat(fc2a):=20add=20TagService=20=E2=80=94=20fi?= =?UTF-8?q?nd-or-create,=20autocomplete,=20image=20association?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validates kind/fandom rules at the service layer: fandom_id only allowed for kind='character', and must reference an existing kind='fandom' tag. Autocomplete ranking: exact match > prefix match > substring, tie-broken by image_count descending. Image-tag association uses INSERT ON CONFLICT DO NOTHING for idempotent re-tagging. conftest.py adds a transactional AsyncSession fixture; each test rolls back so they don't pollute each other. Also includes a sync Session fixture (db_sync) for the Importer tests in Task 5. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/__init__.py | 1 + backend/app/services/tag_service.py | 164 ++++++++++++++++++++++++++++ tests/conftest.py | 75 +++++++++++++ tests/test_tag_service.py | 101 +++++++++++++++++ 4 files changed, 341 insertions(+) create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/tag_service.py create mode 100644 tests/conftest.py create mode 100644 tests/test_tag_service.py diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..535d24d --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Business-logic services consumed by API blueprints and Celery tasks.""" diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py new file mode 100644 index 0000000..1469f86 --- /dev/null +++ b/backend/app/services/tag_service.py @@ -0,0 +1,164 @@ +"""Tag CRUD + autocomplete + image-tag association.""" + +from collections.abc import Sequence +from dataclasses import dataclass + +from sqlalchemy import and_, case, func, select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Tag, TagKind, image_tag + + +class TagValidationError(ValueError): + """Raised when tag construction breaks the kind/fandom rules.""" + + +@dataclass(frozen=True) +class TagAutocompleteHit: + id: int + name: str + kind: str + fandom_id: int | None + fandom_name: str | None + image_count: int + + +class TagService: + def __init__(self, session: AsyncSession): + self.session = session + + async def find_or_create( + self, name: str, kind: TagKind, fandom_id: int | None = None + ) -> Tag: + """Upserts (name, kind, fandom_id) into the tag table. + + Validates kind/fandom rules before touching the DB: + - fandom_id may only be set when kind == TagKind.character + - if fandom_id is set, the referenced tag must exist and have + kind == TagKind.fandom + """ + name = name.strip() + if not name: + raise TagValidationError("Tag name cannot be empty") + + if fandom_id is not None and kind != TagKind.character: + raise TagValidationError( + "fandom_id is only valid for character tags" + ) + + if fandom_id is not None: + fandom = await self.session.get(Tag, fandom_id) + if fandom is None or fandom.kind != TagKind.fandom: + raise TagValidationError( + f"fandom_id {fandom_id} does not reference a fandom tag" + ) + + # Upsert via INSERT ... ON CONFLICT DO NOTHING. We can't use the + # uniqueness index name directly (it's a partial coalesce-based + # expression), so we re-select after insert. + stmt = ( + select(Tag) + .where(Tag.name == name) + .where(Tag.kind == kind) + .where( + Tag.fandom_id.is_(None) if fandom_id is None else Tag.fandom_id == fandom_id + ) + ) + existing = (await self.session.execute(stmt)).scalar_one_or_none() + if existing: + return existing + + new_tag = Tag(name=name, kind=kind, fandom_id=fandom_id) + self.session.add(new_tag) + await self.session.flush() + return new_tag + + async def autocomplete( + self, + query: str, + kind: TagKind | None = None, + limit: int = 20, + ) -> list[TagAutocompleteHit]: + """ILIKE search ranked exact > prefix > substring, then by image count. + + Returns at most `limit` hits. + """ + q = (query or "").strip() + if not q: + return [] + + match_rank = case( + (func.lower(Tag.name) == q.lower(), 0), + (func.lower(Tag.name).startswith(q.lower()), 1), + else_=2, + ) + + fandom_alias = Tag.__table__.alias("fandom_lookup") + image_count = ( + select(func.count(image_tag.c.image_record_id)) + .where(image_tag.c.tag_id == Tag.id) + .correlate(Tag.__table__) + .scalar_subquery() + ) + + stmt = ( + select( + Tag.id, + Tag.name, + Tag.kind, + Tag.fandom_id, + fandom_alias.c.name.label("fandom_name"), + image_count.label("image_count"), + ) + .select_from(Tag.__table__.outerjoin( + fandom_alias, Tag.fandom_id == fandom_alias.c.id + )) + .where(func.lower(Tag.name).like(f"%{q.lower()}%")) + .order_by(match_rank.asc(), image_count.desc(), Tag.name.asc()) + .limit(limit) + ) + if kind is not None: + stmt = stmt.where(Tag.kind == kind) + + rows = (await self.session.execute(stmt)).all() + return [ + TagAutocompleteHit( + id=r.id, + name=r.name, + kind=r.kind.value if hasattr(r.kind, "value") else r.kind, + fandom_id=r.fandom_id, + fandom_name=r.fandom_name, + image_count=r.image_count or 0, + ) + for r in rows + ] + + async def add_to_image(self, image_id: int, tag_id: int, source: str = "manual") -> None: + """Idempotent: re-adding an existing tag does nothing.""" + stmt = insert(image_tag).values( + image_record_id=image_id, tag_id=tag_id, source=source + ) + stmt = stmt.on_conflict_do_nothing( + index_elements=["image_record_id", "tag_id"] + ) + await self.session.execute(stmt) + + async def remove_from_image(self, image_id: int, tag_id: int) -> None: + await self.session.execute( + image_tag.delete().where( + and_( + image_tag.c.image_record_id == image_id, + image_tag.c.tag_id == tag_id, + ) + ) + ) + + async def list_for_image(self, image_id: int) -> Sequence[Tag]: + stmt = ( + select(Tag) + .join(image_tag, image_tag.c.tag_id == Tag.id) + .where(image_tag.c.image_record_id == image_id) + .order_by(Tag.kind.asc(), Tag.name.asc()) + ) + return (await self.session.execute(stmt)).scalars().all() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..73b3ef9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,75 @@ +"""Shared pytest fixtures. + +The async db fixture provides an AsyncSession bound to a transaction that +gets rolled back after each test. CI provisions a real Postgres + pgvector +(see .forgejo/workflows/ci.yml), so tests exercise the actual schema and +migration code paths. +""" + +import os + +import pytest +import pytest_asyncio +from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import sessionmaker + + +def _async_database_url() -> str: + user = os.environ.get("DB_USER", "fabledcurator") + password = os.environ["DB_PASSWORD"] + host = os.environ.get("DB_HOST", "postgres") + port = os.environ.get("DB_PORT", "5432") + name = os.environ.get("DB_NAME", "fabledcurator_test") + return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{name}" + + +def _sync_database_url() -> str: + user = os.environ.get("DB_USER", "fabledcurator") + password = os.environ["DB_PASSWORD"] + host = os.environ.get("DB_HOST", "postgres") + port = os.environ.get("DB_PORT", "5432") + name = os.environ.get("DB_NAME", "fabledcurator_test") + return f"postgresql+psycopg://{user}:{password}@{host}:{port}/{name}" + + +@pytest_asyncio.fixture +async def engine(): + e = create_async_engine(_async_database_url(), future=True) + yield e + await e.dispose() + + +@pytest_asyncio.fixture +async def db(engine) -> AsyncSession: + Session = async_sessionmaker(engine, expire_on_commit=False) + async with Session() as session: + await session.begin() + try: + yield session + finally: + await session.rollback() + + +@pytest.fixture +def sync_engine(): + e = create_engine(_sync_database_url(), future=True) + yield e + e.dispose() + + +@pytest.fixture +def db_sync(sync_engine): + """Synchronous Session bound to a savepoint — used by Importer tests + (the Importer is sync-only by design).""" + SyncSession = sessionmaker(sync_engine, expire_on_commit=False) + with SyncSession() as session: + session.begin() + try: + yield session + finally: + session.rollback() diff --git a/tests/test_tag_service.py b/tests/test_tag_service.py new file mode 100644 index 0000000..65a8f26 --- /dev/null +++ b/tests/test_tag_service.py @@ -0,0 +1,101 @@ +import pytest + +from backend.app.models import TagKind +from backend.app.services.tag_service import TagService, TagValidationError + + +@pytest.mark.asyncio +async def test_find_or_create_creates(db): + svc = TagService(db) + t = await svc.find_or_create("Alice", TagKind.character, fandom_id=None) + assert t.id is not None + assert t.name == "Alice" + assert t.kind == TagKind.character + + +@pytest.mark.asyncio +async def test_find_or_create_idempotent(db): + svc = TagService(db) + a = await svc.find_or_create("Bob", TagKind.artist) + b = await svc.find_or_create("Bob", TagKind.artist) + assert a.id == b.id + + +@pytest.mark.asyncio +async def test_same_name_different_kind_coexists(db): + svc = TagService(db) + artist = await svc.find_or_create("Forge", TagKind.artist) + series = await svc.find_or_create("Forge", TagKind.series) + assert artist.id != series.id + + +@pytest.mark.asyncio +async def test_fandom_id_requires_character(db): + svc = TagService(db) + fandom = await svc.find_or_create("Naruto", TagKind.fandom) + with pytest.raises(TagValidationError, match="character"): + await svc.find_or_create("Bob", TagKind.artist, fandom_id=fandom.id) + + +@pytest.mark.asyncio +async def test_fandom_id_must_reference_fandom(db): + svc = TagService(db) + artist = await svc.find_or_create("X", TagKind.artist) + with pytest.raises(TagValidationError, match="fandom"): + await svc.find_or_create("Y", TagKind.character, fandom_id=artist.id) + + +@pytest.mark.asyncio +async def test_character_with_fandom(db): + svc = TagService(db) + fandom = await svc.find_or_create("Naruto", TagKind.fandom) + char = await svc.find_or_create("Sasuke", TagKind.character, fandom_id=fandom.id) + assert char.fandom_id == fandom.id + + +@pytest.mark.asyncio +async def test_character_same_name_different_fandoms(db): + svc = TagService(db) + f1 = await svc.find_or_create("Series A", TagKind.fandom) + f2 = await svc.find_or_create("Series B", TagKind.fandom) + c1 = await svc.find_or_create("Alice", TagKind.character, fandom_id=f1.id) + c2 = await svc.find_or_create("Alice", TagKind.character, fandom_id=f2.id) + assert c1.id != c2.id + + +@pytest.mark.asyncio +async def test_empty_name_raises(db): + svc = TagService(db) + with pytest.raises(TagValidationError): + await svc.find_or_create(" ", TagKind.general) + + +@pytest.mark.asyncio +async def test_autocomplete_ranking(db): + svc = TagService(db) + await svc.find_or_create("alice", TagKind.character) + await svc.find_or_create("alicent", TagKind.character) + await svc.find_or_create("malice", TagKind.character) + hits = await svc.autocomplete("alice") + # exact match first, then prefix, then substring + names = [h.name for h in hits] + assert names[0] == "alice" + assert names[1] == "alicent" + assert names[2] == "malice" + + +@pytest.mark.asyncio +async def test_autocomplete_kind_filter(db): + svc = TagService(db) + await svc.find_or_create("Foo", TagKind.artist) + await svc.find_or_create("Foo", TagKind.character) + hits = await svc.autocomplete("Foo", kind=TagKind.artist) + assert len(hits) == 1 + assert hits[0].kind == "artist" + + +@pytest.mark.asyncio +async def test_autocomplete_empty_query_returns_nothing(db): + svc = TagService(db) + await svc.find_or_create("Foo", TagKind.artist) + assert await svc.autocomplete("") == []