"""#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