meal-order-manager/tests/test_generate_form.py

528 lines
20 KiB
Python
Raw Normal View History

"""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"] == []