Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 5s
CI / extension-version (push) Successful in 6s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 36s
Build images / build-web (push) Successful in 1m17s
Build images / smoke-web (push) Skipped
CI / integration (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m24s
Build images / promote (push) Skipped
The front door is now a feed, and a blank feed implies things should be here in a way a blank masonry does not. On a fresh install this is the first screen anyone sees — including someone who is not the operator, which is what milestone 328 is making possible. Tells the two empties apart, which is the point. "No sources yet" gets the on-ramp; "sources configured, nothing landed yet" gets told that the first check takes a while and pointed at Downloads. Telling someone to add a source when they already have three and are mid-backfill reads as the app not knowing its own state. Needs total_sources on schedule-status to distinguish them — deliberately not auto_sources, which counts only what is on a schedule, so a source with auto_check off would have read as "nothing configured". Both exact-shape assertions updated in THIS change rather than after CI caught them, which is the lesson from B3's red push. An absent status falls back to the on-ramp on purpose: it is merely redundant to an established operator, whereas "see what's running" shown to someone with nothing configured is a dead end. A filtered miss is deliberately NOT the onboarding case — the operator has posts, they just narrowed past them. Showing a fresh-install on-ramp there would tell someone with a full library to go set it up. This is where logo.svg lands, as the operator asked. It earns its place on a first-run screen and not on a populated feed, and gives the on-ramp something to compose around instead of prose plus two buttons. Large: the mark stops reading below ~48px, which is why the 22px nav slot has a different one. Pinned by test so a later tidy-up cannot quietly shrink it to a glyph. Also extracts mountWithStore into the shared test support module. Writing the second spec created exactly the copy-paste that open issue 3109 tracks for the backend row factories, so it is consolidated now rather than at copy three, and recorded as snippet 3829 with the two traps it does NOT solve — named slots rendering nothing, and components that fetch on mount. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
446 lines
15 KiB
Python
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", "total_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
|