mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 16:18:54 +00:00
Split the monolithic generate_form f-string into form.html.j2/css/js plus admin.js, inject a single window.CONFIG blob, and add structural plus Playwright coverage so qty delegation and clamp stay green in CI.
304 lines
11 KiB
Python
304 lines
11 KiB
Python
"""Structural and Playwright tests for the generated order form."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
SERVER_DIR = REPO_ROOT / "src" / "server"
|
|
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
|
|
|
sys.path.insert(0, str(SERVER_DIR))
|
|
|
|
from generate_form import generate_form # noqa: E402
|
|
|
|
EXPECTED_CONFIG_KEYS = {
|
|
"authMode",
|
|
"meals",
|
|
"roster",
|
|
"week",
|
|
"submitUrl",
|
|
"statusUrl",
|
|
"rosterUrl",
|
|
"adminUrl",
|
|
"adminPdfUrl",
|
|
"apiKey",
|
|
"bulkDiscount",
|
|
"companySubsidy",
|
|
"googleClientId",
|
|
"deadline",
|
|
"scrapedAt",
|
|
"hasDiscount",
|
|
}
|
|
|
|
|
|
def _load_fixtures():
|
|
menu = json.loads((FIXTURES / "menu.json").read_text())
|
|
config = json.loads((FIXTURES / "config.json").read_text())
|
|
return menu, config
|
|
|
|
|
|
def _extract_config(html: str) -> dict:
|
|
match = re.search(
|
|
r"window\.CONFIG\s*=\s*(\{.*?\});",
|
|
html,
|
|
flags=re.DOTALL,
|
|
)
|
|
assert match, "window.CONFIG blob not found"
|
|
return json.loads(match.group(1))
|
|
|
|
|
|
def _render(*, google: bool = False, api_key: str = "test-key") -> str:
|
|
menu, config = _load_fixtures()
|
|
return generate_form(
|
|
menu,
|
|
config,
|
|
api_url="https://api.example.com",
|
|
api_key=api_key,
|
|
bulk_discount=10,
|
|
company_subsidy=50,
|
|
google_client_id="google-client.apps.googleusercontent.com" if google else "",
|
|
)
|
|
|
|
|
|
class TestGenerateFormStructural:
|
|
def test_local_and_google_render(self):
|
|
local = _render(google=False)
|
|
google = _render(google=True)
|
|
assert "FORM_HTML_MARKER" in local
|
|
assert "FORM_HTML_MARKER" in google
|
|
assert 'id="emp-name"' in local
|
|
assert 'id="emp-name"' not in google
|
|
assert "accounts.google.com/gsi/client" in google
|
|
assert "accounts.google.com/gsi/client" not in local
|
|
assert 'id="auth-overlay"' in google
|
|
assert 'id="auth-overlay"' not in local
|
|
|
|
def test_single_config_blob_and_keys(self):
|
|
for google in (False, True):
|
|
html = _render(google=google)
|
|
assert html.count("window.CONFIG =") == 1
|
|
cfg = _extract_config(html)
|
|
assert EXPECTED_CONFIG_KEYS <= set(cfg.keys())
|
|
assert cfg["authMode"] == ("google" if google else "local")
|
|
if google:
|
|
assert cfg["googleClientId"]
|
|
else:
|
|
assert cfg["googleClientId"] == ""
|
|
assert cfg["bulkDiscount"] == 10
|
|
assert cfg["companySubsidy"] == 50
|
|
assert cfg["hasDiscount"] is True
|
|
|
|
def test_no_legacy_const_injections(self):
|
|
html = _render(google=True)
|
|
for pattern in (
|
|
r"\bconst MEALS\s*=",
|
|
r"\bconst ROSTER\s*=",
|
|
r"\bconst SUBMIT_URL\s*=",
|
|
r"\bconst STATUS_URL\s*=",
|
|
r"\bconst ROSTER_URL\s*=",
|
|
r"\bconst ADMIN_URL\s*=",
|
|
r"\bconst ADMIN_PDF_URL\s*=",
|
|
r"\bconst API_KEY\s*=",
|
|
r"\bconst GOOGLE_CLIENT_ID\s*=",
|
|
):
|
|
# Locals derived from CONFIG inside form.js are fine; generator must not
|
|
# inject the seven top-level const lines before the inlined scripts.
|
|
pre_js = html.split("/* FORM_JS_MARKER */")[0]
|
|
assert not re.search(pattern, pre_js), pattern
|
|
|
|
def test_template_markers_inlined(self):
|
|
html = _render()
|
|
assert "FORM_CSS_MARKER" in html
|
|
assert "--color-brand" in html
|
|
assert "FORM_JS_MARKER" in html
|
|
assert "ADMIN_JS_MARKER" in html
|
|
assert "FORM_HTML_MARKER" in html
|
|
assert '<link rel="stylesheet"' not in html
|
|
# Only allowed external script is GSI in google mode
|
|
local = _render(google=False)
|
|
assert "<script src=" not in local
|
|
|
|
def test_script_order_form_then_admin(self):
|
|
html = _render()
|
|
form_pos = html.index("FORM_JS_MARKER")
|
|
admin_pos = html.index("ADMIN_JS_MARKER")
|
|
assert form_pos < admin_pos
|
|
|
|
def test_single_submit_order_definition(self):
|
|
html = _render(google=True)
|
|
# One async function submitOrder in the inlined form.js
|
|
assert len(re.findall(r"async function submitOrder\s*\(", html)) == 1
|
|
|
|
def test_no_onclick_admin_data_embedding(self):
|
|
html = _render(google=True)
|
|
assert 'onclick="admin' not in html
|
|
assert 'onclick="admin' not in html
|
|
# Static markup must not interpolate runtime data into onclick
|
|
assert not re.search(r'onclick="[^"]*\$\{', html)
|
|
|
|
def test_script_breakout_meal_escaped(self):
|
|
html = _render()
|
|
assert "</script><script>alert(1)</script>" not in html
|
|
cfg_match = re.search(r"window\.CONFIG\s*=\s*(\{.*?\});", html, flags=re.DOTALL)
|
|
assert cfg_match
|
|
blob = cfg_match.group(1)
|
|
# Literal </script> inside the CONFIG script would terminate early.
|
|
assert "</script>" not in blob
|
|
assert "<\\/script><script>alert(1)<\\/script>" in blob
|
|
# Parsed JSON still contains the hostile meal name.
|
|
cfg = json.loads(blob)
|
|
assert any(
|
|
m["name"] == "</script><script>alert(1)</script>" for m in cfg["meals"]
|
|
)
|
|
|
|
def test_hostile_names_present_in_config(self):
|
|
cfg = _extract_config(_render())
|
|
names = {m["name"] for m in cfg["meals"]}
|
|
assert "Luis' Lomo Saltado" in names
|
|
assert 'Meal with "quotes"' in names
|
|
assert "</script><script>alert(1)</script>" in names
|
|
|
|
def test_meal_card_template_and_qty_input(self):
|
|
html = _render()
|
|
assert 'id="meal-card-tpl"' in html
|
|
assert 'type="number"' in html
|
|
assert "data-meal-index" in html
|
|
assert "data-qty-delta" in html
|
|
|
|
def test_design_tokens_only_in_definitions(self):
|
|
css = (SERVER_DIR / "templates" / "form.css").read_text()
|
|
# Strip :root block, then assert brand/muted/border hexes are gone
|
|
without_root = re.sub(r":root\s*\{[^}]*\}", "", css, count=1)
|
|
assert "#1a1a2e" not in without_root
|
|
assert "#6b7280" not in without_root
|
|
assert "#d1d5db" not in without_root
|
|
|
|
def test_python_has_no_html_js_fragments(self):
|
|
src = (SERVER_DIR / "generate_form.py").read_text()
|
|
assert "auth_section_html" not in src
|
|
assert "google_auth_js" not in src
|
|
assert "submit_order_js" not in src
|
|
assert "origLoadWeekOrders" not in src
|
|
|
|
|
|
class TestGenerateFormPlaywright:
|
|
@pytest.fixture(scope="class")
|
|
def browser_page(self, tmp_path_factory):
|
|
try:
|
|
from playwright.sync_api import sync_playwright
|
|
except ImportError as exc: # pragma: no cover
|
|
raise RuntimeError(
|
|
"playwright package is required; install requirements.txt"
|
|
) from exc
|
|
|
|
html = _render(google=False, api_key="")
|
|
# Stub status/roster so the page does not hang on network; roster fallback
|
|
# in loadRoster still applies if fetch fails — intercept to be deterministic.
|
|
out = tmp_path_factory.mktemp("form") / "order-form.html"
|
|
out.write_text(html)
|
|
|
|
with sync_playwright() as p:
|
|
try:
|
|
browser = p.chromium.launch(headless=True)
|
|
except Exception as exc:
|
|
raise RuntimeError(
|
|
"Chromium is required for form Playwright tests. "
|
|
"Run: playwright install --with-deps chromium"
|
|
) from exc
|
|
page = browser.new_page()
|
|
page.route(
|
|
"**/api/form-status/**",
|
|
lambda route: route.fulfill(
|
|
status=200,
|
|
content_type="application/json",
|
|
body='{"status":"open"}',
|
|
),
|
|
)
|
|
page.route(
|
|
"**/api/roster**",
|
|
lambda route: route.fulfill(
|
|
status=200,
|
|
content_type="application/json",
|
|
body=json.dumps(
|
|
{
|
|
"employees": [
|
|
{"name": "Ada Lovelace", "email": "ada@example.com"}
|
|
]
|
|
}
|
|
),
|
|
),
|
|
)
|
|
page.goto(out.as_uri())
|
|
page.wait_for_selector('[data-meal-index="0"]')
|
|
yield page
|
|
browser.close()
|
|
|
|
def test_qty_plus_updates_total_without_list_teardown(self, browser_page):
|
|
page = browser_page
|
|
card = page.locator('[data-meal-index="0"]')
|
|
card_handle = card.element_handle()
|
|
assert card_handle is not None
|
|
|
|
page.locator('[data-meal-index="0"] [data-qty-delta="1"]').click()
|
|
page.wait_for_function(
|
|
"() => document.getElementById('total-display').textContent !== '$0.00'"
|
|
)
|
|
|
|
assert page.locator("#total-display").inner_text() == "$5.40"
|
|
assert page.locator("#count-display").inner_text() == "1 meal"
|
|
assert page.locator('[data-meal-index="0"] .qty').input_value() == "1"
|
|
assert "selected" in (card.get_attribute("class") or "")
|
|
|
|
# Same DOM node — partial update, not full list teardown
|
|
page.evaluate(
|
|
"""() => {
|
|
const el = document.querySelector('[data-meal-index="0"]');
|
|
el.dataset.teardownProbe = '1';
|
|
}"""
|
|
)
|
|
page.locator('[data-meal-index="0"] [data-qty-delta="1"]').click()
|
|
page.wait_for_function(
|
|
"() => document.querySelector('[data-meal-index=\"0\"] .qty').value === '2'"
|
|
)
|
|
assert (
|
|
page.locator('[data-meal-index="0"]').get_attribute("data-teardown-probe")
|
|
== "1"
|
|
)
|
|
assert page.locator("#count-display").inner_text() == "2 meals"
|
|
assert page.locator("#total-display").inner_text() == "$10.80"
|
|
|
|
def test_qty_typein_clamps_negative(self, browser_page):
|
|
page = browser_page
|
|
# Reset first card via type-in
|
|
qty = page.locator('[data-meal-index="0"] .qty')
|
|
qty.fill("-3")
|
|
qty.dispatch_event("change")
|
|
page.wait_for_function(
|
|
"() => document.querySelector('[data-meal-index=\"0\"] .qty').value === '0'"
|
|
)
|
|
assert qty.input_value() == "0"
|
|
# Total may still include other meals from prior test if class-scoped page
|
|
# is shared — set all known cards to 0 then set one meal to 2 via type-in.
|
|
page.evaluate(
|
|
"""() => {
|
|
document.querySelectorAll('[data-meal-index] .qty').forEach((input) => {
|
|
input.value = '0';
|
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
});
|
|
}"""
|
|
)
|
|
page.wait_for_function(
|
|
"() => document.getElementById('count-display').textContent === '0 meals'"
|
|
)
|
|
qty.fill("2")
|
|
qty.dispatch_event("change")
|
|
page.wait_for_function(
|
|
"() => document.getElementById('count-display').textContent === '2 meals'"
|
|
)
|
|
assert page.locator("#total-display").inner_text() == "$10.80"
|