0963bf0db3
Parity with pixiv (operator ask): the extension add now resolves the real display name for our other native platforms too, not just the URL handle. patreon_resolver.resolve_display_name reads the campaigns API's attributes.name; SubscribeStarClient.resolve_display_name pulls the creator name off the profile page (og:title, else the <title> stripped of the SubscribeStar suffix). extension_service._resolve_artist_name dispatches per platform (pixiv=token, patreon/subscribestar=cookies via get_cookies_path), best-effort in an executor, falling back to the readable URL handle on any failure. Still all curator core — the extension is unchanged (sends only the URL). gallery-dl platforms keep the handle (readable, no native client). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
243 lines
8.9 KiB
Python
243 lines
8.9 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,
|
|
resolve_display_name,
|
|
)
|
|
|
|
|
|
def test_resolve_display_name_from_campaign():
|
|
fake = MagicMock()
|
|
fake.status_code = 200
|
|
fake.json.return_value = {"data": [
|
|
{"id": "1", "type": "campaign", "attributes": {"name": "Maewix Studios"}},
|
|
]}
|
|
with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake):
|
|
assert resolve_display_name("maewix", None) == "Maewix Studios"
|
|
|
|
|
|
def test_resolve_display_name_none_on_empty_or_error():
|
|
empty = MagicMock()
|
|
empty.status_code = 200
|
|
empty.json.return_value = {"data": []}
|
|
with patch("backend.app.services.patreon_resolver.requests.get", return_value=empty):
|
|
assert resolve_display_name("maewix", None) is None
|
|
import requests as _rq
|
|
with patch(
|
|
"backend.app.services.patreon_resolver.requests.get",
|
|
side_effect=_rq.ConnectionError("x"),
|
|
):
|
|
assert resolve_display_name("maewix", None) is None
|
|
|
|
|
|
@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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_falls_back_to_creator_page_when_api_empty():
|
|
"""When the campaigns API returns empty data (its known failure mode), the
|
|
resolver scrapes the campaign id out of the creator page (gallery-dl's
|
|
method)."""
|
|
api_resp = MagicMock()
|
|
api_resp.status_code = 200
|
|
api_resp.json.return_value = {"data": []}
|
|
page_resp = MagicMock()
|
|
page_resp.status_code = 200
|
|
page_resp.text = (
|
|
'<html><script>window.patreon={"bootstrap":{"campaign":'
|
|
'{"data":{"id":"7654321","type":"campaign"}}}}</script></html>'
|
|
)
|
|
|
|
def _get(url, **kwargs):
|
|
return api_resp if "/api/campaigns" in url else page_resp
|
|
|
|
with patch(
|
|
"backend.app.services.patreon_resolver.requests.get", side_effect=_get
|
|
):
|
|
result = await resolve_campaign_id("alice", cookies_path=None)
|
|
assert result == "7654321"
|
|
|
|
|
|
def test_scrape_campaign_id_patterns():
|
|
from backend.app.services.patreon_resolver import _scrape_campaign_id
|
|
|
|
assert _scrape_campaign_id('foo /api/campaigns/424242 bar') == "424242"
|
|
assert _scrape_campaign_id('"campaign_id":"99"') == "99"
|
|
assert _scrape_campaign_id('{"id":"5","type":"campaign"}') == "5"
|
|
assert _scrape_campaign_id("no id here") is None
|
|
assert _scrape_campaign_id(None) is 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
|
|
|
|
|
|
def test_extract_vanity_handles_all_path_prefixes():
|
|
"""Patreon serves the vanity bare and under c/ and cw/ prefixes; all must
|
|
yield the creator slug, not the prefix (operator-flagged 2026-06-07: a
|
|
/cw/Atole source resolved vanity='cw')."""
|
|
from backend.app.services.patreon_resolver import extract_vanity
|
|
|
|
assert extract_vanity("https://www.patreon.com/Atole") == "Atole"
|
|
assert extract_vanity("https://www.patreon.com/c/Atole") == "Atole"
|
|
assert extract_vanity("https://www.patreon.com/cw/Atole") == "Atole"
|
|
assert extract_vanity("https://patreon.com/cw/Atole") == "Atole"
|
|
# id: URLs are NOT vanities (handled by the id path).
|
|
assert extract_vanity("https://www.patreon.com/id:123") is None
|
|
# /posts/ permalinks are NOT vanities (handled by the post path) — must not
|
|
# masquerade as a creator slug named "posts".
|
|
assert extract_vanity(
|
|
"https://www.patreon.com/posts/mimic-in-dungeon-158372536"
|
|
) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_for_source_resolves_post_url_to_campaign():
|
|
"""A /posts/<slug>-<id> permalink resolves to the OWNING creator's campaign
|
|
(a subscription walks the whole feed, not one post)."""
|
|
post_resp = MagicMock()
|
|
post_resp.status_code = 200
|
|
post_resp.json.return_value = {
|
|
"data": {
|
|
"id": "158372536", "type": "post",
|
|
"relationships": {"campaign": {"data": {"id": "9988", "type": "campaign"}}},
|
|
}
|
|
}
|
|
calls = []
|
|
|
|
def _get(url, **kwargs):
|
|
calls.append(url)
|
|
return post_resp
|
|
|
|
with patch("backend.app.services.patreon_resolver.requests.get", side_effect=_get):
|
|
cid, resolved = await resolve_campaign_id_for_source(
|
|
"https://www.patreon.com/posts/mimic-in-dungeon-158372536", None, {},
|
|
)
|
|
assert cid == "9988"
|
|
assert resolved == "9988" # newly resolved → caller caches it
|
|
assert calls and "/api/posts/158372536" in calls[0]
|