feat(fc2a): add TagService — find-or-create, autocomplete, image association

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:04:48 -04:00
parent 4cb319b393
commit f6c7117231
4 changed files with 341 additions and 0 deletions
+75
View File
@@ -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()
+101
View File
@@ -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("") == []