Files
FabledCurator/tests/test_source_service.py
T
bvandeusenandClaude Opus 5 e3fd8c67d4 feat: switch pixiv off — unregistered, unreachable, and refused at dispatch (406 phase 1)
Milestone 406 retires pixiv (rule 171) in two phases at the operator's explicit ask: switch it off, then later delete its code. This is the switch-off. Steps 2 and 3 ship together because each is a half-state of the other: unregistered but still in the extension, pixiv creator pages would offer a button the backend then refuses.

Reachability removed, never gated (rule 22 - no flag, no `if platform == "pixiv"`):
- platforms registry: pixiv unregistered, so /api/platforms, the source validator and quick-add all refuse it through their existing unknown-platform paths.
- NATIVE_INGESTER_PLATFORMS: pixiv removed.
- extension_service: pixiv's quick-add URL pattern removed (the Python half of the JS mirror).
- extension: pixiv's host permissions, content-script match, platform entry and artist pattern removed; popup's pixiv branches removed; and the whole pixiv PKCE OAuth flow cut out of background.js. That last one could not wait for phase 2 - a webRequest listener on a host the manifest no longer grants is at best dead and at worst a startup failure for the entire background script. On startup the extension now also removes any pixiv refresh token a browser still holds in storage, for the same reason as the server-side credential cleanup (3980).
- frontend: the extension card stops listing pixiv; SourceActions' copy of the native list drops it. platformColor keeps rendering a pixiv key so existing pixiv posts do not look broken.

The guard, and why a registry change alone was not enough. A source outlives its platform: the live instance still had one ENABLED pixiv source (step 1). Tracing it: the scheduler only selects enabled rows and every platform lookup uses .get(), so a disabled row is inert - but re-enabling it and pressing Check would have routed pixiv, no longer native, straight into the gallery-dl branch, which still has a pixiv extractor. And a worker can pick up a still-enabled row before a deploy's migration runs. So run_download and verify_source_credential - the two functions every download and credential probe pass through - now refuse any platform not in the registry: an unsupported_url failure for downloads, and an inconclusive (None, not False) verify, since nothing was probed so nothing was rejected. Generic by registration, so it covers deviantart's leftovers too. Positive-controlled: a supported gallery-dl platform must still reach gallery-dl, or a guard that refused everything would pass (rule 167).

Migration 0097 disables sources on retired platforms (pixiv, deviantart) and clears their failure state exactly as disabling through the app does (1285), so the stale row stops being scheduled and stops showing as failing. Nothing is deleted: removing a source can collide with uq_post_artist_external_id_null_source on real data, which is phase 2's step 6 to check. No post or image is touched.

Tests: the known-platform lists drop pixiv and gain retirement assertions beside deviantart's; pixiv's positive extension cases become negative guards; the pixiv sidecar post-URL test is deleted with the behaviour it tested; quick-add rejects a pixiv URL. The pixiv client/downloader/ingester suites stay - that code stays until phase 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
2026-09-13 11:45:49 -04:00

682 lines
24 KiB
Python

import pytest
from sqlalchemy import select
from backend.app.models import Artist, Source
from backend.app.services.source_service import (
KNOWN_PLATFORMS,
ArtistNotFoundError,
DuplicateSourceError,
EmptyUrlError,
InvalidConfigError,
SourceService,
UnknownPlatformError,
)
pytestmark = pytest.mark.integration
async def _artist(db, name="Alice"):
a = Artist(name=name, slug=name.lower())
db.add(a)
await db.flush()
return a
@pytest.mark.asyncio
async def test_known_platforms_are_the_supported_four(db):
assert KNOWN_PLATFORMS == frozenset({
"patreon", "subscribestar", "hentaifoundry", "discord",
})
assert "fanbox" not in KNOWN_PLATFORMS
# Retired at #3069 — a source can no longer be created on it.
assert "deviantart" not in KNOWN_PLATFORMS
# Retired at milestone #406 — likewise.
assert "pixiv" not in KNOWN_PLATFORMS
@pytest.mark.asyncio
async def test_create_flips_is_subscription_on_first_source(db):
artist = await _artist(db)
svc = SourceService(db)
rec = await svc.create(
artist_id=artist.id, platform="patreon", url="https://patreon.com/alice",
)
assert rec.id is not None
is_sub = (await db.execute(
select(Artist.is_subscription).where(Artist.id == artist.id)
)).scalar_one()
assert is_sub is True
@pytest.mark.asyncio
async def test_delete_last_source_flips_is_subscription_off(db):
artist = await _artist(db)
svc = SourceService(db)
rec = await svc.create(
artist_id=artist.id, platform="patreon", url="https://patreon.com/alice",
)
await svc.delete(rec.id)
is_sub = (await db.execute(
select(Artist.is_subscription).where(Artist.id == artist.id)
)).scalar_one()
assert is_sub is False
@pytest.mark.asyncio
async def test_create_rejects_unknown_platform(db):
artist = await _artist(db)
svc = SourceService(db)
with pytest.raises(UnknownPlatformError):
await svc.create(
artist_id=artist.id, platform="myspace", url="https://m/x",
)
@pytest.mark.asyncio
async def test_create_rejects_non_dict_config(db):
artist = await _artist(db)
svc = SourceService(db)
with pytest.raises(InvalidConfigError):
await svc.create(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice", config_overrides=[1, 2, 3],
)
@pytest.mark.asyncio
async def test_create_rejects_empty_url(db):
artist = await _artist(db)
svc = SourceService(db)
with pytest.raises(EmptyUrlError):
await svc.create(artist_id=artist.id, platform="patreon", url=" ")
@pytest.mark.asyncio
async def test_create_rejects_unknown_artist(db):
svc = SourceService(db)
with pytest.raises(ArtistNotFoundError):
await svc.create(artist_id=99999, platform="patreon", url="https://x/y")
@pytest.mark.asyncio
async def test_create_duplicate_raises_with_existing_id(db):
artist = await _artist(db)
svc = SourceService(db)
first = await svc.create(
artist_id=artist.id, platform="patreon", url="https://patreon.com/alice",
)
with pytest.raises(DuplicateSourceError) as exc:
await svc.create(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice",
)
assert exc.value.existing_id == first.id
@pytest.mark.asyncio
async def test_list_filters_by_artist(db):
a = await _artist(db, "Alice")
b = await _artist(db, "Bob")
svc = SourceService(db)
await svc.create(artist_id=a.id, platform="patreon", url="https://patreon.com/a")
await svc.create(artist_id=b.id, platform="patreon", url="https://patreon.com/b")
only_a = await svc.list(artist_id=a.id)
assert [s.artist_id for s in only_a] == [a.id]
all_rows = await svc.list()
assert len(all_rows) == 2
@pytest.mark.asyncio
async def test_list_joins_tier_gated_count_from_the_latest_event(db):
"""A no-access source carries the count from its most recent walk.
The number lives on the DownloadEvent's run_stats, not on the source, so
`list()` joins it in. Two events are seeded deliberately: the newest must
win, or the row would show a stale figure from a walk where the operator
still held the tier.
"""
from datetime import UTC, datetime, timedelta
from backend.app.models import DownloadEvent
artist = await _artist(db)
svc = SourceService(db)
rec = await svc.create(
artist_id=artist.id, platform="patreon", url="https://patreon.com/gated",
)
src = (await db.execute(
select(Source).where(Source.id == rec.id)
)).scalar_one()
src.error_type = "tier_limited"
src.last_checked_at = datetime.now(UTC)
now = datetime.now(UTC)
db.add(DownloadEvent(
source_id=rec.id, status="ok", started_at=now - timedelta(hours=2),
metadata_={"run_stats": {"tier_gated_count": 3}},
))
db.add(DownloadEvent(
source_id=rec.id, status="ok", started_at=now,
metadata_={"run_stats": {"tier_gated_count": 47}},
))
await db.commit()
rows = await svc.list(artist_id=artist.id)
assert rows[0].tier_gated_count == 47
assert rows[0].to_dict()["tier_gated_count"] == 47
@pytest.mark.asyncio
async def test_list_leaves_tier_gated_count_none_for_ordinary_sources(db):
"""The join is scoped to tier-gated sources, so a healthy row reports None
rather than 0 — the UI distinguishes 'no count available' from 'zero
gated', and must not print a fabricated number."""
artist = await _artist(db)
svc = SourceService(db)
await svc.create(
artist_id=artist.id, platform="patreon", url="https://patreon.com/fine",
)
rows = await svc.list(artist_id=artist.id)
assert rows[0].tier_gated_count is None
@pytest.mark.asyncio
async def test_update_changes_fields(db):
artist = await _artist(db)
svc = SourceService(db)
rec = await svc.create(
artist_id=artist.id, platform="patreon", url="https://patreon.com/a",
)
updated = await svc.update(rec.id, enabled=False, config_overrides={"videos": False})
assert updated.enabled is False
# The operator's key lands, and FC's own state is not collateral. This
# assertion used to read `== {"videos": False}` — it was DOCUMENTING the bug
# the tests below now pin: `create` arms `_backfill_state` (#693), and every
# edit was silently discarding it.
assert updated.config_overrides["videos"] is False
assert updated.config_overrides["_backfill_state"] == "running"
# --- config_overrides: the operator's keys and FC's own share one column ---
#
# `SourceFormDialog` posts the WHOLE object back, and its structured tab rebuilds
# that object from two fields — so saving the dialog without touching anything
# used to discard the resolved campaign id and the backfill position. The id
# matters twice over: `patreon_resolver` reads it BEFORE attempting any lookup,
# and the membership join (#387 C4, snippet #3947) keys on it.
async def _source_with_state(db, svc, artist, **overrides):
"""A source carrying both kinds of key: FC's own state and the operator's."""
rec = await svc.create(
artist_id=artist.id, platform="patreon", url="https://patreon.com/mixed",
)
await db.execute(
Source.__table__.update().where(Source.id == rec.id).values(
config_overrides={
"_backfill_state": "running",
"_backfill_cursor": "03:RESUME",
"patreon_campaign_id": "camp-1",
"videos": True,
**overrides,
},
)
)
await db.commit()
return rec
@pytest.mark.asyncio
async def test_an_operator_edit_keeps_fc_managed_state(db):
"""THE bug. One dialog save used to cost the campaign id and the walk."""
artist = await _artist(db)
svc = SourceService(db)
rec = await _source_with_state(db, svc, artist)
updated = await svc.update(rec.id, config_overrides={"videos": False})
assert updated.config_overrides["patreon_campaign_id"] == "camp-1"
assert updated.config_overrides["_backfill_state"] == "running"
assert updated.config_overrides["_backfill_cursor"] == "03:RESUME"
assert updated.config_overrides["videos"] is False
@pytest.mark.asyncio
async def test_an_operator_key_left_out_of_the_edit_is_still_removed(db):
"""The other half, and why this is a merge rather than a blanket keep: the
operator's own settings must still replace wholesale, or removing a key in
the dialog could never remove it."""
artist = await _artist(db)
svc = SourceService(db)
rec = await _source_with_state(db, svc, artist, since="2026-01-01")
updated = await svc.update(rec.id, config_overrides={"videos": False})
assert "since" not in updated.config_overrides
@pytest.mark.asyncio
async def test_a_null_config_clears_operator_settings_but_not_fc_state(db):
"""`_parseConfig` sends null for an empty object, so this is the dialog's
real "I cleared the box" path — not a request to forget the walk."""
artist = await _artist(db)
svc = SourceService(db)
rec = await _source_with_state(db, svc, artist)
updated = await svc.update(rec.id, config_overrides=None)
assert "videos" not in updated.config_overrides
assert updated.config_overrides["_backfill_state"] == "running"
assert updated.config_overrides["patreon_campaign_id"] == "camp-1"
@pytest.mark.asyncio
async def test_a_stale_echoed_value_cannot_overwrite_what_the_walk_wrote(db):
"""The dialog round-trips whatever it last READ. If the backfill advanced in
the meantime, saving that stale copy must not roll the walk backwards — so
FC's keys are applied last and win."""
artist = await _artist(db)
svc = SourceService(db)
rec = await _source_with_state(db, svc, artist)
updated = await svc.update(rec.id, config_overrides={
"videos": False,
"_backfill_cursor": "00:STALE",
"patreon_campaign_id": "wrong-campaign",
})
assert updated.config_overrides["_backfill_cursor"] == "03:RESUME"
assert updated.config_overrides["patreon_campaign_id"] == "camp-1"
@pytest.mark.asyncio
async def test_repointing_the_url_drops_the_resolved_campaign_id(db):
"""A different creator behind the same row makes the cached id WRONG, not
stale. Preserving it above is exactly what makes this invalidation this
service's job — the old wholesale overwrite used to wipe the id by accident.
"""
artist = await _artist(db)
svc = SourceService(db)
rec = await _source_with_state(db, svc, artist)
updated = await svc.update(rec.id, url="https://patreon.com/someone-else")
assert "patreon_campaign_id" not in updated.config_overrides
# The walk's position survives: it is expensive to rebuild, the walk has its
# own stall guard, and a cosmetic URL edit must not restart a long backfill.
assert updated.config_overrides["_backfill_cursor"] == "03:RESUME"
@pytest.mark.asyncio
async def test_rewriting_the_same_url_keeps_the_campaign_id(db):
"""The dialog posts the URL on every save. An unchanged URL is not a
repoint, and treating it as one would re-resolve after every edit."""
artist = await _artist(db)
svc = SourceService(db)
rec = await _source_with_state(db, svc, artist)
updated = await svc.update(rec.id, url="https://patreon.com/mixed")
assert updated.config_overrides["patreon_campaign_id"] == "camp-1"
def test_the_campaign_id_suffix_agrees_with_the_membership_join():
"""Two modules must agree on the key shape: this one preserves and
invalidates it, `membership_roster` matches on it (snippet #3947). They
deliberately do not import across — a core CRUD service should not depend on
a membership feature — so the agreement is pinned here rather than hoped for.
Do NOT fix a failure by editing one side; decide which shape is right.
"""
from backend.app.services.membership_roster import _CAMPAIGN_KEY_SUFFIX
from backend.app.services.source_service import _CAMPAIGN_ID_SUFFIX
assert _CAMPAIGN_ID_SUFFIX == _CAMPAIGN_KEY_SUFFIX
@pytest.mark.asyncio
async def test_disable_clears_failure_state(db):
"""Disabling a source wipes its failure state so it stops showing as
'failing' (operator: disable subs you're not paying for)."""
artist = await _artist(db)
svc = SourceService(db)
rec = await svc.create(
artist_id=artist.id, platform="patreon", url="https://patreon.com/a",
)
source = (await db.execute(
select(Source).where(Source.id == rec.id)
)).scalar_one()
source.last_error = "auth failed"
source.error_type = "auth_error"
source.consecutive_failures = 5
await db.commit()
updated = await svc.update(rec.id, enabled=False)
assert updated.enabled is False
assert updated.last_error is None
assert updated.consecutive_failures == 0
refetched = (await db.execute(
select(Source).where(Source.id == rec.id)
)).scalar_one()
assert refetched.error_type is None
@pytest.mark.asyncio
async def test_update_while_enabled_keeps_failure_state(db):
"""A non-disable edit must NOT wipe failure state — only the explicit
disable clears it (else a config tweak would hide a real failure)."""
artist = await _artist(db)
svc = SourceService(db)
rec = await svc.create(
artist_id=artist.id, platform="patreon", url="https://patreon.com/a",
)
source = (await db.execute(
select(Source).where(Source.id == rec.id)
)).scalar_one()
source.last_error = "auth failed"
source.consecutive_failures = 3
await db.commit()
await svc.update(rec.id, config_overrides={"videos": False})
refetched = (await db.execute(
select(Source).where(Source.id == rec.id)
)).scalar_one()
assert refetched.last_error == "auth failed"
assert refetched.consecutive_failures == 3
async def _source_with_content(db, svc, artist):
"""A source under `artist` with one post + one image it contributed."""
from backend.app.models import ImageProvenance, ImageRecord, Post
rec = await svc.create(
artist_id=artist.id, platform="pixiv",
url=f"https://www.pixiv.net/users/{artist.id}",
)
post = Post(source_id=rec.id, artist_id=artist.id, external_post_id="p1")
db.add(post)
img = ImageRecord(
path=f"/images/{artist.slug}/pixiv/pixiv/1_a_00.jpg",
sha256=str(artist.id).rjust(64, "0"), size_bytes=1, mime="image/jpeg",
width=1, height=1, origin="imported_filesystem",
integrity_status="unknown", artist_id=artist.id,
)
db.add(img)
await db.flush()
db.add(ImageProvenance(image_record_id=img.id, post_id=post.id, source_id=rec.id))
await db.commit()
return rec, post, img
@pytest.mark.asyncio
async def test_reassign_moves_source_posts_images(db):
from backend.app.models import ImageRecord, Post
old = await _artist(db, "OldOwner")
new = await _artist(db, "NewOwner")
svc = SourceService(db)
rec, post, img = await _source_with_content(db, svc, old)
await svc.reassign(rec.id, new.id)
assert (await db.execute(
select(Source.artist_id).where(Source.id == rec.id)
)).scalar_one() == new.id
assert (await db.execute(
select(Post.artist_id).where(Post.id == post.id)
)).scalar_one() == new.id
assert (await db.execute(
select(ImageRecord.artist_id).where(ImageRecord.id == img.id)
)).scalar_one() == new.id
# Old artist is now empty → deleted.
assert (await db.execute(
select(Artist).where(Artist.id == old.id)
)).scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_reassign_keeps_nonempty_old_artist(db):
from backend.app.models import ImageRecord
old = await _artist(db, "OldMulti")
new = await _artist(db, "NewMulti")
svc = SourceService(db)
rec, _post, _img = await _source_with_content(db, svc, old)
# A second, unrelated image keeps `old` non-empty after the move.
db.add(ImageRecord(
path="/images/oldmulti/loose.jpg", sha256="e" * 64, size_bytes=1,
mime="image/jpeg", width=1, height=1, origin="imported_filesystem",
integrity_status="unknown", artist_id=old.id,
))
await db.commit()
await svc.reassign(rec.id, new.id)
still = (await db.execute(
select(Artist).where(Artist.id == old.id)
)).scalar_one()
assert still.is_subscription is False # lost its last source
@pytest.mark.asyncio
async def test_reassign_same_artist_is_noop(db):
a = await _artist(db, "Solo")
svc = SourceService(db)
rec, _post, _img = await _source_with_content(db, svc, a)
out = await svc.reassign(rec.id, a.id)
assert out.artist_id == a.id
@pytest.mark.asyncio
async def test_reassign_unknown_target_raises(db):
from backend.app.services.source_service import ArtistNotFoundError
a = await _artist(db, "Whom")
svc = SourceService(db)
rec, _post, _img = await _source_with_content(db, svc, a)
with pytest.raises(ArtistNotFoundError):
await svc.reassign(rec.id, 999999)
@pytest.mark.asyncio
async def test_list_hides_sidecar_synthetic_anchors(db):
"""Filesystem-import synthetic Sources (url='sidecar:<platform>:<slug>',
enabled=False — historical pre-alembic-0030 artifact) used to leak into the
Subscriptions UI as phantom subscriptions because list() didn't filter
them. They aren't pollable feeds; hide by default."""
artist = await _artist(db, "Alice")
real = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice", enabled=True, config_overrides={},
)
synthetic = Source(
artist_id=artist.id, platform="patreon",
url="sidecar:patreon:alice", enabled=False, config_overrides={},
)
db.add_all([real, synthetic])
await db.commit()
svc = SourceService(db)
visible = await svc.list()
visible_urls = {s.url for s in visible}
assert "https://patreon.com/alice" in visible_urls
assert "sidecar:patreon:alice" not in visible_urls
# Same filter applies to the artist-scoped list path (the artist detail
# page hits /api/sources?artist_id=N).
artist_scoped = await svc.list(artist_id=artist.id)
assert {s.url for s in artist_scoped} == {"https://patreon.com/alice"}
# include_synthetic=True opts back in for admin tooling.
everything = await svc.list(include_synthetic=True)
assert {s.url for s in everything} >= {
"https://patreon.com/alice", "sidecar:patreon:alice",
}
# --- Plan #544: backfill counter -------------------------------------------
@pytest.mark.asyncio
async def test_start_backfill_arms_run_until_done(db):
"""start_backfill sets state=running + the chunk cap and clears any prior
cursor/chunk state, returning the updated record for the API to echo."""
from backend.app.services.source_service import BACKFILL_MAX_CHUNKS
artist = await _artist(db, "Alice")
svc = SourceService(db)
rec = await svc.create(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice",
)
# Simulate a prior, finished walk leaving stale checkpoint state.
await db.execute(
Source.__table__.update().where(Source.id == rec.id).values(
config_overrides={"_backfill_state": "complete", "_backfill_cursor": "old",
"_backfill_chunks": 7},
)
)
await db.commit()
updated = await svc.start_backfill(rec.id)
assert updated.backfill_state == "running"
assert updated.backfill_chunks == 0
assert updated.backfill_runs_remaining == BACKFILL_MAX_CHUNKS
co = (await db.execute(
select(Source.config_overrides).where(Source.id == rec.id)
)).scalar_one()
assert co.get("_backfill_state") == "running"
assert "_backfill_cursor" not in co
@pytest.mark.asyncio
async def test_stop_backfill_returns_to_idle(db):
artist = await _artist(db, "Alice")
svc = SourceService(db)
rec = await svc.create(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice",
)
await svc.start_backfill(rec.id)
updated = await svc.stop_backfill(rec.id)
assert updated.backfill_state is None
assert updated.backfill_runs_remaining == 0
co = (await db.execute(
select(Source.config_overrides).where(Source.id == rec.id)
)).scalar_one()
assert "_backfill_state" not in (co or {})
@pytest.mark.asyncio
async def test_start_recovery_arms_bypass_flag(db):
"""Plan #697: start_recovery arms the backfill state machine PLUS the
_backfill_bypass_seen flag (recovery), surfaced on the record so the UI badge
can label it 'Recovering'. Clears any prior checkpoint state."""
from backend.app.services.source_service import BACKFILL_MAX_CHUNKS
artist = await _artist(db, "Alice")
svc = SourceService(db)
rec = await svc.create(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice",
)
# Simulate a prior walk's posts counter — start must clear it (plan #704).
await db.execute(
Source.__table__.update().where(Source.id == rec.id).values(
config_overrides={"_backfill_posts": 42},
)
)
await db.commit()
updated = await svc.start_recovery(rec.id)
assert updated.backfill_state == "running"
assert updated.backfill_bypass_seen is True
assert updated.backfill_posts == 0 # cleared for a fresh walk
assert updated.backfill_runs_remaining == BACKFILL_MAX_CHUNKS
co = (await db.execute(
select(Source.config_overrides).where(Source.id == rec.id)
)).scalar_one()
assert co.get("_backfill_bypass_seen") is True
assert "_backfill_posts" not in co
# Stop clears the bypass flag too (shared lifecycle).
stopped = await svc.stop_backfill(rec.id)
assert stopped.backfill_bypass_seen is False
co2 = (await db.execute(
select(Source.config_overrides).where(Source.id == rec.id)
)).scalar_one()
assert "_backfill_bypass_seen" not in (co2 or {})
@pytest.mark.asyncio
async def test_start_recapture_arms_recapture_flag(db):
"""#830: start_recapture arms the backfill state machine PLUS the
_backfill_recapture flag, surfaced on the record so the badge can label it
'Recapturing'. Mutually exclusive with recovery (clears bypass_seen); stop
clears it."""
from backend.app.services.source_service import BACKFILL_MAX_CHUNKS
artist = await _artist(db, "Alice")
svc = SourceService(db)
rec = await svc.create(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice",
)
# Pre-arm recovery, then recapture must clear bypass_seen (mutual exclusion).
await svc.start_recovery(rec.id)
updated = await svc.start_recapture(rec.id)
assert updated.backfill_state == "running"
assert updated.backfill_recapture is True
assert updated.backfill_bypass_seen is False
assert updated.backfill_runs_remaining == BACKFILL_MAX_CHUNKS
co = (await db.execute(
select(Source.config_overrides).where(Source.id == rec.id)
)).scalar_one()
assert co.get("_backfill_recapture") is True
assert "_backfill_bypass_seen" not in co
# to_dict carries the new field for the API/UI.
assert updated.to_dict()["backfill_recapture"] is True
# Stop clears the recapture flag too.
stopped = await svc.stop_backfill(rec.id)
assert stopped.backfill_recapture is False
co2 = (await db.execute(
select(Source.config_overrides).where(Source.id == rec.id)
)).scalar_one()
assert "_backfill_recapture" not in (co2 or {})
@pytest.mark.asyncio
async def test_start_backfill_raises_when_source_missing(db):
svc = SourceService(db)
with pytest.raises(LookupError):
await svc.start_backfill(99999)
@pytest.mark.asyncio
async def test_new_enabled_source_starts_in_backfill_mode(db):
"""Plan #693: freshly added enabled sources have no archive yet, so they
arm run-until-done backfill — state 'running' — to walk the full history
on the first ticks instead of blowing the wall-clock cap in tick mode."""
artist = await _artist(db, "Alice")
svc = SourceService(db)
rec = await svc.create(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice-new",
)
assert rec.backfill_state == "running"
@pytest.mark.asyncio
async def test_new_disabled_source_skips_backfill(db):
"""Disabled sources (incl. sidecar synthetics that arrive disabled) are
never polled, so don't burn a backfill budget on them."""
artist = await _artist(db, "Alice")
svc = SourceService(db)
rec = await svc.create(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice-disabled",
enabled=False,
)
assert rec.backfill_runs_remaining == 0