"""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.""" # Patch the class attribute instead of reload()ing config/blueprints.measure: # monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79). monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST") 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 # Patch the class attribute instead of reload()ing config/blueprints.measure: # monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79). monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST") 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.""" # Patch the class attribute instead of reload()ing config/blueprints.measure: # monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79). monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST") 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/ 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/ 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/ (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/ — 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/ tests.""" @pytest.fixture(autouse=True) def _station(self, monkeypatch): # The side_effect lists below include the /production-runs/current reply, # which is only requested when a station is configured: pin it explicitly # instead of relying on whatever an earlier test left behind (#79). monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST") 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"