Files
FabledCurator/tests/test_source_service.py
T
bvandeusen a69bd1baa8
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m36s
feat(artist): move a source into another artist (#130 step 4)
Operator ask: a surface to merge new sources into existing artists (consolidate
the singleton artist a fresh add spins up). Enabled by the #130 slug decoupling —
the storage path is immutable, so re-attribution moves NO files. SourceService
.reassign moves the source, re-points its posts (Post.source_id==S) and the
images it contributed (ImageProvenance via S, scoped to the old artist so shared
images aren't stolen), and deletes the old artist if it's left fully empty (else
clears its subscription flag). POST /api/sources/<id>/reassign. Frontend: a
'Move…' action per source on the artist Management tab → artist-autocomplete
picker → confirm → routes to the target (whose slug is stable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
2026-07-04 22:15:38 -04:00

483 lines
17 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_is_gs_six(db):
assert KNOWN_PLATFORMS == frozenset({
"patreon", "subscribestar", "hentaifoundry",
"discord", "pixiv", "deviantart",
})
assert "fanbox" 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_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
assert updated.config_overrides == {"videos": False}
@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