Files
FabledCurator/tests/test_patreon_resolver.py
T
bvandeusen 218bfebb92
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 2m59s
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>
2026-06-05 22:49:43 -04:00

137 lines
4.8 KiB
Python

from unittest.mock import MagicMock, patch
import pytest
from backend.app.services.patreon_resolver import (
resolve_campaign_id,
resolve_campaign_id_for_source,
)
@pytest.mark.asyncio
async def test_resolves_on_happy_path():
fake_response = MagicMock()
fake_response.status_code = 200
fake_response.json.return_value = {"data": [{"id": "12345678", "type": "campaign"}]}
with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response):
result = await resolve_campaign_id("alice", cookies_path=None)
assert result == "12345678"
@pytest.mark.asyncio
async def test_returns_none_on_401():
fake_response = MagicMock()
fake_response.status_code = 401
fake_response.json.return_value = {}
with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response):
result = await resolve_campaign_id("alice", cookies_path=None)
assert result is None
@pytest.mark.asyncio
async def test_returns_none_on_network_error():
import requests
with patch(
"backend.app.services.patreon_resolver.requests.get",
side_effect=requests.ConnectionError("nope"),
):
result = await resolve_campaign_id("alice", cookies_path=None)
assert result is None
@pytest.mark.asyncio
async def test_returns_none_on_empty_data():
fake_response = MagicMock()
fake_response.status_code = 200
fake_response.json.return_value = {"data": []}
with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response):
result = await resolve_campaign_id("alice", cookies_path=None)
assert result is None
@pytest.mark.asyncio
async def test_returns_none_on_malformed_json():
fake_response = MagicMock()
fake_response.status_code = 200
fake_response.json.side_effect = ValueError("not json")
with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response):
result = await resolve_campaign_id("alice", cookies_path=None)
assert result is None
@pytest.mark.asyncio
async def test_missing_cookies_file_does_not_raise(tmp_path):
fake_response = MagicMock()
fake_response.status_code = 200
fake_response.json.return_value = {"data": [{"id": "1", "type": "campaign"}]}
with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response):
result = await resolve_campaign_id(
"alice", cookies_path=str(tmp_path / "does-not-exist.txt")
)
assert result == "1"
@pytest.mark.asyncio
async def test_loads_cookies_file_when_present(tmp_path):
cookies = tmp_path / "cookies.txt"
cookies.write_text(
"# Netscape HTTP Cookie File\n"
".patreon.com\tTRUE\t/\tTRUE\t1700000000\tsession_id\tabc\n"
)
fake_response = MagicMock()
fake_response.status_code = 200
fake_response.json.return_value = {"data": [{"id": "9", "type": "campaign"}]}
with patch(
"backend.app.services.patreon_resolver.requests.get",
return_value=fake_response,
) as mock_get:
result = await resolve_campaign_id("alice", cookies_path=str(cookies))
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