Files
FabledCurator/tests/test_external_worker.py
T
bvandeusen 949c9abcc6
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m17s
fix(external): path-safe unlink + per-link staging + orphan repair (#859)
External downloads import IN PLACE, so the post-attach dedup-skip unlink could
delete a file that IS an ImageRecord's backing file — orphaning the record and
404-ing on playback. Two sources of that:

- Two links on the same post (same film from mega + gdrive) emitted the same
  filename into one external/<post_id>/ dir; the second overwrote the first.
  Stage per-LINK now (external/<post_id>/<link_id>/) so each file keeps its path.
- The duplicate_hash/duplicate_phash branch unlinked `f` unconditionally. Make it
  path-safe: only unlink when `f` is NOT the existing record's canonical file.

Plus an operator-triggered orphan-repair maintenance task
(prune_missing_file_records_task) to clean up records already orphaned by the
bug: scans ImageRecords, deletes those whose file is gone (cascade), with an
NFS-stall guard that aborts without deleting if a large sample is mostly missing.
Wired through POST /api/admin/maintenance/prune-missing-files and a
MissingFileRepairCard in the Maintenance panel.

Tests: refetch-same-link keeps the canonical file; orphan repair deletes only
real orphans and aborts on the mostly-missing guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:48:38 -04:00

254 lines
8.8 KiB
Python

"""Integration tests for the external-link download worker (tasks/external)."""
import io
import zipfile
import pytest
from PIL import Image
from sqlalchemy import func, select, update
import backend.app.tasks.external as ext
from backend.app.models import (
Artist,
ExternalLink,
ImageProvenance,
ImageRecord,
Post,
PostAttachment,
Source,
)
from backend.app.services.external_fetch import FetchResult
pytestmark = pytest.mark.integration
def _structured_jpeg_bytes():
# A non-uniform image so it survives the import filters (a solid color would
# phash-collapse / fail thresholds).
im = Image.new("RGB", (256, 256))
px = im.load()
for y in range(256):
for x in range(256):
px[x, y] = (x, y, (x + y) % 256)
buf = io.BytesIO()
im.save(buf, "JPEG")
return buf.getvalue()
class _FakeLock:
def acquire(self, blocking=False):
return True
def release(self):
pass
class _FakeRedis:
def lock(self, name, timeout=None, blocking=False):
return _FakeLock()
def _seed(db_sync, *, host="pixeldrain", status="pending"):
# Key per host so a single test can seed several (artist name is UNIQUE).
artist = Artist(name=f"Ext {host}", slug=f"ext-{host}")
db_sync.add(artist)
db_sync.flush()
source = Source(
artist_id=artist.id, platform="patreon",
url=f"https://patreon.com/{host}", enabled=True, config_overrides={},
)
db_sync.add(source)
db_sync.flush()
post = Post(
source_id=source.id, artist_id=artist.id, external_post_id=f"EXT-{host}",
post_url=f"https://patreon.com/posts/{host}",
)
db_sync.add(post)
db_sync.flush()
link = ExternalLink(
post_id=post.id, artist_id=artist.id, host=host,
url=f"https://{host}.test/file", status=status,
)
db_sync.add(link)
db_sync.commit()
return post, link
def test_fetch_external_link_downloads_and_attaches(db_sync, tmp_path, monkeypatch):
post, link = _seed(db_sync)
link_id = link.id
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
def fake_fetch(host, url, dest_dir, *, timeout, should_stop=lambda: False):
dest_dir.mkdir(parents=True, exist_ok=True)
f = dest_dir / "film.bin" # non-art → PostAttachment (no thumb/ML enqueue)
f.write_bytes(b"a film pack")
return FetchResult(files=[f], bytes=f.stat().st_size)
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
out = ext.fetch_external_link(link_id)
assert out.get("files") == 1
db_sync.expire_all()
refreshed = db_sync.get(ExternalLink, link_id)
assert refreshed.status == "downloaded"
assert refreshed.completed_at is not None
# The file was captured as a PostAttachment linked to the SAME post.
atts = db_sync.execute(
select(PostAttachment).where(PostAttachment.post_id == post.id)
).scalars().all()
assert len(atts) == 1
def test_refetch_same_link_keeps_canonical_file(db_sync, tmp_path, monkeypatch):
"""#859: re-fetching the SAME link (identical bytes into the same per-link
staging dir) must NOT unlink the canonical file out from under its record.
The first fetch imports the image in place; the second dedups on sha256, and
the path-safe unlink recognises the file IS the record's backing file and
keeps it — so the post never 404s on playback."""
_, link = _seed(db_sync, host="pixeldrain")
link_id = link.id
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
def fake_fetch(host, url, dest_dir, *, timeout, should_stop=lambda: False):
dest_dir.mkdir(parents=True, exist_ok=True)
f = dest_dir / "clip.jpg" # art → ImageRecord (imported in place)
f.write_bytes(_structured_jpeg_bytes())
return FetchResult(files=[f], bytes=f.stat().st_size)
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
import backend.app.tasks.ml as ml_mod
import backend.app.tasks.thumbnail as thumb_mod
monkeypatch.setattr(ml_mod.tag_and_embed, "delay", lambda i: None)
monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda i: None)
out = ext.fetch_external_link(link_id)
assert out["images"] == 1
db_sync.expire_all()
rec = db_sync.execute(select(ImageRecord)).scalars().one()
canonical = tmp_path / rec.path # absolute path collapses the join
assert canonical.exists()
# Re-arm the SAME link and fetch again — same staging dir, identical bytes.
db_sync.execute(
update(ExternalLink).where(ExternalLink.id == link_id)
.values(status="pending", attempts=0, completed_at=None)
)
db_sync.commit()
out2 = ext.fetch_external_link(link_id)
assert out2["images"] == 0 # deduped, no new record
db_sync.expire_all()
# Exactly one record still, and its file survived the dedup unlink (the bug).
assert db_sync.execute(select(func.count(ImageRecord.id))).scalar() == 1
assert canonical.exists()
def test_fetch_external_link_records_failure(db_sync, tmp_path, monkeypatch):
_, link = _seed(db_sync)
link_id = link.id
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
monkeypatch.setattr(
ext, "fetch_external",
lambda *a, **k: FetchResult(error="host 503"),
)
ext.fetch_external_link(link_id)
db_sync.expire_all()
refreshed = db_sync.get(ExternalLink, link_id)
assert refreshed.status == "failed"
assert refreshed.attempts == 1
assert "503" in refreshed.last_error
def test_fetch_external_link_dead_letters_at_threshold(db_sync, tmp_path, monkeypatch):
_, link = _seed(db_sync)
link.attempts = ext.DEAD_LETTER_THRESHOLD - 1
db_sync.commit()
link_id = link.id
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
monkeypatch.setattr(ext, "fetch_external", lambda *a, **k: FetchResult(error="nope"))
ext.fetch_external_link(link_id)
db_sync.expire_all()
assert db_sync.get(ExternalLink, link_id).status == "dead"
def test_fetch_external_link_skips_non_claimable(db_sync, tmp_path, monkeypatch):
_, link = _seed(db_sync, status="downloaded")
link_id = link.id
called = []
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
monkeypatch.setattr(
ext, "fetch_external",
lambda *a, **k: called.append(1) or FetchResult(),
)
out = ext.fetch_external_link(link_id)
assert out.get("skipped") == "not claimable"
assert called == [] # never fetched
def test_sweep_enqueues_pending_and_retryable(db_sync, monkeypatch):
_, l1 = _seed(db_sync, host="pixeldrain")
_, l2 = _seed(db_sync, host="mega", status="failed")
# A dead one must NOT be swept.
_, l3 = _seed(db_sync, host="dropbox", status="dead")
enqueued = []
monkeypatch.setattr(ext.fetch_external_link, "delay", lambda lid: enqueued.append(lid))
out = ext.sweep_external_links()
assert out["enqueued"] == 2
assert set(enqueued) == {l1.id, l2.id}
def test_downloaded_archive_gets_provenance_and_tagging(db_sync, tmp_path, monkeypatch):
"""An archive downloaded by the worker is extracted, provenance-linked to the
post, AND queued for tagging + thumbnails — exactly like an extracted zip on
the normal download path."""
post, link = _seed(db_sync, host="mega")
link_id = link.id
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
def fake_fetch(host, url, dest_dir, *, timeout, should_stop=lambda: False):
dest_dir.mkdir(parents=True, exist_ok=True)
zpath = dest_dir / "pack.zip"
with zipfile.ZipFile(zpath, "w") as z:
z.writestr("art.jpg", _structured_jpeg_bytes())
return FetchResult(files=[zpath], bytes=zpath.stat().st_size)
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
tagged, thumbed = [], []
import backend.app.tasks.ml as ml_mod
import backend.app.tasks.thumbnail as thumb_mod
monkeypatch.setattr(ml_mod.tag_and_embed, "delay", lambda i: tagged.append(i))
monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda i: thumbed.append(i))
out = ext.fetch_external_link(link_id)
assert out["images"] >= 1
db_sync.expire_all()
assert db_sync.get(ExternalLink, link_id).status == "downloaded"
# Extracted member is provenance-linked to the SAME post (as extracted zips).
prov = db_sync.execute(
select(ImageProvenance).where(ImageProvenance.post_id == post.id)
).scalars().all()
assert len(prov) >= 1
member_ids = {p.image_record_id for p in prov}
# Tagging + thumbnails queued for exactly those member images.
assert set(tagged) == member_ids
assert set(thumbed) == member_ids