From 1c5292d1e0c8923fb4d668a708f0ab8b6df8d657 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Mon, 21 Sep 2026 22:41:22 +0200 Subject: [PATCH] fix(vision): execute controlla task_type, versione, ricetta attiva, produzione (#38.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execute_task rifiuta 422 se il task non è camera_measure, 409 se la versione non è corrente o la ricetta non è attiva; production_run_id, quando presente, è validato con measurement_service.validate_run_for_version (stessa regola di una lettura manuale). Router: lot_number/serial_number max_length=100, production_run_id gt=0. Co-Authored-By: Claude Sonnet 5 --- src/backend/api/routers/vision.py | 6 +- src/backend/services/vision_service.py | 30 +++ .../tests/test_vision_execute_gates.py | 224 ++++++++++++++++++ 3 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 src/backend/tests/test_vision_execute_gates.py diff --git a/src/backend/api/routers/vision.py b/src/backend/api/routers/vision.py index 18ddedb..433c54f 100644 --- a/src/backend/api/routers/vision.py +++ b/src/backend/api/routers/vision.py @@ -95,9 +95,9 @@ async def _read_image(image: UploadFile) -> bytes: async def execute( task_id: int = Form(...), image: UploadFile = File(...), - lot_number: str | None = Form(None), - serial_number: str | None = Form(None), - production_run_id: int | None = Form(None), + lot_number: str | None = Form(None, max_length=100), + serial_number: str | None = Form(None, max_length=100), + production_run_id: int | None = Form(None, gt=0), db: AsyncSession = Depends(get_db), current_user: User = Depends(require_measurement_tec), ) -> VisionExecuteResponse: diff --git a/src/backend/services/vision_service.py b/src/backend/services/vision_service.py index 20b7c72..3682bd3 100644 --- a/src/backend/services/vision_service.py +++ b/src/backend/services/vision_service.py @@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.backend.config import settings from src.backend.models.orm.measurement import Measurement +from src.backend.models.orm.recipe import RecipeVersion from src.backend.models.orm.task import RecipeSubtask, RecipeTask from src.backend.models.orm.vision import VisionReferenceImage, VisionResult from src.backend.services import measurement_service @@ -132,6 +133,35 @@ async def execute_task( serial_number: str | None = None, production_run_id: int | None = None, ) -> tuple[VisionResult, list[Measurement]]: + if task.task_type != "camera_measure": + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"task type {task.task_type!r} is not a camera measurement", + ) + + version = (await db.execute( + select(RecipeVersion).where(RecipeVersion.id == version_id) + )).scalar_one_or_none() + if version is None or not version.is_current: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="this task's recipe version is no longer current", + ) + + recipe = await measurement_service.recipe_of_version(db, version_id) + if recipe is None or not recipe.active: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="this recipe is not active", + ) + + if production_run_id is not None: + # 404 if the run does not exist, 422 if it belongs to another recipe, + # 409 if it is closed or paused - same rule a manual reading follows. + await measurement_service.validate_run_for_version( + db, production_run_id, version_id, + ) + if not task.vision_json: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, diff --git a/src/backend/tests/test_vision_execute_gates.py b/src/backend/tests/test_vision_execute_gates.py new file mode 100644 index 0000000..8d96923 --- /dev/null +++ b/src/backend/tests/test_vision_execute_gates.py @@ -0,0 +1,224 @@ +"""#38.7: /api/vision/execute must not run a camera graph against a task that +is not one, a version that is no longer current, an inactive recipe, or a +production run that belongs to someone else's job or is already closed - and +lot_number/serial_number/production_run_id must not reach MySQL unbounded. +""" +import io + +import pytest +from PIL import Image +from sqlalchemy import select + +from src.backend.models.api.production import ProductionRunCreate +from src.backend.models.orm.recipe import RecipeVersion +from src.backend.models.orm.station import Station +from src.backend.models.orm.task import RecipeSubtask, RecipeTask +from src.backend.services import production_service +from src.backend.tests.conftest import auth_headers, create_test_recipe + + +def an_image() -> bytes: + buffer = io.BytesIO() + Image.new("L", (64, 64), color=0).save(buffer, format="PNG") + return buffer.getvalue() + + +async def recipe_parts(db_session, recipe_id: int): + version = (await db_session.execute( + select(RecipeVersion).where( + RecipeVersion.recipe_id == recipe_id, + RecipeVersion.is_current == True, # noqa: E712 + ) + )).scalar_one() + task = (await db_session.execute( + select(RecipeTask).where(RecipeTask.version_id == version.id) + )).scalars().first() + subtask = (await db_session.execute( + select(RecipeSubtask).where(RecipeSubtask.task_id == task.id) + )).scalars().first() + return version, task, subtask + + +async def a_camera_task(db_session, recipe_id: int): + version, task, subtask = await recipe_parts(db_session, recipe_id) + task.task_type = "camera_measure" + task.vision_json = {"schema_version": 1, "name": "p", "tools": []} + subtask.vision_output = "t1.diameter" + await db_session.commit() + await db_session.refresh(task) + await db_session.refresh(subtask) + return version, task, subtask + + +@pytest.fixture +def worker_says(monkeypatch): + def _install(outputs: dict): + async def fake_call(image_bytes: bytes, graph: dict) -> dict: + return {"outputs": outputs, "failures": [], + "engine_version": "e" * 40, "duration_ms": 3} + monkeypatch.setattr( + "src.backend.services.vision_service._call_worker", fake_call, + ) + return _install + + +async def _station(db_session, user_id: int, code: str = "ST-VIS") -> Station: + station = Station(code=code, name=f"Stazione {code}", active=True, created_by=user_id) + db_session.add(station) + await db_session.commit() + await db_session.refresh(station) + return station + + +@pytest.mark.asyncio +async def test_execute_rejects_a_task_that_is_not_a_camera_measurement( + client, db_session, measurement_tec_user, worker_says, +): + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task, subtask = await recipe_parts(db_session, recipe.id) + # left at its fixture default task_type ("measure"), no vision_json + worker_says({"t1.diameter": 10.0}) + + response = await client.post( + "/api/vision/execute", + headers=auth_headers(measurement_tec_user), + files={"image": ("p.png", an_image(), "image/png")}, + data={"task_id": str(task.id)}, + ) + + assert response.status_code == 422 + assert "camera" in response.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_execute_rejects_a_non_current_version( + client, db_session, measurement_tec_user, worker_says, +): + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task, subtask = await a_camera_task(db_session, recipe.id) + version.is_current = False + await db_session.commit() + worker_says({"t1.diameter": 10.0}) + + response = await client.post( + "/api/vision/execute", + headers=auth_headers(measurement_tec_user), + files={"image": ("p.png", an_image(), "image/png")}, + data={"task_id": str(task.id)}, + ) + + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_execute_rejects_an_inactive_recipe( + client, db_session, measurement_tec_user, worker_says, +): + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task, subtask = await a_camera_task(db_session, recipe.id) + recipe.active = False + await db_session.commit() + worker_says({"t1.diameter": 10.0}) + + response = await client.post( + "/api/vision/execute", + headers=auth_headers(measurement_tec_user), + files={"image": ("p.png", an_image(), "image/png")}, + data={"task_id": str(task.id)}, + ) + + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_execute_rejects_a_production_run_of_another_recipe( + client, db_session, measurement_tec_user, worker_says, +): + recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="VIS-GATE-1") + version, task, subtask = await a_camera_task(db_session, recipe.id) + other_recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="VIS-GATE-2") + station = await _station(db_session, measurement_tec_user.id) + run = await production_service.open_run( + db_session, + ProductionRunCreate(station_code=station.code, recipe_id=other_recipe.id), + measurement_tec_user, + ) + await db_session.commit() + worker_says({"t1.diameter": 10.0}) + + response = await client.post( + "/api/vision/execute", + headers=auth_headers(measurement_tec_user), + files={"image": ("p.png", an_image(), "image/png")}, + data={"task_id": str(task.id), "production_run_id": str(run.id)}, + ) + + assert response.status_code == 422 + assert "recipe" in response.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_execute_rejects_a_closed_production_run( + client, db_session, measurement_tec_user, worker_says, +): + recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="VIS-GATE-3") + version, task, subtask = await a_camera_task(db_session, recipe.id) + station = await _station(db_session, measurement_tec_user.id, code="ST-VIS-2") + run = await production_service.open_run( + db_session, + ProductionRunCreate(station_code=station.code, recipe_id=recipe.id), + measurement_tec_user, + ) + await db_session.commit() + await production_service.close_run( + db_session, run, measurement_tec_user, measurement_tec_user, + ) + await db_session.commit() + worker_says({"t1.diameter": 10.0}) + + response = await client.post( + "/api/vision/execute", + headers=auth_headers(measurement_tec_user), + files={"image": ("p.png", an_image(), "image/png")}, + data={"task_id": str(task.id), "production_run_id": str(run.id)}, + ) + + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_execute_rejects_a_lot_number_over_the_column_limit( + client, db_session, measurement_tec_user, worker_says, +): + """max_length=100: a longer value must be refused before it can 500 a + strict MySQL VARCHAR(100) column.""" + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task, subtask = await a_camera_task(db_session, recipe.id) + worker_says({"t1.diameter": 10.0}) + + response = await client.post( + "/api/vision/execute", + headers=auth_headers(measurement_tec_user), + files={"image": ("p.png", an_image(), "image/png")}, + data={"task_id": str(task.id), "lot_number": "L" * 101}, + ) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_execute_rejects_a_non_positive_production_run_id( + client, db_session, measurement_tec_user, worker_says, +): + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task, subtask = await a_camera_task(db_session, recipe.id) + worker_says({"t1.diameter": 10.0}) + + response = await client.post( + "/api/vision/execute", + headers=auth_headers(measurement_tec_user), + files={"image": ("p.png", an_image(), "image/png")}, + data={"task_id": str(task.id), "production_run_id": "0"}, + ) + + assert response.status_code == 422