feat(downloads): native Patreon verify + uniform backend dispatch (plan #697)
The credential Verify button still ran gallery-dl --simulate for Patreon after the cutover — testing the wrong path (and prone to the vanity "Failed to extract campaign ID" the native resolver fixes). Wire it to the native ingester, behind a DRY dispatch so callers never branch on platform. - services/download_backends.py (new): the ONE place that knows which platforms are native vs gallery-dl. `uses_native_ingester(platform)` is the shared predicate; `verify_source_credential(...)` is the uniform probe (same (ok|None, message) contract for both backends). As a platform migrates, it moves into NATIVE_INGESTER_PLATFORMS here and BOTH download routing and verify switch together. - PatreonClient.verify_auth(campaign_id): one authenticated /api/posts fetch → True (valid) / False (401/403/HTML-login) / None (drift or network — inconclusive, not a credential verdict). - patreon_ingester.verify_patreon_credential(): resolve campaign id, then verify_auth — the verify counterpart to the download path. - patreon_resolver.resolve_campaign_id_for_source(): extracted the override / id:-URL / vanity resolution into ONE helper now shared by the download ingester and verify (download_service no longer carries its own copy + regex; −`import re`). - download_service: routes on uses_native_ingester() instead of inline `== "patreon"` (3 sites); uses the shared resolver. - api/credentials: calls verify_source_credential — no platform branch. Tests: verify_auth mapping, resolve_campaign_id_for_source (override/id:/ vanity/none), the dispatch predicate, verify_patreon_credential glue, credentials endpoint proves Patreon uses the native path (gallery-dl verify asserted not-called); repointed the gallery-dl verify test to subscribestar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -155,6 +155,8 @@ async def test_verify_no_enabled_source_is_untestable(client):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_runs_gallery_dl_and_stamps_on_success(client, db, monkeypatch):
|
||||
"""A gallery-dl platform routes through GalleryDLService.verify; success
|
||||
stamps last_verified (platform-agnostic endpoint behavior)."""
|
||||
from backend.app.models import Artist, Source
|
||||
from backend.app.services import gallery_dl as gdl_mod
|
||||
|
||||
@@ -163,6 +165,45 @@ async def test_verify_runs_gallery_dl_and_stamps_on_success(client, db, monkeypa
|
||||
return (True, "Credentials valid — the feed authenticated.")
|
||||
monkeypatch.setattr(gdl_mod.GalleryDLService, "verify", _fake_verify)
|
||||
|
||||
await client.post("/api/credentials", json={
|
||||
"platform": "subscribestar", "credential_type": "cookies", "data": _NETSCAPE,
|
||||
})
|
||||
artist = Artist(name="Maewix", slug="maewix")
|
||||
db.add(artist)
|
||||
await db.flush()
|
||||
db.add(Source(
|
||||
artist_id=artist.id, platform="subscribestar",
|
||||
url="https://www.subscribestar.com/maewix", enabled=True, config_overrides={},
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
resp = await client.post("/api/credentials/subscribestar/verify")
|
||||
body = await resp.get_json()
|
||||
assert body["valid"] is True
|
||||
assert body["last_verified"] is not None
|
||||
|
||||
# The stamp is persisted on the credential record.
|
||||
rec = await (await client.get("/api/credentials/subscribestar")).get_json()
|
||||
assert rec["last_verified"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_patreon_uses_native_ingester_not_gallery_dl(client, db, monkeypatch):
|
||||
"""plan #697 cutover: Patreon credential verify routes through the native
|
||||
ingester (verify_patreon_credential), NOT gallery-dl --simulate."""
|
||||
from backend.app.models import Artist, Source
|
||||
from backend.app.services import gallery_dl as gdl_mod
|
||||
from backend.app.services import patreon_ingester as pi_mod
|
||||
|
||||
async def _native_verify(url, cookies_path, overrides):
|
||||
return (True, "Credentials valid — the Patreon feed authenticated.")
|
||||
monkeypatch.setattr(pi_mod, "verify_patreon_credential", _native_verify)
|
||||
|
||||
# gallery-dl must NOT be consulted for Patreon.
|
||||
async def _boom(self, *args, **kwargs):
|
||||
raise AssertionError("gallery-dl verify must not run for patreon")
|
||||
monkeypatch.setattr(gdl_mod.GalleryDLService, "verify", _boom)
|
||||
|
||||
await client.post("/api/credentials", json={
|
||||
"platform": "patreon", "credential_type": "cookies", "data": _NETSCAPE,
|
||||
})
|
||||
@@ -180,10 +221,6 @@ async def test_verify_runs_gallery_dl_and_stamps_on_success(client, db, monkeypa
|
||||
assert body["valid"] is True
|
||||
assert body["last_verified"] is not None
|
||||
|
||||
# The stamp is persisted on the credential record.
|
||||
rec = await (await client.get("/api/credentials/patreon")).get_json()
|
||||
assert rec["last_verified"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_reports_auth_failure(client, db, monkeypatch):
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""download_backends — the single predicate that routes a platform to the
|
||||
native ingester vs. gallery-dl. Pure, no DB."""
|
||||
|
||||
from backend.app.services.download_backends import (
|
||||
NATIVE_INGESTER_PLATFORMS,
|
||||
uses_native_ingester,
|
||||
)
|
||||
|
||||
|
||||
def test_patreon_is_native():
|
||||
assert uses_native_ingester("patreon") is True
|
||||
assert "patreon" in NATIVE_INGESTER_PLATFORMS
|
||||
|
||||
|
||||
def test_gallery_dl_platforms_are_not_native():
|
||||
# The five platforms still served by gallery-dl must NOT route to the
|
||||
# native ingester — guards an accidental over-broad migration.
|
||||
for platform in ("subscribestar", "hentaifoundry", "discord", "pixiv", "deviantart"):
|
||||
assert uses_native_ingester(platform) is False
|
||||
|
||||
|
||||
def test_unknown_platform_is_not_native():
|
||||
assert uses_native_ingester("nonsense") is False
|
||||
@@ -288,7 +288,8 @@ async def test_run_patreon_ingester_resolves_vanity_and_runs(
|
||||
_artist, source = seed_artist_and_source
|
||||
|
||||
monkeypatch.setattr(
|
||||
dl_mod, "resolve_campaign_id", AsyncMock(return_value="4242"),
|
||||
dl_mod, "resolve_campaign_id_for_source",
|
||||
AsyncMock(return_value=("4242", "4242")),
|
||||
)
|
||||
run_kwargs = {}
|
||||
|
||||
@@ -335,7 +336,8 @@ async def test_run_patreon_ingester_unresolvable_fails_loud(
|
||||
_artist, source = seed_artist_and_source
|
||||
|
||||
monkeypatch.setattr(
|
||||
dl_mod, "resolve_campaign_id", AsyncMock(return_value=None),
|
||||
dl_mod, "resolve_campaign_id_for_source",
|
||||
AsyncMock(return_value=(None, None)),
|
||||
)
|
||||
svc = DownloadService(
|
||||
async_session=db, sync_session=db_sync,
|
||||
|
||||
@@ -238,3 +238,40 @@ def test_fetch_other_status_raises_api_error_with_code(monkeypatch, status):
|
||||
def test_fetch_ok_returns_payload(monkeypatch):
|
||||
client = _client_returning(monkeypatch, _FakeResp(200, json_data={"data": []}))
|
||||
assert client._fetch("5555", None) == {"data": []}
|
||||
|
||||
|
||||
# -- verify_auth (credential probe; (True/False/None, message) contract) ---
|
||||
|
||||
|
||||
def test_verify_auth_ok_when_page_authenticates(monkeypatch):
|
||||
client = _client_returning(monkeypatch, _FakeResp(200, json_data={"data": []}))
|
||||
ok, msg = client.verify_auth("5555")
|
||||
assert ok is True
|
||||
assert "valid" in msg.lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 403])
|
||||
def test_verify_auth_false_when_rejected(monkeypatch, status):
|
||||
client = _client_returning(monkeypatch, _FakeResp(status))
|
||||
ok, _msg = client.verify_auth("5555")
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_verify_auth_inconclusive_on_drift(monkeypatch):
|
||||
# 200 + JSON but missing top-level 'data' → drift → inconclusive, NOT a
|
||||
# credential verdict (our parser is stale, the cookie may be fine).
|
||||
client = _client_returning(monkeypatch, _FakeResp(200, json_data={"included": []}))
|
||||
ok, msg = client.verify_auth("5555")
|
||||
assert ok is None
|
||||
assert "api shape changed" in msg.lower()
|
||||
|
||||
|
||||
def test_verify_auth_inconclusive_on_network(monkeypatch):
|
||||
import requests
|
||||
client = PatreonClient(cookies_path=None)
|
||||
|
||||
def _raise(*a, **k):
|
||||
raise requests.ConnectionError("boom")
|
||||
monkeypatch.setattr(client._session, "get", _raise)
|
||||
ok, _msg = client.verify_auth("5555")
|
||||
assert ok is None
|
||||
|
||||
@@ -370,3 +370,32 @@ def test_ledger_key_video_has_no_filehash():
|
||||
kind="postfile", filehash=None, post_id="p9",
|
||||
)
|
||||
assert _ledger_key(video) == "p9:clip.mp4"
|
||||
|
||||
|
||||
# --- verify_patreon_credential (the verify counterpart, plan #697) ---------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_patreon_credential_unresolvable_is_inconclusive():
|
||||
from backend.app.services.patreon_ingester import verify_patreon_credential
|
||||
|
||||
# No campaign id resolvable from a non-Patreon URL → can't test → None.
|
||||
ok, msg = await verify_patreon_credential("not-a-patreon-url", None, {})
|
||||
assert ok is None
|
||||
assert "campaign id" in msg.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_patreon_credential_delegates_to_client(monkeypatch):
|
||||
import backend.app.services.patreon_ingester as mod
|
||||
|
||||
async def _fake_resolve(url, cookies_path, overrides):
|
||||
return "123", None
|
||||
monkeypatch.setattr(mod, "resolve_campaign_id_for_source", _fake_resolve)
|
||||
monkeypatch.setattr(
|
||||
mod.PatreonClient, "verify_auth",
|
||||
lambda self, campaign_id: (True, f"ok:{campaign_id}"),
|
||||
)
|
||||
ok, msg = await mod.verify_patreon_credential("https://patreon.com/x", None, {})
|
||||
assert ok is True
|
||||
assert msg == "ok:123"
|
||||
|
||||
@@ -2,7 +2,10 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.patreon_resolver import resolve_campaign_id
|
||||
from backend.app.services.patreon_resolver import (
|
||||
resolve_campaign_id,
|
||||
resolve_campaign_id_for_source,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -86,3 +89,48 @@ async def test_loads_cookies_file_when_present(tmp_path):
|
||||
assert result == "9"
|
||||
_, kwargs = mock_get.call_args
|
||||
assert kwargs.get("cookies") is not None
|
||||
|
||||
|
||||
# -- resolve_campaign_id_for_source (shared by download + verify) ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_for_source_uses_cached_override_without_lookup():
|
||||
with patch("backend.app.services.patreon_resolver.requests.get") as mock_get:
|
||||
cid, resolved = await resolve_campaign_id_for_source(
|
||||
"https://patreon.com/alice", None, {"patreon_campaign_id": "999"},
|
||||
)
|
||||
assert cid == "999"
|
||||
assert resolved is None # cached → no newly-resolved id to cache
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_for_source_extracts_id_url_without_lookup():
|
||||
with patch("backend.app.services.patreon_resolver.requests.get") as mock_get:
|
||||
cid, resolved = await resolve_campaign_id_for_source(
|
||||
"https://www.patreon.com/id:4242", None, {},
|
||||
)
|
||||
assert cid == "4242"
|
||||
assert resolved is None
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_for_source_resolves_vanity_and_reports_it():
|
||||
fake = MagicMock()
|
||||
fake.status_code = 200
|
||||
fake.json.return_value = {"data": [{"id": "777", "type": "campaign"}]}
|
||||
with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake):
|
||||
cid, resolved = await resolve_campaign_id_for_source(
|
||||
"https://patreon.com/alice", None, {},
|
||||
)
|
||||
assert cid == "777"
|
||||
assert resolved == "777" # newly resolved → caller caches it
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_for_source_unresolvable_returns_none():
|
||||
cid, resolved = await resolve_campaign_id_for_source("not-a-patreon-url", None, {})
|
||||
assert cid is None
|
||||
assert resolved is None
|
||||
|
||||
Reference in New Issue
Block a user