CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Failing after 2m14s
Five of the new tests failed in CI: every one that created a LibraryPlacementRun and then read it back through the client. The ones that touched no rows passed. A flush stays inside the test's own transaction, and the app under test runs on a separate session and connection — so the endpoint queried a database where the row did not exist yet and got its 404 / empty list honestly. `_seed_runs` in test_api_system_backup already commits for this reason; the idiom was there to copy and I did not look first. Recorded in the helper's docstring rather than just fixed, since the next person writing a create-then-fetch API test will reach for flush too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
167 lines
5.4 KiB
Python
167 lines
5.4 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 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"
|