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:
@@ -8,9 +8,10 @@ trustworthy enough to act on.
|
||||
1. `subscribed_not_tracked` — you pay for this and FC does not follow it. The
|
||||
adoption win, and the only bucket carrying an action.
|
||||
2. `tracked_not_subscribed` — FC follows this and the roster does not show you
|
||||
paying for it. REPORT ONLY, by the operator's decision (2026-09-11): it says
|
||||
what it sees and links to the existing Subscriptions row, and offers no
|
||||
one-click disable.
|
||||
paying for it. No longer shown on the card: the operator reversed the
|
||||
2026-09-11 "report only" call on 2026-09-13. The lapsed half of it now ACTS,
|
||||
in `apply_membership_lapses` below (#3995). The absent half still only
|
||||
reports, because absence proves nothing.
|
||||
3. `matched` — the healthy set. Counted, not listed loudly.
|
||||
4. `unidentified` — sources this join cannot speak to at all. Reported as
|
||||
exactly that, because the alternative is filing them under a verdict.
|
||||
@@ -39,7 +40,7 @@ rendered as lapsed. That is the whole reason it returns a tri-state.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -233,3 +234,126 @@ async def reconcile_all(session: AsyncSession, now: datetime | None = None) -> d
|
||||
await reconcile(session, platform=p, now=now) for p in sorted(platforms)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stop pulling what the account no longer pays for (#3995)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Operator decision, 2026-09-13, reversing the 2026-09-11 "report only" call
|
||||
# for this direction: "if I kill a subscription on patreon I would like the
|
||||
# pulling to stop on curator as well", with automatic resume on resubscribing.
|
||||
#
|
||||
# This is a SOURCE-level action taken by the daily sweep, visible on the source
|
||||
# row and reversible there. It is not a fetch-path decision. The line C5 draws,
|
||||
# that the roster never decides a POST is inaccessible, still holds: nothing
|
||||
# here reads per-post access, and no download path reads the roster
|
||||
# (`test_no_fetch_path_can_read_the_roster`). The scheduler keeps selecting on
|
||||
# `enabled` alone.
|
||||
#
|
||||
# Acts ONLY on positive evidence. A source whose matched membership says access
|
||||
# has ended is stopped. A source with NO matched membership is left alone,
|
||||
# because absence has innocent causes: a creator rename, a source never walked
|
||||
# so no id is cached, a membership the platform stopped listing. Stopping on
|
||||
# absence would switch off things the operator still pays for.
|
||||
#
|
||||
# Two app-managed config_overrides keys carry the state. The `_` prefix is
|
||||
# already the "FC writes this, an operator edit preserves it" family.
|
||||
# _membership_stopped set when the sweep stops a source; the sweep resumes
|
||||
# ONLY sources carrying it, so a source the operator
|
||||
# switched off by hand is never switched back on
|
||||
# _membership_kept set by SourceService.update when the operator turns a
|
||||
# stopped source back ON: a deliberate choice to keep
|
||||
# pulling a lapsed creator, which the next sweep must
|
||||
# not undo. Cleared when the membership is paid again.
|
||||
STOPPED_KEY = "_membership_stopped"
|
||||
KEPT_KEY = "_membership_kept"
|
||||
|
||||
|
||||
def _access_expires_at(m: PlatformMembership) -> datetime | None:
|
||||
"""When paid access actually ends, if the platform says.
|
||||
|
||||
Patreon keeps a cancelled membership's access until the end of the billing
|
||||
period and reports that date (`member.access_expires_at`, note #3992).
|
||||
SubscribeStar's page gives no such date, so a cancelled SubscribeStar
|
||||
membership stops at once. Returns None when there is no usable date.
|
||||
"""
|
||||
details = m.details or {}
|
||||
raw = details.get("access_expires_at") or (details.get("member") or {}).get("access_expires_at")
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
async def apply_membership_lapses(
|
||||
session: AsyncSession, *, platform: str, now: datetime | None = None,
|
||||
) -> dict:
|
||||
"""Stop sources whose paid access has ended; resume the ones this stopped.
|
||||
|
||||
Refuses to act on a roster that isn't fresh, for the same reason C4 refuses
|
||||
to draw conclusions from one.
|
||||
"""
|
||||
now = now or datetime.now(UTC)
|
||||
state = await get_sync_state(session, platform)
|
||||
if not roster_is_fresh(state, now=now):
|
||||
return {"platform": platform, "skipped": "roster not fresh", "stopped": 0, "resumed": 0}
|
||||
|
||||
memberships = (await session.execute(
|
||||
select(PlatformMembership).where(PlatformMembership.platform == platform)
|
||||
)).scalars().all()
|
||||
sources = (await session.execute(
|
||||
select(Source).where(Source.platform == platform)
|
||||
)).scalars().all()
|
||||
pairs = pair_sources_with_memberships(list(sources), list(memberships))
|
||||
|
||||
stopped: list[int] = []
|
||||
resumed: list[int] = []
|
||||
for source in sources:
|
||||
pair = pairs.get(source.id)
|
||||
if pair is None:
|
||||
continue # absence is never acted on, see above
|
||||
m, _kind = pair
|
||||
paid = has_paid_access(
|
||||
m.platform, m.status,
|
||||
is_free_member=bool((m.details or {}).get("is_free_member")),
|
||||
)
|
||||
co = dict(source.config_overrides or {})
|
||||
|
||||
if paid is True:
|
||||
changed = co.pop(KEPT_KEY, None) is not None
|
||||
if STOPPED_KEY in co:
|
||||
co.pop(STOPPED_KEY)
|
||||
source.enabled = True
|
||||
resumed.append(source.id)
|
||||
changed = True
|
||||
if changed:
|
||||
source.config_overrides = co
|
||||
continue
|
||||
|
||||
# Unknown status: never a reason to stop something (has_paid_access's
|
||||
# tri-state exists for exactly this).
|
||||
if paid is None:
|
||||
continue
|
||||
if not source.enabled or co.get(KEPT_KEY):
|
||||
continue
|
||||
expires = _access_expires_at(m)
|
||||
if expires is not None and expires > now:
|
||||
continue # still inside the paid-through period
|
||||
|
||||
co[STOPPED_KEY] = {"at": now.isoformat(), "status": m.status}
|
||||
source.config_overrides = co
|
||||
source.enabled = False
|
||||
# The same clean slate a manual disable gives (SourceService.update,
|
||||
# #1285), so a stopped source doesn't linger as failing or gated.
|
||||
source.last_error = None
|
||||
source.error_type = None
|
||||
source.consecutive_failures = 0
|
||||
stopped.append(source.id)
|
||||
|
||||
await session.commit()
|
||||
return {"platform": platform, "stopped": len(stopped), "resumed": len(resumed)}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from ..models import (
|
||||
)
|
||||
from .db_helpers import failing_sources_clause
|
||||
from .gallery_dl import ErrorType
|
||||
from .membership_reconcile import KEPT_KEY, STOPPED_KEY
|
||||
from .membership_roster import gated_reasons_for_sources
|
||||
from .platforms import known_platform_keys
|
||||
from .scheduler_service import compute_next_check_at
|
||||
@@ -171,6 +172,25 @@ def arm_backfill(source: Source) -> None:
|
||||
source.backfill_runs_remaining = BACKFILL_MAX_CHUNKS
|
||||
|
||||
|
||||
def _record_manual_enable_choice(source: Source, *, enabled: bool) -> None:
|
||||
"""Keep the membership sweep (#3995) from overriding the operator.
|
||||
|
||||
Turning a source the sweep STOPPED back on is a deliberate choice to keep
|
||||
pulling a lapsed creator, so it is marked kept and the next sweep leaves it
|
||||
alone. Turning a source off by hand drops any sweep marker, so the sweep
|
||||
never switches back on something the operator switched off themselves.
|
||||
"""
|
||||
co = dict(source.config_overrides or {})
|
||||
if enabled and STOPPED_KEY in co:
|
||||
co.pop(STOPPED_KEY)
|
||||
co[KEPT_KEY] = True
|
||||
elif not enabled:
|
||||
co.pop(STOPPED_KEY, None)
|
||||
else:
|
||||
return
|
||||
source.config_overrides = co
|
||||
|
||||
|
||||
class SourceService:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
@@ -428,6 +448,9 @@ class SourceService:
|
||||
for key, value in fields.items():
|
||||
setattr(source, key, value)
|
||||
|
||||
if "enabled" in fields:
|
||||
_record_manual_enable_choice(source, enabled=bool(fields["enabled"]))
|
||||
|
||||
if url_changed:
|
||||
# Repointing a source at a different creator makes a cached campaign
|
||||
# id WRONG, not merely stale, and `patreon_resolver` consults that
|
||||
|
||||
@@ -1247,6 +1247,7 @@ def sync_memberships() -> str:
|
||||
from ..services.artist_membership_service import rescan as membership_rescan
|
||||
from ..services.credential_crypto import CredentialCrypto
|
||||
from ..services.credential_service import CredentialService
|
||||
from ..services.membership_reconcile import apply_membership_lapses
|
||||
from ..services.membership_roster import roster_user_id, sync_platform
|
||||
from ..services.patreon_client import PatreonClient
|
||||
from ..services.subscribestar_client import SubscribeStarClient
|
||||
@@ -1298,9 +1299,18 @@ def sync_memberships() -> str:
|
||||
)
|
||||
|
||||
async with async_factory() as session:
|
||||
results.append(
|
||||
await sync_platform(session, platform=platform, fetch=fetch)
|
||||
)
|
||||
result = await sync_platform(session, platform=platform, fetch=fetch)
|
||||
results.append(result)
|
||||
|
||||
# #3995: stop pulling sources whose paid access has ended, and
|
||||
# resume the ones this stopped once they are paid again. Only
|
||||
# right after a successful sync, so it always acts on the roster
|
||||
# just written, never on a stale one.
|
||||
if result.get("ok"):
|
||||
async with async_factory() as session:
|
||||
result["lapses"] = await apply_membership_lapses(
|
||||
session, platform=platform,
|
||||
)
|
||||
|
||||
# #388 E4: offer the freshly-synced roster to the artists FC already
|
||||
# tracks. Chained here rather than given its own beat entry because
|
||||
@@ -1321,7 +1331,12 @@ def sync_memberships() -> str:
|
||||
if "skipped" in r:
|
||||
parts.append(f"{r['platform']}=skipped({r['skipped']})")
|
||||
elif r.get("ok"):
|
||||
parts.append(f"{r['platform']}={r['count']}")
|
||||
lapses = r.get("lapses") or {}
|
||||
detail = (
|
||||
f"(stopped={lapses['stopped']},resumed={lapses['resumed']})"
|
||||
if lapses.get("stopped") or lapses.get("resumed") else ""
|
||||
)
|
||||
parts.append(f"{r['platform']}={r['count']}{detail}")
|
||||
else:
|
||||
parts.append(f"{r['platform']}=FAILED({r['error']})")
|
||||
if res.get("suggested") is not None:
|
||||
|
||||
@@ -39,8 +39,25 @@
|
||||
{{ formatRelative(source.next_check_at, { future: true }) }}
|
||||
</td>
|
||||
<td>
|
||||
<!-- #3995: the membership sweep stopped this source because paid access
|
||||
ended. First in the chain — it explains why an otherwise healthy
|
||||
source is off. Neutral, never error: nothing is broken. -->
|
||||
<v-chip
|
||||
v-if="(source.consecutive_failures || 0) > 0"
|
||||
v-if="membershipStopped"
|
||||
size="x-small" variant="tonal" label
|
||||
prepend-icon="mdi-account-cancel-outline"
|
||||
>Membership ended
|
||||
<v-tooltip activator="parent" location="top" max-width="420">
|
||||
<span>
|
||||
Curator stopped checking this source because your membership
|
||||
{{ membershipStopped.status ? `reads "${membershipStopped.status}"` : 'ended' }}.
|
||||
It starts again on its own if you resubscribe. Turn it back on to
|
||||
keep checking anyway.
|
||||
</span>
|
||||
</v-tooltip>
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-else-if="(source.consecutive_failures || 0) > 0"
|
||||
size="x-small" color="error" variant="tonal" label
|
||||
>{{ source.consecutive_failures }}
|
||||
<!-- #1: show the actual failure reason on hover instead of a bare count. -->
|
||||
@@ -109,6 +126,12 @@ const props = defineProps({
|
||||
// operator's subscription, not anything FC can retry. The count is only joined
|
||||
// in by the list endpoint, so phrase it without one when it's absent rather
|
||||
// than rendering a fabricated zero.
|
||||
// #3995: set by the membership sweep when it stops a source whose paid access
|
||||
// ended; cleared when it resumes, or when the operator toggles the source.
|
||||
const membershipStopped = computed(
|
||||
() => props.source.config_overrides?._membership_stopped || null,
|
||||
)
|
||||
|
||||
const noAccessTip = computed(() => {
|
||||
const n = props.source.tier_gated_count
|
||||
const what = n
|
||||
|
||||
@@ -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