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