fix(admin): apostrophe-in-translation broke /admin/stations Alpine bindings

Two error messages on /admin/stations rendered Italian translations
that contain an apostrophe inside a single-quoted JS literal:

  alert(result.detail || '{{ _("Errore nell\'eliminazione") }}');

Jinja outputs the apostrophe verbatim, so the JS string closed
prematurely:

  alert(result.detail || 'Errore nell'eliminazione');

That syntax error blew up the entire <script> block, which means
stationManagement() was never defined and EVERY Alpine binding on the
page failed silently. Symptom: clicking "Nuova Stazione" did nothing,
DevTools showed "Alpine Expression Error: openCreateModal is not
defined".

Fix: swap outer JS quotes to double quotes and reword the IT string so
the apostrophe disappears anyway ("Errore nella eliminazione",
"Errore nella assegnazione"). Same for the assignment-error path.

Regression guard: src/frontend/flask_app/tests/test_template_js_syntax.py
renders /admin/stations and /admin/users with the IT locale forced,
extracts every inline <script>, and runs `node --check` on each. The
test is skipped if `node` is not on PATH so CI without Node still
passes. Verified the test catches the original bug (revert + run +
fail) before re-applying the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 12:32:32 +02:00
parent 742cc1fb58
commit 6e284b0c0c
2 changed files with 129 additions and 2 deletions
@@ -477,7 +477,7 @@ function stationManagement(initialStations, initialRecipes) {
});
if (!resp.ok) {
const result = await resp.json().catch(() => ({}));
alert(result.detail || '{{ _("Errore nell\'eliminazione") }}');
alert(result.detail || "{{ _('Errore nella eliminazione') }}");
return;
}
this.stations = this.stations.filter(s => s.id !== this.deleteTarget.id);
@@ -528,7 +528,7 @@ function stationManagement(initialStations, initialRecipes) {
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || '{{ _("Errore nell\'assegnazione") }}';
this.errorMsg = result.detail || "{{ _('Errore nella assegnazione') }}";
return;
}
const recipe = this.allRecipes.find(r => r.id === parseInt(this.recipeToAdd, 10));
@@ -0,0 +1,127 @@
"""Verify that inline <script> blocks in admin templates parse as valid JS.
We had a regression where an Italian translation containing an apostrophe
(e.g. "Errore nell'eliminazione") broke a single-quoted JS string literal,
killing every Alpine binding on the page (the "Nuova Stazione" button no
longer triggered openCreateModal).
Strategy: render each admin page through the Flask test client, extract every
inline <script> body and feed it to `node --check` for a syntax verdict. We
force the IT locale so the worst-case translations (which contain apostrophes)
are the ones evaluated.
The test is skipped if `node` is not on PATH; CI images that exclude Node
get a soft skip rather than a false failure.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import tempfile
from unittest.mock import MagicMock, patch
import pytest
# Match every inline <script>…</script> (i.e. without `src=`) so we don't try
# to syntax-check Alpine.js / Plotly bundles served from a CDN.
_INLINE_SCRIPT_RX = re.compile(
r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>",
re.DOTALL | re.IGNORECASE,
)
def _node_check(script: str, label: str) -> None:
"""Fail the test if `node --check` rejects `script`."""
if not script.strip():
return
node = shutil.which("node")
if node is None:
pytest.skip("node binary not found on PATH; cannot validate JS syntax")
fd, path = tempfile.mkstemp(suffix=".js")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(script)
result = subprocess.run(
[node, "--check", path],
capture_output=True,
text=True,
timeout=10,
)
finally:
os.unlink(path)
if result.returncode != 0:
snippet = script.strip()
if len(snippet) > 600:
snippet = snippet[:600] + "\n…(truncated)…"
pytest.fail(
f"{label}: node rejected the inline <script> as invalid JS.\n"
f"--- node stderr ---\n{result.stderr.strip()}\n"
f"--- script (first 600 chars) ---\n{snippet}"
)
def _force_italian(client) -> None:
"""Make the next request render with the IT locale (worst case for JS)."""
with client.session_transaction() as sess:
sess["language"] = "it"
@pytest.fixture
def mock_admin_api():
"""Patch api_client used inside the admin blueprint."""
mock = MagicMock()
with patch("blueprints.admin.api_client", mock):
yield mock
def test_admin_stations_inline_js_is_valid(logged_in_client, mock_admin_api):
"""The /admin/stations page must emit syntactically valid inline JS in IT.
Regression guard for the apostrophe-in-single-quote bug.
"""
mock_admin_api.get.side_effect = [
# GET /api/stations
[{
"id": 1, "code": "ST-DEFAULT", "name": "Default Station",
"location": None, "notes": None, "active": True,
"created_by": 1, "created_at": "2026-04-25T10:00:00",
}],
# GET /api/recipes
[{"id": 1, "code": "DEMO-001", "name": "Demo Recipe", "active": True}],
]
_force_italian(logged_in_client)
resp = logged_in_client.get("/admin/stations")
assert resp.status_code == 200, resp.data[:300]
html = resp.data.decode("utf-8")
scripts = _INLINE_SCRIPT_RX.findall(html)
assert scripts, "expected at least one inline <script> on /admin/stations"
for i, body in enumerate(scripts):
_node_check(body, f"/admin/stations script[{i}]")
def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
"""Same guard for /admin/users (reuses the userManagement Alpine pattern)."""
mock_admin_api.get.return_value = [{
"id": 1, "username": "admin", "display_name": "Admin",
"email": None, "roles": ["Maker"], "is_admin": True,
"language_pref": "it", "theme_pref": "light", "active": True,
}]
_force_italian(logged_in_client)
resp = logged_in_client.get("/admin/users")
assert resp.status_code == 200
html = resp.data.decode("utf-8")
scripts = _INLINE_SCRIPT_RX.findall(html)
assert scripts
for i, body in enumerate(scripts):
_node_check(body, f"/admin/users script[{i}]")