fc5: tag_apply — joins manifest entries to ImageRecord by sha256 after filesystem scan

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 09:01:17 -04:00
parent 77b17f7bb6
commit 177d6728e1
2 changed files with 328 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
"""FC-5: tag_apply unit tests."""
import json
import pytest
from sqlalchemy import select
from backend.app.models import Artist, ImageRecord, SeriesPage, Tag, TagKind, image_tag
from backend.app.services.migrators import tag_apply
pytestmark = pytest.mark.integration
async def _seed_image(db, sha: str, *, suffix="x"):
img = ImageRecord(
path=f"/images/imported/aa/{suffix}.jpg",
sha256=sha, size_bytes=10, mime="image/jpeg",
width=10, height=10, origin="imported_filesystem",
)
db.add(img)
await db.flush()
return img
def _write_manifest(tmp_path, *, artist_assignments=None, tag_associations=None, series_pages=None):
d = tmp_path / "_migration_state"
d.mkdir(parents=True, exist_ok=True)
(d / "ir_tag_manifest.json").write_text(json.dumps({
"schema_version": 1,
"image_artist_assignments": artist_assignments or [],
"image_tag_associations": tag_associations or [],
"series_pages": series_pages or [],
}))
@pytest.mark.asyncio
async def test_artist_assignment_sets_image_artist_id(db, tmp_path):
sha = "a" * 64
await _seed_image(db, sha)
await db.commit()
_write_manifest(tmp_path, artist_assignments=[
{"sha256": sha, "artist_name": "BobAuthor"},
])
result = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
assert result["counts"]["rows_inserted"] >= 1
img = (await db.execute(
select(ImageRecord).where(ImageRecord.sha256 == sha)
)).scalar_one()
artist = (await db.execute(
select(Artist).where(Artist.id == img.artist_id)
)).scalar_one()
assert artist.name == "BobAuthor"
@pytest.mark.asyncio
async def test_tag_association_creates_image_tag(db, tmp_path):
sha = "b" * 64
await _seed_image(db, sha, suffix="b")
# Seed the tag itself (ir_ingest would have created it).
tag = Tag(name="blue_eyes", kind=TagKind.general)
db.add(tag)
await db.commit()
_write_manifest(tmp_path, tag_associations=[
{"sha256": sha, "tag_name": "blue_eyes", "tag_kind": "general", "fandom_name": None},
])
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
img_id = (await db.execute(
select(ImageRecord.id).where(ImageRecord.sha256 == sha)
)).scalar_one()
row = (await db.execute(
select(image_tag.c.tag_id).where(image_tag.c.image_record_id == img_id)
)).all()
assert len(row) == 1
@pytest.mark.asyncio
async def test_unmatched_sha_appears_in_unmatched_list(db, tmp_path):
_write_manifest(tmp_path, tag_associations=[
{"sha256": "z" * 64, "tag_name": "missing_image", "tag_kind": "general", "fandom_name": None},
])
result = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
assert any(e["sha256"] == "z" * 64 for e in result["unmatched"])
@pytest.mark.asyncio
async def test_series_page_created(db, tmp_path):
sha = "c" * 64
await _seed_image(db, sha, suffix="c")
series_tag = Tag(name="MySeries", kind=TagKind.series)
db.add(series_tag)
await db.commit()
_write_manifest(tmp_path, series_pages=[
{"sha256": sha, "series_tag_name": "MySeries", "page_number": 1},
])
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
img_id = (await db.execute(
select(ImageRecord.id).where(ImageRecord.sha256 == sha)
)).scalar_one()
sp = (await db.execute(
select(SeriesPage).where(SeriesPage.image_id == img_id)
)).scalar_one()
assert sp.page_number == 1
@pytest.mark.asyncio
async def test_idempotent_on_rerun(db, tmp_path):
sha = "d" * 64
await _seed_image(db, sha, suffix="d")
tag = Tag(name="rerun_tag", kind=TagKind.general)
db.add(tag)
await db.commit()
_write_manifest(tmp_path, tag_associations=[
{"sha256": sha, "tag_name": "rerun_tag", "tag_kind": "general", "fandom_name": None},
])
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
result2 = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
assert result2["counts"]["rows_skipped"] >= 1
@pytest.mark.asyncio
async def test_missing_manifest_raises(db, tmp_path):
with pytest.raises(FileNotFoundError):
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
@pytest.mark.asyncio
async def test_dry_run_makes_no_changes(db, tmp_path):
sha = "e" * 64
await _seed_image(db, sha, suffix="e")
tag = Tag(name="dry_tag", kind=TagKind.general)
db.add(tag)
await db.commit()
_write_manifest(tmp_path, tag_associations=[
{"sha256": sha, "tag_name": "dry_tag", "tag_kind": "general", "fandom_name": None},
])
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=True)
img_id = (await db.execute(
select(ImageRecord.id).where(ImageRecord.sha256 == sha)
)).scalar_one()
rows = (await db.execute(
select(image_tag.c.tag_id).where(image_tag.c.image_record_id == img_id)
)).all()
assert len(rows) == 0