feat: a source stops pulling once its membership ends, and resumes on resubscribe (3995)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 34s
Build images / build-web (push) Successful in 1m24s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m52s
Build images / promote (push) Skipped
CI / integration (push) Successful in 3m1s
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 34s
Build images / build-web (push) Successful in 1m24s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m52s
Build images / promote (push) Skipped
CI / integration (push) Successful in 3m1s
Operator, 2026-09-13: "if I kill a subscription on patreon I would like the pulling to stop on curator as well", with auto-resume chosen. This reverses the 2026-09-11 "report only" decision for lapsed sources. membership_reconcile.apply_membership_lapses runs in sync_memberships right after each platform's successful sync, so it only ever acts on the roster just written. It stops a source (enabled=false, with the same failure-state reset as a manual disable, #1285) only when all of these hold: - the roster is fresh - the source's matched membership says has_paid_access is False (lapsed, or a free follow) - the paid-through date has passed, where the platform gives one (Patreon's member.access_expires_at; SubscribeStar gives none, so it stops at once) - the source is enabled - the operator hasn't chosen to keep it It never acts on absence. A source with no matched membership keeps pulling, because a rename or a never-walked source produces the same absence. An unrecognised status is never a lapse either. It resumes only sources carrying its own `_membership_stopped` marker, once the membership is paid again. The operator outranks the sweep both ways (SourceService.update): - turning a stopped source back on marks it `_membership_kept`, so the next sweep leaves it alone until it's paid again - turning a source off by hand drops the marker, so the sweep never switches it back on Both are `_`-prefixed app-managed config keys, which operator edits already preserve. No migration. The roster/fetch line holds. This is a source-level action by the sweep. No download path reads the roster, and the scheduler still selects on `enabled` alone. test_no_fetch_path_can_read_the_roster is unchanged. UI: SourceRow shows a neutral "Membership ended" chip, with the status and the resume/keep explanation, ahead of the other chips. The sweep's task summary reports stopped/resumed counts. Tests (tests/test_membership_lapses.py): - a lapse stops the source with a clean slate and keeps the id cache - paid-through is honoured - absence, an unknown status and a stale roster never stop anything - a resume touches only what the sweep stopped - a manual on sticks, a manual off drops the marker, and a kept source is released once paid Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
"""#3995: stop pulling a source once the account stops paying, resume on resubscribe.
|
||||
|
||||
Operator, 2026-09-13: "if I kill a subscription on patreon I would like the
|
||||
pulling to stop on curator as well", with automatic resume when they resubscribe.
|
||||
|
||||
Stopping a source is easy. Not stopping the wrong one is the work, so most of
|
||||
these pin refusals: an absent membership is never a lapse, an unknown status is
|
||||
never a lapse, a stale roster decides nothing, a paid-through period is honoured,
|
||||
and the operator's own on/off choice always outranks the sweep in both directions.
|
||||
"""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models import Artist, Source
|
||||
from backend.app.services.membership_reconcile import (
|
||||
KEPT_KEY,
|
||||
STOPPED_KEY,
|
||||
apply_membership_lapses,
|
||||
)
|
||||
from backend.app.services.membership_roster import ROSTER_STALE_AFTER
|
||||
from backend.app.services.source_service import SourceService
|
||||
from tests.roster_builders import membership as _membership
|
||||
from tests.roster_builders import synced as _synced
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def _source(
|
||||
db, *, overrides=None, enabled=True, url="https://www.patreon.com/an-old-handle",
|
||||
):
|
||||
artist = Artist(name="Maewix", slug="maewix")
|
||||
db.add(artist)
|
||||
await db.flush()
|
||||
s = Source(
|
||||
artist_id=artist.id, platform="patreon", url=url, enabled=enabled,
|
||||
config_overrides={"patreon_campaign_id": "c1", **(overrides or {})},
|
||||
last_error="boom", error_type="tier_limited", consecutive_failures=3,
|
||||
)
|
||||
db.add(s)
|
||||
await db.flush()
|
||||
return s.id
|
||||
|
||||
|
||||
async def _state(db, source_id):
|
||||
row = (await db.execute(
|
||||
select(Source.enabled, Source.config_overrides, Source.error_type,
|
||||
Source.consecutive_failures).where(Source.id == source_id)
|
||||
)).one()
|
||||
return row
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_lapsed_membership_stops_its_source_with_a_clean_slate(db):
|
||||
sid = await _source(db)
|
||||
await _membership(db, campaign="c1", status="former_patron")
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
out = await apply_membership_lapses(db, platform="patreon")
|
||||
assert out["stopped"] == 1
|
||||
|
||||
enabled, co, error_type, failures = await _state(db, sid)
|
||||
assert enabled is False
|
||||
assert co[STOPPED_KEY]["status"] == "former_patron"
|
||||
assert co["patreon_campaign_id"] == "c1" # the identity cache survives
|
||||
assert (error_type, failures) == (None, 0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_paid_through_period_is_honoured(db):
|
||||
"""Patreon keeps access until the billing period ends, and says when."""
|
||||
sid = await _source(db)
|
||||
later = (datetime.now(UTC) + timedelta(days=10)).isoformat()
|
||||
await _membership(
|
||||
db, campaign="c1", status="former_patron",
|
||||
details={"member": {"access_expires_at": later}},
|
||||
)
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
assert (await apply_membership_lapses(db, platform="patreon"))["stopped"] == 0
|
||||
assert (await _state(db, sid)).enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_absent_membership_is_never_a_lapse(db):
|
||||
"""No match has innocent causes (a rename, a never-walked source), so
|
||||
absence alone must never switch a source off."""
|
||||
sid = await _source(db, overrides={"patreon_campaign_id": "nobody-has-this"})
|
||||
await _membership(db, campaign="c1", status="former_patron", details={})
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
assert (await apply_membership_lapses(db, platform="patreon"))["stopped"] == 0
|
||||
assert (await _state(db, sid)).enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unrecognised_status_is_never_a_lapse(db):
|
||||
sid = await _source(db)
|
||||
await _membership(db, campaign="c1", status="some_word_nobody_characterised")
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
assert (await apply_membership_lapses(db, platform="patreon"))["stopped"] == 0
|
||||
assert (await _state(db, sid)).enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_stale_roster_decides_nothing(db):
|
||||
sid = await _source(db)
|
||||
await _membership(db, campaign="c1", status="former_patron")
|
||||
await _synced(db, ago=ROSTER_STALE_AFTER + timedelta(hours=1))
|
||||
await db.commit()
|
||||
|
||||
out = await apply_membership_lapses(db, platform="patreon")
|
||||
assert out["skipped"] == "roster not fresh"
|
||||
assert (await _state(db, sid)).enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resubscribing_resumes_only_what_the_sweep_stopped(db):
|
||||
stopped = await _source(
|
||||
db, enabled=False, overrides={STOPPED_KEY: {"status": "former_patron"}},
|
||||
)
|
||||
await _membership(db, campaign="c1", status="active_patron")
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
assert (await apply_membership_lapses(db, platform="patreon"))["resumed"] == 1
|
||||
enabled, co, _e, _f = await _state(db, stopped)
|
||||
assert enabled is True
|
||||
assert STOPPED_KEY not in co
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_source_the_operator_switched_off_is_never_switched_back_on(db):
|
||||
sid = await _source(db, enabled=False)
|
||||
await _membership(db, campaign="c1", status="active_patron")
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
assert (await apply_membership_lapses(db, platform="patreon"))["resumed"] == 0
|
||||
assert (await _state(db, sid)).enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turning_a_stopped_source_back_on_keeps_it_on(db):
|
||||
"""Re-enabling a stopped source by hand is a choice to keep pulling a lapsed
|
||||
creator. The next sweep must not undo it."""
|
||||
sid = await _source(
|
||||
db, enabled=False, overrides={STOPPED_KEY: {"status": "former_patron"}},
|
||||
)
|
||||
await _membership(db, campaign="c1", status="former_patron")
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
await SourceService(db).update(sid, enabled=True)
|
||||
enabled, co, _e, _f = await _state(db, sid)
|
||||
assert enabled is True
|
||||
assert co.get(KEPT_KEY) is True and STOPPED_KEY not in co
|
||||
|
||||
assert (await apply_membership_lapses(db, platform="patreon"))["stopped"] == 0
|
||||
assert (await _state(db, sid)).enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switching_a_stopped_source_off_by_hand_drops_the_resume_marker(db):
|
||||
sid = await _source(
|
||||
db, enabled=False, overrides={STOPPED_KEY: {"status": "former_patron"}},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await SourceService(db).update(sid, enabled=False)
|
||||
_enabled, co, _e, _f = await _state(db, sid)
|
||||
assert STOPPED_KEY not in co
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_kept_source_is_released_once_paid_again(db):
|
||||
sid = await _source(db, overrides={KEPT_KEY: True})
|
||||
await _membership(db, campaign="c1", status="active_patron")
|
||||
await _synced(db)
|
||||
await db.commit()
|
||||
|
||||
await apply_membership_lapses(db, platform="patreon")
|
||||
_enabled, co, _e, _f = await _state(db, sid)
|
||||
assert KEPT_KEY not in co
|
||||
Reference in New Issue
Block a user