meal-order-manager/tests/test_generate_form.py

527 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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_mobile_admin_card_structure_and_breakpoint(self):
html = _render()
css = (SERVER_DIR / "templates" / "form.css").read_text()
admin_js = (SERVER_DIR / "templates" / "admin.js").read_text()
assert 'id="admin-cards"' in html
assert 'id="admin-alert"' in html
assert 'role="alert"' in html
assert 'id="admin-status"' in html
assert 'role="status"' in html
assert "@media (max-width: 767px)" in css
assert ".admin-table-wrap { display: none; }" in css
assert "buildAdminCard" in admin_js
assert "dataset.adminAction" in admin_js
assert "alert(" not in admin_js
assert "confirm(" in admin_js
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"
@pytest.fixture(scope="class")
def mobile_admin_page(self, tmp_path_factory, browser_page):
html = _render(google=False, api_key="")
out = tmp_path_factory.mktemp("admin-form") / "order-form.html"
out.write_text(html)
hostile_name = """O'Reilly "<b>Ops</b>\""""
state = {
"fail_put": False,
"dialogs": [],
"orders": [
{
"employee_name": hostile_name,
"employee_email": "hostile@example.com",
"submitted_at": "2026-07-30T14:15:00Z",
"items": [
{
"name": "Chicken Bowl",
"quantity": 1,
"subtotal": 12.0,
}
],
"total": 12.0,
}
],
}
prices = {
"Chicken Bowl": 12.0,
"Luis' Lomo Saltado": 14.5,
'Meal with "quotes"': 11.0,
"</script><script>alert(1)</script>": 9.0,
}
def admin_payload():
return {
"week": "2026-W30",
"orders": state["orders"],
"total_employees": len(state["orders"]),
"grand_total": sum(order["total"] for order in state["orders"]),
}
def handle_admin(route):
request = route.request
if request.method == "PUT":
if state["fail_put"]:
route.fulfill(
status=500,
content_type="application/json",
body='{"error":"hostile update failed"}',
)
return
payload = request.post_data_json
order = state["orders"][0]
order["items"] = [
{
"name": item["name"],
"quantity": item["quantity"],
"subtotal": prices[item["name"]] * item["quantity"],
}
for item in payload["items"]
]
order["total"] = sum(item["subtotal"] for item in order["items"])
route.fulfill(
status=200,
content_type="application/json",
body='{"ok":true}',
)
return
if request.method == "DELETE":
state["orders"] = []
route.fulfill(
status=200,
content_type="application/json",
body='{"ok":true}',
)
return
route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(admin_payload()),
)
context = browser_page.context.browser.new_context(
viewport={"width": 375, "height": 800},
has_touch=True,
is_mobile=True,
)
page = context.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='{"employees":[]}',
),
)
page.route("**/api/admin/orders**", handle_admin)
page.on(
"dialog",
lambda dialog: (
state["dialogs"].append(dialog.message),
dialog.accept(),
),
)
page.goto(out.as_uri())
page.evaluate(
"() => { window.showAdmin(); window.loadWeekOrders('2026-W30'); }"
)
page.wait_for_selector(".admin-order-card")
yield page, state, hostile_name
context.close()
def test_mobile_admin_card_complete_workflow(self, mobile_admin_page):
page, state, hostile_name = mobile_admin_page
card = page.locator(".admin-order-card")
assert card.is_visible()
assert not page.locator(".admin-table-wrap").is_visible()
assert hostile_name in card.inner_text()
assert "hostile@example.com" in card.inner_text()
assert "Chicken Bowl ×1" in card.inner_text()
assert "$12.00" in card.inner_text()
assert page.evaluate(
"() => document.documentElement.scrollWidth === "
"document.documentElement.clientWidth"
)
card.locator('[data-admin-action="start-edit"]').click()
page.wait_for_selector('.admin-order-card [data-admin-action="save-edit"]')
minus = page.locator(
'.admin-order-card [data-admin-action="edit-qty"][data-delta="-1"]'
)
assert minus.get_attribute("aria-label") == "Remove one Chicken Bowl"
assert minus.evaluate("(el) => el.getBoundingClientRect().height") >= 44
add_item = page.locator(
'.admin-order-card [data-admin-action="add-item-select"]'
)
add_item.select_option(label="Luis' Lomo Saltado ($14.50)")
page.wait_for_selector(
'.admin-order-card [data-meal-name="Luis\\\' Lomo Saltado"]'
)
page.locator(
'.admin-order-card [data-meal-name="Luis\\\' Lomo Saltado"][data-delta="1"]'
).click()
page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
page.wait_for_function(
"() => document.querySelector('.admin-order-card')"
'.textContent.includes("Luis\' Lomo Saltado ×2")'
)
assert state["orders"][0]["employee_name"] == hostile_name
assert state["orders"][0]["total"] == 41.0
page.locator('.admin-order-card [data-admin-action="start-edit"]').click()
page.wait_for_selector('.admin-order-card [data-admin-action="cancel-edit"]')
page.locator(
'.admin-order-card [data-admin-action="edit-qty"][data-delta="1"]'
).first.click()
page.locator('.admin-order-card [data-admin-action="cancel-edit"]').click()
assert page.locator(
'.admin-order-card [data-admin-action="start-edit"]'
).is_visible()
for width in (320, 375, 767):
page.set_viewport_size({"width": width, "height": 800})
assert page.locator(".admin-order-card").is_visible()
assert page.evaluate(
"() => document.documentElement.scrollWidth === "
"document.documentElement.clientWidth"
)
assert page.evaluate(
"""() => Array.from(document.querySelectorAll('#admin-panel *'))
.filter((el) => el.offsetParent !== null)
.every((el) => el.scrollWidth <= el.clientWidth + 1)"""
)
page.set_viewport_size({"width": 768, "height": 800})
assert page.locator(".admin-table-wrap").is_visible()
assert not page.locator(".admin-order-card").is_visible()
page.set_viewport_size({"width": 375, "height": 800})
state["fail_put"] = True
page.locator('.admin-order-card [data-admin-action="start-edit"]').click()
page.wait_for_selector('.admin-order-card [data-admin-action="save-edit"]')
page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
page.wait_for_function(
"() => document.getElementById('admin-alert').textContent"
".includes('hostile update failed')"
)
state["fail_put"] = False
page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
page.wait_for_selector('.admin-order-card [data-admin-action="start-edit"]')
assert page.locator("#admin-alert").inner_text() == ""
page.locator('.admin-order-card [data-admin-action="delete"]').click()
page.wait_for_selector("#admin-empty:not(.is-hidden)")
assert hostile_name in state["dialogs"][-1]
assert state["orders"] == []