Files
FabledCurator/tests/test_download_service.py
T
bvandeusenandClaude Opus 5.5 da2091a875
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 31s
CI and images / integration (push) Failing after 2m17s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped
fix: a download is marked seen only after it is imported
A run killed between download and import left its files on disk and in the
seen-ledger but never in the library, and every later walk trusted the
ledger. TamadaHeijun's 12PCG post lost 7 of 13 images this way (stranded
run 90402), which read as the duplicates filter.

The ingester now hands phase 3 a mark_seen_after_import hook, called after
the import loop. A file on disk with no ImageRecord at its path is fed to
import, not reconciled into the ledger. Recapture reaches files already
orphaned, because it looks past the ledger to the disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-24 14:13:12 -04:00

1126 lines
40 KiB
Python

"""DownloadService orchestrator tests.
GalleryDLService is mocked to return a canned DownloadResult; tests
provide on-disk files for the importer to attach_in_place.
"""
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy import func, select
from backend.app.models import Artist, DownloadEvent, ImageRecord, ImportSettings, Source
from backend.app.services.credential_crypto import CredentialCrypto
from backend.app.services.credential_service import CredentialService
from backend.app.services.thumbnailer import Thumbnailer
pytestmark = pytest.mark.integration
@pytest.fixture
async def seed_artist_and_source(db, db_sync):
artist = Artist(name="Alice", slug="alice")
db.add(artist)
await db.flush()
source = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice", enabled=True,
config_overrides={},
)
db.add(source)
await db.commit()
return artist, source
def _make_jpg(path: Path, split: str = "h"):
"""Write a structured JPEG so phash isn't degenerate.
Solid-color images all phash-collapse (DCT of a flat image is zero).
Use `split='h'` (top/bottom halves) or `split='v'` (left/right) to
give each fixture file a distinct phash.
"""
from PIL import Image
path.parent.mkdir(parents=True, exist_ok=True)
im = Image.new("RGB", (64, 64), (255, 255, 255))
px = im.load()
if split == "h":
for y in range(32):
for x in range(64):
px[x, y] = (0, 0, 0)
else: # 'v'
for y in range(64):
for x in range(32):
px[x, y] = (0, 0, 0)
im.save(path, "JPEG")
def _make_fake_dl_result(
*, success=True, written_paths=None, quarantined_paths=None,
files_downloaded=0, error_type=None, error_message=None,
stdout="", stderr="", cursor=None, run_stats=None, posts_processed=0,
relink_source_paths=None,
):
return SimpleNamespace(
relink_source_paths=relink_source_paths or [],
success=success,
url="https://patreon.com/alice",
artist_slug="alice",
platform="patreon",
files_downloaded=files_downloaded,
files_quarantined=len(quarantined_paths or []),
quarantined_paths=quarantined_paths or [],
written_paths=written_paths or [],
stdout=stdout,
stderr=stderr,
return_code=0 if success else 1,
error_type=error_type,
error_message=error_message,
duration_seconds=1.23,
started_at="2026-05-20T14:00:00+00:00",
completed_at="2026-05-20T14:01:00+00:00",
# plan #704: native ingester now returns the cursor + run_stats
# structurally (no more stderr `Cursor:` scraping).
cursor=cursor,
run_stats=run_stats,
posts_processed=posts_processed,
)
def _fake_gdl_with_result(result):
fake = MagicMock()
fake.download = AsyncMock(return_value=result)
# Real values for the attrs run_download's native branch reads off the gdl
# (the native pacing config, plan #703) — a MagicMock would break the
# arithmetic (max(0.5, rate_limit/4)).
fake._rate_limit = 3.0
fake._validate_files = True
fake._compute_run_stats = lambda *a, **k: {
"exit_code": 0, "downloaded_count": len(result.written_paths),
"skipped_count": 0, "per_item_failures": 0,
"warning_count": 0, "tier_gated_count": 0,
}
fake._extract_errors_warnings = lambda *a, **k: ""
fake._truncate_log = lambda x, **k: x
return fake
def _stub_patreon_ingester(svc, result, resolved_campaign_id=None):
"""Route the phase-2 dispatch (download_backends.run_download via
DownloadService._run_download, plan #707 A5) to a canned DownloadResult so
these tests exercise download_service's phase-2-result→phase-3 handling
(status mapping, backfill lifecycle, health) without the real ingester's
network/DB. The ingester itself is covered by test_patreon_ingester.py; the
native construction/resolution by the run_download tests below. Returns a list
capturing (ctx, source_config, skip_value, mode) per call so a test can assert
mode / resume_cursor threading."""
calls = []
async def fake(*, ctx, source_config, skip_value, mode):
calls.append({
"ctx": ctx, "source_config": source_config,
"skip_value": skip_value, "mode": mode,
})
return result, resolved_campaign_id
svc._run_download = fake
return calls
@pytest.mark.asyncio
async def test_download_source_attaches_written_files(
db, db_sync, tmp_path, seed_artist_and_source,
):
from backend.app.services.download_service import DownloadService
from backend.app.services.importer import Importer
_artist, source = seed_artist_and_source
images_root = tmp_path / "images"
f1 = images_root / "alice" / "patreon" / "post" / "a.jpg"
f2 = images_root / "alice" / "patreon" / "post" / "b.jpg"
# Structured h/v split → distinct sha256 AND distinct phash;
# also pin phash_threshold=0 so even tiny perceptual similarity
# between the two doesn't trigger duplicate_phash.
_make_jpg(f1, split="h")
_make_jpg(f2, split="v")
result = _make_fake_dl_result(
success=True,
written_paths=[str(f1), str(f2)],
files_downloaded=2,
stdout=f"{f1}\n{f2}\n",
)
# The ledger is marked only once the files are in: a run killed before
# import must leave them unmarked so the next walk imports them.
imported_when_marked = []
result.mark_seen_after_import = lambda: imported_when_marked.append(
db_sync.execute(
select(func.count(ImageRecord.id)).where(ImageRecord.path.in_([str(f1), str(f2)]))
).scalar_one()
)
fake_gdl = _fake_gdl_with_result(result)
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
sync_settings.phash_threshold = 0 # only exact phash collisions dedup
importer = Importer(
session=db_sync, images_root=images_root,
import_root=images_root,
thumbnailer=Thumbnailer(images_root=images_root),
settings=sync_settings,
)
crypto = CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True)
cred_service = CredentialService(db, crypto)
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
_stub_patreon_ingester(svc, result)
event_id = await svc.download_source(source.id)
ev = (await db.execute(
select(DownloadEvent).where(DownloadEvent.id == event_id)
)).scalar_one()
assert ev.status == "ok"
assert ev.files_count == 2
assert ev.metadata_["import_summary"]["attached"] == 2
assert ev.metadata_["run_stats"]["downloaded_count"] == 2
assert imported_when_marked == [2]
@pytest.mark.asyncio
async def test_download_source_skipped_when_disabled(
db, db_sync, tmp_path, seed_artist_and_source,
):
from backend.app.services.download_service import DownloadService
from backend.app.services.importer import Importer
_artist, source = seed_artist_and_source
source.enabled = False
await db.commit()
fake_gdl = MagicMock()
fake_gdl.download = AsyncMock()
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
importer = Importer(
session=db_sync, images_root=tmp_path,
import_root=tmp_path,
thumbnailer=Thumbnailer(images_root=tmp_path),
settings=sync_settings,
)
cred_service = CredentialService(db, MagicMock())
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
event_id = await svc.download_source(source.id)
ev = (await db.execute(
select(DownloadEvent).where(DownloadEvent.id == event_id)
)).scalar_one()
assert ev.status == "skipped"
assert ev.error == "source disabled"
fake_gdl.download.assert_not_called()
@pytest.mark.asyncio
async def test_in_flight_idempotency_returns_existing_event(
db, db_sync, tmp_path, seed_artist_and_source,
):
from backend.app.services.download_service import DownloadService
_artist, source = seed_artist_and_source
running = DownloadEvent(source_id=source.id, status="running")
db.add(running)
await db.commit()
await db.refresh(running)
fake_gdl = MagicMock()
fake_gdl.download = AsyncMock()
importer_stub = MagicMock()
cred_service = CredentialService(db, MagicMock())
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer_stub, cred_service=cred_service,
)
event_id = await svc.download_source(source.id)
assert event_id == running.id
fake_gdl.download.assert_not_called()
@pytest.mark.asyncio
async def test_patreon_resolved_campaign_id_is_cached(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""plan #697: the native ingester resolves a Patreon vanity → campaign id
on the fly (replacing gallery-dl's reactive campaign-id retry). When phase 2
reports a freshly-resolved id, phase 3 caches it on the source so later runs
skip the lookup. Stub the ingester to report a resolved id; assert it lands
in config_overrides."""
from backend.app.services.download_service import DownloadService
from backend.app.services.importer import Importer
_artist, source = seed_artist_and_source
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
importer = Importer(
session=db_sync, images_root=tmp_path,
import_root=tmp_path,
thumbnailer=Thumbnailer(images_root=tmp_path),
settings=sync_settings,
)
cred_service = CredentialService(db, MagicMock())
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=_fake_gdl_with_result(_make_fake_dl_result(success=True)),
importer=importer, cred_service=cred_service,
)
_stub_patreon_ingester(
svc, _make_fake_dl_result(success=True, written_paths=[]),
resolved_campaign_id="99",
)
await svc.download_source(source.id)
overrides = db_sync.execute(
select(Source.config_overrides).where(Source.id == source.id)
).scalar_one()
assert overrides.get("patreon_campaign_id") == "99"
@pytest.mark.asyncio
async def test_run_download_native_resolves_vanity_and_runs(
db, db_sync, tmp_path, seed_artist_and_source, monkeypatch,
):
"""run_download (native branch, plan #707 A5): with no cached campaign id,
resolve the vanity and pass the resolved id straight to the ingester; report
it back so phase 3 can cache it."""
from backend.app.services import download_backends as db_mod
from backend.app.services.gallery_dl import SourceConfig
_artist, source = seed_artist_and_source
monkeypatch.setattr(
db_mod, "resolve_campaign_id_for_source",
AsyncMock(return_value=("4242", "4242")),
)
run_kwargs = {}
class _FakeIngester:
def __init__(self, **kw):
pass
def run(self, **kw):
run_kwargs.update(kw)
return _make_fake_dl_result(success=True, written_paths=[])
monkeypatch.setattr(db_mod, "PatreonIngester", _FakeIngester)
ctx = {
"platform": "patreon",
"source_id": source.id, "url": "https://patreon.com/alice",
"artist_slug": "alice", "cookies_path": None, "auth_token": None,
"config_overrides": {},
}
result, resolved = await db_mod.run_download(
ctx=ctx, source_config=SourceConfig.from_dict({}), skip_value=True,
mode="tick",
gdl=_fake_gdl_with_result(_make_fake_dl_result(success=True)),
sync_session_factory=MagicMock(),
)
assert result.success is True
assert resolved == "4242"
assert run_kwargs["campaign_id"] == "4242"
assert run_kwargs["mode"] == "tick"
@pytest.mark.asyncio
async def test_run_download_native_unresolvable_fails_loud(
db, db_sync, tmp_path, seed_artist_and_source, monkeypatch,
):
"""A campaign id we can't resolve is a loud NOT_FOUND failure, never a
silent empty success."""
from backend.app.services import download_backends as db_mod
from backend.app.services.gallery_dl import ErrorType, SourceConfig
_artist, source = seed_artist_and_source
monkeypatch.setattr(
db_mod, "resolve_campaign_id_for_source",
AsyncMock(return_value=(None, None)),
)
ctx = {
"platform": "patreon",
"source_id": source.id, "url": "https://patreon.com/alice",
"artist_slug": "alice", "cookies_path": None, "auth_token": None,
"config_overrides": {},
}
result, resolved = await db_mod.run_download(
ctx=ctx, source_config=SourceConfig.from_dict({}), skip_value=True,
mode="tick", gdl=MagicMock(), sync_session_factory=MagicMock(),
)
assert result.success is False
assert result.error_type == ErrorType.NOT_FOUND
assert resolved is None
# --- FC-3d: finalize hook updates Source health columns -------------------
async def _seed_source_with_health(
db, *, failures=0, last_error=None, suffix="fz", error_type=None,
):
artist = Artist(name=f"alice-{suffix}", slug=f"alice-{suffix}")
db.add(artist)
await db.flush()
source = Source(
artist_id=artist.id, platform="patreon",
url=f"https://patreon.com/alice-{suffix}", enabled=True,
consecutive_failures=failures, last_error=last_error,
error_type=error_type,
)
db.add(source)
await db.flush()
event = DownloadEvent(source_id=source.id, status="running")
db.add(event)
await db.commit()
return source.id, event.id
@pytest.mark.asyncio
async def test_finalize_ok_resets_failures_and_clears_error(db):
from backend.app.services.download_service import DownloadService
source_id, event_id = await _seed_source_with_health(
db, failures=3, last_error="prev", suffix="ok",
)
svc = DownloadService(
async_session=db, sync_session=None,
gdl=None, importer=None, cred_service=None,
)
await svc._finalize_event_and_source(
event_id=event_id, source_id=source_id, status="ok",
error_message=None,
)
row = (await db.execute(
select(
Source.consecutive_failures, Source.last_error, Source.last_checked_at,
).where(Source.id == source_id)
)).one()
assert row.consecutive_failures == 0
assert row.last_error is None
assert row.last_checked_at is not None
@pytest.mark.asyncio
async def test_finalize_error_increments_failures_and_sets_error(db):
from backend.app.services.download_service import DownloadService
source_id, event_id = await _seed_source_with_health(
db, failures=2, last_error=None, suffix="err",
)
svc = DownloadService(
async_session=db, sync_session=None,
gdl=None, importer=None, cred_service=None,
)
await svc._finalize_event_and_source(
event_id=event_id, source_id=source_id, status="error",
error_message="boom",
)
row = (await db.execute(
select(
Source.consecutive_failures, Source.last_error, Source.last_checked_at,
).where(Source.id == source_id)
)).one()
assert row.consecutive_failures == 3
assert row.last_error == "boom"
assert row.last_checked_at is not None
@pytest.mark.asyncio
async def test_rate_limited_cooldown_honors_retry_after(db, monkeypatch):
"""plan #708 B1: a RATE_LIMITED result with a Retry-After stamps the platform
cooldown at that duration, clamped to [60, 3600]; no hint → the flat default."""
from unittest.mock import AsyncMock, MagicMock
from backend.app.services import download_service as dl_mod
from backend.app.services.download_service import DownloadService
source_id, _event_id = await _seed_source_with_health(db, suffix="rl")
cooldown = AsyncMock()
monkeypatch.setattr(dl_mod, "set_platform_cooldown", cooldown)
svc = DownloadService(
async_session=db, sync_session=None,
gdl=MagicMock(), importer=MagicMock(), cred_service=MagicMock(),
)
async def _health(retry):
cooldown.reset_mock()
await svc._update_source_health(
source_id=source_id, status="error", error_message="rate limited",
error_type="rate_limited", retry_after_seconds=retry,
)
await _health(120.0)
assert cooldown.await_args.kwargs["seconds"] == 120 # honored as-is
await _health(5.0)
assert cooldown.await_args.kwargs["seconds"] == 60 # floored
await _health(99999.0)
assert cooldown.await_args.kwargs["seconds"] == 3600 # capped
await _health(None)
assert "seconds" not in cooldown.await_args.kwargs # default cooldown
@pytest.mark.asyncio
async def test_finalize_skipped_preserves_failures_clears_error(db):
from backend.app.services.download_service import DownloadService
source_id, event_id = await _seed_source_with_health(
db, failures=4, last_error="stale", suffix="skip",
)
svc = DownloadService(
async_session=db, sync_session=None,
gdl=None, importer=None, cred_service=None,
)
await svc._finalize_event_and_source(
event_id=event_id, source_id=source_id, status="skipped",
error_message=None,
)
row = (await db.execute(
select(
Source.consecutive_failures, Source.last_error, Source.last_checked_at,
).where(Source.id == source_id)
)).one()
assert row.consecutive_failures == 4
assert row.last_error is None
assert row.last_checked_at is not None
# --- Plan #693: backfill state machine (time-boxed chunks, run-until-done) ---
def _backfill_svc(db, db_sync, tmp_path, result):
"""DownloadService wired with a real importer (so empty written_paths just
attaches nothing) whose Patreon phase-2 branch is stubbed to return `result`.
The seeded source is Patreon, so phase 2 routes to the native ingester
(plan #697); the stub returns the canned result + captures the per-call
(ctx, source_config, mode). Returns (svc, ingester_calls)."""
from backend.app.services.download_service import DownloadService
from backend.app.services.importer import Importer
images_root = tmp_path / "images"
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
importer = Importer(
session=db_sync, images_root=images_root, import_root=images_root,
thumbnailer=Thumbnailer(images_root=images_root), settings=sync_settings,
)
cred_service = CredentialService(db, CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True))
fake_gdl = _fake_gdl_with_result(result)
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
calls = _stub_patreon_ingester(svc, result)
return svc, calls
@pytest.mark.asyncio
async def test_backfill_chunk_progress_advances_cursor(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""A running backfill chunk that didn't finish but advanced (new cursor)
stays 'running', checkpoints the cursor, bumps the chunk counter, and
spends one safety-cap chunk."""
_artist, source = seed_artist_and_source
source.config_overrides = {"_backfill_state": "running"}
source.backfill_runs_remaining = 5
await db.commit()
svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
success=False, written_paths=[], files_downloaded=0,
cursor="03:PAGE2:xyz",
))
await svc.download_source(source.id)
remaining, co = (await db.execute(
select(Source.backfill_runs_remaining, Source.config_overrides)
.where(Source.id == source.id)
)).one()
assert co.get("_backfill_state") == "running"
assert co.get("_backfill_cursor") == "03:PAGE2:xyz"
assert co.get("_backfill_chunks") == 1
assert remaining == 4
@pytest.mark.asyncio
async def test_backfill_state_running_selects_backfill_mode_and_resumes(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""state=='running' drives backfill mode (chunk budget) and threads the
stored cursor to the native ingester as the resume point."""
from backend.app.services.gallery_dl import BACKFILL_CHUNK_SECONDS
_artist, source = seed_artist_and_source
source.config_overrides = {"_backfill_state": "running", "_backfill_cursor": "03:RESUME:here"}
source.backfill_runs_remaining = 5
await db.commit()
svc, calls = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
success=False, written_paths=[],
cursor="03:RESUME2:next",
))
await svc.download_source(source.id)
assert len(calls) == 1
assert calls[0]["mode"] == "backfill"
assert calls[0]["source_config"].resume_cursor == "03:RESUME:here"
assert calls[0]["source_config"].timeout == BACKFILL_CHUNK_SECONDS
co = (await db.execute(
select(Source.config_overrides).where(Source.id == source.id)
)).scalar_one()
assert co.get("_backfill_cursor") == "03:RESUME2:next"
@pytest.mark.asyncio
async def test_backfill_completes_when_every_post_was_tier_gated(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""A fully-paywalled backfill chunk still COMPLETES.
The completion test used to require `error_type is None`, so classifying a
gated walk as TIER_LIMITED would have dropped it into the not-finished
branch: zero files downloaded means `advanced` is False, two strikes marks
it 'stalled', and the creator we can see least becomes the one we re-walk
most. `walk_completed` admits informational classes for exactly this.
"""
from backend.app.services.gallery_dl import ErrorType
_artist, source = seed_artist_and_source
source.config_overrides = {
"_backfill_state": "running", "_backfill_cursor": "03:NEAR:bottom",
}
source.backfill_runs_remaining = 5
await db.commit()
svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
success=True, written_paths=[], files_downloaded=0,
error_type=ErrorType.TIER_LIMITED,
error_message="Subscription tier does not grant access to 9 posts",
))
await svc.download_source(source.id)
co = (await db.execute(
select(Source.config_overrides).where(Source.id == source.id)
)).scalar_one()
assert co.get("_backfill_state") == "complete"
assert "_backfill_cursor" not in co
@pytest.mark.asyncio
async def test_ok_run_keeps_the_tier_limited_chip_but_not_the_failure_state(
db,
):
"""An informational class survives a successful run; a failure class doesn't.
`status == "ok"` clears `error_type` (alembic 0032) because it is the
failure-class chip. tier_limited rides an OK run, so that clear is what made
FailingSourcesCard's `tier_limited` palette entry unreachable — the chip was
wiped by the same success that produced it. It must persist while the
failure bookkeeping stays clean: no accrued failures, no last_error.
"""
from backend.app.services.download_service import DownloadService
source_id, event_id = await _seed_source_with_health(
db, failures=3, last_error="prev", suffix="gated",
)
svc = DownloadService(
async_session=db, sync_session=None,
gdl=None, importer=None, cred_service=None,
)
await svc._update_source_health(
source_id=source_id, status="ok", error_message=None,
error_type="tier_limited",
)
row = (await db.execute(
select(
Source.error_type, Source.consecutive_failures, Source.last_error,
).where(Source.id == source_id)
)).one()
assert row.error_type == "tier_limited"
assert row.consecutive_failures == 0
assert row.last_error is None
@pytest.mark.asyncio
async def test_ok_run_still_clears_a_real_failure_class(db):
"""The exemption is narrow: a genuine failure class is still cleared on OK,
so a recovered source stops showing a stale chip."""
from backend.app.services.download_service import DownloadService
source_id, _event_id = await _seed_source_with_health(
db, failures=2, last_error="boom", suffix="recovered",
error_type="auth_error",
)
svc = DownloadService(
async_session=db, sync_session=None,
gdl=None, importer=None, cred_service=None,
)
await svc._update_source_health(
source_id=source_id, status="ok", error_message=None,
error_type=None,
)
row = (await db.execute(
select(Source.error_type, Source.consecutive_failures)
.where(Source.id == source_id)
)).one()
assert row.error_type is None
assert row.consecutive_failures == 0
@pytest.mark.asyncio
async def test_backfill_clean_exit_marks_complete(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""A clean rc=0 chunk = gallery-dl reached the bottom → state 'complete',
cursor cleared, returns to tick mode."""
_artist, source = seed_artist_and_source
source.config_overrides = {"_backfill_state": "running", "_backfill_cursor": "03:NEAR:bottom"}
source.backfill_runs_remaining = 5
await db.commit()
svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
success=True, written_paths=[], files_downloaded=0,
))
await svc.download_source(source.id)
remaining, co = (await db.execute(
select(Source.backfill_runs_remaining, Source.config_overrides)
.where(Source.id == source.id)
)).one()
assert co.get("_backfill_state") == "complete"
assert "_backfill_cursor" not in co
assert remaining == 0
@pytest.mark.asyncio
async def test_backfill_cap_exhaustion_stalls(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""A progressing chunk that spends the last safety-cap chunk without
reaching the bottom pauses as 'stalled' (doesn't loop forever)."""
_artist, source = seed_artist_and_source
source.config_overrides = {"_backfill_state": "running"}
source.backfill_runs_remaining = 1
await db.commit()
svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
success=False, written_paths=[],
cursor="03:MORE:left",
))
await svc.download_source(source.id)
remaining, co = (await db.execute(
select(Source.backfill_runs_remaining, Source.config_overrides)
.where(Source.id == source.id)
)).one()
assert co.get("_backfill_state") == "stalled"
assert remaining == 0
@pytest.mark.asyncio
async def test_backfill_stuck_guard_stalls_after_two_no_advance(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""Two consecutive chunks that fail to advance the cursor → 'stalled',
cursor cleared, so a wedged walk can't re-strand forever."""
from unittest.mock import MagicMock
from backend.app.services.download_service import DownloadService
_artist, source = seed_artist_and_source
source.config_overrides = {"_backfill_state": "running", "_backfill_cursor": "stuck"}
source.backfill_runs_remaining = 5
await db.commit()
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=MagicMock(), importer=MagicMock(), cred_service=MagicMock(),
)
ctx = {
"source_id": source.id, "platform": "patreon",
"config_overrides": {"_backfill_state": "running", "_backfill_cursor": "stuck"},
"backfill_runs_remaining": 5,
}
stuck = _make_fake_dl_result(success=False, cursor="stuck")
await svc._apply_backfill_lifecycle(ctx, stuck)
await db.commit()
co = (await db.execute(
select(Source.config_overrides).where(Source.id == source.id)
)).scalar_one()
assert co.get("_backfill_state") == "running"
assert co.get("_backfill_cursor_stalls") == 1
ctx["config_overrides"] = dict(co)
await svc._apply_backfill_lifecycle(ctx, stuck)
await db.commit()
co2 = (await db.execute(
select(Source.config_overrides).where(Source.id == source.id)
)).scalar_one()
assert co2.get("_backfill_state") == "stalled"
assert "_backfill_cursor" not in co2
@pytest.mark.asyncio
async def test_backfill_lifecycle_leaves_posts_to_ingester(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""plan #704 (#5), revised: _backfill_posts is OWNED by the ingester now (it
writes a monotonic absolute live mid-walk), so the post-chunk lifecycle must
NOT touch it — it carries the ingester's committed value forward unchanged."""
from unittest.mock import MagicMock
from backend.app.services.download_service import DownloadService
_artist, source = seed_artist_and_source
source.config_overrides = {"_backfill_state": "running", "_backfill_posts": 12}
source.backfill_runs_remaining = 5
await db.commit()
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=MagicMock(), importer=MagicMock(), cred_service=MagicMock(),
)
ctx = {
"source_id": source.id, "platform": "patreon",
"config_overrides": {"_backfill_state": "running", "_backfill_posts": 12},
"backfill_runs_remaining": 5,
}
await svc._apply_backfill_lifecycle(
ctx, _make_fake_dl_result(success=False, cursor="C1", posts_processed=10),
)
await db.commit()
co = (await db.execute(
select(Source.config_overrides).where(Source.id == source.id)
)).scalar_one()
assert co.get("_backfill_posts") == 12 # untouched — the ingester owns it
@pytest.mark.asyncio
async def test_backfill_timeout_chunk_reclassified_to_ok(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""A backfill chunk that hit its time-box (TIMEOUT) but advanced is
reclassified to PARTIAL → event status 'ok' (progress, not error), and
consecutive_failures is not bumped."""
from backend.app.services.gallery_dl import ErrorType
_artist, source = seed_artist_and_source
source.config_overrides = {"_backfill_state": "running"}
source.backfill_runs_remaining = 5
await db.commit()
svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
success=False, files_downloaded=3,
error_type=ErrorType.TIMEOUT,
error_message="Download timed out",
cursor="03:NEXT:page",
))
event_id = await svc.download_source(source.id)
ev = (await db.execute(
select(DownloadEvent).where(DownloadEvent.id == event_id)
)).scalar_one()
assert ev.status == "ok"
failures = (await db.execute(
select(Source.consecutive_failures).where(Source.id == source.id)
)).scalar_one()
assert failures == 0
@pytest.mark.asyncio
async def test_tick_mode_when_not_running_leaves_state_untouched(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""No _backfill_state → not backfilling; the lifecycle is a no-op and the
counter/state aren't mutated."""
_artist, source = seed_artist_and_source
assert (source.config_overrides or {}).get("_backfill_state") is None
svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
success=True, written_paths=[], files_downloaded=0,
))
await svc.download_source(source.id)
co = (await db.execute(
select(Source.config_overrides).where(Source.id == source.id)
)).scalar_one()
assert (co or {}).get("_backfill_state") is None
@pytest.mark.asyncio
async def test_recovery_mode_selected_and_flag_cleared_on_complete(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""plan #697: `_backfill_bypass_seen` alongside a running backfill selects
recovery mode (ingester bypasses the seen-ledger). A clean rc=0 walk
completes the shared lifecycle AND clears the bypass flag so the next tick
honors the ledger again."""
_artist, source = seed_artist_and_source
source.config_overrides = {"_backfill_state": "running", "_backfill_bypass_seen": True}
source.backfill_runs_remaining = 5
await db.commit()
svc, calls = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
success=True, written_paths=[], files_downloaded=0,
))
await svc.download_source(source.id)
assert calls[0]["mode"] == "recovery"
co = (await db.execute(
select(Source.config_overrides).where(Source.id == source.id)
)).scalar_one()
assert co.get("_backfill_state") == "complete"
assert "_backfill_bypass_seen" not in co
@pytest.mark.asyncio
async def test_recapture_mode_selected_and_flag_cleared_on_complete(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""#830: `_backfill_recapture` alongside a running backfill selects recapture
mode. A clean rc=0 walk completes the shared lifecycle AND clears the
recapture flag so the next tick is a plain tick again."""
_artist, source = seed_artist_and_source
source.config_overrides = {"_backfill_state": "running", "_backfill_recapture": True}
source.backfill_runs_remaining = 5
await db.commit()
svc, calls = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
success=True, written_paths=[], files_downloaded=0,
))
await svc.download_source(source.id)
assert calls[0]["mode"] == "recapture"
co = (await db.execute(
select(Source.config_overrides).where(Source.id == source.id)
)).scalar_one()
assert co.get("_backfill_state") == "complete"
assert "_backfill_recapture" not in co
@pytest.mark.asyncio
async def test_partial_error_type_maps_to_ok_status(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""A non-zero gallery-dl exit accompanied by PARTIAL error_type
(real files were downloaded before the run was cut short) is treated
as status=ok by the orchestrator — next tick continues."""
from backend.app.services.download_service import DownloadService
from backend.app.services.gallery_dl import ErrorType
from backend.app.services.importer import Importer
_artist, source = seed_artist_and_source
images_root = tmp_path / "images"
f1 = images_root / "alice" / "patreon" / "post" / "a.jpg"
_make_jpg(f1, split="h")
fake_result = _make_fake_dl_result(
success=False, written_paths=[str(f1)], files_downloaded=1,
error_type=ErrorType.PARTIAL,
error_message="Downloaded 1 file; run did not complete in budget",
stdout=f"{f1}\n",
)
fake_gdl = _fake_gdl_with_result(fake_result)
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
importer = Importer(
session=db_sync, images_root=images_root, import_root=images_root,
thumbnailer=Thumbnailer(images_root=images_root), settings=sync_settings,
)
cred_service = CredentialService(db, CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True))
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
_stub_patreon_ingester(svc, fake_result)
event_id = await svc.download_source(source.id)
ev = (await db.execute(
select(DownloadEvent).where(DownloadEvent.id == event_id)
)).scalar_one()
assert ev.status == "ok"
assert ev.error is None
# And the source's failure counter isn't bumped — PARTIAL isn't a failure.
src_after = (await db.execute(
select(Source.consecutive_failures).where(Source.id == source.id)
)).scalar_one()
assert src_after == 0
@pytest.mark.asyncio
async def test_download_enqueues_thumbnail_and_ml_per_attached_image(
db, db_sync, tmp_path, seed_artist_and_source, monkeypatch,
):
"""Operator-flagged 2026-06-01: downloaded images stayed at
thumbnail_path=NULL until periodic backfill swept them up, surfacing
as broken-thumbnail tiles in the gallery. Importer.attach_in_place
deliberately skips inline generation; the calling code MUST enqueue
the thumbnail + ML tasks per attached image (matching the pattern in
tasks/import_file.py)."""
from backend.app.services.download_service import DownloadService
from backend.app.services.importer import Importer
_artist, source = seed_artist_and_source
images_root = tmp_path / "images"
f1 = images_root / "alice" / "patreon" / "post" / "a.jpg"
f2 = images_root / "alice" / "patreon" / "post" / "b.jpg"
_make_jpg(f1, split="h")
_make_jpg(f2, split="v")
result = _make_fake_dl_result(
success=True, written_paths=[str(f1), str(f2)],
files_downloaded=2, stdout=f"{f1}\n{f2}\n",
)
fake_gdl = _fake_gdl_with_result(result)
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
sync_settings.phash_threshold = 0
importer = Importer(
session=db_sync, images_root=images_root, import_root=images_root,
thumbnailer=Thumbnailer(images_root=images_root), settings=sync_settings,
)
cred_service = CredentialService(db, CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True))
# Capture the IDs that the orchestrator hands off to each Celery task.
# The .delay() shim runs inside DownloadService._phase3_persist (lazy
# imports under ..tasks.thumbnail / ..tasks.ml), so monkeypatch the
# symbols on those modules.
thumb_calls: list[int] = []
ml_calls: list[int] = []
from backend.app.tasks import ml as ml_mod
from backend.app.tasks import thumbnail as thumb_mod
monkeypatch.setattr(
thumb_mod.generate_thumbnail, "delay",
lambda image_id: thumb_calls.append(image_id),
)
monkeypatch.setattr(
ml_mod.embed_image, "delay",
lambda image_id: ml_calls.append(image_id),
)
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
_stub_patreon_ingester(svc, result)
await svc.download_source(source.id)
# Two files attached → two thumbnail enqueues + two ML enqueues, IDs
# match the actually-imported records (not ad-hoc — drawn from the
# importer's returned image_id so future supersede paths stay covered).
assert len(thumb_calls) == 2
assert len(ml_calls) == 2
assert sorted(thumb_calls) == sorted(ml_calls)
@pytest.mark.asyncio
async def test_releases_db_connections_before_subprocess(
db, db_sync, tmp_path, seed_artist_and_source, monkeypatch,
):
"""Phase 1's DB connections must be released BEFORE the (multi-minute)
phase-2 fetch, so they don't idle-die and strand phase 3 with
ConnectionDoesNotExistError (Anduo #40014). Spy on the async session close
and assert it happened before the phase-2 work (here the native Patreon
ingester) runs; phase 3 must still finalize the event."""
from backend.app.services.download_service import DownloadService
from backend.app.services.importer import Importer
_artist, source = seed_artist_and_source
closed = {"async": False}
orig_close = db.close
async def spy_close():
closed["async"] = True
await orig_close()
monkeypatch.setattr(db, "close", spy_close)
seen = {}
fake_gdl = _fake_gdl_with_result(
_make_fake_dl_result(success=True, written_paths=[], stdout="")
)
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
importer = Importer(
session=db_sync, images_root=tmp_path, import_root=tmp_path,
thumbnailer=Thumbnailer(images_root=tmp_path), settings=sync_settings,
)
cred_service = CredentialService(
db, CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True)
)
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
async def fake_ingest(*, ctx, source_config, skip_value, mode):
seen["closed_before_phase2"] = closed["async"]
return _make_fake_dl_result(success=True, written_paths=[], stdout=""), None
svc._run_download = fake_ingest
event_id = await svc.download_source(source.id)
assert seen["closed_before_phase2"] is True
ev = (await db.execute(
select(DownloadEvent).where(DownloadEvent.id == event_id)
)).scalar_one()
assert ev.status == "ok"
@pytest.mark.asyncio
async def test_async_task_engine_uses_nullpool():
"""The per-task async engine must use NullPool so phase 3 re-acquires a
fresh connection instead of a pooled one the server reaped during the
long subprocess (Anduo #40014)."""
from sqlalchemy.pool import NullPool
from backend.app.tasks._async_session import async_session_factory
_factory, engine = async_session_factory()
try:
assert isinstance(engine.sync_engine.pool, NullPool)
finally:
await engine.dispose()