"""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, **kwargs): 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, **kwargs): 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.embed_image, "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, **kwargs): 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.embed_image, "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