meal-order-manager/tests/test_generate_form.py
Adam Moussa cc506f3c1f
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
feat(menu): publish the weekly menu from the job worker (PLAT-229) (#219)
* feat(menu): publish the weekly menu from the job worker

Monday publish parses the catalog embedded in the Redefine menu page and runs on the Fargate worker, so the GitHub Actions scrape cron can go away.

* fix(menu): address review feedback

Use the form deadline in the Monday Slack post, and compare that message exactly so CodeQL does not treat the test as URL sanitization.
2026-09-25 22:10:24 +00:00

1273 lines
50 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 base64
import json
import re
import sys
from pathlib import Path
from types import SimpleNamespace
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))
import generate_form as generate_form_module # noqa: E402
generate_form = generate_form_module.generate_form
EXPECTED_CONFIG_KEYS = {
"authMode",
"meals",
"roster",
"week",
"submitUrl",
"statusUrl",
"rosterUrl",
"adminUrl",
"adminPdfUrl",
"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) -> str:
menu, config = _load_fixtures()
return generate_form(
menu,
config,
api_url="https://api.example.com" if google else "",
bulk_discount=10,
company_subsidy=50,
google_client_id="google-client.apps.googleusercontent.com" if google else "",
)
def _render_for_browser(*, google: bool = False) -> str:
html = _render(google=google)
if not google:
html = html.replace("<head>", '<head><base href="https://api.example.com/">', 1)
return html
def _google_credential(extra: dict | None = None) -> str:
payload_obj = {
"name": "Test Admin",
"email": "test-admin@example.com",
"picture": (
"data:image/svg+xml,"
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'/>"
),
}
if extra:
payload_obj.update(extra)
encoded = (
base64.urlsafe_b64encode(json.dumps(payload_obj).encode()).decode().rstrip("=")
)
return f"e30.{encoded}.signature"
def _mock_form_routes(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.route(
"**/api/admin/orders",
lambda route: route.fulfill(
status=403,
content_type="application/json",
body='{"error":"Forbidden"}',
),
)
class TestGenerateFormStructural:
def test_cloud_mode_requires_google_auth(self):
menu, config = _load_fixtures()
for client_id in ("", " "):
with pytest.raises(ValueError, match="Google client ID is required"):
generate_form(
menu,
config,
api_url="https://api.example.com",
google_client_id=client_id,
)
def test_cloud_cli_rejects_whitespace_ssm_client_id(self, monkeypatch, capsys):
menu, config = _load_fixtures()
ssm = SimpleNamespace(get_parameter=lambda **_: {"Parameter": {"Value": " "}})
monkeypatch.setitem(sys.modules, "boto3", SimpleNamespace(client=lambda _: ssm))
monkeypatch.setattr(generate_form_module, "load_config", lambda: config)
monkeypatch.setattr(generate_form_module, "latest_menu", lambda: menu)
monkeypatch.setattr(
sys,
"argv",
["generate_form.py", "--api-url", "https://api.example.com"],
)
with pytest.raises(SystemExit) as exc_info:
generate_form_module.main()
assert exc_info.value.code == 2
assert "Google auth is required in cloud mode" in capsys.readouterr().err
def test_generated_form_contains_no_api_key_handling(self):
html = _render(google=True)
assert "apiKey" not in _extract_config(html)
assert "API_KEY" not in html
assert "x-api-key" not in html
def test_cloud_configuration_has_no_form_api_key(self):
http_api = (REPO_ROOT / "src" / "server" / "http_api.py").read_text()
publish_job = (
REPO_ROOT / "src" / "server" / "jobs" / "publish_menu.py"
).read_text()
for text in (http_api, publish_job):
assert "FORM_APIKEY" not in text
assert "form-api-key" not in text
assert "x-api-key" not in text
assert "--api-key" not in text
def test_submit_route_has_explicit_throttling(self):
http_api = (REPO_ROOT / "src" / "server" / "http_api.py").read_text()
assert "SUBMIT_RATE_PER_SEC = 5.0" in http_api
assert "def _allow_submit()" in http_api
def test_manual_publish_stays_hmac_and_scheduled_job_is_in_process(self):
script = (REPO_ROOT / "scripts" / "upload_menu.py").read_text()
locals_tf = (REPO_ROOT / "terraform" / "locals.tf").read_text()
scheduler = (REPO_ROOT / "terraform" / "scheduler.tf").read_text()
publish_job = (
REPO_ROOT / "src" / "server" / "jobs" / "publish_menu.py"
).read_text()
for route in ("/api/publish/settings", "/api/publish/menu"):
assert route in script
assert 'authorizer = "HMAC"' in locals_tf
assert "X-Meals-Publish-Key" in script
assert 'boto3.resource("dynamodb")' not in script
assert 'event = "publish_menu"' in scheduler
assert 'schedule = "cron(30 7 ? * MON *)"' in scheduler
assert "put_menu" in publish_job
assert not (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").exists()
def test_explicit_week_is_embedded_in_the_form(self):
menu, config = _load_fixtures()
html = generate_form(menu, config, week="2026-W38")
config_blob = _extract_config(html)
assert config_blob["week"] == "2026-W38"
assert "/api/form-status/2026-W38" in html
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_mobile_viewport_and_safe_area_clearance_are_preserved(self):
html = _render()
css = (SERVER_DIR / "templates" / "form.css").read_text()
assert (
'content="width=device-width, initial-scale=1.0, viewport-fit=cover"'
in html
)
assert "--sticky-footer-height: 80px;" in css
assert (
"padding-bottom: calc(var(--sticky-footer-height) + "
"env(safe-area-inset-bottom, 0px));"
) in css
assert (
"padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));"
) in css
assert (
"min-height: calc(var(--sticky-footer-height) + "
"env(safe-area-inset-bottom, 0px));"
) in css
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_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-status-alert"' in html
assert 'id="admin-status-info"' 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 "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
# 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_a11y_status_and_live_total_markup(self):
html = _render()
assert 'id="form-status-alert"' in html
assert 'id="form-status-info"' in html
assert 'id="admin-status-alert"' in html
assert 'id="admin-status-info"' in html
assert 'role="alert"' in html
assert 'role="status"' in html
assert 'aria-live="polite"' in html
assert 'id="success-heading"' in html
assert 'tabindex="-1"' in html
def test_no_alert_dialogs_in_form_scripts(self):
form_js = (SERVER_DIR / "templates" / "form.js").read_text()
admin_js = (SERVER_DIR / "templates" / "admin.js").read_text()
assert "alert(" not in form_js
assert "alert(" not in admin_js
# confirm() retained for destructive admin delete / zero-qty save.
assert "confirm(" in admin_js
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_for_browser(google=False)
# 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()
_mock_form_routes(page)
page.goto(out.as_uri())
page.wait_for_selector('[data-meal-index="0"]')
yield page
finally:
browser.close()
@pytest.mark.parametrize("width", [320, 375, 480])
@pytest.mark.parametrize("google", [False, True], ids=["local", "google"])
def test_sticky_footer_fits_narrow_viewports(self, tmp_path, width, google):
sync_api = pytest.importorskip("playwright.sync_api")
sync_playwright = sync_api.sync_playwright
html_path = tmp_path / f"order-form-{'google' if google else 'local'}.html"
html_path.write_text(_render_for_browser(google=google))
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(viewport={"width": width, "height": 800})
page.add_init_script(
"""window.google = {
accounts: {
id: {
initialize() {},
renderButton() {},
prompt() {},
disableAutoSelect() {},
},
},
};"""
)
page.route(
"https://accounts.google.com/gsi/client",
lambda route: route.abort(),
)
_mock_form_routes(page)
page.goto(html_path.as_uri(), wait_until="domcontentloaded")
page.wait_for_selector('[data-meal-index="0"]', state="attached")
if google:
page.evaluate(
"(credential) => handleCredentialResponse({ credential })",
_google_credential(),
)
page.wait_for_selector('[data-meal-index="0"]', state="visible")
first_increment = page.locator(
'[data-meal-index="0"] [data-qty-delta="1"]'
)
first_increment.click()
first_increment.click()
page.wait_for_function(
"() => document.getElementById('count-display').textContent === "
"'2 meals'"
)
page.evaluate("() => document.fonts.ready")
total = page.locator("#total-display")
count = page.locator("#count-display")
submit = page.locator("#submit-btn")
assert total.inner_text() == "$10.80"
assert count.inner_text() == "2 meals"
assert total.is_visible()
assert count.is_visible()
assert submit.is_visible()
assert submit.is_enabled()
submit.focus()
assert submit.evaluate(
"(element) => element === document.activeElement"
)
page.evaluate(
"() => window.scrollTo(0, document.documentElement.scrollHeight)"
)
layout = page.evaluate(
"""() => {
const rect = (selector) => {
const box = document.querySelector(selector)
.getBoundingClientRect();
return {
left: box.left,
right: box.right,
top: box.top,
bottom: box.bottom,
};
};
const footer = document.querySelector('#sticky-footer');
return {
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
viewportHeight: window.innerHeight,
footer: {
...rect('#sticky-footer'),
position: getComputedStyle(footer).position,
},
total: rect('#total-display'),
count: rect('#count-display'),
submit: rect('#submit-btn'),
lastCard: rect('#meals-list .meal-card:last-child'),
};
}"""
)
assert layout["scrollWidth"] == layout["clientWidth"]
for selector in ("footer", "total", "count", "submit"):
assert layout[selector]["left"] >= 0
assert layout[selector]["right"] <= layout["clientWidth"]
assert layout["footer"]["position"] == "fixed"
assert layout["footer"]["top"] >= 0
assert layout["footer"]["bottom"] <= layout["viewportHeight"]
assert layout["lastCard"]["bottom"] <= layout["footer"]["top"]
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_labels_present_after_init(self, browser_page):
page = browser_page
card = page.locator('[data-meal-index="0"]')
assert (
card.locator('[data-qty-delta="-1"]').get_attribute("aria-label")
== "Remove one Chicken Bowl"
)
assert (
card.locator('[data-qty-delta="1"]').get_attribute("aria-label")
== "Add one Chicken Bowl"
)
assert (
card.locator(".qty").get_attribute("aria-label")
== "Quantity of Chicken Bowl"
)
def test_filter_chip_aria_pressed(self, browser_page):
page = browser_page
all_btn = page.locator("#filters button", has_text="All Meals")
assert all_btn.get_attribute("aria-pressed") == "true"
other = page.locator("#filters button").nth(1)
other_label = other.inner_text()
other.click()
page.wait_for_function(
"""(label) => {
const active = document.querySelector('#filters button.active');
return active && active.textContent === label
&& active.getAttribute('aria-pressed') === 'true';
}""",
arg=other_label,
)
assert all_btn.get_attribute("aria-pressed") == "false"
assert other.get_attribute("aria-pressed") == "true"
all_btn.click()
page.wait_for_function(
"""() => {
const active = document.querySelector('#filters button.active');
return active && active.textContent === 'All Meals'
&& active.getAttribute('aria-pressed') === 'true';
}"""
)
def test_desc_more_only_when_overflow(self, browser_page):
page = browser_page
# Ensure All Meals filter so both short and long cards are visible.
page.locator("#filters button", has_text="All Meals").click()
page.wait_for_selector('[data-meal-index="0"]')
page.wait_for_selector('[data-meal-index="1"]')
short_card = page.locator('[data-meal-index="0"]')
long_card = page.locator('[data-meal-index="1"]')
page.wait_for_function(
"""() => {
const long = document.querySelector('[data-meal-index="1"] .meal-desc-toggle');
return !!long;
}"""
)
assert short_card.locator(".meal-desc-toggle").count() == 0
toggle = long_card.locator(".meal-desc-toggle")
assert toggle.inner_text() == "More"
assert toggle.get_attribute("aria-expanded") == "false"
toggle.click()
assert toggle.inner_text() == "Less"
assert toggle.get_attribute("aria-expanded") == "true"
assert "is-expanded" in (
long_card.locator(".meal-desc").get_attribute("class") or ""
)
def test_submit_failure_lands_in_alert_region(self, browser_page):
page = browser_page
page.route(
"**/api/submit-order**",
lambda route: route.fulfill(
status=500,
content_type="application/json",
body='{"error":"Simulated submit failure"}',
),
)
page.locator("#emp-name").select_option(label="Ada Lovelace")
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'"
)
page.locator('[data-meal-index="0"] [data-qty-delta="1"]').click()
page.wait_for_function(
"() => document.getElementById('count-display').textContent === '1 meal'"
)
page.locator("#submit-btn").click()
page.wait_for_function(
"""() => document.getElementById('form-status-alert').textContent
=== 'Simulated submit failure'"""
)
assert (
page.locator("#form-status-alert").inner_text()
== "Simulated submit failure"
)
assert page.locator("#form-status-info").inner_text() == ""
page.unroute("**/api/submit-order**")
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, 479])
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"]
@pytest.fixture
def mobile_admin_page(self, tmp_path, browser_page):
html = _render_for_browser(google=False)
out = tmp_path / "admin-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()),
)
browser = browser_page.context.browser
assert browser is not None
context = browser.new_context(
viewport={"width": 375, "height": 800},
has_touch=True,
is_mobile=True,
)
try:
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()
if state.get("accept_dialogs", True)
else dialog.dismiss(),
),
)
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
finally:
context.close()
def test_mobile_admin_card_initial_render_and_overflow(self, mobile_admin_page):
page, _, 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() # noqa: RUF001
assert "$12.00" in card.inner_text()
submitted_time = card.locator(".admin-card-time")
assert submitted_time.is_visible()
assert submitted_time.inner_text() == "Jul 30, 10:15 AM"
assert page.evaluate(
"() => document.documentElement.scrollWidth === "
"document.documentElement.clientWidth"
)
def test_mobile_admin_card_edit_and_save(self, mobile_admin_page):
page, state, hostile_name = mobile_admin_page
card = page.locator(".admin-order-card")
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()
saved_card = page.locator(
".admin-order-card",
has_text="Luis' Lomo Saltado ×2", # noqa: RUF001
)
saved_card.wait_for(state="visible")
assert "Luis' Lomo Saltado ×2" in saved_card.inner_text() # noqa: RUF001
assert state["orders"][0]["employee_name"] == hostile_name
assert state["orders"][0]["total"] == 41.0
def test_mobile_admin_card_cancel(self, mobile_admin_page):
page, _, _ = mobile_admin_page
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()
def test_mobile_admin_card_responsive_breakpoints(self, mobile_admin_page):
page, _, _ = mobile_admin_page
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()
def test_mobile_admin_card_save_failure_and_retry(self, mobile_admin_page):
page, state, _ = mobile_admin_page
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-status-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-status-alert").inner_text() == ""
def test_mobile_admin_card_delete(self, mobile_admin_page):
page, state, hostile_name = mobile_admin_page
orders_before = list(state["orders"])
state["accept_dialogs"] = False
page.locator('.admin-order-card [data-admin-action="delete"]').click()
assert page.locator(".admin-order-card").is_visible()
assert state["orders"] == orders_before
state["accept_dialogs"] = True
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"] == []
class TestGenerateFormGooglePlaywright:
def test_desc_toggle_attaches_after_auth_reveal(self, tmp_path):
sync_api = pytest.importorskip("playwright.sync_api")
sync_playwright = sync_api.sync_playwright
out = tmp_path / "google-order-form.html"
out.write_text(_render_for_browser(google=True))
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.add_init_script(
"""window.google = {
accounts: {
id: {
initialize() {},
renderButton() {},
prompt() {},
disableAutoSelect() {},
},
},
};"""
)
page.route(
"https://accounts.google.com/gsi/client",
lambda route: route.abort(),
)
_mock_form_routes(page)
page.goto(out.as_uri(), wait_until="domcontentloaded")
short_card = page.locator('[data-meal-index="0"]')
long_card = page.locator('[data-meal-index="1"]')
long_card.wait_for(state="attached")
page.evaluate(
"""() => new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
})"""
)
assert "is-hidden" in (
page.locator("#app").get_attribute("class") or ""
)
assert long_card.locator(".meal-desc-toggle").count() == 0
page.evaluate(
"(credential) => handleCredentialResponse({ credential })",
_google_credential(),
)
toggle = long_card.locator(".meal-desc-toggle")
toggle.wait_for(state="visible")
assert short_card.locator(".meal-desc-toggle").count() == 0
assert toggle.inner_text() == "More"
assert toggle.get_attribute("aria-expanded") == "false"
toggle.click()
assert toggle.inner_text() == "Less"
assert toggle.get_attribute("aria-expanded") == "true"
assert "is-expanded" in (
long_card.locator(".meal-desc").get_attribute("class") or ""
)
page.evaluate("signOut()")
assert "is-hidden" in (
page.locator("#app").get_attribute("class") or ""
)
page.evaluate(
"(credential) => handleCredentialResponse({ credential })",
_google_credential(),
)
page.wait_for_function(
"""() => {
const card = document.querySelector('[data-meal-index="1"]');
const desc = card?.querySelector('.meal-desc');
const toggle = card?.querySelector('.meal-desc-toggle');
return !desc?.classList.contains('is-expanded')
&& toggle?.textContent === 'More'
&& toggle?.getAttribute('aria-expanded') === 'false';
}"""
)
toggle = long_card.locator(".meal-desc-toggle")
toggle.click()
assert toggle.inner_text() == "Less"
assert toggle.get_attribute("aria-expanded") == "true"
toggle.click()
assert toggle.inner_text() == "More"
assert toggle.get_attribute("aria-expanded") == "false"
assert "is-expanded" not in (
long_card.locator(".meal-desc").get_attribute("class") or ""
)
finally:
browser.close()
def test_refresh_restores_google_session(self, tmp_path):
sync_api = pytest.importorskip("playwright.sync_api")
sync_playwright = sync_api.sync_playwright
out = tmp_path / "google-session-form.html"
out.write_text(_render_for_browser(google=True))
credential = _google_credential({"exp": 2_000_000_000})
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.add_init_script(
"""window.google = {
accounts: {
id: {
initialize() {},
renderButton() {},
prompt() {},
disableAutoSelect() {},
},
},
};"""
)
page.route(
"https://accounts.google.com/gsi/client",
lambda route: route.abort(),
)
_mock_form_routes(page)
page.goto(out.as_uri(), wait_until="domcontentloaded")
page.evaluate(
"(token) => handleCredentialResponse({ credential: token })",
credential,
)
page.wait_for_function(
"""() => {
const app = document.getElementById('app');
return app && !app.classList.contains('is-hidden');
}"""
)
assert page.evaluate(
"() => sessionStorage.getItem('seahaven.meals.googleIdToken')"
)
page.reload(wait_until="domcontentloaded")
_mock_form_routes(page)
page.wait_for_function(
"""() => {
const app = document.getElementById('app');
const overlay = document.getElementById('auth-overlay');
const email = document.getElementById('user-email');
return app && !app.classList.contains('is-hidden')
&& overlay && overlay.classList.contains('is-hidden')
&& email && email.textContent === 'test-admin@example.com';
}"""
)
page.evaluate("signOut()")
assert (
page.evaluate(
"() => sessionStorage.getItem('seahaven.meals.googleIdToken')"
)
is None
)
page.reload(wait_until="domcontentloaded")
_mock_form_routes(page)
page.wait_for_function(
"""() => {
const overlay = document.getElementById('auth-overlay');
const app = document.getElementById('app');
return overlay && !overlay.classList.contains('is-hidden')
&& app && app.classList.contains('is-hidden');
}"""
)
page.evaluate(
"(token) => sessionStorage.setItem('seahaven.meals.googleIdToken', token)",
_google_credential({"exp": 1}),
)
page.reload(wait_until="domcontentloaded")
_mock_form_routes(page)
page.wait_for_function(
"""() => {
const overlay = document.getElementById('auth-overlay');
const app = document.getElementById('app');
return overlay && !overlay.classList.contains('is-hidden')
&& app && app.classList.contains('is-hidden')
&& !sessionStorage.getItem('seahaven.meals.googleIdToken');
}"""
)
finally:
browser.close()
@pytest.fixture(scope="class")
@classmethod
def signed_in_admin_page(cls, 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_for_browser(google=True))
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.add_init_script(
"""window.google = {
accounts: {
id: {
initialize() {},
renderButton() {},
prompt() {},
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
finally:
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",
):
locator = page.locator(selector)
assert locator.is_visible()
bounds = locator.evaluate(
"""element => {
const rect = element.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
};
}"""
)
assert bounds["left"] >= 0
assert bounds["top"] >= 0
assert bounds["right"] <= bounds["viewportWidth"]
assert bounds["bottom"] <= bounds["viewportHeight"]
for selector in ("#user-name", "#user-email"):
assert page.locator(selector).evaluate(
"""element =>
element.scrollWidth <= element.clientWidth &&
element.scrollHeight <= element.clientHeight"""
)
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"
)