Files
FabledCurator/tests/test_api_sources.py
T
bvandeusenandClaude Opus 5 3f8306f705
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 5s
Build images / build-ml (push) Successful in 9s
Build images / build-agent (push) Successful in 10s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m14s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m9s
fix: two exact-shape assertions pinned the old schedule-status payload
B3 added failing_sources / no_access_sources to schedule-status and
broke test_schedule_status_shape and test_summary_returns_rollup_shape,
both of which assert the payload's EXACT key set. My own tests passed;
these two did not, and integration caught it.

A rule 90 miss: I grepped for the predicates I changed and not for
consumers of the response shape. The shape is the thing I actually
changed.

Both keys added to the assertions rather than loosening them to a
subset check — an exact-set assertion is what catches a key being
renamed out from under a consumer, which is precisely the value these
two tests just demonstrated.

The other readers (PipelineStatusChip, SchedulerStatusBar) pull
individual keys, so they were unaffected. SchedulerStatusBar's prop
comment documented the old shape and is corrected here; a comment that
lies about a contract is worth the same as a doc that does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
2026-09-09 23:01:08 -04:00

446 lines
15 KiB
Python

import pytest
from backend.app.models import Artist, Source
pytestmark = pytest.mark.integration
@pytest.fixture(autouse=True)
def _stub_preflight_verify(monkeypatch):
"""Backfill/recover arms run a pre-flight credential verify (plan #703 #2)
which for Patreon would hit the network. Default it to 'valid' so the
endpoint tests stay network-free; the dedicated pre-flight tests override
this with a rejection/inconclusive verdict."""
from backend.app.services import download_backends as db_mod
async def _ok(**kwargs):
return (True, "Credentials valid")
monkeypatch.setattr(db_mod, "verify_source_credential", _ok)
@pytest.fixture
async def artist(db):
a = Artist(name="Alice", slug="alice")
db.add(a)
await db.commit()
return a
@pytest.mark.asyncio
async def test_list_failing_filter_returns_only_failing(client, artist, db):
healthy = Source(
artist_id=artist.id, platform="patreon", url="https://p/ok",
enabled=True, consecutive_failures=0,
)
broken = Source(
artist_id=artist.id, platform="pixiv", url="https://p/bad",
enabled=True, consecutive_failures=3, last_error="boom",
)
db.add_all([healthy, broken])
await db.commit()
resp = await client.get("/api/sources?failing=true")
assert resp.status_code == 200
body = await resp.get_json()
assert len(body) == 1
assert body[0]["consecutive_failures"] == 3
assert body[0]["last_error"] == "boom"
@pytest.mark.asyncio
async def test_schedule_status_shape(client):
resp = await client.get("/api/sources/schedule-status")
assert resp.status_code == 200
body = await resp.get_json()
assert set(body) == {
"last_tick_at", "next_due_at", "due_now", "auto_sources",
"failing_sources", "no_access_sources",
"platform_cooldowns",
}
assert isinstance(body["due_now"], int)
assert isinstance(body["auto_sources"], int)
# #387 B3: the front-door ribbon reads these two. Kept in the exact-set
# assertion deliberately — it is what catches a key being renamed out from
# under a consumer, which is how this test earned its keep.
assert isinstance(body["failing_sources"], int)
assert isinstance(body["no_access_sources"], int)
@pytest.mark.asyncio
async def test_create_list_get_delete(client, artist):
create = await client.post("/api/sources", json={
"artist_id": artist.id, "platform": "patreon",
"url": "https://patreon.com/alice",
})
assert create.status_code == 201
created = await create.get_json()
assert created["artist_name"] == "Alice"
sid = created["id"]
listing = await client.get("/api/sources")
assert listing.status_code == 200
assert len(await listing.get_json()) == 1
filtered = await client.get(f"/api/sources?artist_id={artist.id}")
assert filtered.status_code == 200
assert len(await filtered.get_json()) == 1
one = await client.get(f"/api/sources/{sid}")
assert one.status_code == 200
assert (await one.get_json())["id"] == sid
deleted = await client.delete(f"/api/sources/{sid}")
assert deleted.status_code == 204
assert (await client.get(f"/api/sources/{sid}")).status_code == 404
@pytest.mark.asyncio
async def test_create_kicks_off_backfill_for_enabled_source(client, artist, monkeypatch):
"""A new enabled source dispatches its first walk immediately (no waiting
for the next scheduler tick), unless its platform is in cooldown."""
delays: list = []
monkeypatch.setattr(
"backend.app.tasks.download.download_source.delay",
lambda *a, **k: delays.append(a),
)
resp = await client.post("/api/sources", json={
"artist_id": artist.id, "platform": "patreon",
"url": "https://patreon.com/kickoff",
})
assert resp.status_code == 201
sid = (await resp.get_json())["id"]
assert delays == [(sid,)]
@pytest.mark.asyncio
async def test_create_disabled_source_does_not_kick_off(client, artist, monkeypatch):
delays: list = []
monkeypatch.setattr(
"backend.app.tasks.download.download_source.delay",
lambda *a, **k: delays.append(a),
)
resp = await client.post("/api/sources", json={
"artist_id": artist.id, "platform": "patreon",
"url": "https://patreon.com/disabled", "enabled": False,
})
assert resp.status_code == 201
assert delays == []
@pytest.mark.asyncio
async def test_create_rejects_unknown_platform(client, artist):
resp = await client.post("/api/sources", json={
"artist_id": artist.id, "platform": "myspace", "url": "https://m/x",
})
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "unknown_platform"
assert "known" in body
@pytest.mark.asyncio
async def test_create_rejects_bad_config(client, artist):
resp = await client.post("/api/sources", json={
"artist_id": artist.id, "platform": "patreon",
"url": "https://p/a", "config_overrides": [1, 2, 3],
})
assert resp.status_code == 400
assert (await resp.get_json())["error"] == "invalid_config"
@pytest.mark.asyncio
async def test_create_rejects_empty_url(client, artist):
resp = await client.post("/api/sources", json={
"artist_id": artist.id, "platform": "patreon", "url": " ",
})
assert resp.status_code == 400
assert (await resp.get_json())["error"] == "empty_url"
@pytest.mark.asyncio
async def test_create_rejects_unknown_artist(client):
resp = await client.post("/api/sources", json={
"artist_id": 99999, "platform": "patreon", "url": "https://p/a",
})
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_create_duplicate_returns_409_with_existing_id(client, artist):
a = await client.post("/api/sources", json={
"artist_id": artist.id, "platform": "patreon", "url": "https://p/a",
})
first = (await a.get_json())["id"]
b = await client.post("/api/sources", json={
"artist_id": artist.id, "platform": "patreon", "url": "https://p/a",
})
assert b.status_code == 409
body = await b.get_json()
assert body["error"] == "duplicate"
assert body["existing_id"] == first
@pytest.mark.asyncio
async def test_patch_updates_fields(client, artist):
create = await client.post("/api/sources", json={
"artist_id": artist.id, "platform": "patreon", "url": "https://p/a",
})
sid = (await create.get_json())["id"]
patch = await client.patch(f"/api/sources/{sid}", json={"enabled": False})
assert patch.status_code == 200
assert (await patch.get_json())["enabled"] is False
@pytest.mark.asyncio
async def test_get_404(client):
assert (await client.get("/api/sources/99999")).status_code == 404
@pytest.mark.asyncio
async def test_patch_404(client):
assert (await client.patch("/api/sources/99999", json={"enabled": False})).status_code == 404
@pytest.mark.asyncio
async def test_delete_404(client):
assert (await client.delete("/api/sources/99999")).status_code == 404
# --- FC-3d: SourceRecord surfaces consecutive_failures + next_check_at ----
@pytest.mark.asyncio
async def test_create_response_includes_fc3d_fields(client, artist):
resp = await client.post("/api/sources", json={
"artist_id": artist.id, "platform": "patreon",
"url": "https://patreon.com/alice-fc3d-new",
})
assert resp.status_code == 201
body = await resp.get_json()
assert body["consecutive_failures"] == 0
assert body["next_check_at"] is None # never checked yet
@pytest.mark.asyncio
async def test_list_derives_next_check_at_when_last_checked_set(
client, artist, db,
):
from datetime import UTC, datetime
from backend.app.models import Source
src = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice-fc3d-checked", enabled=True,
consecutive_failures=0,
last_checked_at=datetime.now(UTC),
)
db.add(src)
await db.commit()
resp = await client.get("/api/sources")
body = await resp.get_json()
target = next(
r for r in body if r["url"] == "https://patreon.com/alice-fc3d-checked"
)
assert target["next_check_at"] is not None
assert "T" in target["next_check_at"] # ISO 8601
# --- Plan #544: POST /api/sources/{id}/backfill ----------------------------
@pytest.mark.asyncio
async def test_backfill_endpoint_start_and_stop(client, artist, db):
src = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice-backfill", enabled=True,
)
db.add(src)
await db.commit()
sid = src.id
resp = await client.post(f"/api/sources/{sid}/backfill", json={"action": "start"})
assert resp.status_code == 200
body = await resp.get_json()
assert body["backfill_state"] == "running"
# GET reflects the running state.
one = await client.get(f"/api/sources/{sid}")
assert (await one.get_json())["backfill_state"] == "running"
stopped = await client.post(f"/api/sources/{sid}/backfill", json={"action": "stop"})
assert (await stopped.get_json())["backfill_state"] is None
@pytest.mark.asyncio
async def test_backfill_endpoint_defaults_to_start(client, artist, db):
src = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice-backfill-default", enabled=True,
)
db.add(src)
await db.commit()
resp = await client.post(f"/api/sources/{src.id}/backfill", json={})
body = await resp.get_json()
assert body["backfill_state"] == "running"
@pytest.mark.asyncio
async def test_backfill_endpoint_rejects_bad_action(client, artist, db):
src = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice-backfill-bad", enabled=True,
)
db.add(src)
await db.commit()
bad = await client.post(f"/api/sources/{src.id}/backfill", json={"action": "nope"})
assert bad.status_code == 400
@pytest.mark.asyncio
async def test_backfill_endpoint_404_when_source_missing(client):
resp = await client.post("/api/sources/999999/backfill", json={"action": "start"})
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_backfill_endpoint_recover_arms_bypass(client, artist, db):
"""Plan #697: action='recover' arms a backfill that bypasses the Patreon
seen-ledger; the response exposes backfill_bypass_seen=True so the UI badge
can label it 'Recovering'. Stop clears it."""
src = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice-recover", enabled=True,
)
db.add(src)
await db.commit()
sid = src.id
resp = await client.post(f"/api/sources/{sid}/backfill", json={"action": "recover"})
assert resp.status_code == 200
body = await resp.get_json()
assert body["backfill_state"] == "running"
assert body["backfill_bypass_seen"] is True
stopped = await client.post(f"/api/sources/{sid}/backfill", json={"action": "stop"})
stopped_body = await stopped.get_json()
assert stopped_body["backfill_state"] is None
assert stopped_body["backfill_bypass_seen"] is False
@pytest.mark.asyncio
async def test_backfill_endpoint_recapture_arms_flag(client, artist, db):
"""#830: action='recapture' arms a backfill that re-grabs post bodies/links +
localizes on-disk inline images; the response exposes backfill_recapture=True
(badge 'Recapturing') and NOT backfill_bypass_seen. Stop clears it."""
src = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice-recapture", enabled=True,
)
db.add(src)
await db.commit()
sid = src.id
resp = await client.post(f"/api/sources/{sid}/backfill", json={"action": "recapture"})
assert resp.status_code == 200
body = await resp.get_json()
assert body["backfill_state"] == "running"
assert body["backfill_recapture"] is True
assert body["backfill_bypass_seen"] is False
stopped = await client.post(f"/api/sources/{sid}/backfill", json={"action": "stop"})
stopped_body = await stopped.get_json()
assert stopped_body["backfill_state"] is None
assert stopped_body["backfill_recapture"] is False
# --- Plan #703 #2: pre-flight credential verify on backfill/recover arm -----
@pytest.mark.asyncio
async def test_arm_blocked_when_credential_rejected(client, artist, db, monkeypatch):
"""A definitively-rejected credential refuses the arm (409 + reason) instead
of starting a doomed walk; the source is NOT armed."""
from backend.app.services import download_backends as db_mod
async def _reject(**kwargs):
return (False, "Patreon rejected the credential — cookies expired")
monkeypatch.setattr(db_mod, "verify_source_credential", _reject)
src = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice-preflight", enabled=True,
)
db.add(src)
await db.commit()
sid = src.id
resp = await client.post(f"/api/sources/{sid}/backfill", json={"action": "recover"})
assert resp.status_code == 409
assert "expired" in ((await resp.get_json()).get("detail") or "")
# Not armed.
one = await (await client.get(f"/api/sources/{sid}")).get_json()
assert one["backfill_state"] is None
@pytest.mark.asyncio
async def test_arm_proceeds_when_credential_inconclusive(client, artist, db, monkeypatch):
"""An inconclusive verify (network blip / drift) must NOT block the arm."""
from backend.app.services import download_backends as db_mod
async def _inconclusive(**kwargs):
return (None, "couldn't verify (network)")
monkeypatch.setattr(db_mod, "verify_source_credential", _inconclusive)
src = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice-incon", enabled=True,
)
db.add(src)
await db.commit()
resp = await client.post(f"/api/sources/{src.id}/backfill", json={"action": "start"})
assert resp.status_code == 200
assert (await resp.get_json())["backfill_state"] == "running"
@pytest.mark.asyncio
async def test_stop_never_pre_flights(client, artist, db, monkeypatch):
from backend.app.services import download_backends as db_mod
async def _boom(**kwargs):
raise AssertionError("stop must not pre-flight verify")
monkeypatch.setattr(db_mod, "verify_source_credential", _boom)
src = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice-stop", enabled=True,
)
db.add(src)
await db.commit()
resp = await client.post(f"/api/sources/{src.id}/backfill", json={"action": "stop"})
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_gallery_dl_platform_arm_skips_pre_flight(client, artist, db, monkeypatch):
"""Pre-flight is gated to native-ingester platforms (cheap verify); a
gallery-dl source arms without the slow --simulate probe."""
from backend.app.services import download_backends as db_mod
async def _boom(**kwargs):
raise AssertionError("gallery-dl arm must not pre-flight verify")
monkeypatch.setattr(db_mod, "verify_source_credential", _boom)
src = Source(
artist_id=artist.id, platform="hentaifoundry",
url="https://www.hentai-foundry.com/user/x", enabled=True,
)
db.add(src)
await db.commit()
resp = await client.post(f"/api/sources/{src.id}/backfill", json={"action": "start"})
assert resp.status_code == 200