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
+240
View File
@@ -0,0 +1,240 @@
# TieMeasureFlow i18n Setup Guide
## Overview
TieMeasureFlow supports complete internationalization (i18n) with:
- **Server-side**: Flask-Babel for Python/Jinja2 templates
- **Client-side**: alpinejs-i18n for JavaScript/Alpine.js
- **Supported languages**: Italian (IT - default) and English (EN)
## Directory Structure
```
client/
├── translations/ # Flask-Babel translations
│ ├── babel.cfg # Extraction config
│ ├── it/LC_MESSAGES/
│ │ ├── messages.po # Italian strings
│ │ └── messages.mo # Compiled binary (generated)
│ └── en/LC_MESSAGES/
│ ├── messages.po # English translations
│ └── messages.mo # Compiled binary (generated)
├── static/js/locales/ # Alpine.js i18n
│ ├── it.json # Italian client-side strings
│ └── en.json # English client-side strings
└── compile_translations.py # Utility to compile .po → .mo
```
## How It Works
### Language Selection
1. **User session**: Language stored in `session["language"]` (persistent)
2. **Browser fallback**: Uses `Accept-Language` header if no session
3. **Default**: Italian (IT)
### Server-Side (Flask-Babel)
**In Python code:**
```python
from flask_babel import gettext as _
flash(_("Profilo aggiornato con successo"))
```
**In Jinja2 templates:**
```jinja2
<h1>{{ _('Accedi al sistema') }}</h1>
{# With variables #}
<p>{{ _('Benvenuto, %(name)s!', name=user.display_name) }}</p>
```
### Client-Side (Alpine.js i18n)
**Load i18n in base template:**
```html
<script src="/static/js/lib/alpinejs-i18n.min.js"></script>
<script>
// Initialize i18n with current language
Alpine.plugin(AlpineI18n);
Alpine.store('i18n', {
locale: '{{ current_language }}',
fallbackLocale: 'it',
messages: {}
});
// Load locale files
fetch('/static/js/locales/{{ current_language }}.json')
.then(r => r.json())
.then(data => Alpine.store('i18n').messages['{{ current_language }}'] = data);
</script>
```
**Use in templates:**
```html
<button @click="submit" x-text="$t('numpad.submit')"></button>
<p x-text="$t('auth.welcome', {name: userName})"></p>
```
## Workflow
### 1. Add New Strings
**Python/Templates:**
1. Wrap strings with `_()` or `{{ _() }}`
2. Extract strings: `pybabel extract -F translations/babel.cfg -o translations/messages.pot .`
3. Update catalogs: `pybabel update -i translations/messages.pot -d translations`
4. Edit `translations/*/LC_MESSAGES/messages.po` files
5. Compile: `python compile_translations.py`
**JavaScript/Alpine:**
1. Add keys to `static/js/locales/it.json` (Italian strings)
2. Add translations to `static/js/locales/en.json`
3. No compilation needed (loaded at runtime)
### 2. Compile Translations
After editing .po files:
```bash
cd client
python compile_translations.py
```
This generates `.mo` binary files that Flask-Babel uses at runtime.
### 3. Language Switching
**Endpoint**: `GET /set-language/<lang>`
**Example**:
```html
<a href="{{ url_for('set_language', lang='en') }}">English</a>
<a href="{{ url_for('set_language', lang='it') }}">Italiano</a>
```
**With Alpine.js**:
```html
<button @click="$store.i18n.locale = 'en'; window.location.href='/set-language/en'">
English
</button>
```
## Translation Keys
### Server-Side (messages.po)
56 strings covering:
- **Login**: "Accedi al sistema", "Credenziali non valide", etc.
- **Navbar**: "Misure", "Ricette", "Statistiche", etc.
- **Profile**: "Profilo Utente", "Salva Modifiche", etc.
- **Flash messages**: "Benvenuto, %(name)s!", "Logout effettuato", etc.
- **Common UI**: "Caricamento...", "Salva", "Annulla", "Conferma", etc.
- **Measurements**: "Conforme", "Non Conforme", etc.
### Client-Side (locales/*.json)
Structured keys:
```json
{
"numpad": { "title", "clear", "submit", "decimal", "close" },
"measurement": { "pass", "warning", "fail", "value", "next" },
"common": { "loading", "save", "cancel", "confirm", "delete", "edit", ... },
"theme": { "light", "dark", "toggle" },
"language": { "switch", "it", "en" },
"auth": { "login", "username", "password", "signIn", ... },
"nav": { "measurements", "recipes", "statistics", ... },
"profile": { "title", "displayName", "language", "theme", ... }
}
```
## Configuration
**config.py** must define:
```python
BABEL_DEFAULT_LOCALE = 'it'
BABEL_TRANSLATION_DIRECTORIES = 'translations'
LANGUAGES = {
'it': 'Italiano',
'en': 'English'
}
```
**app.py** provides:
- `get_locale()`: Language selector function
- `/set-language/<lang>`: Language switch endpoint
- `inject_globals()`: Exposes `current_language` and `languages` to templates
## Best Practices
1. **Always use Italian as msgid**: Italian strings are the canonical keys
2. **Keep client/server keys synced**: Use similar naming across .po and .json
3. **Test both languages**: Switch languages and verify all pages
4. **Recompile after editing**: Run `compile_translations.py` after changing .po files
5. **Use variables for dynamic content**: `_('Welcome, %(name)s!', name=user)` or `$t('auth.welcome', {name})`
6. **Avoid hardcoded strings**: Always use translation functions
## Troubleshooting
**Translations not appearing:**
- Verify `.mo` files exist: `find translations -name "*.mo"`
- Check session language: `session["language"]` or browser console
- Recompile: `python compile_translations.py`
- Restart Flask server
**Client-side i18n not working:**
- Check browser console for fetch errors
- Verify JSON files exist: `static/js/locales/{it,en}.json`
- Check Alpine.js i18n plugin is loaded
- Verify `Alpine.store('i18n')` is initialized
**Missing translations show as keys:**
- Add missing keys to .po or .json files
- Recompile .po files if server-side
- Reload page if client-side
## Commands Reference
```bash
# Extract translatable strings from code
pybabel extract -F translations/babel.cfg -o translations/messages.pot .
# Initialize new language (first time)
pybabel init -i translations/messages.pot -d translations -l en
# Update existing catalogs with new strings
pybabel update -i translations/messages.pot -d translations
# Compile .po to .mo (required after editing)
python compile_translations.py
# Check translation statistics
msgfmt --statistics translations/it/LC_MESSAGES/messages.po
msgfmt --statistics translations/en/LC_MESSAGES/messages.po
```
## Future Expansion
To add a new language (e.g., German):
1. **Server-side**:
```bash
pybabel init -i translations/messages.pot -d translations -l de
# Edit translations/de/LC_MESSAGES/messages.po
python compile_translations.py
```
2. **Client-side**:
- Create `static/js/locales/de.json`
- Add to config: `LANGUAGES = {..., 'de': 'Deutsch'}`
3. **Update loader**: Modify base template to support new locale
## Resources
- Flask-Babel: https://python-babel.github.io/flask-babel/
- Babel: http://babel.pocoo.org/
- Alpine.js i18n: https://github.com/alpine-collective/alpine-i18n
- PO file format: https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html
+4
View File
@@ -10,6 +10,7 @@ class Config:
# Flask
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", "change-this-to-another-random-secret-key")
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
# API Server connection
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
@@ -17,6 +18,9 @@ class Config:
# Session
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = not DEBUG # Only secure cookies in production (HTTPS)
PERMANENT_SESSION_LIFETIME = 28800 # 8 hours
WTF_CSRF_TIME_LIMIT = 3600 # 1 hour
# Babel i18n
BABEL_DEFAULT_LOCALE = "it"
+4
View File
@@ -0,0 +1,4 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
+4
View File
@@ -9,3 +9,7 @@ urllib3>=2.0.0
# Utilities
python-dotenv>=1.0.0
# Testing
pytest>=8.0.0
coverage>=7.0.0
+26
View File
@@ -1,3 +1,29 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Tablet Touch Targets (44px minimum) */
@layer components {
.touch-target {
min-height: 44px;
min-width: 44px;
}
}
@media (pointer: coarse) {
button,
.btn,
[role="button"],
a.inline-flex {
min-height: 44px;
}
/* Flash message dismiss button */
.fixed button[x-data] {
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
}
+28
View File
@@ -372,3 +372,31 @@ textarea:focus-visible {
[x-cloak] {
display: none !important;
}
/* ============================================================
Tablet Touch Targets (44px minimum per WCAG 2.5.5)
============================================================ */
.touch-target {
min-height: 44px;
min-width: 44px;
}
@media (pointer: coarse) {
button,
.btn,
[role="button"],
a.inline-flex {
min-height: 44px;
}
/* Flash message dismiss button */
.fixed button[x-data] {
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
}
+5 -3
View File
@@ -5,6 +5,8 @@
*/
function barcodeScanner() {
const _t = (key) => (window.BARCODE_I18N && window.BARCODE_I18N[key]) || key;
return {
scanning: false,
result: null,
@@ -19,7 +21,7 @@ function barcodeScanner() {
// Check library availability
if (!window.Html5Qrcode) {
this.error = 'Scanner library not loaded';
this.error = _t('scanner_lib_not_loaded');
this.scanning = false;
return;
}
@@ -29,7 +31,7 @@ function barcodeScanner() {
const devices = await Html5Qrcode.getCameras();
if (!devices || devices.length === 0) {
this.error = 'Nessuna fotocamera disponibile';
this.error = _t('no_camera_available');
this.scanning = false;
return;
}
@@ -72,7 +74,7 @@ function barcodeScanner() {
} catch (err) {
console.error('Scanner initialization error:', err);
this.error = 'Impossibile accedere alla fotocamera';
this.error = _t('camera_access_error');
this.scanning = false;
}
},
+32 -30
View File
@@ -5,6 +5,8 @@
*/
function csvExport() {
const _t = (key) => (window.CSV_I18N && window.CSV_I18N[key]) || key;
return {
// Locale-specific settings
delimiter: ';', // Italian Excel standard
@@ -50,20 +52,20 @@ function csvExport() {
buildMeasurementCSV(measurements) {
const headers = [
'ID',
'Subtask ID',
'Nome Sottotask',
'Valore Misurato',
'Unità',
'Valore Nominale',
'Tolleranza +',
'Tolleranza -',
'Scarto',
'Esito',
'Numero Lotto',
'Numero Seriale',
'Metodo Input',
'Data Misurazione',
'Operatore'
_t('subtask_id'),
_t('subtask_name'),
_t('measured_value'),
_t('unit'),
_t('nominal_value'),
_t('tolerance_plus'),
_t('tolerance_minus'),
_t('deviation'),
_t('result'),
_t('lot_number'),
_t('serial_number'),
_t('input_method'),
_t('measurement_date'),
_t('operator')
];
let csv = headers.join(this.delimiter) + '\n';
@@ -97,28 +99,28 @@ function csvExport() {
*/
buildTaskSummaryCSV(task) {
// Header section
let csv = 'RIEPILOGO ESECUZIONE TASK\n\n';
let csv = _t('task_summary_title') + '\n\n';
csv += `Task ID${this.delimiter}${task.id}\n`;
csv += `Nome Task${this.delimiter}${this.escapeCsvValue(task.name || '')}\n`;
csv += `Ricetta${this.delimiter}${this.escapeCsvValue(task.recipe_name || '')}\n`;
csv += `Operatore${this.delimiter}${this.escapeCsvValue(task.operator_name || '')}\n`;
csv += `Data Inizio${this.delimiter}${this.formatDateTime(task.started_at)}\n`;
csv += `Data Fine${this.delimiter}${this.formatDateTime(task.completed_at)}\n`;
csv += `Stato${this.delimiter}${task.status || ''}\n`;
csv += `Lotto${this.delimiter}${this.escapeCsvValue(task.lot_number || '')}\n`;
csv += `Seriale${this.delimiter}${this.escapeCsvValue(task.serial_number || '')}\n`;
csv += `${_t('task_id')}${this.delimiter}${task.id}\n`;
csv += `${_t('task_name')}${this.delimiter}${this.escapeCsvValue(task.name || '')}\n`;
csv += `${_t('recipe')}${this.delimiter}${this.escapeCsvValue(task.recipe_name || '')}\n`;
csv += `${_t('operator')}${this.delimiter}${this.escapeCsvValue(task.operator_name || '')}\n`;
csv += `${_t('start_date')}${this.delimiter}${this.formatDateTime(task.started_at)}\n`;
csv += `${_t('end_date')}${this.delimiter}${this.formatDateTime(task.completed_at)}\n`;
csv += `${_t('status')}${this.delimiter}${task.status || ''}\n`;
csv += `${_t('lot')}${this.delimiter}${this.escapeCsvValue(task.lot_number || '')}\n`;
csv += `${_t('serial')}${this.delimiter}${this.escapeCsvValue(task.serial_number || '')}\n`;
// Statistics
csv += `\nSTATISTICHE\n`;
csv += `\n${_t('statistics')}\n`;
const stats = this.calculateStats(task.measurements);
csv += `Totale Misure${this.delimiter}${stats.total}\n`;
csv += `Passate${this.delimiter}${stats.passed}\n`;
csv += `Fallite${this.delimiter}${stats.failed}\n`;
csv += `Percentuale Successo${this.delimiter}${this.formatNumber(stats.passRate)}%\n`;
csv += `${_t('total_measurements')}${this.delimiter}${stats.total}\n`;
csv += `${_t('passed')}${this.delimiter}${stats.passed}\n`;
csv += `${_t('failed')}${this.delimiter}${stats.failed}\n`;
csv += `${_t('pass_rate')}${this.delimiter}${this.formatNumber(stats.passRate)}%\n`;
// Measurements detail
csv += `\nDETTAGLIO MISURE\n`;
csv += `\n${_t('measurement_details')}\n`;
csv += this.buildMeasurementCSV(task.measurements);
return csv;
+1 -1
View File
@@ -134,7 +134,7 @@
<span class="text-sm font-medium flex-1">{{ message }}</span>
<!-- Dismiss -->
<button @click="show = false" class="shrink-0 hover:opacity-70 transition-opacity">
<button @click="show = false" class="shrink-0 hover:opacity-70 transition-opacity touch-target">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
@@ -148,4 +148,12 @@
</div>
</div>
</div>
<script>
window.BARCODE_I18N = {
scanner_lib_not_loaded: "{{ _('Libreria scanner non caricata') }}",
no_camera_available: "{{ _('Nessuna fotocamera disponibile') }}",
camera_access_error: "{{ _('Impossibile accedere alla fotocamera') }}"
};
</script>
</div>
@@ -262,6 +262,39 @@
{% block extra_js %}
<script src="{{ url_for('static', filename='js/csv-export.js') }}"></script>
<script>
window.CSV_I18N = {
subtask_id: "{{ _('Subtask ID') }}",
subtask_name: "{{ _('Nome Sottotask') }}",
measured_value: "{{ _('Valore Misurato') }}",
unit: "{{ _('Unita') }}",
nominal_value: "{{ _('Valore Nominale') }}",
tolerance_plus: "{{ _('Tolleranza +') }}",
tolerance_minus: "{{ _('Tolleranza -') }}",
deviation: "{{ _('Scarto') }}",
result: "{{ _('Esito') }}",
lot_number: "{{ _('Numero Lotto') }}",
serial_number: "{{ _('Numero Seriale') }}",
input_method: "{{ _('Metodo Input') }}",
measurement_date: "{{ _('Data Misurazione') }}",
operator: "{{ _('Operatore') }}",
task_summary_title: "{{ _('RIEPILOGO ESECUZIONE TASK') }}",
task_id: "{{ _('Task ID') }}",
task_name: "{{ _('Nome Task') }}",
recipe: "{{ _('Ricetta') }}",
start_date: "{{ _('Data Inizio') }}",
end_date: "{{ _('Data Fine') }}",
status: "{{ _('Stato') }}",
lot: "{{ _('Lotto') }}",
serial: "{{ _('Seriale') }}",
statistics: "{{ _('STATISTICHE') }}",
total_measurements: "{{ _('Totale Misure') }}",
passed: "{{ _('Passate') }}",
failed: "{{ _('Fallite') }}",
pass_rate: "{{ _('Percentuale Successo') }}",
measurement_details: "{{ _('DETTAGLIO MISURE') }}"
};
</script>
<script>
// Pass data to csv-export.js
window.measurementData = {
recipeName: {{ recipe.name|tojson }},
View File
+104
View File
@@ -0,0 +1,104 @@
"""Shared test fixtures for Flask client tests.
Creates a Flask test client and mocks the API client (requests to FastAPI server).
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Ensure the client package is importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app import create_app
# All blueprint modules that import api_client at module level.
# We must patch at the *use-site* (blueprint namespace) so the local name
# ``api_client`` in each module points to the mock, not the real singleton.
_API_CLIENT_PATCH_TARGETS = [
"blueprints.auth.api_client",
"blueprints.maker.api_client",
"blueprints.measure.api_client",
"blueprints.statistics.api_client",
]
@pytest.fixture
def flask_app():
"""Create a Flask application configured for testing."""
app = create_app()
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
app.config["SECRET_KEY"] = "test-secret-key"
# Provide default template variables that are normally set inside
# specific Jinja2 blocks and therefore not visible to sibling blocks.
@app.context_processor
def _inject_template_defaults():
return {
"versions": [],
"current_version": None,
}
return app
@pytest.fixture
def client(flask_app):
"""Yield a Flask test client."""
with flask_app.test_client() as test_client:
yield test_client
@pytest.fixture
def logged_in_client(flask_app):
"""Yield a Flask test client with a logged-in session."""
with flask_app.test_client() as test_client:
with test_client.session_transaction() as sess:
sess["api_key"] = "test-api-key-12345"
sess["user"] = {
"id": 1,
"username": "testuser",
"display_name": "Test User",
"roles": ["Maker", "MeasurementTec", "Metrologist"],
"is_admin": True,
"language_pref": "en",
"theme_pref": "light",
"active": True,
}
sess["user_id"] = 1
sess["language"] = "en"
sess["theme"] = "light"
yield test_client
@pytest.fixture
def mock_api_client():
"""Patch the api_client singleton used in blueprints.
Patches every blueprint module that does
``from services.api_client import api_client`` so the local name
in each module resolves to the same MagicMock instance.
Yields the mocked APIClient instance so tests can configure responses:
mock_api_client.get.return_value = {"items": [...], "total": 1}
mock_api_client.post.return_value = {"user": {...}, "api_key": "..."}
"""
mock = MagicMock()
# Default: all methods return empty success dict
mock.get.return_value = {}
mock.post.return_value = {}
mock.put.return_value = {}
mock.delete.return_value = {}
# Stack patches for every blueprint that imports api_client
patchers = [patch(target, mock) for target in _API_CLIENT_PATCH_TARGETS]
for p in patchers:
p.start()
yield mock
for p in patchers:
p.stop()
+130
View File
@@ -0,0 +1,130 @@
"""Tests for auth blueprint (client/blueprints/auth.py).
Covers login GET/POST, logout, unauthenticated redirect, and language/theme switch.
"""
import pytest
from unittest.mock import patch
class TestLoginPage:
"""GET /auth/login tests."""
def test_login_page_renders(self, client):
"""Login page renders successfully with 200 status."""
resp = client.get("/auth/login")
assert resp.status_code == 200
assert b"login" in resp.data.lower() or b"Login" in resp.data
def test_login_page_redirects_when_already_logged_in(self, logged_in_client):
"""Already authenticated user is redirected away from login."""
resp = logged_in_client.get("/auth/login")
assert resp.status_code == 302
class TestLoginPost:
"""POST /auth/login tests."""
def test_login_post_success_redirects(self, client, mock_api_client):
"""Successful login POST sets session and redirects."""
mock_api_client.post.return_value = {
"api_key": "new-api-key-abc",
"user": {
"id": 1,
"username": "testuser",
"display_name": "Test User",
"roles": ["MeasurementTec"],
"is_admin": False,
"language_pref": "en",
"theme_pref": "light",
"active": True,
},
}
# Mock settings call made after login
mock_api_client.get.return_value = {}
resp = client.post(
"/auth/login",
data={"username": "testuser", "password": "pass123"},
follow_redirects=False,
)
assert resp.status_code == 302
mock_api_client.post.assert_called_once()
# Verify session was populated
with client.session_transaction() as sess:
assert sess.get("api_key") == "new-api-key-abc"
assert sess["user"]["username"] == "testuser"
def test_login_post_error_shows_error(self, client, mock_api_client):
"""Failed login returns the login page with error flash."""
mock_api_client.post.return_value = {
"error": True,
"detail": "Credenziali non valide",
}
resp = client.post(
"/auth/login",
data={"username": "bad", "password": "wrong"},
follow_redirects=True,
)
assert resp.status_code == 200
# Error message should appear in the response (flashed)
assert b"Credenziali non valide" in resp.data or b"login" in resp.data.lower()
def test_login_post_empty_fields(self, client, mock_api_client):
"""Login with empty username/password shows validation error."""
resp = client.post(
"/auth/login",
data={"username": "", "password": ""},
follow_redirects=True,
)
assert resp.status_code == 200
# Should render login again (not crash)
class TestLogout:
"""GET/POST /auth/logout tests."""
def test_logout_clears_session(self, logged_in_client, mock_api_client):
"""Logout clears session and redirects to login."""
mock_api_client.post.return_value = {}
resp = logged_in_client.get("/auth/logout", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
# Session should be cleared
with logged_in_client.session_transaction() as sess:
assert "api_key" not in sess
assert "user" not in sess
class TestUnauthenticatedRedirect:
"""Protected routes redirect unauthenticated users."""
def test_profile_requires_login(self, client):
"""Profile page redirects to login when not authenticated."""
resp = client.get("/auth/profile", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
class TestLanguageSwitch:
"""Language and theme switching via app routes."""
def test_set_language_stores_in_session(self, client):
"""Setting language updates session and redirects back."""
resp = client.get("/set-language/en", follow_redirects=False)
assert resp.status_code == 302
with client.session_transaction() as sess:
assert sess.get("language") == "en"
def test_set_language_invalid_ignored(self, client):
"""Setting an unsupported language does not crash and still redirects."""
resp = client.get("/set-language/xx", follow_redirects=False)
assert resp.status_code == 302
with client.session_transaction() as sess:
# Invalid language should NOT be stored
assert sess.get("language") != "xx"
+89
View File
@@ -0,0 +1,89 @@
"""Tests for maker blueprint (client/blueprints/maker.py).
Covers recipe list, recipe creation page, role enforcement, and recipe edit.
"""
import pytest
class TestRecipeList:
"""GET /maker/recipes tests."""
def test_recipe_list_renders(self, logged_in_client, mock_api_client):
"""Recipe list page renders for Maker role."""
mock_api_client.get.return_value = {
"items": [
{"id": 1, "code": "REC-001", "name": "Recipe A"},
],
"total": 1,
"pages": 1,
}
resp = logged_in_client.get("/maker/recipes")
assert resp.status_code == 200
def test_recipe_list_requires_login(self, client):
"""Unauthenticated user is redirected to login."""
resp = client.get("/maker/recipes", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
class TestRecipeNew:
"""GET /maker/recipes/new tests."""
def test_recipe_new_renders(self, logged_in_client, mock_api_client):
"""Recipe creation page renders for Maker role."""
resp = logged_in_client.get("/maker/recipes/new")
assert resp.status_code == 200
class TestRoleRequired:
"""Maker role enforcement tests."""
def test_requires_maker_role(self, flask_app):
"""User without Maker role gets 403 on maker endpoints."""
with flask_app.test_client() as c:
with c.session_transaction() as sess:
sess["api_key"] = "test-key"
sess["user"] = {
"id": 2,
"username": "tec_only",
"display_name": "Tec Only",
"roles": ["MeasurementTec"],
"is_admin": False,
"language_pref": "en",
"theme_pref": "light",
"active": True,
}
sess["user_id"] = 2
sess["language"] = "en"
sess["theme"] = "light"
resp = c.get("/maker/recipes")
assert resp.status_code == 403
class TestRecipeEdit:
"""GET /maker/recipes/<id>/edit tests."""
def test_recipe_edit_renders(self, logged_in_client, mock_api_client):
"""Recipe edit page renders with recipe data."""
# First call: recipe detail, second call: versions list
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Recipe A"},
[{"version_number": 1, "is_current": True}],
]
resp = logged_in_client.get("/maker/recipes/1/edit")
assert resp.status_code == 200
def test_recipe_edit_not_found(self, logged_in_client, mock_api_client):
"""Recipe edit with API error redirects to recipe list."""
mock_api_client.get.return_value = {
"error": True,
"detail": "Not found",
}
resp = logged_in_client.get("/maker/recipes/999/edit", follow_redirects=False)
assert resp.status_code == 302
assert "/maker/recipes" in resp.headers["Location"]
+91
View File
@@ -0,0 +1,91 @@
"""Tests for measure blueprint (client/blueprints/measure.py).
Covers recipe selection, task list, login requirement, and measurement submission.
"""
import pytest
class TestSelectRecipe:
"""GET /measure/select tests."""
def test_select_recipe_renders(self, logged_in_client, mock_api_client):
"""Recipe selection page renders for MeasurementTec role."""
mock_api_client.get.return_value = {
"items": [
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
],
"total": 1,
"pages": 1,
}
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 200
def test_select_recipe_requires_login(self, client):
"""Unauthenticated user is redirected to login."""
resp = client.get("/measure/select", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
class TestTaskList:
"""GET /measure/tasks/<recipe_id> tests."""
def test_task_list_renders(self, logged_in_client, mock_api_client):
"""Task list page renders with recipe and task data."""
# First call: recipe details (dict), second call: tasks list.
# The route calls tasks_resp.get("error") so the mock must return
# a dict (not a bare list) to avoid AttributeError.
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
{
"items": [
{"id": 1, "title": "Task 1", "order_index": 0},
{"id": 2, "title": "Task 2", "order_index": 1},
],
},
]
resp = logged_in_client.get("/measure/tasks/1")
assert resp.status_code == 200
class TestSaveMeasurement:
"""POST /measure/save-measurement tests."""
def test_save_measurement_proxy(self, logged_in_client, mock_api_client):
"""Save measurement forwards data to API and returns 201."""
mock_api_client.post.return_value = {
"id": 1,
"subtask_id": 10,
"task_id": 5,
"value": 9.95,
"pass_fail": "pass",
}
resp = logged_in_client.post(
"/measure/save-measurement",
json={
"subtask_id": 10,
"task_id": 5,
"value": 9.95,
"pass_fail": "pass",
"deviation": 0.05,
},
content_type="application/json",
)
assert resp.status_code == 201
data = resp.get_json()
assert data["id"] == 1
assert data["pass_fail"] == "pass"
def test_save_measurement_missing_fields(self, logged_in_client, mock_api_client):
"""Missing required fields return 400."""
resp = logged_in_client.post(
"/measure/save-measurement",
json={"subtask_id": 10}, # missing task_id and value
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert data["error"] is True
+88
View File
@@ -0,0 +1,88 @@
"""Tests for statistics blueprint (client/blueprints/statistics.py).
Covers dashboard rendering, role enforcement, and SPC/summary API proxies.
"""
import pytest
class TestDashboard:
"""GET /statistics/dashboard tests."""
def test_dashboard_renders(self, logged_in_client, mock_api_client):
"""Dashboard renders for Metrologist role."""
mock_api_client.get.return_value = {
"items": [
{"id": 1, "code": "REC-001", "name": "Recipe A"},
],
"total": 1,
"pages": 1,
}
resp = logged_in_client.get("/statistics/dashboard")
assert resp.status_code == 200
def test_dashboard_requires_login(self, client):
"""Unauthenticated user is redirected to login."""
resp = client.get("/statistics/dashboard", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
class TestRoleRequired:
"""Metrologist role enforcement tests."""
def test_requires_metrologist_role(self, flask_app):
"""User without Metrologist role gets 403 on statistics endpoints."""
with flask_app.test_client() as c:
with c.session_transaction() as sess:
sess["api_key"] = "test-key"
sess["user"] = {
"id": 3,
"username": "maker_only",
"display_name": "Maker Only",
"roles": ["Maker"],
"is_admin": False,
"language_pref": "en",
"theme_pref": "light",
"active": True,
}
sess["user_id"] = 3
sess["language"] = "en"
sess["theme"] = "light"
resp = c.get("/statistics/dashboard")
assert resp.status_code == 403
class TestSPCProxy:
"""SPC data proxy endpoint tests."""
def test_summary_proxy(self, logged_in_client, mock_api_client):
"""Summary API proxy returns JSON from backend."""
mock_api_client.get.return_value = {
"total": 100,
"pass_count": 90,
"fail_count": 5,
"warning_count": 5,
}
resp = logged_in_client.get(
"/statistics/api/summary?recipe_id=1"
)
assert resp.status_code == 200
data = resp.get_json()
assert data["total"] == 100
def test_capability_proxy(self, logged_in_client, mock_api_client):
"""Capability API proxy returns JSON from backend."""
mock_api_client.get.return_value = {
"cp": 1.33,
"cpk": 1.20,
}
resp = logged_in_client.get(
"/statistics/api/capability?recipe_id=1&subtask_id=1"
)
assert resp.status_code == 200
data = resp.get_json()
assert "cp" in data
@@ -1048,3 +1048,77 @@ msgstr "Measurements Report"
msgid "Errore nella generazione del report"
msgstr "Error generating report"
# Barcode Scanner i18n
msgid "Libreria scanner non caricata"
msgstr "Scanner library not loaded"
msgid "Nessuna fotocamera disponibile"
msgstr "No camera available"
msgid "Impossibile accedere alla fotocamera"
msgstr "Unable to access camera"
# CSV Export i18n
msgid "Subtask ID"
msgstr "Subtask ID"
msgid "Nome Sottotask"
msgstr "Subtask Name"
msgid "Valore Misurato"
msgstr "Measured Value"
msgid "Valore Nominale"
msgstr "Nominal Value"
msgid "Tolleranza +"
msgstr "Tolerance +"
msgid "Tolleranza -"
msgstr "Tolerance -"
msgid "Scarto"
msgstr "Deviation"
msgid "Metodo Input"
msgstr "Input Method"
msgid "Data Misurazione"
msgstr "Measurement Date"
msgid "RIEPILOGO ESECUZIONE TASK"
msgstr "TASK EXECUTION SUMMARY"
msgid "Task ID"
msgstr "Task ID"
msgid "Nome Task"
msgstr "Task Name"
msgid "Data Inizio"
msgstr "Start Date"
msgid "Data Fine"
msgstr "End Date"
msgid "Stato"
msgstr "Status"
msgid "STATISTICHE"
msgstr "STATISTICS"
msgid "Totale Misure"
msgstr "Total Measurements"
msgid "Passate"
msgstr "Passed"
msgid "Fallite"
msgstr "Failed"
msgid "Percentuale Successo"
msgstr "Pass Rate"
msgid "DETTAGLIO MISURE"
msgstr "MEASUREMENT DETAILS"
@@ -1048,3 +1048,77 @@ msgstr "Report Misurazioni"
msgid "Errore nella generazione del report"
msgstr "Errore nella generazione del report"
# Barcode Scanner i18n
msgid "Libreria scanner non caricata"
msgstr "Libreria scanner non caricata"
msgid "Nessuna fotocamera disponibile"
msgstr "Nessuna fotocamera disponibile"
msgid "Impossibile accedere alla fotocamera"
msgstr "Impossibile accedere alla fotocamera"
# CSV Export i18n
msgid "Subtask ID"
msgstr "Subtask ID"
msgid "Nome Sottotask"
msgstr "Nome Sottotask"
msgid "Valore Misurato"
msgstr "Valore Misurato"
msgid "Valore Nominale"
msgstr "Valore Nominale"
msgid "Tolleranza +"
msgstr "Tolleranza +"
msgid "Tolleranza -"
msgstr "Tolleranza -"
msgid "Scarto"
msgstr "Scarto"
msgid "Metodo Input"
msgstr "Metodo Input"
msgid "Data Misurazione"
msgstr "Data Misurazione"
msgid "RIEPILOGO ESECUZIONE TASK"
msgstr "RIEPILOGO ESECUZIONE TASK"
msgid "Task ID"
msgstr "Task ID"
msgid "Nome Task"
msgstr "Nome Task"
msgid "Data Inizio"
msgstr "Data Inizio"
msgid "Data Fine"
msgstr "Data Fine"
msgid "Stato"
msgstr "Stato"
msgid "STATISTICHE"
msgstr "STATISTICHE"
msgid "Totale Misure"
msgstr "Totale Misure"
msgid "Passate"
msgstr "Passate"
msgid "Fallite"
msgstr "Fallite"
msgid "Percentuale Successo"
msgstr "Percentuale Successo"
msgid "DETTAGLIO MISURE"
msgstr "DETTAGLIO MISURE"