Files
TieMeasureFlow/server/tests/test_measurements.py
Adriano dd2ebf863a feat: FASE 7 - Polish & Testing (security, i18n, test suite, docs)
Security hardening: CORS lockdown, rate limiting middleware con sliding
window e eviction IP stale, security headers (CSP, HSTS, X-Frame-Options),
session cookie hardening, filename sanitization upload.

i18n completion: internazionalizzati barcode.js e csv-export.js con bridge
window.BARCODE_I18N/CSV_I18N, aggiornati .po IT/EN con 27 nuove stringhe.

Tablet UX: touch target 44px per dispositivi coarse pointer.

Test suite: 101 test totali (76 server + 25 client), copertura completa
di tutti i router API, autenticazione, ruoli, CRUD, SPC, file upload,
security integration. Infrastruttura SQLite async in-memory con fixtures.

Fix critici: MissingGreenlet in recipe_service (selectinload eager),
route ordering tasks.py, auth_service bcrypt diretto, Measurement.id
Integer per SQLite.

Documentazione: API.md (riferimento completo 40+ endpoint),
DEPLOYMENT.md (guida produzione con Docker/Nginx/SSL),
USER_GUIDE.md (manuale utente per ruolo).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 17:10:24 +01:00

279 lines
8.6 KiB
Python

"""Tests for measurements router (/api/measurements)."""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User
from models.recipe import RecipeVersion
from models.task import RecipeTask, RecipeSubtask
from tests.conftest import 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_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 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_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