Extension channels: dev and main each carry their own signed extension #237
@@ -0,0 +1,324 @@
|
||||
"""Layer-2 auto-refetch remediation — `services/refetch_service.py` (#3071).
|
||||
|
||||
This module was the only one under `backend/app/services/` with no test
|
||||
file, which matters more than a coverage gap normally would: it runs
|
||||
UNATTENDED off the recovery sweep (`tasks/maintenance.py`, gated by
|
||||
FC_AUTO_REFETCH_CORRUPT) and it DELETES a file from disk before asking a
|
||||
downloader for a fresh copy. The frontend cites it by name as the reason
|
||||
the Import tab could be retired at all (`stores/import.js`: imports
|
||||
"heal themselves").
|
||||
|
||||
`test_api_import_admin.py` already drives the happy path end-to-end
|
||||
through `POST /api/import/tasks/<id>/refetch` — file deleted, task
|
||||
flagged, one dispatch, second attempt a no-op. What it CANNOT reach is
|
||||
the branching inside `resolve_refetch_source`, and it never proves the
|
||||
negative that actually protects the operator's data: that a file whose
|
||||
source does NOT resolve is still on disk afterwards. Its `no_source`
|
||||
case points at a path that never existed, so nothing survives to check.
|
||||
|
||||
Those two things are what this module covers.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models import Artist, ImportBatch, ImportTask, Source
|
||||
from backend.app.services.refetch_service import (
|
||||
attempt_refetch,
|
||||
resolve_refetch_source,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
# --- fixtures / helpers ----------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def import_root(tmp_path):
|
||||
root = tmp_path / "import"
|
||||
root.mkdir()
|
||||
return root
|
||||
|
||||
|
||||
def _media(import_root: Path, artist_dir: str, name: str = "post.jpg") -> Path:
|
||||
"""A corrupt-import stand-in at import_root/<artist_dir>/<name>."""
|
||||
d = import_root / artist_dir if artist_dir else import_root
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
m = d / name
|
||||
m.write_bytes(b"corrupt-bytes")
|
||||
return m
|
||||
|
||||
|
||||
def _sidecar(media: Path, payload) -> Path:
|
||||
"""gallery-dl writes `<stem>.json` beside the media file."""
|
||||
sc = media.with_suffix(".json")
|
||||
sc.write_text(payload if isinstance(payload, str) else json.dumps(payload))
|
||||
return sc
|
||||
|
||||
|
||||
def _artist(session, name: str) -> Artist:
|
||||
a = Artist(name=name, slug=name.lower())
|
||||
session.add(a)
|
||||
session.flush()
|
||||
return a
|
||||
|
||||
|
||||
def _source(session, artist, platform="patreon", url=None, enabled=True) -> Source:
|
||||
s = Source(
|
||||
artist_id=artist.id,
|
||||
platform=platform,
|
||||
url=url if url is not None else f"https://www.{platform}.com/{artist.slug}",
|
||||
enabled=enabled,
|
||||
config_overrides={},
|
||||
)
|
||||
session.add(s)
|
||||
session.flush()
|
||||
return s
|
||||
|
||||
|
||||
def _task(session, media: Path, refetched: bool = False) -> ImportTask:
|
||||
batch = ImportBatch(
|
||||
triggered_by="manual", source_path=str(media.parent), scan_mode="quick",
|
||||
)
|
||||
session.add(batch)
|
||||
session.flush()
|
||||
t = ImportTask(
|
||||
batch_id=batch.id, source_path=str(media), task_type="media",
|
||||
status="failed", refetched=refetched,
|
||||
)
|
||||
session.add(t)
|
||||
session.flush()
|
||||
return t
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_dispatch(monkeypatch):
|
||||
"""Capture download_source.delay instead of queueing a real re-check.
|
||||
|
||||
refetch_service imports the task lazily (inside attempt_refetch, to
|
||||
dodge a tasks->services->tasks cycle), so patching the attribute on
|
||||
the module is enough — the import resolves at call time.
|
||||
"""
|
||||
from backend.app.tasks import download as download_mod
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(download_mod.download_source, "delay", calls.append)
|
||||
return calls
|
||||
|
||||
|
||||
# --- resolve_refetch_source: what counts as re-pollable --------------------
|
||||
|
||||
def test_resolve_finds_enabled_source_matching_the_sidecar_platform(db_sync, import_root):
|
||||
m = _media(import_root, "Alice")
|
||||
_sidecar(m, {"category": "patreon", "post_id": 1})
|
||||
artist = _artist(db_sync, "Alice")
|
||||
src = _source(db_sync, artist)
|
||||
|
||||
assert resolve_refetch_source(db_sync, str(m), import_root).id == src.id
|
||||
|
||||
|
||||
def test_resolve_skips_a_disabled_source(db_sync, import_root):
|
||||
"""A disabled Source is not re-pollable: the operator turned it off,
|
||||
and a sweep must not reach past that to delete their file."""
|
||||
m = _media(import_root, "Alice")
|
||||
_sidecar(m, {"category": "patreon"})
|
||||
_source(db_sync, _artist(db_sync, "Alice"), enabled=False)
|
||||
|
||||
assert resolve_refetch_source(db_sync, str(m), import_root) is None
|
||||
|
||||
|
||||
def test_resolve_rejects_a_synthetic_sidecar_anchor_url(db_sync, import_root):
|
||||
"""`sidecar:<platform>:<slug>` is a bookkeeping anchor for files that
|
||||
arrived on disk, not a feed. Re-polling it is impossible, so it must
|
||||
not qualify — otherwise the file is deleted for a fetch that can
|
||||
never happen."""
|
||||
m = _media(import_root, "Alice")
|
||||
_sidecar(m, {"category": "patreon"})
|
||||
_source(db_sync, _artist(db_sync, "Alice"), url="sidecar:patreon:alice")
|
||||
|
||||
assert resolve_refetch_source(db_sync, str(m), import_root) is None
|
||||
|
||||
|
||||
def test_resolve_needs_a_source_on_the_sidecars_own_platform(db_sync, import_root):
|
||||
m = _media(import_root, "Alice")
|
||||
_sidecar(m, {"category": "patreon"})
|
||||
_source(db_sync, _artist(db_sync, "Alice"), platform="pixiv")
|
||||
|
||||
assert resolve_refetch_source(db_sync, str(m), import_root) is None
|
||||
|
||||
|
||||
def test_resolve_picks_the_lowest_id_when_several_sources_qualify(db_sync, import_root):
|
||||
m = _media(import_root, "Alice")
|
||||
_sidecar(m, {"category": "patreon"})
|
||||
artist = _artist(db_sync, "Alice")
|
||||
first = _source(db_sync, artist, url="https://www.patreon.com/alice-one")
|
||||
_source(db_sync, artist, url="https://www.patreon.com/alice-two")
|
||||
|
||||
# Deterministic choice, not "whichever the planner returned first" —
|
||||
# the pick decides which downloader runs.
|
||||
assert resolve_refetch_source(db_sync, str(m), import_root).id == first.id
|
||||
|
||||
|
||||
def test_resolve_reads_the_gallery_dl_numbered_sidecar(db_sync, import_root):
|
||||
"""gallery-dl prefixes media with `NN_` for in-post ordering but
|
||||
writes the sidecar under the UNPREFIXED stem. Refetch resolves real
|
||||
downloaded files, so it has to follow that convention."""
|
||||
m = _media(import_root, "Alice", name="01_post.jpg")
|
||||
(import_root / "Alice" / "post.json").write_text(json.dumps({"category": "patreon"}))
|
||||
src = _source(db_sync, _artist(db_sync, "Alice"))
|
||||
|
||||
assert resolve_refetch_source(db_sync, str(m), import_root).id == src.id
|
||||
|
||||
|
||||
# --- resolve_refetch_source: every way it declines -------------------------
|
||||
|
||||
def test_resolve_declines_without_a_sidecar(db_sync, import_root):
|
||||
m = _media(import_root, "Alice")
|
||||
_source(db_sync, _artist(db_sync, "Alice"))
|
||||
|
||||
assert resolve_refetch_source(db_sync, str(m), import_root) is None
|
||||
|
||||
|
||||
def test_resolve_declines_on_unreadable_sidecar_json(db_sync, import_root):
|
||||
m = _media(import_root, "Alice")
|
||||
_sidecar(m, "{not valid json")
|
||||
_source(db_sync, _artist(db_sync, "Alice"))
|
||||
|
||||
assert resolve_refetch_source(db_sync, str(m), import_root) is None
|
||||
|
||||
|
||||
def test_resolve_declines_when_the_sidecar_is_not_an_object(db_sync, import_root):
|
||||
# A bare JSON list parses fine but has no `category` to read.
|
||||
m = _media(import_root, "Alice")
|
||||
_sidecar(m, ["patreon"])
|
||||
_source(db_sync, _artist(db_sync, "Alice"))
|
||||
|
||||
assert resolve_refetch_source(db_sync, str(m), import_root) is None
|
||||
|
||||
|
||||
def test_resolve_declines_when_the_sidecar_names_no_platform(db_sync, import_root):
|
||||
m = _media(import_root, "Alice")
|
||||
_sidecar(m, {"post_id": 1})
|
||||
_source(db_sync, _artist(db_sync, "Alice"))
|
||||
|
||||
assert resolve_refetch_source(db_sync, str(m), import_root) is None
|
||||
|
||||
|
||||
def test_resolve_declines_for_a_file_sitting_directly_in_import_root(db_sync, import_root):
|
||||
"""No artist folder means no artist bucket to resolve — the
|
||||
filesystem-only drop case."""
|
||||
m = _media(import_root, "")
|
||||
_sidecar(m, {"category": "patreon"})
|
||||
_source(db_sync, _artist(db_sync, "Alice"))
|
||||
|
||||
assert resolve_refetch_source(db_sync, str(m), import_root) is None
|
||||
|
||||
|
||||
def test_resolve_declines_when_no_artist_row_matches_the_folder(db_sync, import_root):
|
||||
m = _media(import_root, "Nobody")
|
||||
_sidecar(m, {"category": "patreon"})
|
||||
_source(db_sync, _artist(db_sync, "Alice"))
|
||||
|
||||
assert resolve_refetch_source(db_sync, str(m), import_root) is None
|
||||
|
||||
|
||||
# --- attempt_refetch: the destructive half ---------------------------------
|
||||
|
||||
def test_attempt_refetch_deletes_the_file_and_queues_one_recheck(
|
||||
db_sync, import_root, no_dispatch,
|
||||
):
|
||||
m = _media(import_root, "Alice")
|
||||
_sidecar(m, {"category": "patreon"})
|
||||
src = _source(db_sync, _artist(db_sync, "Alice"))
|
||||
task = _task(db_sync, m)
|
||||
|
||||
result = attempt_refetch(db_sync, task, import_root)
|
||||
|
||||
assert result == {"status": "refetch_queued", "source_id": src.id}
|
||||
assert not m.exists() # the bad copy is gone...
|
||||
assert no_dispatch == [src.id] # ...and exactly one re-check was queued
|
||||
assert task.refetched is True
|
||||
|
||||
|
||||
def test_attempt_refetch_leaves_the_file_alone_when_nothing_resolves(
|
||||
db_sync, import_root, no_dispatch,
|
||||
):
|
||||
"""THE assertion this module exists for. `no_source` is the common
|
||||
case on a filesystem-only library, and the file on disk is then the
|
||||
operator's ONLY copy — deleting it without a downloader that can
|
||||
replace it destroys the thing the remediation was meant to repair.
|
||||
|
||||
The route-level `no_source` test cannot catch a regression here: its
|
||||
path never existed, so an unconditional unlink would pass it.
|
||||
"""
|
||||
m = _media(import_root, "Alice")
|
||||
_sidecar(m, {"category": "patreon"})
|
||||
_source(db_sync, _artist(db_sync, "Alice"), enabled=False)
|
||||
task = _task(db_sync, m)
|
||||
|
||||
assert attempt_refetch(db_sync, task, import_root) == {"status": "no_source"}
|
||||
assert m.exists()
|
||||
assert m.read_bytes() == b"corrupt-bytes"
|
||||
assert no_dispatch == []
|
||||
assert task.refetched is False # not consumed — a real fix can still run
|
||||
|
||||
|
||||
def test_attempt_refetch_is_bounded_to_a_single_attempt(
|
||||
db_sync, import_root, no_dispatch,
|
||||
):
|
||||
"""The `refetched` bound is what stops SOURCE-side corruption from
|
||||
looping: re-downloading a file that is broken upstream returns the
|
||||
same bytes forever. The check must come FIRST — a second call has to
|
||||
leave the (re-downloaded) file untouched, not delete it again.
|
||||
"""
|
||||
m = _media(import_root, "Alice")
|
||||
_sidecar(m, {"category": "patreon"})
|
||||
_source(db_sync, _artist(db_sync, "Alice"))
|
||||
task = _task(db_sync, m, refetched=True)
|
||||
|
||||
assert attempt_refetch(db_sync, task, import_root) == {"status": "already_refetched"}
|
||||
assert m.exists()
|
||||
assert no_dispatch == []
|
||||
|
||||
|
||||
def test_attempt_refetch_proceeds_when_the_file_is_already_gone(
|
||||
db_sync, import_root, no_dispatch,
|
||||
):
|
||||
"""`missing_ok=True`: the sweep races an operator who deleted the bad
|
||||
file by hand. The re-check is still the right next move."""
|
||||
m = _media(import_root, "Alice")
|
||||
_sidecar(m, {"category": "patreon"})
|
||||
src = _source(db_sync, _artist(db_sync, "Alice"))
|
||||
task = _task(db_sync, m)
|
||||
m.unlink()
|
||||
|
||||
assert attempt_refetch(db_sync, task, import_root)["status"] == "refetch_queued"
|
||||
assert no_dispatch == [src.id]
|
||||
|
||||
|
||||
def test_attempt_refetch_survives_an_unlink_failure(
|
||||
db_sync, import_root, no_dispatch,
|
||||
):
|
||||
"""An unremovable path is logged and stepped over, not raised — this
|
||||
runs unattended, and a raise would abort the whole recovery sweep for
|
||||
every OTHER poison-pill row in the batch.
|
||||
|
||||
A directory standing where the media file should be produces a
|
||||
genuine IsADirectoryError (an OSError) without patching pathlib, so
|
||||
the handler is exercised rather than simulated.
|
||||
"""
|
||||
d = import_root / "Alice" / "post.jpg"
|
||||
d.mkdir(parents=True)
|
||||
(import_root / "Alice" / "post.json").write_text(json.dumps({"category": "patreon"}))
|
||||
src = _source(db_sync, _artist(db_sync, "Alice"))
|
||||
task = _task(db_sync, d)
|
||||
|
||||
assert attempt_refetch(db_sync, task, import_root)["status"] == "refetch_queued"
|
||||
assert d.exists() # removal genuinely failed...
|
||||
assert no_dispatch == [src.id] # ...and the sweep carried on anyway
|
||||
assert db_sync.execute(
|
||||
select(ImportTask.refetched).where(ImportTask.id == task.id)
|
||||
).scalar_one() is True
|
||||
Reference in New Issue
Block a user