7ac5c7e522
DRY pass commit 1 (process #594). Consolidate the helpers + download plumbing the Patreon and SubscribeStar adapters had duplicated (SubscribeStar was importing patreon privates — wrong owner). New backend/app/services/ native_ingest_common.py is the neutral home for: - make_session (was _load_session ×2), retry_after_seconds + 429 constants, sanitize_segment, basename_from_url, post_dir_name, MediaOutcome / PostRecordOutcome. - BaseNativeDownloader: the shared streaming GET (transient-retry + Range-resume) and validation/quarantine. Patreon + SubscribeStar downloaders now subclass it; each keeps only what differs (Patreon's Mux/yt-dlp video branch + detail-fetch enrichment; SubscribeStar nothing extra). Behavior preserved exactly; the divergence-bug risk (a fix to one _fetch_to_file not reaching the other) is gone. - Folds in #899 L2: a quarantine now log.warning's path+reason (was counted only). post_dir_name merges both date handlers (accepts trailing-Z and pre-parsed ISO). Tests repointed to the single source at every consumer (rule 93 / §8b parity): patreon_client/downloader, subscribestar_native. Exception-trio consolidation + base _failure_result (2/3) and the remaining ingest_core logging (3/3) follow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
462 lines
17 KiB
Python
462 lines
17 KiB
Python
"""PatreonClient tests — parsing only, no network.
|
|
|
|
The fixture is loaded from disk and fed directly to the pure parsing methods
|
|
(_transform / extract_media / _validate_response / parse_cursor_from_url); no
|
|
live requests call is exercised here.
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
from backend.app.services import patreon_client as pc_mod
|
|
from backend.app.services.native_ingest_common import retry_after_seconds
|
|
from backend.app.services.patreon_client import (
|
|
MediaItem,
|
|
PatreonAPIError,
|
|
PatreonAuthError,
|
|
PatreonClient,
|
|
PatreonDriftError,
|
|
parse_cursor_from_url,
|
|
)
|
|
|
|
_FIXTURE = Path(__file__).parent / "fixtures" / "patreon_posts_page1.json"
|
|
|
|
|
|
@pytest.fixture
|
|
def response():
|
|
return json.loads(_FIXTURE.read_text())
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
# No cookies file → empty session, but we never make a request in tests.
|
|
return PatreonClient(cookies_path=None)
|
|
|
|
|
|
def _post_by_id(response, post_id):
|
|
for post in response["data"]:
|
|
if post["id"] == post_id:
|
|
return post
|
|
raise AssertionError(f"no post {post_id} in fixture")
|
|
|
|
|
|
def test_transform_indexes_included(client, response):
|
|
index = client._transform(response)
|
|
assert index[("media", "9001")]["file_name"] == "gallery-one.jpg"
|
|
assert index[("media", "9003")]["file_name"] == "bonus-pack.zip"
|
|
assert index[("campaign", "5555")]["name"] == "Example Creator"
|
|
# Unknown keys are absent, not errors.
|
|
assert ("media", "0000") not in index
|
|
|
|
|
|
def test_extract_gallery_dedups_image_large(client, response):
|
|
index = client._transform(response)
|
|
post = _post_by_id(response, "1001")
|
|
items = client.extract_media(post, index)
|
|
|
|
# Two gallery images; the image_large cover shares filehash aaaa... with
|
|
# gallery image 9001 → collapses to one, leaving exactly 2 items.
|
|
assert len(items) == 2
|
|
assert all(isinstance(m, MediaItem) for m in items)
|
|
|
|
kinds = [m.kind for m in items]
|
|
assert kinds == ["images", "images"] # image_large deduped away
|
|
|
|
filenames = {m.filename for m in items}
|
|
assert filenames == {"gallery-one.jpg", "gallery-two.jpg"}
|
|
|
|
hashes = {m.filehash for m in items}
|
|
assert hashes == {"a" * 32, "b" * 32}
|
|
assert all(m.post_id == "1001" for m in items)
|
|
|
|
|
|
def test_extract_attachment_postfile_same_file_collapses(client, response):
|
|
index = client._transform(response)
|
|
post = _post_by_id(response, "1002")
|
|
items = client.extract_media(post, index)
|
|
|
|
# The attachment (attachments_media 9003) and the post_file are the SAME
|
|
# zip — both carry filehash c…. attachments resolve before postfile, so the
|
|
# postfile collapses into the attachment by filehash → exactly one survives.
|
|
# (postfile-as-a-kind is exercised separately by the video post test.)
|
|
assert len(items) == 1
|
|
item = items[0]
|
|
assert item.kind == "attachments"
|
|
assert item.filename == "bonus-pack.zip"
|
|
assert item.filehash == "c" * 32
|
|
|
|
|
|
def test_extract_inline_content_img(client, response):
|
|
index = client._transform(response)
|
|
post = _post_by_id(response, "1003")
|
|
items = client.extract_media(post, index)
|
|
|
|
assert len(items) == 1
|
|
item = items[0]
|
|
assert item.kind == "content"
|
|
assert item.filehash == "d" * 32
|
|
# & in the src must be unescaped to & .
|
|
assert "&" not in item.url
|
|
assert "token=x&v=2" in item.url
|
|
|
|
|
|
def test_extract_video_postfile(client, response):
|
|
index = client._transform(response)
|
|
post = _post_by_id(response, "1004")
|
|
items = client.extract_media(post, index)
|
|
|
|
assert len(items) == 1
|
|
item = items[0]
|
|
assert item.kind == "postfile"
|
|
assert item.url.startswith("https://stream.mux.com/")
|
|
assert item.filehash == "e" * 32
|
|
|
|
|
|
def test_parse_cursor_from_url_extracts_cursor(response):
|
|
next_url = response["links"]["next"]
|
|
assert parse_cursor_from_url(next_url) == "NEXTCURSOR123"
|
|
|
|
|
|
def test_parse_cursor_from_url_none_when_absent():
|
|
assert parse_cursor_from_url("https://www.patreon.com/api/posts?sort=-x") is None
|
|
assert parse_cursor_from_url(None) is None
|
|
|
|
|
|
def test_iter_posts_yields_triples_and_paginates(client, response, monkeypatch):
|
|
# Page 1 = the fixture (4 posts, links.next → NEXTCURSOR123); page 2 = one
|
|
# post, no links.next → stop. Monkeypatch _fetch so no network is touched.
|
|
page2 = {
|
|
"data": [{"id": "1005", "type": "post", "attributes": {"title": "older"}}],
|
|
"included": [],
|
|
"links": {},
|
|
}
|
|
|
|
def fake_fetch(campaign_id, cursor):
|
|
return page2 if cursor == "NEXTCURSOR123" else response
|
|
|
|
monkeypatch.setattr(client, "_fetch", fake_fetch)
|
|
|
|
seen = list(client.iter_posts("5555"))
|
|
# 4 posts on page 1 + 1 on page 2.
|
|
assert [p["id"] for p, _idx, _cur in seen] == ["1001", "1002", "1003", "1004", "1005"]
|
|
# page_cursor is the cursor that FETCHED the post's page.
|
|
cursors = {p["id"]: cur for p, _idx, cur in seen}
|
|
assert cursors["1001"] is None and cursors["1005"] == "NEXTCURSOR123"
|
|
# included_index is the page's flattened resources, ready for extract_media.
|
|
page1_index = next(idx for p, idx, _cur in seen if p["id"] == "1001")
|
|
assert page1_index[("media", "9001")]["file_name"] == "gallery-one.jpg"
|
|
|
|
|
|
def test_missing_data_raises_drift(client):
|
|
with pytest.raises(PatreonDriftError):
|
|
client._validate_response({"included": []})
|
|
|
|
|
|
def test_non_list_data_raises_drift(client):
|
|
with pytest.raises(PatreonDriftError):
|
|
client._validate_response({"data": {"id": "1"}})
|
|
|
|
|
|
def test_media_missing_file_name_falls_back_to_url_basename(client):
|
|
# A gallery image with a valid URL but no file_name is NOT drift — Patreon
|
|
# serves these (operator-flagged 2026-06-07, BlenderKnight post 73665615);
|
|
# fall back to the URL basename like gallery-dl does.
|
|
post = {
|
|
"id": "2000",
|
|
"type": "post",
|
|
"attributes": {"title": "broken"},
|
|
"relationships": {"images": {"data": [{"id": "7777", "type": "media"}]}},
|
|
}
|
|
index = {
|
|
("media", "7777"): {
|
|
"download_url": "https://c10.patreonusercontent.com/4/orig/"
|
|
+ "f" * 32
|
|
+ "/x.jpg"
|
|
}
|
|
}
|
|
items = client.extract_media(post, index)
|
|
assert len(items) == 1
|
|
assert items[0].filename == "x.jpg"
|
|
assert items[0].kind == "images"
|
|
|
|
|
|
def test_media_referenced_but_absent_raises_drift(client):
|
|
post = {
|
|
"id": "2001",
|
|
"type": "post",
|
|
"attributes": {"title": "dangling"},
|
|
"relationships": {"images": {"data": [{"id": "8888", "type": "media"}]}},
|
|
}
|
|
with pytest.raises(PatreonDriftError):
|
|
client.extract_media(post, {})
|
|
|
|
|
|
# -- HTTP status classification (plan #697 step 4) -------------------------
|
|
|
|
|
|
class _FakeResp:
|
|
def __init__(self, status_code, *, json_data=None, raise_json=False, headers=None):
|
|
self.status_code = status_code
|
|
self._json_data = json_data
|
|
self._raise_json = raise_json
|
|
self.headers = headers or {}
|
|
|
|
def json(self):
|
|
if self._raise_json:
|
|
raise ValueError("Expecting value: line 1 column 1")
|
|
return self._json_data
|
|
|
|
|
|
def _client_returning(monkeypatch, resp):
|
|
client = PatreonClient(cookies_path=None)
|
|
monkeypatch.setattr(client._session, "get", lambda *a, **k: resp)
|
|
return client
|
|
|
|
|
|
@pytest.mark.parametrize("status", [401, 403])
|
|
def test_fetch_auth_status_raises_auth_error(monkeypatch, status):
|
|
client = _client_returning(monkeypatch, _FakeResp(status))
|
|
with pytest.raises(PatreonAuthError) as ei:
|
|
client._fetch("5555", None)
|
|
assert ei.value.status_code == status
|
|
|
|
|
|
def test_fetch_non_json_body_is_auth_not_drift(monkeypatch):
|
|
# An HTML login/challenge page (non-JSON) means the session expired — auth,
|
|
# actionable as "rotate cookies", NOT API drift.
|
|
client = _client_returning(monkeypatch, _FakeResp(200, raise_json=True))
|
|
with pytest.raises(PatreonAuthError):
|
|
client._fetch("5555", None)
|
|
|
|
|
|
@pytest.mark.parametrize("status", [404, 429, 500])
|
|
def test_fetch_other_status_raises_api_error_with_code(monkeypatch, status):
|
|
client = _client_returning(monkeypatch, _FakeResp(status))
|
|
with pytest.raises(PatreonAPIError) as ei:
|
|
client._fetch("5555", None)
|
|
assert ei.value.status_code == status
|
|
# Not auth — the ingester maps these by status, not to auth_error.
|
|
assert not isinstance(ei.value, PatreonAuthError)
|
|
|
|
|
|
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
|
|
|
|
|
|
# -- 429 backoff + pacing (plan #703) --------------------------------------
|
|
|
|
|
|
def test_retry_after_seconds_honors_numeric_header():
|
|
assert retry_after_seconds(_FakeResp(429, headers={"Retry-After": "7"}), 1) == 7.0
|
|
|
|
|
|
def test_retry_after_seconds_exponential_without_header():
|
|
resp = _FakeResp(429)
|
|
assert retry_after_seconds(resp, 1) == 2.0
|
|
assert retry_after_seconds(resp, 2) == 4.0
|
|
assert retry_after_seconds(resp, 3) == 8.0
|
|
|
|
|
|
def test_retry_after_seconds_caps():
|
|
assert retry_after_seconds(_FakeResp(429), 10) == 30.0
|
|
assert retry_after_seconds(_FakeResp(429, headers={"Retry-After": "999"}), 1) == 30.0
|
|
|
|
|
|
def _client_with_sequence(monkeypatch, responses):
|
|
"""A client whose session yields `responses` in order; time.sleep stubbed so
|
|
backoff never really sleeps. Returns (client, slept_list)."""
|
|
client = PatreonClient(cookies_path=None)
|
|
it = iter(responses)
|
|
monkeypatch.setattr(client._session, "get", lambda *a, **k: next(it))
|
|
slept: list[float] = []
|
|
monkeypatch.setattr(pc_mod.time, "sleep", lambda s: slept.append(s))
|
|
return client, slept
|
|
|
|
|
|
def test_fetch_retries_then_succeeds_on_429(monkeypatch):
|
|
client, slept = _client_with_sequence(monkeypatch, [
|
|
_FakeResp(429, headers={"Retry-After": "5"}),
|
|
_FakeResp(200, json_data={"data": []}),
|
|
])
|
|
assert client._fetch("5555", None) == {"data": []}
|
|
assert 5.0 in slept # backed off once before the retry
|
|
|
|
|
|
def test_fetch_persistent_429_raises_after_retries(monkeypatch):
|
|
client, _slept = _client_with_sequence(monkeypatch, [_FakeResp(429)] * 10)
|
|
with pytest.raises(PatreonAPIError) as ei:
|
|
client._fetch("5555", None)
|
|
assert ei.value.status_code == 429
|
|
assert not isinstance(ei.value, PatreonAuthError)
|
|
|
|
|
|
def test_fetch_persistent_429_surfaces_retry_after(monkeypatch):
|
|
"""plan #708 B1: a terminal 429 carries the server's raw Retry-After seconds
|
|
(uncapped here — the cooldown clamps) so the platform cooldown matches it."""
|
|
client, _slept = _client_with_sequence(
|
|
monkeypatch, [_FakeResp(429, headers={"Retry-After": "120"})] * 10,
|
|
)
|
|
with pytest.raises(PatreonAPIError) as ei:
|
|
client._fetch("5555", None)
|
|
assert ei.value.status_code == 429
|
|
assert ei.value.retry_after == 120.0
|
|
|
|
|
|
def test_request_sleep_paces_before_fetch(monkeypatch):
|
|
client = PatreonClient(cookies_path=None, request_sleep=1.5)
|
|
monkeypatch.setattr(
|
|
client._session, "get",
|
|
lambda *a, **k: _FakeResp(200, json_data={"data": []}),
|
|
)
|
|
slept: list[float] = []
|
|
monkeypatch.setattr(pc_mod.time, "sleep", lambda s: slept.append(s))
|
|
client._fetch("5555", None)
|
|
assert slept == [1.5]
|
|
|
|
|
|
# -- fetch_post_detail_content (best-effort full-body enrichment) ----------
|
|
|
|
|
|
def test_fetch_post_detail_content_returns_legacy_content(monkeypatch):
|
|
"""Old posts still carry the flat `content` HTML — returned as-is. The detail
|
|
fetch uses the DEFAULT fieldset (no sparse `fields[post]`, which nulls the
|
|
body) — #842."""
|
|
payload = {"data": {"id": "42", "attributes": {"content": "<p>full body</p>"}}}
|
|
captured: dict = {}
|
|
client = PatreonClient(cookies_path=None)
|
|
|
|
def _get(url, params=None, timeout=None):
|
|
captured["url"] = url
|
|
captured["params"] = params
|
|
return _FakeResp(200, json_data=payload)
|
|
|
|
monkeypatch.setattr(client._session, "get", _get)
|
|
assert client.fetch_post_detail_content("42") == "<p>full body</p>"
|
|
assert captured["url"].endswith("/api/posts/42")
|
|
# No sparse fieldset — that's what nulled the body on the live API.
|
|
assert not captured["params"] or "fields[post]" not in captured["params"]
|
|
|
|
|
|
def test_fetch_post_detail_content_converts_content_json_string(monkeypatch):
|
|
"""Current posts: `content` is null and the body lives in the
|
|
`content_json_string` ProseMirror doc → converted to HTML (#842)."""
|
|
doc = (
|
|
'{"type":"doc","content":[{"type":"paragraph","content":'
|
|
'[{"type":"text","text":"Hello"}]}]}'
|
|
)
|
|
payload = {"data": {"attributes": {"content": None, "content_json_string": doc}}}
|
|
client = _client_returning(monkeypatch, _FakeResp(200, json_data=payload))
|
|
assert client.fetch_post_detail_content("42") == "<p>Hello</p>"
|
|
|
|
|
|
def test_fetch_post_detail_content_empty_body_is_none(monkeypatch):
|
|
payload = {"data": {"attributes": {"content": " ", "content_json_string": ""}}}
|
|
client = _client_returning(monkeypatch, _FakeResp(200, json_data=payload))
|
|
assert client.fetch_post_detail_content("42") is None
|
|
|
|
|
|
def test_fetch_post_detail_content_blank_id_skips_request(monkeypatch):
|
|
calls: list = []
|
|
client = PatreonClient(cookies_path=None)
|
|
monkeypatch.setattr(
|
|
client._session, "get",
|
|
lambda *a, **k: calls.append(1) or _FakeResp(200, json_data={}),
|
|
)
|
|
assert client.fetch_post_detail_content("") is None
|
|
assert calls == []
|
|
|
|
|
|
def test_fetch_post_detail_content_swallows_http_and_transport_errors(monkeypatch):
|
|
# Non-200 → None (best-effort; a missing body must never fail the walk).
|
|
client = _client_returning(monkeypatch, _FakeResp(500))
|
|
assert client.fetch_post_detail_content("42") is None
|
|
# Non-JSON body (HTML challenge) → None, not a raise.
|
|
client = _client_returning(monkeypatch, _FakeResp(200, raise_json=True))
|
|
assert client.fetch_post_detail_content("42") is None
|
|
# Transport error → None.
|
|
client = PatreonClient(cookies_path=None)
|
|
|
|
def _boom(*a, **k):
|
|
raise requests.ConnectionError("boom")
|
|
|
|
monkeypatch.setattr(client._session, "get", _boom)
|
|
assert client.fetch_post_detail_content("42") is None
|
|
|
|
|
|
# -- post_record_key (media-less post seen-ledger gate) --------------------
|
|
|
|
|
|
def test_post_record_key_returns_synthetic_key_and_id():
|
|
assert PatreonClient.post_record_key({"id": "987"}) == ("post:987", "987")
|
|
assert PatreonClient.post_record_key({"id": 987}) == ("post:987", "987")
|
|
|
|
|
|
def test_post_record_key_none_without_id():
|
|
assert PatreonClient.post_record_key({}) is None
|
|
assert PatreonClient.post_record_key({"id": None}) is None
|
|
|
|
|
|
# -- post_is_gated (#874 tier-gated access filter) --------------------------
|
|
|
|
|
|
def test_post_is_gated_only_on_explicit_false():
|
|
# The blurred-preview case: account can't view the post → gate it.
|
|
assert PatreonClient.post_is_gated(
|
|
{"attributes": {"current_user_can_view": False}}
|
|
) is True
|
|
|
|
|
|
def test_post_is_gated_viewable_and_missing_are_not_gated():
|
|
# Explicit True, a missing flag, an explicit None, and a missing attributes
|
|
# block ALL count as viewable — gate only on an explicit False so an absent
|
|
# field never over-filters accessible posts.
|
|
assert PatreonClient.post_is_gated(
|
|
{"attributes": {"current_user_can_view": True}}
|
|
) is False
|
|
assert PatreonClient.post_is_gated({"attributes": {}}) is False
|
|
assert PatreonClient.post_is_gated(
|
|
{"attributes": {"current_user_can_view": None}}
|
|
) is False
|
|
assert PatreonClient.post_is_gated({}) is False
|