a829bf3eb0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
694 lines
29 KiB
Python
694 lines
29 KiB
Python
"""Tests for measure blueprint (client/blueprints/measure.py).
|
|
|
|
Covers recipe selection, task list, login requirement, and measurement submission.
|
|
"""
|
|
import pytest
|
|
|
|
from tests.conftest import api_get_router
|
|
|
|
|
|
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"]
|
|
|
|
def test_select_recipe_query_params_do_not_break_out_of_x_data(
|
|
self, logged_in_client, mock_api_client, monkeypatch
|
|
):
|
|
"""#10: recipe/lot query params are JSON-encoded inside x-data.
|
|
|
|
A raw ``'{{ auto_lot }}'`` interpolation lets a value containing a
|
|
single quote close the JS string literal and inject new keys into
|
|
the Alpine ``x-data`` object (reflected XSS, CSP has unsafe-eval).
|
|
The fix is ``|tojson_attr``: the payload must survive only as the
|
|
*content* of a single quoted JSON string token, never as JS syntax
|
|
that opens a new object key (``lot_number:`` must directly follow).
|
|
"""
|
|
import re
|
|
from html.parser import HTMLParser
|
|
|
|
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 = []
|
|
|
|
payload = "',x:alert(document.domain),y:'"
|
|
resp = logged_in_client.get("/measure/select", query_string={"recipe": 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*lot_number:', 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
|
|
|
|
def test_select_recipe_lot_with_apostrophe_stays_valid_json_string(
|
|
self, logged_in_client, mock_api_client, monkeypatch
|
|
):
|
|
"""#10: a lot number like ``L'23`` must not break the page."""
|
|
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 = []
|
|
|
|
resp = logged_in_client.get("/measure/select", query_string={"lot": "L'23"})
|
|
assert resp.status_code == 200
|
|
# Raw HTML (pre-entity-decoding): the value is a JSON string whose
|
|
# delimiting quotes are the HTML entity, and the apostrophe is the
|
|
# JS unicode escape - never a bare ``'`` that could end the token.
|
|
assert "lot_number: "L\\u002723"" in resp.get_data(as_text=True)
|
|
|
|
|
|
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."""
|
|
mock_api_client.get.side_effect = api_get_router({
|
|
"/api/recipes/1/tasks": {
|
|
"items": [
|
|
{"id": 1, "title": "Task 1", "order_index": 0},
|
|
{"id": 2, "title": "Task 2", "order_index": 1},
|
|
],
|
|
},
|
|
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
|
"/api/production-runs/current": None,
|
|
})
|
|
|
|
resp = logged_in_client.get("/measure/tasks/1")
|
|
assert resp.status_code == 200
|
|
|
|
def test_task_list_marks_the_task_left_half_done(
|
|
self, logged_in_client, mock_api_client,
|
|
):
|
|
"""A task with some of its quotes taken says so, and says how many."""
|
|
with logged_in_client.session_transaction() as sess:
|
|
sess["language"] = "it"
|
|
mock_api_client.get.side_effect = api_get_router({
|
|
"/api/recipes/1/tasks": [
|
|
{"id": 7, "title": "Misura", "order_index": 0, "task_type": "measure"},
|
|
{"id": 8, "title": "Nota", "order_index": 1, "task_type": "note"},
|
|
],
|
|
"/api/recipes/1": {
|
|
"id": 1, "code": "REC-001", "name": "Test Recipe",
|
|
"current_version": {"id": 42, "version_number": 1},
|
|
},
|
|
"/api/production-runs/current": None,
|
|
"/api/measurements/task-progress": {
|
|
"tasks": [
|
|
{"task_id": 7, "quotes": 3, "measured": 1, "state": "partial"},
|
|
],
|
|
},
|
|
})
|
|
|
|
resp = logged_in_client.get("/measure/tasks/1")
|
|
assert resp.status_code == 200
|
|
html = resp.get_data(as_text=True)
|
|
assert "Incompiuto" in html
|
|
assert "1/3" in html
|
|
|
|
def test_task_list_survives_a_progress_lookup_that_fails(
|
|
self, logged_in_client, mock_api_client,
|
|
):
|
|
"""Losing the badges must not lose the page."""
|
|
mock_api_client.get.side_effect = api_get_router({
|
|
"/api/recipes/1/tasks": [
|
|
{"id": 7, "title": "Misura", "order_index": 0, "task_type": "measure"},
|
|
],
|
|
"/api/recipes/1": {
|
|
"id": 1, "code": "REC-001", "name": "Test Recipe",
|
|
"current_version": {"id": 42},
|
|
},
|
|
"/api/production-runs/current": None,
|
|
"/api/measurements/task-progress": {
|
|
"error": True, "status_code": 500, "detail": "boom",
|
|
},
|
|
})
|
|
|
|
resp = logged_in_client.get("/measure/tasks/1")
|
|
assert resp.status_code == 200
|
|
assert "Misura" in resp.get_data(as_text=True)
|
|
|
|
|
|
class TestApiTaskProgress:
|
|
"""GET /measure/api/task-progress/<task_id> tests (gitea#37.8).
|
|
|
|
The browser cannot call /api/measurements/task-progress directly (no
|
|
X-API-Key); this proxy forwards it with the same version_id/
|
|
production_run_id scoping _task_progress() already uses for task_list.
|
|
"""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _station_configured(self, monkeypatch):
|
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
|
|
|
def test_returns_this_task_progress_row(self, logged_in_client, mock_api_client):
|
|
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
|
|
mock_api_client.get.side_effect = api_get_router({
|
|
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
|
|
"/api/production-runs/current": None,
|
|
"/api/measurements/task-progress": {
|
|
"tasks": [{"task_id": 7, "quotes": 3, "measured": 1, "state": "partial"}],
|
|
},
|
|
})
|
|
|
|
resp = logged_in_client.get("/measure/api/task-progress/7")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.get_json() == {
|
|
"task_id": 7, "quotes": 3, "measured": 1, "state": "partial",
|
|
}
|
|
# No production run open: the backend call must not carry a
|
|
# production_run_id it would then use to scope by the wrong cycle.
|
|
progress_call = next(
|
|
call for call in mock_api_client.get.call_args_list
|
|
if call.args[0] == "/api/measurements/task-progress"
|
|
)
|
|
assert progress_call.kwargs["params"] == {"version_id": 42}
|
|
|
|
def test_task_absent_from_progress_defaults_to_empty(
|
|
self, logged_in_client, mock_api_client,
|
|
):
|
|
"""A task with no quotes yet has no row in the backend's answer."""
|
|
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
|
|
mock_api_client.get.side_effect = api_get_router({
|
|
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
|
|
"/api/production-runs/current": None,
|
|
"/api/measurements/task-progress": {"tasks": []},
|
|
})
|
|
|
|
resp = logged_in_client.get("/measure/api/task-progress/7")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.get_json() == {
|
|
"task_id": 7, "quotes": 0, "measured": 0, "state": "empty",
|
|
}
|
|
|
|
def test_refuses_a_recipe_not_assigned_to_this_station(
|
|
self, logged_in_client, mock_api_client,
|
|
):
|
|
"""Same #24 guard task_execute() uses: no bypass through this proxy."""
|
|
mock_api_client.get_station_recipes.return_value = [{"id": 99}]
|
|
mock_api_client.get.side_effect = api_get_router({
|
|
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
|
|
})
|
|
|
|
resp = logged_in_client.get("/measure/api/task-progress/7")
|
|
|
|
assert resp.status_code == 403
|
|
|
|
def test_propagates_a_task_not_found(self, logged_in_client, mock_api_client):
|
|
mock_api_client.get.side_effect = api_get_router({
|
|
"/api/tasks/999": {"error": True, "status_code": 404, "detail": "not found"},
|
|
})
|
|
|
|
resp = logged_in_client.get("/measure/api/task-progress/999")
|
|
|
|
assert resp.status_code == 404
|
|
|
|
def test_requires_login(self, client):
|
|
resp = client.get("/measure/api/task-progress/7")
|
|
assert resp.status_code in (302, 401)
|
|
|
|
|
|
class TestApiCurrentCycle:
|
|
"""GET /measure/api/current-cycle/<task_id> (gitea#40)."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _station_configured(self, monkeypatch):
|
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
|
|
|
def test_forwards_run_and_task(self, logged_in_client, mock_api_client):
|
|
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
|
|
payload = {"measurements": [
|
|
{"subtask_id": 3, "value": 10.1, "pass_fail": "pass",
|
|
"measured_at": "2026-10-01T08:01:00"},
|
|
]}
|
|
mock_api_client.get.side_effect = api_get_router({
|
|
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
|
|
"/api/production-runs/current": {"id": 5},
|
|
"/api/measurements/current-cycle": payload,
|
|
})
|
|
|
|
resp = logged_in_client.get("/measure/api/current-cycle/7")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.get_json() == payload
|
|
call = next(
|
|
c for c in mock_api_client.get.call_args_list
|
|
if c.args[0] == "/api/measurements/current-cycle"
|
|
)
|
|
assert call.kwargs["params"] == {"production_run_id": 5, "task_id": 7}
|
|
|
|
def test_no_run_means_empty_and_no_backend_call(
|
|
self, logged_in_client, mock_api_client,
|
|
):
|
|
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
|
|
mock_api_client.get.side_effect = api_get_router({
|
|
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
|
|
"/api/production-runs/current": None,
|
|
})
|
|
|
|
resp = logged_in_client.get("/measure/api/current-cycle/7")
|
|
|
|
assert resp.get_json() == {"measurements": []}
|
|
assert not any(
|
|
c.args[0] == "/api/measurements/current-cycle"
|
|
for c in mock_api_client.get.call_args_list
|
|
)
|
|
|
|
def test_refuses_a_recipe_not_assigned_to_this_station(
|
|
self, logged_in_client, mock_api_client,
|
|
):
|
|
mock_api_client.get_station_recipes.return_value = [{"id": 99}]
|
|
mock_api_client.get.side_effect = api_get_router({
|
|
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
|
|
})
|
|
|
|
assert logged_in_client.get("/measure/api/current-cycle/7").status_code == 403
|
|
|
|
def test_requires_login(self, client):
|
|
resp = client.get("/measure/api/current-cycle/7")
|
|
assert resp.status_code in (302, 401)
|
|
|
|
|
|
class TestStartSequence:
|
|
"""GET /measure/start/<recipe_id> — the operator's way in."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _station_configured(self, monkeypatch):
|
|
# start_sequence() checks recipe-station assignment (#24); STATION_CODE
|
|
# defaults to unset in the test env, so every test in this class needs it.
|
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
|
|
|
def test_start_goes_to_the_first_task(self, logged_in_client, mock_api_client):
|
|
# start_sequence() only accepts a recipe assigned to this station (#24).
|
|
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
|
|
mock_api_client.get.side_effect = api_get_router({
|
|
"/api/recipes/1/tasks": [
|
|
{"id": 31, "title": "Nota", "order_index": 1},
|
|
{"id": 30, "title": "Prima", "order_index": 0},
|
|
],
|
|
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
|
})
|
|
|
|
resp = logged_in_client.get("/measure/start/1?lot_number=L1")
|
|
assert resp.status_code == 302
|
|
assert resp.headers["Location"].endswith("/measure/execute/30")
|
|
|
|
def test_start_remembers_the_traceability(self, logged_in_client, mock_api_client):
|
|
"""The list used to be where lot and serial were stored; the sequence
|
|
skips it, so the storing has to happen on the way in."""
|
|
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
|
|
mock_api_client.get.side_effect = api_get_router({
|
|
"/api/recipes/1/tasks": [{"id": 30, "title": "Prima", "order_index": 0}],
|
|
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
|
})
|
|
|
|
logged_in_client.get("/measure/start/1?lot_number=L9&serial_number=S9")
|
|
with logged_in_client.session_transaction() as sess:
|
|
assert sess["lot_number"] == "L9"
|
|
assert sess["serial_number"] == "S9"
|
|
|
|
def test_start_refuses_without_the_compulsory_lot(
|
|
self, logged_in_client, mock_api_client,
|
|
):
|
|
"""Point 8 holds on this door too: it is a way into the measurement."""
|
|
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
|
|
mock_api_client.get.side_effect = api_get_router({
|
|
"/api/recipes/1/tasks": [{"id": 30, "title": "Prima", "order_index": 0}],
|
|
"/api/recipes/1": {
|
|
"id": 1, "code": "REC-001", "name": "Test Recipe",
|
|
"requires_lot": True,
|
|
},
|
|
})
|
|
|
|
resp = logged_in_client.get("/measure/start/1")
|
|
assert resp.status_code == 302
|
|
assert "/measure/select" in resp.headers["Location"]
|
|
|
|
def test_start_without_tasks_falls_back_to_the_list(
|
|
self, logged_in_client, mock_api_client,
|
|
):
|
|
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
|
|
mock_api_client.get.side_effect = api_get_router({
|
|
"/api/recipes/1/tasks": [],
|
|
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
|
})
|
|
|
|
resp = logged_in_client.get("/measure/start/1")
|
|
assert resp.status_code == 302
|
|
assert resp.headers["Location"].endswith("/measure/tasks/1")
|
|
|
|
def test_start_refuses_recipe_not_assigned_to_station(
|
|
self, logged_in_client, mock_api_client,
|
|
):
|
|
"""#24: a recipe not on this station's list cannot be started via a direct link."""
|
|
mock_api_client.get_station_recipes.return_value = [{"id": 99}]
|
|
mock_api_client.get.side_effect = api_get_router({
|
|
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
|
})
|
|
|
|
resp = logged_in_client.get("/measure/start/1")
|
|
assert resp.status_code == 403
|
|
|
|
|
|
class TestTaskComplete:
|
|
"""GET /measure/complete/<recipe_id> tests."""
|
|
|
|
def test_task_complete_renders(self, logged_in_client, mock_api_client):
|
|
"""The summary carries the measurement clock, so it has to render with it."""
|
|
mock_api_client.get.side_effect = [
|
|
# The summary serialises the version number, so the recipe carries one.
|
|
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
|
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
|
|
None, # current run
|
|
{"items": []}, # measurements
|
|
]
|
|
|
|
resp = logged_in_client.get("/measure/complete/1?version_id=5")
|
|
assert resp.status_code == 200
|
|
assert b"productionClock(" in resp.data
|
|
|
|
def test_measured_at_shown_in_local_time(self, logged_in_client, mock_api_client):
|
|
"""#42: the API sends UTC; the summary shows Europe/Rome."""
|
|
mock_api_client.get.side_effect = [
|
|
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
|
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [
|
|
{"id": 1, "marker_number": 1, "description": "d", "nominal": 1.0,
|
|
"unit": "mm"}]}],
|
|
None,
|
|
{"items": [{"id": 1, "subtask_id": 1, "value": 1.0, "pass_fail": "pass",
|
|
"measured_at": "2026-10-06T08:00:00+00:00"}]},
|
|
]
|
|
resp = logged_in_client.get("/measure/complete/1?version_id=5")
|
|
assert resp.status_code == 200
|
|
assert "06/10/2026 10:00" in resp.get_data(as_text=True)
|
|
|
|
def test_measurements_filtered_by_lot_serial_and_operator(
|
|
self, logged_in_client, mock_api_client,
|
|
):
|
|
"""#30: a second production on the same version must not pull in every
|
|
other station's/day's readings - only this lot/serial and operator."""
|
|
with logged_in_client.session_transaction() as sess:
|
|
sess["lot_number"] = "L1"
|
|
sess["serial_number"] = "S1"
|
|
|
|
mock_api_client.get.side_effect = [
|
|
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
|
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
|
|
None, # current run
|
|
{"items": [], "pages": 1},
|
|
]
|
|
|
|
resp = logged_in_client.get("/measure/complete/1?version_id=5")
|
|
assert resp.status_code == 200
|
|
|
|
endpoint, kwargs = mock_api_client.get.call_args_list[3]
|
|
assert endpoint[0] == "/api/measurements"
|
|
params = kwargs["params"]
|
|
assert params["version_id"] == "5"
|
|
assert params["lot_number"] == "L1"
|
|
assert params["serial_number"] == "S1"
|
|
assert params["measured_by"] == 1 # session["user_id"] from the fixture
|
|
|
|
def test_measurements_filtered_by_the_active_production_run(
|
|
self, logged_in_client, mock_api_client,
|
|
):
|
|
"""#43: when the station has a run open, the summary asks for that run."""
|
|
mock_api_client.get.side_effect = [
|
|
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
|
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
|
|
{"id": 77}, # current run
|
|
{"items": [], "pages": 1},
|
|
]
|
|
|
|
resp = logged_in_client.get("/measure/complete/1?version_id=5")
|
|
assert resp.status_code == 200
|
|
|
|
endpoint, kwargs = mock_api_client.get.call_args_list[3]
|
|
assert endpoint[0] == "/api/measurements"
|
|
assert kwargs["params"]["production_run_id"] == 77
|
|
|
|
def test_measurements_without_lot_or_serial_still_scope_to_the_operator(
|
|
self, logged_in_client, mock_api_client,
|
|
):
|
|
"""No lot/serial in session (recipe does not require them): the
|
|
operator filter alone still narrows the summary to this run."""
|
|
mock_api_client.get.side_effect = [
|
|
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
|
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
|
|
None, # current run
|
|
{"items": [], "pages": 1},
|
|
]
|
|
|
|
resp = logged_in_client.get("/measure/complete/1?version_id=5")
|
|
assert resp.status_code == 200
|
|
|
|
endpoint, kwargs = mock_api_client.get.call_args_list[3]
|
|
params = kwargs["params"]
|
|
assert "lot_number" not in params
|
|
assert "serial_number" not in params
|
|
assert params["measured_by"] == 1
|
|
|
|
def test_measurements_beyond_500_are_paged_not_truncated(
|
|
self, logged_in_client, mock_api_client,
|
|
):
|
|
"""#30: a cycle over pages instead of a single capped per_page=500 call."""
|
|
subtask = {"id": 1, "nominal": 5.0, "unit": "mm"}
|
|
page1 = {
|
|
"items": [{"id": i, "subtask_id": 1, "value": 5.0} for i in range(500)],
|
|
"pages": 2,
|
|
}
|
|
page2 = {"items": [{"id": 500, "subtask_id": 1, "value": 5.0}], "pages": 2}
|
|
mock_api_client.get.side_effect = [
|
|
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
|
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [subtask]}],
|
|
None, # current run
|
|
page1,
|
|
page2,
|
|
]
|
|
|
|
resp = logged_in_client.get("/measure/complete/1?version_id=5")
|
|
assert resp.status_code == 200
|
|
assert mock_api_client.get.call_count == 5
|
|
first_page_call = mock_api_client.get.call_args_list[3]
|
|
second_page_call = mock_api_client.get.call_args_list[4]
|
|
assert first_page_call.kwargs["params"]["page"] == 1
|
|
assert second_page_call.kwargs["params"]["page"] == 2
|
|
|
|
|
|
class TestOutOfTolerance:
|
|
"""Proxies for point 5: the capoturno's answer has to reach the measurement."""
|
|
|
|
def test_authorise_forwards_the_credentials(self, logged_in_client, mock_api_client):
|
|
mock_api_client.post.return_value = {"id": 9, "supervisor_id": 4}
|
|
resp = logged_in_client.post(
|
|
"/measure/api/measurements/9/authorise",
|
|
json={"supervisor_username": "capo", "supervisor_password": "segreta"},
|
|
)
|
|
assert resp.status_code == 200
|
|
endpoint, kwargs = mock_api_client.post.call_args
|
|
assert endpoint[0] == "/api/measurements/9/authorise"
|
|
assert kwargs["data"]["supervisor_username"] == "capo"
|
|
|
|
def test_authorise_needs_credentials(self, logged_in_client, mock_api_client):
|
|
resp = logged_in_client.post(
|
|
"/measure/api/measurements/9/authorise", json={},
|
|
)
|
|
assert resp.status_code == 400
|
|
mock_api_client.post.assert_not_called()
|
|
|
|
def test_authorise_propagates_refusal(self, logged_in_client, mock_api_client):
|
|
"""A operator who is not a supervisor must not authorise their own value."""
|
|
mock_api_client.post.return_value = {
|
|
"error": True, "status_code": 403, "detail": "not a supervisor",
|
|
}
|
|
resp = logged_in_client.post(
|
|
"/measure/api/measurements/9/authorise",
|
|
json={"supervisor_username": "tec", "supervisor_password": "x"},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
def test_pending_passes_the_scope(self, logged_in_client, mock_api_client):
|
|
mock_api_client.get.return_value = {"id": 9, "subtask_id": 21}
|
|
resp = logged_in_client.get(
|
|
"/measure/api/measurements/pending-authorisation"
|
|
"?version_id=5&production_run_id=7",
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["id"] == 9
|
|
endpoint, kwargs = mock_api_client.get.call_args
|
|
assert endpoint[0] == "/api/measurements/pending-authorisation"
|
|
assert kwargs["params"] == {"version_id": "5", "production_run_id": "7"}
|
|
|
|
def test_pending_without_a_run_omits_it(self, logged_in_client, mock_api_client):
|
|
mock_api_client.get.return_value = None
|
|
logged_in_client.get(
|
|
"/measure/api/measurements/pending-authorisation?version_id=5",
|
|
)
|
|
assert mock_api_client.get.call_args[1]["params"] == {"version_id": "5"}
|
|
|
|
def test_routes_require_login(self, client):
|
|
for method, url in (
|
|
("get", "/measure/api/measurements/pending-authorisation?version_id=5"),
|
|
("post", "/measure/api/measurements/9/authorise"),
|
|
):
|
|
resp = getattr(client, method)(url)
|
|
assert resp.status_code in (302, 401), f"{method} {url}"
|
|
|
|
|
|
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,
|
|
"version_id": 3,
|
|
"value": 9.95,
|
|
"pass_fail": "pass",
|
|
}
|
|
|
|
resp = logged_in_client.post(
|
|
"/measure/save-measurement",
|
|
json={
|
|
"subtask_id": 10,
|
|
"version_id": 3,
|
|
"value": 9.95,
|
|
},
|
|
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_forwards_input_duration(
|
|
self, logged_in_client, mock_api_client
|
|
):
|
|
"""input_duration_ms is forwarded to the backend payload."""
|
|
mock_api_client.post.return_value = {"id": 1, "pass_fail": "pass"}
|
|
|
|
resp = logged_in_client.post(
|
|
"/measure/save-measurement",
|
|
json={
|
|
"subtask_id": 10,
|
|
"version_id": 3,
|
|
"value": 9.95,
|
|
"input_duration_ms": 4250,
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
assert resp.status_code == 201
|
|
payload = mock_api_client.post.call_args.kwargs["data"]
|
|
assert payload["input_duration_ms"] == 4250
|
|
|
|
def test_save_measurement_omits_input_duration_when_absent(
|
|
self, logged_in_client, mock_api_client
|
|
):
|
|
"""Without input_duration_ms the payload sends null (backend default)."""
|
|
mock_api_client.post.return_value = {"id": 1, "pass_fail": "pass"}
|
|
|
|
resp = logged_in_client.post(
|
|
"/measure/save-measurement",
|
|
json={
|
|
"subtask_id": 10,
|
|
"version_id": 3,
|
|
"value": 9.95,
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
assert resp.status_code == 201
|
|
payload = mock_api_client.post.call_args.kwargs["data"]
|
|
assert payload.get("input_duration_ms") is None
|
|
|
|
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 version_id and value
|
|
content_type="application/json",
|
|
)
|
|
assert resp.status_code == 400
|
|
data = resp.get_json()
|
|
assert data["error"] is True
|
|
|
|
|
|
class TestLookupBarcode:
|
|
"""POST /measure/lookup-barcode tests (#37.4)."""
|
|
|
|
def test_non_string_code_returns_400_not_500(self, logged_in_client, mock_api_client):
|
|
"""{"code": 5} used to reach code.strip() on an int and crash to a 500."""
|
|
resp = logged_in_client.post(
|
|
"/measure/lookup-barcode", json={"code": 5},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert resp.get_json()["error"] is True
|
|
mock_api_client.get.assert_not_called()
|
|
|
|
def test_code_with_slash_is_url_quoted(
|
|
self, logged_in_client, mock_api_client, monkeypatch,
|
|
):
|
|
""""AB/123" must reach the backend as one path segment, not two."""
|
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
|
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
|
|
mock_api_client.get.return_value = {"id": 1, "code": "AB/123"}
|
|
|
|
resp = logged_in_client.post(
|
|
"/measure/lookup-barcode", json={"code": "AB/123"},
|
|
)
|
|
assert resp.status_code == 200
|
|
endpoint = mock_api_client.get.call_args[0][0]
|
|
assert endpoint == "/api/recipes/code/AB%2F123"
|
|
|
|
def test_code_with_path_traversal_is_quoted_not_walked(
|
|
self, logged_in_client, mock_api_client,
|
|
):
|
|
mock_api_client.get.return_value = {"error": True, "status_code": 404}
|
|
|
|
resp = logged_in_client.post(
|
|
"/measure/lookup-barcode", json={"code": "../users"},
|
|
)
|
|
assert resp.status_code == 404
|
|
endpoint = mock_api_client.get.call_args[0][0]
|
|
assert endpoint == "/api/recipes/code/..%2Fusers"
|