feat(vision): immagini di riferimento non orfane, GET/DELETE (#38.5)

save_reference_image scrive il file dopo il flush riuscito; il router
lo cancella se il commit successivo fallisce. Aggiunti GET
/api/vision/reference-images?task_id= (lista, Maker) e DELETE
/api/vision/reference-images/{id} (cancella riga e file, Maker).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:43:35 +02:00
parent 5ca3dc14cd
commit 65f6a4b18b
4 changed files with 217 additions and 8 deletions
+107
View File
@@ -85,6 +85,113 @@ async def test_a_reference_image_keeps_its_provenance(
assert saved.path
@pytest.mark.asyncio
async def test_the_file_survives_a_failed_commit_as_nothing(
client, db_session, maker_user, monkeypatch,
):
"""#38.5: the file is written only after a successful flush, and removed
if the commit that follows still fails - never an orphan on disk."""
recipe = await create_test_recipe(db_session, maker_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
await db_session.commit()
original_commit = db_session.commit
async def boom(*args, **kwargs):
raise RuntimeError("simulated commit failure")
monkeypatch.setattr(db_session, "commit", boom)
try:
# The ASGI transport re-raises an unhandled app exception rather
# than turning it into a response - the same way it would surface
# in production logs. What matters here is what is left on disk.
with pytest.raises(RuntimeError, match="simulated commit failure"):
await client.post(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
files={"image": ("ref.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
finally:
# The db_session fixture commits again on its own teardown - restore
# the real commit so that does not also blow up.
monkeypatch.setattr(db_session, "commit", original_commit)
folder = settings.upload_path / "vision" / "reference" / str(task.id)
assert not folder.exists() or list(folder.iterdir()) == []
@pytest.mark.asyncio
async def test_reference_images_are_listed_for_their_task(
client, db_session, maker_user,
):
recipe = await create_test_recipe(db_session, maker_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
await db_session.commit()
for _ in range(2):
response = await client.post(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
files={"image": ("ref.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 200
listing = await client.get(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
params={"task_id": task.id},
)
assert listing.status_code == 200
body = listing.json()
assert len(body) == 2
assert {row["task_id"] for row in body} == {task.id}
@pytest.mark.asyncio
async def test_deleting_a_reference_image_removes_the_row_and_the_file(
client, db_session, maker_user,
):
recipe = await create_test_recipe(db_session, maker_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
await db_session.commit()
created = await client.post(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
files={"image": ("ref.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
reference_id = created.json()["id"]
file_path = settings.upload_path / created.json()["path"]
assert file_path.exists()
response = await client.delete(
f"/api/vision/reference-images/{reference_id}",
headers=auth_headers(maker_user),
)
assert response.status_code == 204
assert (await db_session.execute(
select(VisionReferenceImage).where(VisionReferenceImage.id == reference_id)
)).scalar_one_or_none() is None
assert not file_path.exists()
@pytest.mark.asyncio
async def test_deleting_an_unknown_reference_image_is_404(
client, db_session, maker_user,
):
response = await client.delete(
"/api/vision/reference-images/999999",
headers=auth_headers(maker_user),
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_preview_runs_the_graph_and_saves_no_measurement(
client, db_session, maker_user, worker_says,