dd2ebf863a
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>
279 lines
8.6 KiB
Python
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
|