c9089b1d03
gallery_service._provenance_clause artist branch was correlating its bare Post reference to the outer query's primary_post_id outer-join, so the artist filter silently matched zero rows for images with no primary post. Alias Post inside the EXISTS subquery so SQLAlchemy adds it to the inner FROM rather than treating it as a correlated outer table. Five sidecar/import tests still asserted that a synthetic Source row appears after a filesystem import. Alembic 0030 retired that behavior; the Post sits null-source and the artist linkage lives on Post.artist_id. Updated test_sidecar_creates_provenance, test_reimport_same_post_idempotent, test_sidecar_artist_used_when_no_folder_artist, test_supersede_applies_new_file_sidecar, and test_apply_sidecar_recovers_from_integrity_error to assert post.source_id IS NULL + post.artist_id linkage instead.
183 lines
6.3 KiB
Python
183 lines
6.3 KiB
Python
"""FC-2d-v: sidecar → Source/Post/ImageProvenance (sync importer)."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
from sqlalchemy import func, select
|
|
|
|
from backend.app.models import (
|
|
ImageProvenance,
|
|
ImageRecord,
|
|
ImportSettings,
|
|
Post,
|
|
Source,
|
|
)
|
|
from backend.app.services.importer import Importer
|
|
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
|
|
|
|
|
|
@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()
|
|
return Importer(
|
|
session=db_sync,
|
|
images_root=images_root,
|
|
import_root=import_root,
|
|
thumbnailer=Thumbnailer(images_root=images_root),
|
|
settings=settings,
|
|
)
|
|
|
|
|
|
def _split(path: Path, orient, size=(256, 256)):
|
|
"""Structured image (half/half) — solid colors phash-collapse, which
|
|
would route a 2nd image to the supersede path (no sidecar applied)."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
w, h = size
|
|
im = Image.new("L", size, 0)
|
|
px = im.load()
|
|
for y in range(h):
|
|
for x in range(w):
|
|
if (x / w if orient == "v" else y / h) >= 0.5:
|
|
px[x, y] = 255
|
|
im.convert("RGB").save(path, "JPEG")
|
|
|
|
|
|
def _sidecar(media: Path, payload: dict):
|
|
media.with_suffix(".json").write_text(json.dumps(payload))
|
|
|
|
|
|
def test_sidecar_creates_provenance(importer, import_layout):
|
|
import_root, _ = import_layout
|
|
m = import_root / "Alice" / "a.jpg"
|
|
_split(m, "v")
|
|
_sidecar(m, {
|
|
"category": "patreon", "id": 555,
|
|
"url": "https://patreon.com/posts/555", "title": "Set 1",
|
|
"content": "<p>hi</p>", "page_count": 2,
|
|
"published_at": "2023-08-01T00:00:00Z",
|
|
})
|
|
r = importer.import_one(m)
|
|
assert r.status == "imported"
|
|
rec = importer.session.get(ImageRecord, r.image_id)
|
|
post = importer.session.execute(select(Post)).scalar_one()
|
|
# Filesystem-imported sidecar posts no longer create a synthetic Source
|
|
# (alembic 0030 / nullable post.source_id refactor). The Post is linked
|
|
# to the artist via Post.artist_id; Post.source_id stays NULL until a
|
|
# real subscription for the (artist, platform) gets added.
|
|
assert post.source_id is None
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(Source)
|
|
).scalar_one() == 0
|
|
assert post.external_post_id == "555"
|
|
assert post.post_url == "https://patreon.com/posts/555"
|
|
assert post.post_title == "Set 1"
|
|
assert post.description == "<p>hi</p>"
|
|
assert post.attachment_count == 2
|
|
assert post.post_date is not None
|
|
assert post.raw_metadata["id"] == 555
|
|
prov = importer.session.execute(select(ImageProvenance)).scalar_one()
|
|
assert prov.image_record_id == rec.id and prov.post_id == post.id
|
|
assert rec.primary_post_id == post.id
|
|
|
|
|
|
def test_reimport_same_post_idempotent(importer, import_layout):
|
|
import_root, _ = import_layout
|
|
# Threshold 0: only an exact phash match collapses. Orthogonal splits
|
|
# still sit within the default Hamming-10 at 64-bit, so without this
|
|
# the 2nd image would be skipped as a near-dup (phash dedup working).
|
|
importer.settings.phash_threshold = 0
|
|
payload = {"category": "patreon", "id": 777, "title": "P"}
|
|
m1 = import_root / "Bob" / "p1.jpg"
|
|
_split(m1, "v")
|
|
_sidecar(m1, payload)
|
|
importer.import_one(m1)
|
|
m2 = import_root / "Bob" / "p2.jpg"
|
|
_split(m2, "h") # distinct phash; threshold 0 → both import
|
|
_sidecar(m2, payload)
|
|
r2 = importer.import_one(m2)
|
|
assert r2.status == "imported"
|
|
# No synthetic Source after alembic 0030; both imports still resolve to
|
|
# a single null-source Post (deduped by uq_post_artist_external_id_null_source).
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(Source)
|
|
).scalar_one() == 0
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(Post)
|
|
).scalar_one() == 1
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(ImageProvenance)
|
|
).scalar_one() == 2
|
|
|
|
|
|
def test_garbage_sidecar_still_imports(importer, import_layout):
|
|
import_root, _ = import_layout
|
|
m = import_root / "Carol" / "c.jpg"
|
|
_split(m, "v")
|
|
m.with_suffix(".json").write_text("{ not json")
|
|
r = importer.import_one(m)
|
|
assert r.status == "imported"
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(Post)
|
|
).scalar_one() == 0
|
|
|
|
|
|
def test_no_sidecar_unchanged(importer, import_layout):
|
|
import_root, _ = import_layout
|
|
m = import_root / "Dave" / "d.jpg"
|
|
_split(m, "v")
|
|
r = importer.import_one(m)
|
|
assert r.status == "imported"
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(Post)
|
|
).scalar_one() == 0
|
|
|
|
|
|
def test_no_artist_anywhere_skips_provenance(importer, import_layout):
|
|
import_root, _ = import_layout
|
|
m = import_root / "rootfile.jpg" # no top-level artist folder
|
|
_split(m, "v")
|
|
_sidecar(m, {"category": "x", "id": 1}) # no artist key
|
|
r = importer.import_one(m)
|
|
assert r.status == "imported"
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(Source)
|
|
).scalar_one() == 0
|
|
|
|
|
|
def test_sidecar_artist_used_when_no_folder_artist(importer, import_layout):
|
|
from backend.app.models import Artist
|
|
|
|
import_root, _ = import_layout
|
|
m = import_root / "e.jpg" # root → no folder artist
|
|
_split(m, "v")
|
|
_sidecar(m, {"category": "pixiv", "id": 9, "artist": "Yuki"})
|
|
r = importer.import_one(m)
|
|
assert r.status == "imported"
|
|
a = importer.session.execute(
|
|
select(Artist).where(Artist.slug == "yuki")
|
|
).scalar_one()
|
|
# No synthetic Source after alembic 0030; the artist linkage lives on
|
|
# Post.artist_id (NOT NULL FK).
|
|
post = importer.session.execute(select(Post)).scalar_one()
|
|
assert post.artist_id == a.id
|
|
assert post.source_id is None
|
|
assert importer.session.execute(
|
|
select(func.count()).select_from(Source)
|
|
).scalar_one() == 0
|