a69bd1baa8
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
483 lines
17 KiB
Python
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
|