"""#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