Files
TieMeasureFlow/src/backend/tests/test_vision_execute_gates.py
T

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, **kwargs) -> 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