"""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"] def test_recipe_list_search_does_not_break_out_of_x_data( self, logged_in_client, mock_api_client ): """#10: ``?search=`` is JSON-encoded inside x-data, not string-interpolated. The payload must land as the *content* of a single quoted JSON string token (recovered by undoing only the ``\\u0027`` escape), and the object key right after it must still be the untouched ``filter: 'all'`` from the template - proof no new key was injected. """ import re from html.parser import HTMLParser mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1} payload = "',x:alert(1),y:'" resp = logged_in_client.get("/maker/recipes", query_string={"search": payload}) assert resp.status_code == 200 html = resp.get_data(as_text=True) captured = {} class _P(HTMLParser): def handle_starttag(self, tag, attrs): d = dict(attrs) if d.get("x-data") and "search:" in d["x-data"]: captured["x_data"] = d["x-data"] _P().feed(html) assert "x_data" in captured m = re.search(r'search:\s*"([^"]*)"\s*,\s*filter:\s*\'all\'', captured["x_data"]) assert m, f"search is not a single quoted JSON token: {captured['x_data']!r}" assert m.group(1).replace("\\u0027", "'") == payload 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 def test_recipe_new_default_interval_present(self, logged_in_client, mock_api_client): """#49: default_measurement_interval_minutes pre-fills the loop duration.""" mock_api_client.get.return_value = {"default_measurement_interval_minutes": "45"} html = logged_in_client.get("/maker/recipes/new").get_data(as_text=True) assert "measurementIntervalMinutes: 45," in html def test_recipe_new_default_interval_absent(self, logged_in_client, mock_api_client): """#49: key missing, empty or invalid -> unchanged behaviour (null).""" for payload in ({}, {"default_measurement_interval_minutes": ""}, {"default_measurement_interval_minutes": "abc"}, {"default_measurement_interval_minutes": "5000"}): mock_api_client.get.return_value = payload html = logged_in_client.get("/maker/recipes/new").get_data(as_text=True) assert 'measurementIntervalMinutes: "null",' in html def test_recipe_edit_ignores_default_interval(self, logged_in_client, mock_api_client): """#49: an existing recipe never receives the default.""" def fake_get(path, *a, **k): if path == "/api/recipes/7": return {"id": 7, "code": "R", "name": "R"} if path.endswith("/versions"): return [] return {"default_measurement_interval_minutes": "45"} mock_api_client.get.side_effect = fake_get html = logged_in_client.get("/maker/recipes/7/edit").get_data(as_text=True) assert 'measurementIntervalMinutes: "null",' in html class TestRecipeDuplicateProxy: """POST /maker/api/recipes//duplicate (#52).""" def test_duplicate_proxy_ok(self, logged_in_client, mock_api_client): mock_api_client.post.return_value = {"id": 9, "code": "A-COPIA"} resp = logged_in_client.post( "/maker/api/recipes/3/duplicate", json={"code": "A-COPIA", "name": "A (copia)"} ) assert resp.status_code == 201 assert resp.get_json()["id"] == 9 mock_api_client.post.assert_called_with( "/api/recipes/3/duplicate", data={"code": "A-COPIA", "name": "A (copia)"} ) def test_duplicate_proxy_conflict(self, logged_in_client, mock_api_client): mock_api_client.post.return_value = {"error": True, "status_code": 409, "detail": "exists"} resp = logged_in_client.post( "/maker/api/recipes/3/duplicate", json={"code": "A", "name": "A"} ) assert resp.status_code == 409 def test_recipe_list_has_duplicate_button(self, logged_in_client, mock_api_client): mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1} html = logged_in_client.get("/maker/recipes").get_data(as_text=True) assert "openDupModal" in html 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//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"] class TestVersionConflictProxy: """gitea#4: a 409 from the backend (edit targeting a non-current version, left behind by an earlier copy-on-write) must reach the browser as a 409 - task_editor.html's _reloadForNewVersion() only triggers on that status. """ _CONFLICT = { "error": True, "status_code": 409, "detail": "Cannot modify a non-current version. Create a new version first.", } def test_update_task_proxies_409(self, logged_in_client, mock_api_client): mock_api_client.put.return_value = self._CONFLICT resp = logged_in_client.put("/maker/api/tasks/11", json={"title": "x"}) assert resp.status_code == 409 assert resp.get_json()["detail"] == self._CONFLICT["detail"] def test_update_subtask_proxies_409(self, logged_in_client, mock_api_client): mock_api_client.put.return_value = self._CONFLICT resp = logged_in_client.put("/maker/api/subtasks/21", json={"nominal": 1.0}) assert resp.status_code == 409 assert resp.get_json()["detail"] == self._CONFLICT["detail"] def test_create_subtask_proxies_409(self, logged_in_client, mock_api_client): mock_api_client.post.return_value = self._CONFLICT resp = logged_in_client.post( "/maker/api/tasks/11/subtasks", json={"marker_number": 1, "description": "x"}, ) assert resp.status_code == 409 assert resp.get_json()["detail"] == self._CONFLICT["detail"] class TestVisionRecipeProxy: """Proxy ricetta visione (#46): /maker/api/vision/tasks//recipe.""" def test_upload_forwards_recipe_and_files(self, logged_in_client, mock_api_client): import io mock_api_client.post.return_value = { "task_id": 7, "recipe_dir": "vision/recipes/7/abc", "files": ["a.dxf"], "outputs": [{"key": "n1.points_in_pct", "quantity": None, "unit": None}], } resp = logged_in_client.post( "/maker/api/vision/tasks/7/recipe", data={ "recipe": (io.BytesIO(b"{}"), "r.json"), "files": [(io.BytesIO(b"dxf"), "a.dxf"), (io.BytesIO(b"npz"), "m.npz")], }, content_type="multipart/form-data", ) assert resp.status_code == 200 assert resp.get_json()["outputs"][0]["key"] == "n1.points_in_pct" endpoint = mock_api_client.post.call_args.args[0] sent = mock_api_client.post.call_args.kwargs["files"] assert endpoint == "/api/vision/tasks/7/recipe" # un campo `recipe` e un campo `files` per ogni allegato assert [name for name, _f in sent] == ["recipe", "files", "files"] def test_upload_without_recipe_is_400(self, logged_in_client, mock_api_client): resp = logged_in_client.post( "/maker/api/vision/tasks/7/recipe", data={}, content_type="multipart/form-data" ) assert resp.status_code == 400 mock_api_client.post.assert_not_called() def test_upload_proxies_422(self, logged_in_client, mock_api_client): import io mock_api_client.post.return_value = { "error": True, "status_code": 422, "detail": "file mancante: a.dxf", } resp = logged_in_client.post( "/maker/api/vision/tasks/7/recipe", data={"recipe": (io.BytesIO(b"{}"), "r.json")}, content_type="multipart/form-data", ) assert resp.status_code == 422 assert "a.dxf" in resp.get_json()["detail"] def test_get_recipe_proxies(self, logged_in_client, mock_api_client): mock_api_client.get.return_value = { "task_id": 7, "recipe_dir": None, "recipe": None, "files": [], "outputs": [], } resp = logged_in_client.get("/maker/api/vision/tasks/7/recipe") assert resp.status_code == 200 mock_api_client.get.assert_called_once_with("/api/vision/tasks/7/recipe") class TestTaskEditorVisionUi: """task_editor.html espone i tipi visione e la select vision_output (#46).""" def test_editor_has_vision_types_and_section(self, logged_in_client, mock_api_client): from tests.conftest import api_get_router mock_api_client.get.side_effect = api_get_router({ "/api/recipes/3": {"id": 3, "code": "R", "name": "R"}, "/api/recipes/3/tasks": [{ "id": 11, "title": "T", "task_type": "dxf_compare", "subtasks": [], "file_path": None, "order_index": 1, }], }) html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True) assert 'value="camera_measure"' in html assert 'value="dxf_compare"' in html assert "visionRecipe(" in html assert "vision_output" in html assert "js/vision_recipe.js" in html class TestImageCropInclusion: """#56: i due editor includono image-crop.js e agganciano il ritaglio all'upload.""" def test_task_editor_includes_crop(self, logged_in_client, mock_api_client): from tests.conftest import api_get_router mock_api_client.get.side_effect = api_get_router({ "/api/recipes/3": {"id": 3, "code": "R", "name": "R"}, "/api/recipes/3/tasks": [{ "id": 11, "title": "T", "task_type": "measure", "subtasks": [], "file_path": None, "order_index": 1, }], }) html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True) assert "js/image-crop.js" in html assert "tmfCropBeforeUpload(file)" in html # #107: conversione BMP->PNG e errore upload nel riquadro Disegno Tecnico assert "js/image_to_png.js" in html assert "tmfImageToPng(file)" in html assert "uploadErrorTask" in html def test_recipe_editor_includes_crop(self, logged_in_client, mock_api_client): mock_api_client.get.side_effect = [ {"id": 1, "code": "REC-001", "name": "Recipe A"}, [{"version_number": 1, "is_current": True}], ] html = logged_in_client.get("/maker/recipes/1/edit").get_data(as_text=True) assert "js/image-crop.js" in html assert "tmfCropBeforeUpload(file)" in html # #107: conversione BMP->PNG prima dei controlli di formato assert "js/image_to_png.js" in html assert "tmfImageToPng(file)" in html