mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 16:18:54 +00:00
Keep the mobile layout below 480px and lock both sides of the breakpoint with browser coverage.
446 lines
16 KiB
Python
446 lines
16 KiB
Python
"""Structural and Playwright tests for the generated order form."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
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 "",
|
|
)
|
|
|
|
|
|
def _google_credential() -> str:
|
|
payload = json.dumps(
|
|
{
|
|
"name": "Adam Moussa",
|
|
"email": "adam.moussa@seahavenind.com",
|
|
"picture": (
|
|
"data:image/svg+xml,"
|
|
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'/>"
|
|
),
|
|
}
|
|
).encode()
|
|
encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
|
|
return f"e30.{encoded}.signature"
|
|
|
|
|
|
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
|
|
# 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
|
|
# 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
|
|
assert "<!--<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
|
|
def browser_page(self, tmp_path_factory):
|
|
sync_api = pytest.importorskip("playwright.sync_api")
|
|
sync_playwright = sync_api.sync_playwright
|
|
|
|
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
|
|
try:
|
|
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
|
|
finally:
|
|
browser.close()
|
|
|
|
def test_admin_bundle_loads(self, browser_page):
|
|
assert browser_page.evaluate("typeof window.loadWeekOrders") == "function"
|
|
|
|
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.blur()
|
|
page.wait_for_function(
|
|
"() => document.querySelector('[data-meal-index=\"0\"] .qty').value === '0'"
|
|
)
|
|
assert qty.input_value() == "0"
|
|
qty.fill("2")
|
|
qty.blur()
|
|
page.wait_for_function(
|
|
"() => document.getElementById('count-display').textContent === '2 meals'"
|
|
)
|
|
assert page.locator("#total-display").inner_text() == "$10.80"
|
|
|
|
@pytest.mark.parametrize("width", [320, 375])
|
|
def test_local_roster_has_no_horizontal_overflow(self, browser_page, width):
|
|
page = browser_page
|
|
page.set_viewport_size({"width": width, "height": 800})
|
|
dimensions = page.evaluate(
|
|
"""() => ({
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
clientWidth: document.documentElement.clientWidth,
|
|
})"""
|
|
)
|
|
assert dimensions["scrollWidth"] == dimensions["clientWidth"]
|
|
|
|
|
|
class TestGenerateFormGooglePlaywright:
|
|
@pytest.fixture(scope="class")
|
|
def signed_in_admin_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
|
|
|
|
out = tmp_path_factory.mktemp("google-form") / "order-form.html"
|
|
out.write_text(_render(google=True, api_key=""))
|
|
|
|
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.add_init_script(
|
|
"""window.google = {
|
|
accounts: {
|
|
id: {
|
|
initialize() {},
|
|
renderButton() {},
|
|
disableAutoSelect() {},
|
|
},
|
|
},
|
|
};"""
|
|
)
|
|
page.route(
|
|
"https://accounts.google.com/gsi/client",
|
|
lambda route: route.abort(),
|
|
)
|
|
|
|
def handle_api(route):
|
|
if "/api/form-status/" in route.request.url:
|
|
route.fulfill(
|
|
status=200,
|
|
content_type="application/json",
|
|
body='{"status":"open"}',
|
|
)
|
|
elif "/api/admin/orders" in route.request.url:
|
|
route.fulfill(
|
|
status=200,
|
|
content_type="application/json",
|
|
body='{"weeks":["2026-W30"]}',
|
|
)
|
|
else:
|
|
route.fulfill(
|
|
status=404,
|
|
content_type="application/json",
|
|
body="{}",
|
|
)
|
|
|
|
page.route("https://api.example.com/**", handle_api)
|
|
page.goto(out.as_uri(), wait_until="domcontentloaded")
|
|
page.evaluate(
|
|
"(credential) => handleCredentialResponse({ credential })",
|
|
_google_credential(),
|
|
)
|
|
page.locator("#admin-btn").wait_for(state="visible")
|
|
yield page
|
|
browser.close()
|
|
|
|
@pytest.mark.parametrize("width", [320, 375])
|
|
def test_signed_in_admin_user_bar_fits_phone_width(
|
|
self, signed_in_admin_page, width
|
|
):
|
|
page = signed_in_admin_page
|
|
page.set_viewport_size({"width": width, "height": 800})
|
|
|
|
for selector in (
|
|
"#user-avatar",
|
|
"#user-name",
|
|
"#user-email",
|
|
"#admin-btn",
|
|
"#sign-out-btn",
|
|
):
|
|
assert page.locator(selector).is_visible()
|
|
assert page.locator("#admin-btn").is_enabled()
|
|
assert page.locator("#sign-out-btn").is_enabled()
|
|
|
|
dimensions = page.evaluate(
|
|
"""() => ({
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
clientWidth: document.documentElement.clientWidth,
|
|
})"""
|
|
)
|
|
assert dimensions["scrollWidth"] == dimensions["clientWidth"]
|
|
|
|
def test_user_bar_stays_single_row_at_480(self, signed_in_admin_page):
|
|
page = signed_in_admin_page
|
|
page.set_viewport_size({"width": 480, "height": 800})
|
|
assert (
|
|
page.locator(".user-bar").evaluate(
|
|
"(element) => getComputedStyle(element).flexWrap"
|
|
)
|
|
== "nowrap"
|
|
)
|
|
dimensions = page.evaluate(
|
|
"""() => ({
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
clientWidth: document.documentElement.clientWidth,
|
|
})"""
|
|
)
|
|
assert dimensions["scrollWidth"] == dimensions["clientWidth"]
|
|
|
|
def test_user_bar_wraps_below_shared_mobile_breakpoint(
|
|
self, signed_in_admin_page
|
|
):
|
|
page = signed_in_admin_page
|
|
page.set_viewport_size({"width": 479, "height": 800})
|
|
assert (
|
|
page.locator(".user-bar").evaluate(
|
|
"(element) => getComputedStyle(element).flexWrap"
|
|
)
|
|
== "wrap"
|
|
)
|