Files
FabledCurator/tests/test_api_placement.py
T
bvandeusenandClaude Opus 5 abe449b4f2
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 1m1s
Build images / build-web (push) Successful in 1m34s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m26s
Build images / promote (push) Skipped
CI / integration (push) Failing after 3m5s
feat: placement reconciler tasks + API (4246, slice 3b)
Three Celery tasks wrapping the 3a service, and the endpoints that drive
them. Routed to `maintenance_long` alongside backups: 33k renames on NFS have
no business in the quick lane where the self-healing sweeps live (the
2026-06-07 starvation).

A durability bug in 3a, found by thinking about what a crash costs rather
than by a failing test: `apply_run` wrote its ledger only at the end, so a
worker dying at row 30,000 of 33,789 would have taken the undo information
for the first 29,999 with it — and that ledger is the ONLY record of where
those files came from. It now persists every 200 moves. Two things fell out
of writing that:

- `_persist` reassigns `run.moves`, so the loop had to snapshot the plan
  first rather than iterate the attribute it rewrites.
- the reassignment is itself load-bearing: SQLAlchemy does not track in-place
  mutation of a JSONB list, so an `.append()` alone would never reach the
  database and the ledger would have stayed silently empty.

Re-running a partially-applied plan is safe — the moved rows no longer match
their `from` and refuse as "row moved since planning" — but `apply_placement`
deliberately has NO autoretry: re-entering a half-applied plan should be the
operator's call after reading what happened, not the queue's.

Endpoints gate on run state as well as the service does, so a stray POST
cannot re-apply an applied run. The list response omits `moves` (an applied
whole-library run carries tens of thousands of entries); the detail endpoint
includes them, because that detail IS the preview read before agreeing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-21 14:13:25 -04:00

164 lines
5.3 KiB
Python

"""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
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 flush. `db` is the ASYNC session,
so flushing here would leave an un-awaited coroutine and the row would
never reach the database."""
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.flush()
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.flush()
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.flush()
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.flush()
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.flush()
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.flush()
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"