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:
@@ -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}]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user