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>
This commit is contained in:
Adriano
2026-02-07 17:10:24 +01:00
parent 26e5b9343d
commit dd2ebf863a
46 changed files with 6322 additions and 90 deletions
+213
View File
@@ -0,0 +1,213 @@
"""Tests for statistics router (/api/statistics)."""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User
from models.measurement import Measurement
from tests.conftest import auth_headers, create_test_recipe
async def _seed_measurements(
client: AsyncClient,
tec_user: User,
recipe_id: int,
values: list[float],
) -> tuple[int, int]:
"""Create measurements and return (subtask_id, version_id)."""
tasks_resp = await client.get(
f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(tec_user)
)
task = tasks_resp.json()[0]
subtask_id = task["subtasks"][0]["id"]
version_id = task["version_id"]
for val in values:
await client.post(
"/api/measurements/",
headers=auth_headers(tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": val,
},
)
return subtask_id, version_id
class TestSummary:
"""GET /api/statistics/summary tests."""
async def test_get_statistics_by_recipe(
self,
client: AsyncClient,
measurement_tec_user: User,
metrologist_user: User,
db_session: AsyncSession,
):
"""Metrologist can get summary statistics."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
await _seed_measurements(
client, measurement_tec_user, recipe.id,
[10.0, 10.1, 10.4, 10.6, 9.4], # pass, pass, warning, fail, fail
)
resp = await client.get(
"/api/statistics/summary",
headers=auth_headers(metrologist_user),
params={"recipe_id": recipe.id},
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 5
assert data["pass_count"] >= 0
assert data["warning_count"] >= 0
assert data["fail_count"] >= 0
assert data["pass_rate"] >= 0
# Total rates should sum to ~100
total_rate = data["pass_rate"] + data["warning_rate"] + data["fail_rate"]
assert abs(total_rate - 100.0) < 0.1
async def test_statistics_require_metrologist(
self,
client: AsyncClient,
maker_user: User,
db_session: AsyncSession,
):
"""Non-Metrologist cannot access statistics."""
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.get(
"/api/statistics/summary",
headers=auth_headers(maker_user),
params={"recipe_id": recipe.id},
)
assert resp.status_code == 403
async def test_statistics_empty(
self,
client: AsyncClient,
metrologist_user: User,
db_session: AsyncSession,
):
"""Statistics on recipe with no measurements returns zeros."""
recipe = await create_test_recipe(
db_session, metrologist_user.id
)
resp = await client.get(
"/api/statistics/summary",
headers=auth_headers(metrologist_user),
params={"recipe_id": recipe.id},
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 0
assert data["pass_count"] == 0
class TestCapability:
"""GET /api/statistics/capability tests."""
async def test_capability_indices(
self,
client: AsyncClient,
measurement_tec_user: User,
metrologist_user: User,
db_session: AsyncSession,
):
"""Capability endpoint returns Cp/Cpk/Pp/Ppk indices."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, _ = await _seed_measurements(
client, measurement_tec_user, recipe.id,
[9.9, 10.0, 10.1, 9.8, 10.2, 10.0, 9.95, 10.05, 10.1, 9.9],
)
resp = await client.get(
"/api/statistics/capability",
headers=auth_headers(metrologist_user),
params={
"recipe_id": recipe.id,
"subtask_id": subtask_id,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["n"] == 10
assert data["mean"] is not None
assert data["std_dev"] is not None
# With UTL=10.5 and LTL=9.5, spread=1.0, Cp should be > 0
if data["cp"] is not None:
assert data["cp"] > 0
class TestSPCData:
"""GET /api/statistics/control-chart tests."""
async def test_spc_data(
self,
client: AsyncClient,
measurement_tec_user: User,
metrologist_user: User,
db_session: AsyncSession,
):
"""Control chart returns values, mean, UCL, LCL."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, _ = await _seed_measurements(
client, measurement_tec_user, recipe.id,
[10.0, 10.1, 9.9, 10.05, 9.95],
)
resp = await client.get(
"/api/statistics/control-chart",
headers=auth_headers(metrologist_user),
params={
"recipe_id": recipe.id,
"subtask_id": subtask_id,
},
)
assert resp.status_code == 200
data = resp.json()
assert len(data["values"]) == 5
assert data["mean"] is not None
assert data["ucl"] is not None
assert data["lcl"] is not None
assert data["ucl"] > data["mean"]
assert data["lcl"] < data["mean"]
class TestDateFilter:
"""Date filter tests."""
async def test_statistics_date_filter(
self,
client: AsyncClient,
measurement_tec_user: User,
metrologist_user: User,
db_session: AsyncSession,
):
"""Date filter parameters are accepted without error."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
await _seed_measurements(
client, measurement_tec_user, recipe.id,
[10.0, 10.1],
)
resp = await client.get(
"/api/statistics/summary",
headers=auth_headers(metrologist_user),
params={
"recipe_id": recipe.id,
"date_from": "2020-01-01T00:00:00",
"date_to": "2099-12-31T23:59:59",
},
)
assert resp.status_code == 200
# All measurements should be within this wide range
assert resp.json()["total"] >= 2