fix(ingest): skip tier-gated Patreon posts entirely (#874)
CI / lint (push) Failing after 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Failing after 3m20s

Patreon serves only blurred locked-preview thumbnails for posts the
authenticated account can't fully view; the native ingester was
downloading those as real media. current_user_can_view was already in
_FIELDS_POST but never read.

Add PatreonClient.post_is_gated (gate ONLY on explicit
current_user_can_view=False; missing/None → viewable, never over-filter)
and skip gated posts at the top of the ingest_core run() and preview()
loops — no media download AND no post-record stub (operator: 'no stub
for gated content'). Skipped before the post-record block so gated posts
never inflate the #862 body canary; surfaced as 'N gated-skipped' in the
run summary. Same gate in preview() for preview/apply parity (rule 93).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 14:43:00 -04:00
parent 60a9c9e6ef
commit b3afc2437e
4 changed files with 125 additions and 1 deletions
+24
View File
@@ -435,3 +435,27 @@ def test_post_record_key_returns_synthetic_key_and_id():
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
+55 -1
View File
@@ -50,12 +50,15 @@ class _FakeClient:
"""Stub PatreonClient. `pages` is a list of (page_cursor, [posts]); each post
is (post_id, [MediaItem]). `raise_on_first` lets a test trip drift."""
def __init__(self, pages, raise_exc=None, empty_body=False):
def __init__(self, pages, raise_exc=None, empty_body=False, gated=None):
self._pages = pages
self._raise_exc = raise_exc
# empty_body simulates a body-field schema break: every post comes back
# with no content (the #862 canary's trip condition).
self._empty_body = empty_body
# #874: post_ids the account can't view → current_user_can_view=False,
# so the walk must skip them entirely (no media, no post-record).
self._gated = set(gated or ())
self.consumed_posts = 0
def iter_posts(self, campaign_id, cursor=None):
@@ -75,6 +78,7 @@ class _FakeClient:
"title": f"Post {post_id}",
"post_type": "image_file",
"content": content,
"current_user_can_view": post_id not in self._gated,
},
},
{},
@@ -87,6 +91,10 @@ class _FakeClient:
def post_meta(self, post):
return {"title": post.get("id"), "date": None}
@staticmethod
def post_is_gated(post):
return (post.get("attributes") or {}).get("current_user_can_view") is False
@staticmethod
def post_record_key(post):
pid = str(post.get("id") or "")
@@ -657,6 +665,52 @@ async def test_backfill_skips_already_captured_post_but_recapture_forces_it(
)
assert len(res_recap.post_record_paths) == 1 # gate bypassed → recaptured
assert downloader2.post_records == 1
@pytest.mark.asyncio
async def test_gated_post_skipped_entirely_no_media_no_record(
source_id, sync_engine, tmp_path,
):
"""#874: a tier-gated post (current_user_can_view=False) is skipped ENTIRELY
— no media download AND no post-record stub — while an accessible post in the
same walk is fully ingested. Patreon serves only blurred locked-preview media
for gated posts; downloading it produced unusable images."""
gm = _media("gated", 1)
am = _media("open", 1)
client = _FakeClient([(None, [("gated", [gm]), ("open", [am])])], gated={"gated"})
downloader = _FakeDownloader(tmp_path)
ing = _ingester(sync_engine, tmp_path, client, downloader)
result = ing.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="backfill",
)
# Only the accessible post's media downloaded; the gated post contributed
# nothing — no file, no post-record.
assert result.files_downloaded == 1
assert [p for p in result.written_paths if "gated" in p] == []
assert downloader.download_calls == 1
assert len(result.post_record_paths) == 1 # only the open post
assert downloader.post_records == 1
# The gated post left NO trace in the seen-ledger (no media key, no post key):
# only the open post's media key + synthetic post key are recorded.
assert _count_ledger(sync_engine, source_id) == 2
@pytest.mark.asyncio
async def test_preview_excludes_gated_posts(source_id, sync_engine, tmp_path):
"""#874 preview/apply parity (rule 93): the dry-run must not count a gated
post's blurred preview media as new, or it overstates a gated source's
backlog."""
gm = _media("gated", 1)
am = _media("open", 1)
client = _FakeClient([(None, [("gated", [gm]), ("open", [am])])], gated={"gated"})
ing = _ingester(sync_engine, tmp_path, client, _FakeDownloader(tmp_path))
preview = ing.preview(source_id, "c1")
assert preview["total_new"] == 1 # only the open post
assert [s for s in preview["sample"] if s["title"] == "gated"] == []
assert res_recap.files_downloaded == 0 # no media re-download
assert downloader2.download_calls == 0
# The on-disk media is surfaced as a (path, CDN url) relink pair.