1c5292d1e0
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 <noreply@anthropic.com>
225 lines
8.0 KiB
Python
225 lines
8.0 KiB
Python
"""#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
|