chore(v2): restructure monorepo to src/ layout with uv

Aligns the repo with the python-project-spec-design.md template chosen
for V2.0.0. Big move, no logic changes. The 3 pre-existing test
failures (test_recipes::test_update_recipe, test_recipes::
test_recipe_versioning, test_tasks::test_reorder_tasks, plus the
client test_save_measurement_proxy) survive unchanged.

Layout changes
- server/        -> src/backend/
- server/middleware/ -> src/backend/api/middleware/
- server/routers/    -> src/backend/api/routers/
- server/models/     -> src/backend/models/orm/
- server/schemas/    -> src/backend/models/api/
- server/uploads/    -> uploads/ (project root, mounted volume)
- server/tests/      -> src/backend/tests/
- client/            -> src/frontend/flask_app/ (Flask kept; React
  deroga is documented in CLAUDE.md, justified by tablet UX, USB
  caliper/barcode workflow and Fabric.js integration)

Tooling
- pyproject.toml: monorepo with [project] core deps and
  optional-dependencies server / client / dev. Replaces both
  server/requirements.txt and client/requirements.txt.
- uv.lock + .python-version (3.11) committed for reproducible builds.
- Dockerfile (root, backend) and Dockerfile.frontend rewritten to use
  uv sync --frozen --no-dev --extra server|client; legacy Dockerfiles
  preserved as Dockerfile.legacy for reference but excluded from build
  context via .dockerignore.
- docker-compose.dev.yml + docker-compose.yml: build context now ".",
  dockerfile pointing to the root files.

Code adjustments forced by the move
- Every "from config|database|models|schemas|services|routers|middleware
  import ..." rewritten to its src.backend.* equivalent (50+ files
  including indented inline imports inside test bodies).
- src/backend/migrations/env.py: insert project root into sys.path so
  alembic can resolve src.backend.* imports regardless of cwd.
- src/backend/config.py: env_file ../../.env (was ../.env), upload_path
  resolves project root via parents[2].
- src/backend/tests/conftest.py + tests: import ... from src.backend.*
  instead of bare names; old per-directory pytest.ini files removed in
  favor of root pyproject.toml [tool.pytest.ini_options].
- .gitignore: uploads/ at root, src/frontend/flask_app/static/css/
  tailwind.css path; .dockerignore tightened.
- CLAUDE.md: rewrote sections "Layout del repository", "Comandi di
  Sviluppo", "Database & Migrations", "Test", "i18n", and all path
  references throughout the architecture sections.

Verified
- uv lock resolves 77 packages; uv sync --extra server --extra client
  --extra dev installs cleanly.
- uv run pytest: 171 passed, 4 pre-existing failures.
- uv run alembic -c src/backend/migrations/alembic.ini check loads
  config and metadata (errors only on the absent local MySQL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 12:26:47 +02:00
parent 86df67f2e5
commit 1a0431366f
174 changed files with 2568 additions and 308 deletions
+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()
@@ -0,0 +1,134 @@
"""Tests for the admin Stations management UI and proxy endpoints."""
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture
def mock_admin_api():
"""Patch api_client used by the admin blueprint."""
mock = MagicMock()
with patch("blueprints.admin.api_client", mock):
yield mock
def test_station_list_page_requires_admin(logged_in_client, mock_admin_api):
mock_admin_api.get.return_value = []
resp = logged_in_client.get("/admin/stations")
assert resp.status_code == 200
assert b"Gestione Stazioni" in resp.data or b"stations" in resp.data.lower()
def test_station_list_page_calls_correct_endpoints(logged_in_client, mock_admin_api):
mock_admin_api.get.side_effect = [
[{"id": 1, "code": "ST-001", "name": "Linea A", "location": None,
"notes": None, "active": True, "created_by": 1, "created_at": "2026-04-25T10:00:00"}],
[{"id": 1, "code": "REC-001", "name": "Test Recipe", "active": True}],
]
resp = logged_in_client.get("/admin/stations")
assert resp.status_code == 200
calls = [c.args[0] for c in mock_admin_api.get.call_args_list]
assert "/api/stations" in calls
assert "/api/recipes" in calls
def test_create_station_proxy(logged_in_client, mock_admin_api):
mock_admin_api.post.return_value = {
"id": 5, "code": "ST-005", "name": "Nuova",
"location": None, "notes": None, "active": True,
"created_by": 1, "created_at": "2026-04-25T10:00:00",
}
resp = logged_in_client.post(
"/admin/api/stations",
json={"code": "ST-005", "name": "Nuova", "active": True},
)
assert resp.status_code == 201
body = resp.get_json()
assert body["code"] == "ST-005"
mock_admin_api.post.assert_called_once_with(
"/api/stations",
data={"code": "ST-005", "name": "Nuova", "active": True},
)
def test_create_station_propagates_error(logged_in_client, mock_admin_api):
mock_admin_api.post.return_value = {
"error": True, "status_code": 409, "detail": "code already exists",
}
resp = logged_in_client.post(
"/admin/api/stations", json={"code": "ST-001", "name": "x"},
)
assert resp.status_code == 409
assert resp.get_json()["detail"] == "code already exists"
def test_update_station_proxy(logged_in_client, mock_admin_api):
mock_admin_api.put.return_value = {
"id": 3, "code": "ST-003", "name": "Aggiornata",
"location": "Reparto B", "notes": None, "active": False,
"created_by": 1, "created_at": "2026-04-25T10:00:00",
}
resp = logged_in_client.put(
"/admin/api/stations/3",
json={"name": "Aggiornata", "location": "Reparto B", "active": False},
)
assert resp.status_code == 200
mock_admin_api.put.assert_called_once_with(
"/api/stations/3",
data={"name": "Aggiornata", "location": "Reparto B", "active": False},
)
def test_delete_station_proxy(logged_in_client, mock_admin_api):
mock_admin_api.delete.return_value = {}
resp = logged_in_client.delete("/admin/api/stations/7")
assert resp.status_code == 200
assert resp.get_json() == {"deleted": True}
mock_admin_api.delete.assert_called_once_with("/api/stations/7")
def test_assign_recipe_proxy(logged_in_client, mock_admin_api):
mock_admin_api.post.return_value = {
"id": 1, "station_id": 2, "recipe_id": 10,
"assigned_by": 1, "assigned_at": "2026-04-25T10:00:00",
}
resp = logged_in_client.post(
"/admin/api/stations/2/recipes", json={"recipe_id": 10},
)
assert resp.status_code == 201
mock_admin_api.post.assert_called_once_with(
"/api/stations/2/recipes", data={"recipe_id": 10},
)
def test_unassign_recipe_proxy(logged_in_client, mock_admin_api):
mock_admin_api.delete.return_value = {}
resp = logged_in_client.delete("/admin/api/stations/2/recipes/10")
assert resp.status_code == 200
mock_admin_api.delete.assert_called_once_with("/api/stations/2/recipes/10")
def test_list_station_recipes_proxy(logged_in_client, mock_admin_api):
mock_admin_api.get.return_value = [
{"id": 1, "code": "REC-001", "name": "R1", "active": True},
]
resp = logged_in_client.get("/admin/api/stations/2/recipes")
assert resp.status_code == 200
assert resp.get_json() == [{"id": 1, "code": "REC-001", "name": "R1", "active": True}]
mock_admin_api.get.assert_called_once_with("/api/stations/2/recipes")
def test_non_admin_cannot_access(client, mock_admin_api):
"""Non-admin user gets redirected away from station management."""
with client.session_transaction() as sess:
sess["api_key"] = "test-key"
sess["user"] = {
"id": 2,
"username": "operator",
"roles": ["MeasurementTec"],
"is_admin": False,
"active": True,
}
sess["user_id"] = 2
resp = client.get("/admin/stations", follow_redirects=False)
assert resp.status_code in (301, 302)
@@ -0,0 +1,70 @@
"""Tests that APIClient forwards the real tablet IP via X-Forwarded-For."""
from unittest.mock import patch
from services.api_client import api_client
def _last_call_headers(mock):
"""Return the headers kwarg from the most recent requests.* call."""
return mock.call_args.kwargs["headers"]
def test_get_forwards_real_client_ip(flask_app):
with flask_app.test_request_context(
"/", environ_base={"REMOTE_ADDR": "203.0.113.10"}
):
with patch("services.api_client.requests.get") as mock_get:
mock_get.return_value.ok = True
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {}
api_client.get("/api/health")
headers = _last_call_headers(mock_get)
assert headers["X-Forwarded-For"] == "203.0.113.10"
assert headers["X-Real-IP"] == "203.0.113.10"
def test_post_json_forwards_real_client_ip(flask_app):
with flask_app.test_request_context(
"/", environ_base={"REMOTE_ADDR": "198.51.100.42"}
):
with patch("services.api_client.requests.post") as mock_post:
mock_post.return_value.ok = True
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = {}
api_client.post("/api/foo", data={"x": 1})
headers = _last_call_headers(mock_post)
assert headers["X-Forwarded-For"] == "198.51.100.42"
def test_post_with_files_forwards_real_client_ip(flask_app):
with flask_app.test_request_context(
"/", environ_base={"REMOTE_ADDR": "198.51.100.77"}
):
with patch("services.api_client.requests.post") as mock_post:
mock_post.return_value.ok = True
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = {}
api_client.post(
"/api/files/upload", data={}, files={"file": ("x.txt", b"data")}
)
headers = _last_call_headers(mock_post)
assert headers["X-Forwarded-For"] == "198.51.100.77"
assert headers["X-Real-IP"] == "198.51.100.77"
def test_real_client_ip_returns_none_outside_request_context(flask_app):
"""Background workers without a Flask request must NOT inject a fake IP."""
from services.api_client import APIClient
with flask_app.app_context():
assert APIClient._real_client_ip() is None
def test_proxy_fix_is_installed(flask_app):
"""ProxyFix must wrap wsgi_app so REMOTE_ADDR is rewritten in production."""
from werkzeug.middleware.proxy_fix import ProxyFix
assert isinstance(flask_app.wsgi_app, ProxyFix)
@@ -0,0 +1,15 @@
"""Tests for the station-related helpers in api_client."""
from unittest.mock import patch
from services.api_client import APIClient
def test_get_station_recipes_calls_correct_endpoint():
client = APIClient()
with patch.object(client, "get") as mock_get:
mock_get.return_value = [{"id": 1, "code": "R1", "name": "R1", "active": True}]
result = client.get_station_recipes("ST-001")
# The helper must call the underlying .get() with the exact endpoint path.
called_args, called_kwargs = mock_get.call_args
assert called_args[0] == "/api/stations/by-code/ST-001/recipes"
assert result == [{"id": 1, "code": "R1", "name": "R1", "active": True}]
+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"
@@ -0,0 +1,17 @@
"""Tests that STATION_CODE is loaded from env and exposed on the client Config class."""
import importlib
import os
def test_station_code_read_from_env(monkeypatch):
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import config
importlib.reload(config)
assert config.Config.STATION_CODE == "ST-TEST"
def test_station_code_defaults_to_none_when_missing(monkeypatch):
monkeypatch.delenv("STATION_CODE", raising=False)
import config
importlib.reload(config)
assert config.Config.STATION_CODE is None
@@ -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"]
@@ -0,0 +1,94 @@
"""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, monkeypatch):
"""Recipe selection page renders for MeasurementTec role."""
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import config
import importlib
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = [
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
]
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
@@ -0,0 +1,37 @@
"""Verify that /measure/select reads STATION_CODE and filters recipes via the server."""
import importlib
from unittest.mock import patch, MagicMock
def _reload_measure(monkeypatch, station_code=None):
"""Reload config and measure module under the given STATION_CODE env."""
if station_code is None:
monkeypatch.delenv("STATION_CODE", raising=False)
else:
monkeypatch.setenv("STATION_CODE", station_code)
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
def test_select_recipe_calls_station_endpoint(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code="ST-TEST")
from blueprints import measure as measure_bp_mod
with patch.object(measure_bp_mod, "api_client") as mock_api:
mock_api.get_station_recipes.return_value = [
{"id": 1, "code": "R1", "name": "Recipe 1", "active": True},
]
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 200
mock_api.get_station_recipes.assert_called_once()
args, kwargs = mock_api.get_station_recipes.call_args
assert args[0] == "ST-TEST" or kwargs.get("station_code") == "ST-TEST"
def test_select_recipe_without_station_code_shows_error(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code=None)
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 503
body = resp.data.lower()
assert b"station_code" in body or b"stazione" in body
@@ -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