"""The placement reconciler's task + API surface (milestone #421, slice 3b). The move logic itself is covered in tests/test_library_layout.py; this module covers the wrapper — that the tasks are registered and routed, that the endpoints gate on run state, and that a list response stays small. """ import pytest from sqlalchemy import select import backend.app.tasks.library_placement # noqa: F401 — register tasks from backend.app.celery_app import celery from backend.app.models import Artist, LibraryPlacementRun pytestmark = pytest.mark.integration _TASKS = ( "backend.app.tasks.library_placement.plan_placement", "backend.app.tasks.library_placement.apply_placement", "backend.app.tasks.library_placement.revert_placement", ) @pytest.mark.parametrize("name", _TASKS) def test_placement_tasks_are_registered(name): assert name in celery.tasks def test_placement_runs_on_the_long_maintenance_lane(): """33k renames must not sit in the quick lane, which is where the self-healing sweeps live (the 2026-06-07 starvation).""" routes = celery.conf.task_routes assert routes["backend.app.tasks.library_placement.*"] == { "queue": "maintenance_long" } def _run(db, status="ready", moves=None, artist_id=None): """Adds the row; the caller awaits the COMMIT. Commit, not flush: the app under test runs on its own session and connection, so a flush that stays inside this test's transaction is invisible to the endpoint — the row simply is not there yet. Same reason `_seed_runs` in test_api_system_backup commits.""" run = LibraryPlacementRun( status=status, artist_id=artist_id, moves=moves or [], planned_count=len(moves or []), ) db.add(run) return run @pytest.mark.asyncio async def test_runs_list_omits_the_moves(client, db): """An applied whole-library run carries tens of thousands of entries. Fine in Postgres, wrong in every list response.""" run = LibraryPlacementRun( status="applied", moves=[{"image_id": 1, "from": "/images/A/x.png", "to": "/images/a/x.png"}], planned_count=1, moved_count=1, ) db.add(run) await db.commit() resp = await client.get("/api/cleanup/placement/runs") assert resp.status_code == 200 body = await resp.get_json() assert body["runs"][0]["planned_count"] == 1 assert "moves" not in body["runs"][0] @pytest.mark.asyncio async def test_run_detail_carries_the_moves(client, db): """The detail IS the preview the operator reads before agreeing.""" run = LibraryPlacementRun( status="ready", moves=[{"image_id": 7, "from": "/images/Conto/x.png", "to": "/images/conto/x.png"}], planned_count=1, ) db.add(run) await db.commit() resp = await client.get(f"/api/cleanup/placement/runs/{run.id}") assert resp.status_code == 200 body = await resp.get_json() assert body["moves"][0]["from"] == "/images/Conto/x.png" assert body["moves"][0]["to"] == "/images/conto/x.png" @pytest.mark.asyncio async def test_run_detail_404s_for_an_unknown_run(client): resp = await client.get("/api/cleanup/placement/runs/999999") assert resp.status_code == 404 @pytest.mark.asyncio async def test_plan_rejects_a_non_integer_artist(client): resp = await client.post( "/api/cleanup/placement/plan", json={"artist_id": "conto"}, ) assert resp.status_code == 400 assert (await resp.get_json())["error"] == "invalid_artist_id" @pytest.mark.asyncio async def test_plan_accepts_an_artist_scope(client, db, monkeypatch): sent = {} from backend.app.tasks import library_placement monkeypatch.setattr( library_placement.plan_placement, "delay", lambda artist_id=None: sent.update(artist_id=artist_id), ) artist = Artist(name="Conto", slug="conto") db.add(artist) await db.commit() resp = await client.post( "/api/cleanup/placement/plan", json={"artist_id": artist.id}, ) assert resp.status_code == 202 assert sent["artist_id"] == artist.id @pytest.mark.asyncio async def test_apply_refuses_a_run_that_is_not_ready(client, db): """The gate is here as well as in the service — an applied run must not be re-applied by a stray POST.""" run = _run(db, status="applied") await db.commit() resp = await client.post(f"/api/cleanup/placement/runs/{run.id}/apply") assert resp.status_code == 400 assert (await resp.get_json())["error"] == "not_ready" @pytest.mark.asyncio async def test_revert_refuses_a_run_that_was_never_applied(client, db): run = _run(db, status="ready") await db.commit() resp = await client.post(f"/api/cleanup/placement/runs/{run.id}/revert") assert resp.status_code == 400 assert (await resp.get_json())["error"] == "not_applied" @pytest.mark.asyncio async def test_apply_dispatches_for_a_ready_run(client, db, monkeypatch): sent = {} from backend.app.tasks import library_placement monkeypatch.setattr( library_placement.apply_placement, "delay", lambda run_id: sent.update(run_id=run_id), ) run = _run(db, status="ready") await db.commit() resp = await client.post(f"/api/cleanup/placement/runs/{run.id}/apply") assert resp.status_code == 202 assert sent["run_id"] == run.id # Dispatch only — the endpoint must not have moved anything itself. still = (await db.execute( select(LibraryPlacementRun.status) .where(LibraryPlacementRun.id == run.id) )).scalar_one() assert still == "ready"