Files
TieMeasureFlow/src/backend/tests/test_measurements.py
T
Adriano 1d5f24510c fix(measurements): export CSV ammette admin senza ruolo esplicito (#36 punto 3)
Il controllo ruolo di GET /api/measurements/export/csv ignorava is_admin:
un admin senza MeasurementTec/Metrologist assegnato prendeva 403, a
differenza di ogni altro endpoint che passa da require_role (ruolo o admin).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:39:10 +02:00

748 lines
26 KiB
Python

"""Tests for measurements router (/api/measurements)."""
import csv
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.station import Station
from src.backend.models.orm.user import User
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
from src.backend.services import auth_service
from src.backend.tests.conftest import _create_user, auth_headers, create_test_recipe
async def _get_subtask_and_version(
client: AsyncClient, user: User, recipe_id: int
) -> tuple[int, int]:
"""Helper: return (subtask_id, version_id) from the recipe's current version."""
tasks_resp = await client.get(
f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user)
)
task = tasks_resp.json()[0]
subtask_id = task["subtasks"][0]["id"]
version_id = task["version_id"]
return subtask_id, version_id
class TestCreateMeasurement:
"""POST /api/measurements/ tests."""
async def test_create_measurement(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""MeasurementTec can create a measurement."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.1,
"input_method": "manual",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["value"] == pytest.approx(10.1, rel=1e-4)
assert data["pass_fail"] in ("pass", "warning", "fail")
assert data["measured_by"] == measurement_tec_user.id
async def test_create_measurement_with_input_duration(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""input_duration_ms is stored and returned when provided."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
"input_duration_ms": 4250,
},
)
assert resp.status_code == 200
assert resp.json()["input_duration_ms"] == 4250
async def test_create_measurement_without_input_duration(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""input_duration_ms defaults to null when omitted."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
},
)
assert resp.status_code == 200
assert resp.json()["input_duration_ms"] is None
async def test_measurement_requires_auth(self, client: AsyncClient):
"""Creating measurement without auth returns 401."""
resp = await client.post(
"/api/measurements/",
json={
"subtask_id": 1,
"version_id": 1,
"value": 10.0,
},
)
assert resp.status_code == 401
async def test_measurement_validation(
self,
client: AsyncClient,
measurement_tec_user: User,
):
"""Measurement with non-existent subtask returns 400."""
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": 99999,
"version_id": 99999,
"value": 10.0,
},
)
assert resp.status_code == 400
class TestMeasurementPassFail:
"""Pass/fail calculation tests."""
async def test_measurement_pass(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""Value within tolerance -> pass."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
# nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
},
)
assert resp.status_code == 200
assert resp.json()["pass_fail"] == "pass"
async def test_measurement_warning(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""Value outside warning but inside tolerance -> warning."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
# uwl=10.3 < 10.4 < utl=10.5 -> warning
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.4,
},
)
assert resp.status_code == 200
assert resp.json()["pass_fail"] == "warning"
async def test_measurement_fail(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""Value outside tolerance limits -> fail."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
# value > utl=10.5 -> fail
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.6,
},
)
assert resp.status_code == 200
assert resp.json()["pass_fail"] == "fail"
class TestMeasurementValueBounds:
"""value rejects NaN/Infinity and out-of-DECIMAL(12,6)-scale numbers (#20)."""
@pytest.mark.parametrize("bad_value", [1e300, 1_000_000])
async def test_out_of_scale_value_is_422(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
bad_value: float,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": bad_value,
},
)
assert resp.status_code == 422
@pytest.mark.parametrize("raw_token", ["NaN", "Infinity", "-Infinity"])
async def test_nan_and_infinity_are_422(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
raw_token: str,
):
"""NaN/Infinity are not valid JSON: httpx's own encoder refuses to send
them via ``json=``, so the raw body is built by hand here - exactly
what a client that is not httpx could still put on the wire."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
body = (
'{"subtask_id": %d, "version_id": %d, "value": %s}'
% (subtask_id, version_id, raw_token)
)
resp = await client.post(
"/api/measurements/",
headers={**auth_headers(measurement_tec_user), "Content-Type": "application/json"},
content=body,
)
assert resp.status_code == 422
async def test_value_is_rounded_before_the_verdict(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""10.5000004 rounds to 10.5 (=utl) first, so it is not a fail."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.5000004,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["value"] == pytest.approx(10.5)
# 10.5 is > uwl(10.3) but not > utl(10.5): warning, not fail.
assert data["pass_fail"] == "warning"
class TestSaveMeasurementCoherence:
"""save_measurement checks quote/version/run coherence before insert (#5)."""
async def _station(self, db_session, user_id, code):
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
async def _supervisor(self, db_session, username):
user = await auth_service.create_user(
db_session, username=username, password="TurnoPwd1",
display_name="Capo Turno", roles=["Supervisor"],
)
await db_session.commit()
return user, "TurnoPwd1"
async def test_quota_of_another_recipe_version_is_422(
self, client: AsyncClient, measurement_tec_user: User, db_session: AsyncSession,
):
recipe_a = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-A")
recipe_b = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-B")
subtask_a, _version_a = await _get_subtask_and_version(
client, measurement_tec_user, recipe_a.id
)
_subtask_b, version_b = await _get_subtask_and_version(
client, measurement_tec_user, recipe_b.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={"subtask_id": subtask_a, "version_id": version_b, "value": 10.0},
)
assert resp.status_code == 422
async def test_measurement_of_other_recipe_in_run_is_422(
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
db_session: AsyncSession,
):
recipe_a = await create_test_recipe(
db_session, measurement_tec_user.id, code="COH-RUNA"
)
recipe_b = await create_test_recipe(
db_session, measurement_tec_user.id, code="COH-RUNB"
)
station = await self._station(db_session, admin_user.id, "ST-COH1")
run = (await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={"station_code": station.code, "recipe_id": recipe_a.id},
)).json()
subtask_b, version_b = await _get_subtask_and_version(
client, measurement_tec_user, recipe_b.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_b,
"version_id": version_b,
"value": 10.0,
"production_run_id": run["id"],
},
)
assert resp.status_code == 422
async def test_measurement_on_closed_run_is_409(
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-CLOSED")
station = await self._station(db_session, admin_user.id, "ST-COH2")
run = (await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={"station_code": station.code, "recipe_id": recipe.id},
)).json()
supervisor, password = await self._supervisor(db_session, "capo-coh-close")
await client.post(
f"/api/production-runs/{run['id']}/close",
headers=auth_headers(measurement_tec_user),
json={
"supervisor_username": supervisor.username,
"supervisor_password": password,
},
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
"production_run_id": run["id"],
},
)
assert resp.status_code == 409
async def test_measurement_on_paused_run_is_409(
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-PAUSED")
station = await self._station(db_session, admin_user.id, "ST-COH3")
run = (await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={"station_code": station.code, "recipe_id": recipe.id},
)).json()
supervisor, password = await self._supervisor(db_session, "capo-coh-pause")
await client.post(
f"/api/production-runs/{run['id']}/pause",
headers=auth_headers(measurement_tec_user),
json={
"supervisor_username": supervisor.username,
"supervisor_password": password,
},
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
"production_run_id": run["id"],
},
)
assert resp.status_code == 409
async def test_valid_measurement_in_matching_run_is_200(
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-OK")
station = await self._station(db_session, admin_user.id, "ST-COH4")
run = (await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={"station_code": station.code, "recipe_id": recipe.id},
)).json()
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
"production_run_id": run["id"],
},
)
assert resp.status_code == 200
async def test_measurement_on_unknown_run_is_404(
self, client: AsyncClient, measurement_tec_user: User, db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-404")
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
"production_run_id": 999999,
},
)
assert resp.status_code == 404
class TestListMeasurements:
"""GET /api/measurements/ tests."""
async def test_list_measurements(
self,
client: AsyncClient,
measurement_tec_user: User,
metrologist_user: User,
db_session: AsyncSession,
):
"""Metrologist can list measurements with pagination."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
# Create some measurements
for val in [9.8, 10.0, 10.2]:
await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": val,
},
)
# List as Metrologist
resp = await client.get(
"/api/measurements/",
headers=auth_headers(metrologist_user),
)
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert "total" in data
assert data["total"] >= 3
async def test_measurement_tec_can_list_measurements(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""Regression: MeasurementTec must be able to list measurements.
The Flask client renders task_complete.html by calling GET
/api/measurements?version_id=X right after the operator finishes a
recipe. The endpoint used to require Metrologist, which silently
produced an empty riepilogo for every operator without that role.
"""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
for val in [9.8, 10.0, 10.2]:
await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": val,
},
)
resp = await client.get(
f"/api/measurements/?version_id={version_id}",
headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["total"] >= 3
async def test_measurement_by_recipe_filter(
self,
client: AsyncClient,
measurement_tec_user: User,
metrologist_user: User,
db_session: AsyncSession,
):
"""Filtering measurements by recipe_id returns only matching records."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
},
)
resp = await client.get(
"/api/measurements/",
headers=auth_headers(metrologist_user),
params={"recipe_id": recipe.id},
)
assert resp.status_code == 200
assert resp.json()["total"] >= 1
class TestMeasurementStatistics:
"""Measurement deviation / statistics edge case tests."""
async def test_measurement_has_deviation(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""Measurement response includes deviation from nominal."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.2,
},
)
assert resp.status_code == 200
data = resp.json()
# deviation = 10.2 - nominal(10.0) = 0.2
assert data["deviation"] is not None
assert abs(data["deviation"] - 0.2) < 0.001
class TestExportCsv:
"""GET /api/measurements/export/csv tests (gitea#22, gitea#36 point 3)."""
async def test_admin_without_explicit_role_can_export(
self, client: AsyncClient, db_session: AsyncSession,
):
"""An admin with no MeasurementTec/Metrologist role must not get 403 (#36.3).
admin_user (conftest) already has both roles, which would pass even
without the fix: a bare admin (roles=[]) is the actual regression case.
"""
bare_admin = await _create_user(
db_session, username="bare_admin", roles=[], is_admin=True,
)
resp = await client.get(
"/api/measurements/export/csv",
headers=auth_headers(bare_admin),
)
assert resp.status_code == 200
async def test_maker_without_role_still_forbidden(
self, client: AsyncClient, maker_user: User,
):
"""A plain Maker (no MeasurementTec/Metrologist/admin) still gets 403."""
resp = await client.get(
"/api/measurements/export/csv",
headers=auth_headers(maker_user),
)
assert resp.status_code == 403
async def test_lot_number_formula_is_neutralised(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""A lot_number starting with '=' must not reach the CSV as a live formula (#22)."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
"lot_number": '=HYPERLINK("http://x","y")',
},
)
assert resp.status_code == 200
export_resp = await client.get(
"/api/measurements/export/csv",
headers=auth_headers(measurement_tec_user),
)
assert export_resp.status_code == 200
rows = list(csv.reader(export_resp.text.splitlines()))
header, row = rows[0], rows[1]
lot_cell = row[header.index("lot_number")]
# Quoted with a leading ' so a spreadsheet reads it as literal text,
# not the raw formula that would otherwise be live in Excel/LibreOffice.
assert lot_cell == '\'=HYPERLINK("http://x","y")'
async def test_negative_value_stays_numeric(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""Neutralisation must never touch numeric cells like the measured value."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": -1.25,
},
)
assert resp.status_code == 200
export_resp = await client.get(
"/api/measurements/export/csv",
headers=auth_headers(measurement_tec_user),
)
assert export_resp.status_code == 200
rows = list(csv.reader(export_resp.text.splitlines()))
header, row = rows[0], rows[1]
value_cell = row[header.index("value")]
assert value_cell == "-1.250000"