Files
FabledCurator/tests/test_importer.py
T
bvandeusen 5b34c9221c
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m32s
feat(ia): wave 1 — Import tab dissolves, Maintenance regroups by system, one extension home
Settings IA per the approved A3 design (the old layout was the two-app merge
fossilized):
- Import tab retired: ImportTriggerPanel + ImportTaskList deleted (manual
  /import scans stay API-level; imports arrive via downloads/extension, heal
  via the Layer-2 auto-refetch sweep, and show in Activity). ImportFiltersForm
  moves to Maintenance → 'Ingestion & filters' and loads its own settings; the
  import store shrinks to settings-only (no remaining consumers of the
  scan/task-list machinery). Overview's pending banner now points at Activity.
- Maintenance regrouped: Ingestion & filters / GPU agent & embeddings
  (GpuAgent, Failed processing, CPU embedding backfill) / Tagging (sliders,
  Heads, Aliases) / Library health (MissingFiles, Thumbnails, DB, Archive
  re-extract demoted last) / Storage.
- One extension home: BrowserExtensionCard moves from Settings → Overview to
  Subscriptions → Settings, above the API key bar it authenticates.
- Single-color import filter WIRED: skip_single_color/threshold existed since
  FC-2 but nothing read them (the audit module's docstring said as much) —
  now enforced on both import paths via the audit's canonical predicate
  (tolerance 30, matching the Cleanup card default; animated images exempt
  like the transparency check). Default stays off; test added.
- Dead weight: PlaceholderView (zero refs) and the permanently-disabled
  'Export failed logs (CSV — v2)' menu stub deleted; stale docs fixed
  (celery queue docstring, threshold comment citing retired tasks, ml
  package docstring, HeadsCard 'replaces Camie' blurb).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
2026-07-02 17:37:21 -04:00

216 lines
7.6 KiB
Python

"""Importer integration tests.
These exercise the real Importer against a real Postgres + a real filesystem
(via tmp_path). The DB fixture rolls back after each test so the import_root
fixture is fresh per-test.
"""
from pathlib import Path
import pytest
from PIL import Image
from sqlalchemy import select
from backend.app.models import Artist, ImageRecord, ImportSettings
from backend.app.services.importer import Importer, SkipReason
from backend.app.services.thumbnailer import Thumbnailer
pytestmark = pytest.mark.integration
@pytest.fixture
def import_layout(tmp_path):
import_root = tmp_path / "import"
images_root = tmp_path / "images"
import_root.mkdir()
images_root.mkdir()
return import_root, images_root
def _make_jpeg(path: Path, size: tuple[int, int] = (800, 600)):
path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", size, color=(120, 200, 80)).save(path, "JPEG")
def _make_png_rgba(path: Path, size: tuple[int, int], alpha: int):
path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGBA", size, color=(120, 200, 80, alpha)).save(path, "PNG")
@pytest.fixture
def importer(db_sync, import_layout):
import_root, images_root = import_layout
settings = db_sync.execute(select(ImportSettings).where(ImportSettings.id == 1)).scalar_one()
thumbnailer = Thumbnailer(images_root=images_root)
return Importer(
session=db_sync,
images_root=images_root,
import_root=import_root,
thumbnailer=thumbnailer,
settings=settings,
)
def test_import_one_happy_path(importer, import_layout):
import_root, images_root = import_layout
src = import_root / "Alice" / "first.jpg"
_make_jpeg(src)
result = importer.import_one(src)
assert result.status == "imported"
record = importer.session.get(ImageRecord, result.image_id)
assert record.path.startswith(str(images_root / "Alice"))
assert record.path.endswith(".jpg")
def test_folder_creates_artist_and_links_artist_id(importer, import_layout):
# FC-2d-vii-c: folder import creates the Artist and sets the canonical
# image_record.artist_id — no artist-kind Tag (that path was retired;
# the no-tag invariant is covered by test_importer_artist_id.py).
import_root, _ = import_layout
src = import_root / "Alice" / "first.jpg"
_make_jpeg(src)
result = importer.import_one(src)
artist = importer.session.execute(
select(Artist).where(Artist.slug == "alice")
).scalar_one()
assert artist.name == "Alice"
record = importer.session.get(ImageRecord, result.image_id)
assert record.artist_id == artist.id
def test_dedup_by_hash(importer, import_layout):
import_root, _ = import_layout
src1 = import_root / "Alice" / "first.jpg"
src2 = import_root / "Bob" / "duplicate.jpg"
_make_jpeg(src1)
src2.parent.mkdir(parents=True, exist_ok=True)
src2.write_bytes(src1.read_bytes())
r1 = importer.import_one(src1)
r2 = importer.import_one(src2)
assert r1.status == "imported"
assert r2.status == "skipped"
assert r2.skip_reason == SkipReason.duplicate_hash
def test_min_width_filter(importer, import_layout):
import_root, _ = import_layout
importer.settings.min_width = 1000 # mutate the in-memory copy for this test
src = import_root / "small.jpg"
_make_jpeg(src, size=(500, 500))
result = importer.import_one(src)
assert result.status == "skipped"
assert result.skip_reason == SkipReason.too_small
def test_transparent_filter(importer, import_layout):
import_root, _ = import_layout
importer.settings.skip_transparent = True
importer.settings.transparency_threshold = 0.5
src = import_root / "ghost.png"
_make_png_rgba(src, size=(100, 100), alpha=0) # fully transparent
result = importer.import_one(src)
assert result.status == "skipped"
assert result.skip_reason == SkipReason.too_transparent
def test_single_color_filter(importer, import_layout):
"""The skip_single_color setting existed since FC-2 but was never wired
(the audit module's docstring said so); wired 2026-07-02 using the same
canonical predicate as the Cleanup audit. Solid fill skips when enabled,
imports when disabled (the default)."""
from PIL import Image as PILImage
import_root, _ = import_layout
solid = import_root / "solid.png"
solid.parent.mkdir(parents=True, exist_ok=True)
PILImage.new("RGB", (100, 100), (12, 34, 56)).save(solid)
importer.settings.skip_single_color = True
importer.settings.single_color_threshold = 0.95
result = importer.import_one(solid)
assert result.status == "skipped"
assert result.skip_reason == SkipReason.single_color
importer.settings.skip_single_color = False
solid2 = import_root / "solid2.png"
PILImage.new("RGB", (100, 100), (200, 10, 10)).save(solid2)
assert importer.import_one(solid2).status == "imported"
def test_unsupported_extension(importer, import_layout):
# FC-2d-iii: non-media is no longer skipped — it's captured as a
# PostAttachment so nothing a post contained is lost.
import_root, _ = import_layout
src = import_root / "Alice" / "notes.txt"
src.parent.mkdir(parents=True, exist_ok=True)
src.write_text("hello")
result = importer.import_one(src)
assert result.status == "attached"
def test_root_level_file_has_no_artist(importer, import_layout):
import_root, _ = import_layout
src = import_root / "loose.jpg"
_make_jpeg(src)
importer.import_one(src)
artists = importer.session.execute(select(Artist)).scalars().all()
assert artists == []
def test_pil_load_oserror_in_transparency_check_skips_not_raises(
importer, import_layout, monkeypatch,
):
"""PIL.verify() only validates header structure — broken pixel data
only surfaces when load() actually decodes. The importer must catch
the OSError and return a skipped: invalid_image result so the Celery
autoretry loop doesn't bounce the same broken file forever.
Operator hit this 2026-05-25 with a corrupt JPEG in the IR set."""
import_root, _ = import_layout
src = import_root / "Bob" / "corrupt.png"
# Make a real RGBA PNG so the has_alpha path engages.
_make_png_rgba(src, (100, 100), alpha=128)
importer.settings.skip_transparent = True
importer.settings.transparency_threshold = 0.5
# Force the next _transparency_pct call to raise as if PIL's load()
# blew up on truncated pixel data.
def _boom(_self, _src):
raise OSError("broken data stream when reading image file")
monkeypatch.setattr(
type(importer), "_transparency_pct", _boom,
)
result = importer.import_one(src)
assert result.status == "skipped"
assert result.skip_reason == SkipReason.invalid_image
assert "transparency check" in (result.error or "")
def test_pil_load_oserror_in_phash_compute_skips_not_raises(
importer, import_layout, monkeypatch,
):
"""Same shape as the transparency-check guard, but for the phash
compute block — the OTHER place PIL.load() runs implicitly during
the dedup pipeline."""
import_root, _ = import_layout
src = import_root / "Carol" / "corrupt.jpg"
_make_jpeg(src)
# Disable transparency check so we reach the phash compute block.
importer.settings.skip_transparent = False
from backend.app.services import importer as importer_module
def _boom(_im):
raise OSError("broken data stream when reading image file")
monkeypatch.setattr(importer_module, "compute_phash", _boom)
result = importer.import_one(src)
assert result.status == "skipped"
assert result.skip_reason == SkipReason.invalid_image
assert "phash compute" in (result.error or "")