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:
@@ -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
|
||||
Reference in New Issue
Block a user