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//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}