249999fa1e
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
332 lines
14 KiB
Python
332 lines
14 KiB
Python
"""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/<id>/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/<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"]
|
|
|
|
|
|
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/<id>/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
|