19aece1fc4
Routine subscription polls walked the entire post history every tick
even when nothing had changed, because gallery-dl's default `skip: True`
continues iterating archived posts. A creator with ~550 archived posts
(Knuxy patreon) saturates the 870s wall-clock cap before completing,
even with zero downloads needed. Plus, a tier-limited run that
downloaded hundreds of files but ran out the clock should be a
warning, not an error.
Two coupled changes, both operator-flagged 2026-06-01:
* **Tick mode (default, cron polls).** New `TICK_SKIP_VALUE = "exit:20"`
asks gallery-dl to exit after 20 contiguous archived items. Fresh
subscriptions + new-content cases still walk normally; established
subscription with zero new content exits in ~30s of HEAD requests
instead of pegging the timeout. 20 (not 5) gives headroom against
paywall warnings interleaving with archived items.
* **Backfill mode (explicit, operator-triggered).** Sticky for N runs
via new `Source.backfill_runs_remaining` (alembic 0031). While > 0,
downloads use `skip: True` + 1800s timeout. Auto-decrements per run
with early-reset to 0 when a clean run finds zero files (queue
drained). N defaults to 3 — multiple runs give the system enough
budget to finish a deep walk across timeout boundaries. New
`POST /api/sources/{id}/backfill` arms the source; "Deep scan"
button on each SourceRow (chip shows remaining count) wires it.
Plus partial-success classifier: non-zero gallery-dl exit + ≥1 file
downloaded + no source-level error fires `ErrorType.PARTIAL`, which
download_service maps to `status=\"ok\"`. The run did real work; the
next tick continues via gallery-dl's archive. No more red events for
"timed out mid-walk after downloading 300 files."
Retires `SourceConfig.skip_existing` — skip value is now derived from
the source state and passed as a separate `skip_value` parameter
through download() / _build_config_for_source(). `GD_DEFAULTS` drops
the now-dead key (was inert data after this refactor).
Tests cover:
* tick + backfill skip-value emission in _build_config_for_source
* PARTIAL classifier branch + TIER_LIMITED-wins-over-PARTIAL ordering
* SourceService.set_backfill_runs validation + persistence
* /api/sources/{id}/backfill 200/400/404 paths
* download_service auto-decrement / auto-reset / tick-mode-no-touch
* PARTIAL → status=ok in the orchestrator (no consecutive_failures bump)
254 lines
7.9 KiB
Python
254 lines
7.9 KiB
Python
import pytest
|
|
|
|
from backend.app.models import Artist, Source
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
@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",
|
|
"platform_cooldowns",
|
|
}
|
|
assert isinstance(body["due_now"], int)
|
|
assert isinstance(body["auto_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_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_arms_source(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={"runs": 5})
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["backfill_runs_remaining"] == 5
|
|
|
|
# GET reflects the new state.
|
|
one = await client.get(f"/api/sources/{sid}")
|
|
assert (await one.get_json())["backfill_runs_remaining"] == 5
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_backfill_endpoint_defaults_to_three(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_runs_remaining"] == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_backfill_endpoint_rejects_out_of_range(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={"runs": 0})
|
|
assert bad.status_code == 400
|
|
too_big = await client.post(
|
|
f"/api/sources/{src.id}/backfill", json={"runs": 99}
|
|
)
|
|
assert too_big.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={"runs": 3})
|
|
assert resp.status_code == 404
|