def967a1a8
Removed the app/client fixtures duplicated across 36 test files (two variants: separate app + client(app), and a self-contained client() that called create_app inline) and the now-unused create_app imports. Both fixtures now live once in conftest.py. test_suggestions_bulk keeps its import (builds the app inline in two tests); test_health drops its local client + unused pytest_asyncio. Net -415 lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
292 lines
9.9 KiB
Python
292 lines
9.9 KiB
Python
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
|
|
from backend.app.celery_app import celery
|
|
from backend.app.models import ImportBatch, ImportTask
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def eager():
|
|
celery.conf.task_always_eager = True
|
|
yield
|
|
celery.conf.task_always_eager = False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_when_idle(client):
|
|
resp = await client.get("/api/import/status")
|
|
body = await resp.get_json()
|
|
assert body["active_batch"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_tasks_with_status_filter(client, db):
|
|
batch = ImportBatch(triggered_by="manual", source_path="/import", scan_mode="quick")
|
|
db.add(batch)
|
|
await db.flush()
|
|
for status in ("complete", "failed", "complete"):
|
|
db.add(ImportTask(
|
|
batch_id=batch.id, source_path="/x", task_type="media", status=status,
|
|
finished_at=datetime.now(UTC),
|
|
))
|
|
await db.commit()
|
|
|
|
resp = await client.get("/api/import/tasks?status=failed")
|
|
body = await resp.get_json()
|
|
assert len(body["tasks"]) == 1
|
|
assert body["tasks"][0]["status"] == "failed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_failed(client, db):
|
|
batch = ImportBatch(triggered_by="manual", source_path="/import", scan_mode="quick")
|
|
db.add(batch)
|
|
await db.flush()
|
|
db.add(ImportTask(batch_id=batch.id, source_path="/x", task_type="media", status="failed"))
|
|
await db.commit()
|
|
|
|
resp = await client.post("/api/import/retry-failed")
|
|
body = await resp.get_json()
|
|
assert body["retried"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_completed(client, db):
|
|
batch = ImportBatch(triggered_by="manual", source_path="/import", scan_mode="quick")
|
|
db.add(batch)
|
|
await db.flush()
|
|
long_ago = datetime.now(UTC) - timedelta(days=30)
|
|
db.add(ImportTask(
|
|
batch_id=batch.id, source_path="/x", task_type="media",
|
|
status="complete", finished_at=long_ago,
|
|
))
|
|
db.add(ImportTask(
|
|
batch_id=batch.id, source_path="/y", task_type="media",
|
|
status="complete", finished_at=datetime.now(UTC),
|
|
))
|
|
await db.commit()
|
|
|
|
resp = await client.post("/api/import/clear-completed", json={"age_days": 7})
|
|
body = await resp.get_json()
|
|
assert body["deleted"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_stuck_fails_non_terminal_and_finalizes_orphan_batch(client, db):
|
|
"""Operator-flagged 2026-05-25: 3 large PNGs got stuck in 'processing'
|
|
for 2 days, the active ImportBatch never finalized, and the UI's
|
|
'Scanning...' banner persisted with 0/0 files. /api/import/clear-stuck
|
|
is the escape hatch to break the autoretry loop manually."""
|
|
from sqlalchemy import select as _select
|
|
|
|
batch = ImportBatch(triggered_by="manual", source_path="/import", scan_mode="quick")
|
|
db.add(batch)
|
|
await db.flush()
|
|
# Three stuck rows in mixed non-terminal states.
|
|
db.add(ImportTask(
|
|
batch_id=batch.id, source_path="/p1", task_type="media", status="processing",
|
|
))
|
|
db.add(ImportTask(
|
|
batch_id=batch.id, source_path="/p2", task_type="media", status="queued",
|
|
))
|
|
db.add(ImportTask(
|
|
batch_id=batch.id, source_path="/p3", task_type="media", status="pending",
|
|
))
|
|
# One already-complete row should be untouched.
|
|
db.add(ImportTask(
|
|
batch_id=batch.id, source_path="/done", task_type="media",
|
|
status="complete", finished_at=datetime.now(UTC),
|
|
))
|
|
await db.commit()
|
|
|
|
resp = await client.post("/api/import/clear-stuck")
|
|
body = await resp.get_json()
|
|
assert resp.status_code == 200
|
|
assert body["tasks_failed"] == 3
|
|
assert body["batches_finalized"] == 1
|
|
|
|
statuses = {
|
|
row.status for row in
|
|
(await db.execute(_select(ImportTask).where(ImportTask.batch_id == batch.id)))
|
|
.scalars().all()
|
|
}
|
|
assert statuses == {"failed", "complete"}
|
|
|
|
batch_status = (await db.execute(
|
|
_select(ImportBatch.status).where(ImportBatch.id == batch.id)
|
|
)).scalar_one()
|
|
assert batch_status == "complete"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_stuck_no_op_when_nothing_stuck(client, db):
|
|
resp = await client.post("/api/import/clear-stuck")
|
|
body = await resp.get_json()
|
|
assert resp.status_code == 200
|
|
assert body == {"tasks_failed": 0, "batches_finalized": 0}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_accepts_deep(client, monkeypatch):
|
|
# Stub the task dispatch — assert the API accepts 'deep' and forwards
|
|
# mode, without running the real (eager) scan_directory.
|
|
from backend.app.tasks import scan as scan_mod
|
|
|
|
seen = {}
|
|
|
|
class _Res:
|
|
id = "celery-deep"
|
|
|
|
def _fake_delay(*, triggered_by, mode):
|
|
seen["triggered_by"] = triggered_by
|
|
seen["mode"] = mode
|
|
return _Res()
|
|
|
|
monkeypatch.setattr(scan_mod.scan_directory, "delay", _fake_delay)
|
|
resp = await client.post("/api/import/trigger", json={"mode": "deep"})
|
|
assert resp.status_code == 202
|
|
body = await resp.get_json()
|
|
assert body["mode"] == "deep"
|
|
assert seen == {"triggered_by": "manual", "mode": "deep"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_still_rejects_unknown_mode(client):
|
|
resp = await client.post("/api/import/trigger", json={"mode": "wat"})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refetch_404_for_unknown_task(client):
|
|
resp = await client.post("/api/import/tasks/999999/refetch")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refetch_400_for_non_failed_task(client, db):
|
|
batch = ImportBatch(triggered_by="manual", source_path="/import", scan_mode="quick")
|
|
db.add(batch)
|
|
await db.flush()
|
|
task = ImportTask(
|
|
batch_id=batch.id, source_path="/x.jpg", task_type="media",
|
|
status="complete", finished_at=datetime.now(UTC),
|
|
)
|
|
db.add(task)
|
|
await db.commit()
|
|
resp = await client.post(f"/api/import/tasks/{task.id}/refetch")
|
|
assert resp.status_code == 400
|
|
assert (await resp.get_json())["status"] == "not_failed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refetch_no_source_when_unresolvable(client, db):
|
|
"""A failed task whose file has no sidecar / no resolvable Source
|
|
returns no_source (filesystem-only import — nothing to re-poll)."""
|
|
batch = ImportBatch(triggered_by="manual", source_path="/import", scan_mode="quick")
|
|
db.add(batch)
|
|
await db.flush()
|
|
task = ImportTask(
|
|
batch_id=batch.id, source_path="/import/nowhere/x.jpg",
|
|
task_type="media", status="failed", finished_at=datetime.now(UTC),
|
|
)
|
|
db.add(task)
|
|
await db.commit()
|
|
resp = await client.post(f"/api/import/tasks/{task.id}/refetch")
|
|
assert resp.status_code == 200
|
|
assert (await resp.get_json())["status"] == "no_source"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refetch_queued_with_resolvable_source(client, db, tmp_path, monkeypatch):
|
|
"""A failed task whose file resolves (via sidecar → artist+platform)
|
|
to an enabled, real-URL Source: the file is deleted, the task is
|
|
marked refetched, and ONE source re-check is queued."""
|
|
import json as _json
|
|
|
|
from sqlalchemy import update as _update
|
|
|
|
from backend.app.models import Artist, ImportSettings, Source
|
|
from backend.app.tasks import download as download_mod
|
|
|
|
# Stub the downloader so the eager test doesn't run a real fetch.
|
|
dispatched = []
|
|
monkeypatch.setattr(download_mod.download_source, "delay", dispatched.append)
|
|
|
|
# import_root/<ArtistName>/post.jpg + sidecar identifying the platform.
|
|
import_root = tmp_path / "import"
|
|
artist_dir = import_root / "Maewix"
|
|
artist_dir.mkdir(parents=True)
|
|
media = artist_dir / "post.jpg"
|
|
media.write_bytes(b"corrupt-bytes")
|
|
(artist_dir / "post.jpg.json").write_text(
|
|
_json.dumps({"category": "patreon", "post_id": 123})
|
|
)
|
|
|
|
# import_settings(id=1) is migration-seeded; point its scan path at
|
|
# our tmp import root rather than inserting a conflicting row.
|
|
await db.execute(
|
|
_update(ImportSettings).where(ImportSettings.id == 1)
|
|
.values(import_scan_path=str(import_root))
|
|
)
|
|
artist = Artist(name="Maewix", slug="maewix")
|
|
db.add(artist)
|
|
await db.flush()
|
|
db.add(Source(
|
|
artist_id=artist.id, platform="patreon",
|
|
url="https://www.patreon.com/maewix", enabled=True,
|
|
config_overrides={},
|
|
))
|
|
batch = ImportBatch(triggered_by="manual", source_path=str(import_root), scan_mode="quick")
|
|
db.add(batch)
|
|
await db.flush()
|
|
task = ImportTask(
|
|
batch_id=batch.id, source_path=str(media), task_type="media",
|
|
status="failed", finished_at=datetime.now(UTC),
|
|
)
|
|
db.add(task)
|
|
await db.commit()
|
|
|
|
resp = await client.post(f"/api/import/tasks/{task.id}/refetch")
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["status"] == "refetch_queued"
|
|
assert len(dispatched) == 1
|
|
assert not media.exists() # corrupt copy removed for re-fetch
|
|
|
|
from sqlalchemy import select as _select
|
|
refetched = (await db.execute(
|
|
_select(ImportTask.refetched).where(ImportTask.id == task.id)
|
|
)).scalar_one()
|
|
assert refetched is True
|
|
|
|
# Second attempt is a no-op (bounded to one).
|
|
resp2 = await client.post(f"/api/import/tasks/{task.id}/refetch")
|
|
assert (await resp2.get_json())["status"] == "already_refetched"
|
|
assert len(dispatched) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_accepts_verify(client, monkeypatch):
|
|
# Stub the verify task's dispatch so the API contract is asserted
|
|
# without running the real (eager) verify_integrity end-to-end.
|
|
from backend.app.tasks import maintenance as m
|
|
|
|
seen = {}
|
|
|
|
class _Res:
|
|
id = "celery-verify"
|
|
|
|
def _fake_delay():
|
|
seen["called"] = True
|
|
return _Res()
|
|
|
|
monkeypatch.setattr(m.verify_integrity, "delay", _fake_delay)
|
|
resp = await client.post("/api/import/trigger", json={"mode": "verify"})
|
|
assert resp.status_code == 202
|
|
body = await resp.get_json()
|
|
assert body["mode"] == "verify"
|
|
assert seen == {"called": True}
|