Home›Menura›Test run
Menura · Test run · run 2026-11-07

Menura Test run, as shipped in the download

464 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-11-07 with php tests/run.php. Run it yourself after you download.

== 1. moneyToCents: US, EU, symbols, negatives, raw numbers ==
  PASS  US "$1,234.56" (got 123456, want 123456)
  PASS  EU "1.234,56 €" (got 123456, want 123456)
  PASS  plain "1234.5" (got 123450, want 123450)
  PASS  thousands "1,234" (got 123400, want 123400)
  PASS  EU decimal "12,34" (got 1234, want 1234)
  PASS  "0.99" (got 99, want 99)
  PASS  "$0" (got 0, want 0)
  PASS  bare "9" (got 900, want 900)
  PASS  "$12.50" (got 1250, want 1250)
  PASS  "8,50 €" EU (got 850, want 850)
  PASS  parens negative "(45.00)" (got -4500, want -4500)
  PASS  minus negative "-45.00" (got -4500, want -4500)
  PASS  float 1234.56 (got 123456, want 123456)
  PASS  int 1234 (major units) (got 123400, want 123400)
  PASS  "USD 89.90" (got 8990, want 8990)
  PASS  space thousands "1 234,56" (got 123456, want 123456)
  PASS  US big "1,234,567.89" (got 123456789, want 123456789)
  PASS  EU "€1.000,00" (got 100000, want 100000)
  PASS  empty -> null (got NULL, want NULL)
  PASS  "Market Price" -> null (got NULL, want NULL)
  PASS  "MP" -> null (got NULL, want NULL)
  PASS  em-dash -> null (got NULL, want NULL)
  PASS  null -> null (got NULL, want NULL)
  PASS  array -> null (got NULL, want NULL)

== 2. centsToDecimal round-trips ==
  PASS  123456 -> "1234.56" (got '1234.56', want '1234.56')
  PASS  5 -> "0.05" (got '0.05', want '0.05')
  PASS  1250 -> "12.50" (got '12.50', want '12.50')
  PASS  900 -> "9.00" (got '9.00', want '9.00')
  PASS  -4500 -> "-45.00" (got '-45.00', want '-45.00')
  PASS  0 -> "0.00" (got '0.00', want '0.00')
  PASS  null -> "" (got '', want '')

== 3. normalizeDate: ISO, textual, numeric, ambiguity, invalid ==
  PASS  ISO passthrough (got '2026-04-03', want '2026-04-03')
  PASS  US m/d/Y (default) (got '2026-04-03', want '2026-04-03')
  PASS  d/m/Y with dayFirst (got '2026-03-04', want '2026-03-04')
  PASS  day>12 disambiguates (got '2026-04-13', want '2026-04-13')
  PASS  EU dotted 03.04.2026 (got '2026-03-04', want '2026-03-04')
  PASS  textual "Apr 3, 2026" (got '2026-04-03', want '2026-04-03')
  PASS  year-first 2026/04/03 (got '2026-04-03', want '2026-04-03')
  PASS  impossible -> null (got NULL, want NULL)
  PASS  garbage -> null (got NULL, want NULL)
  PASS  empty -> null (got NULL, want NULL)

== 4. currencyCode from code or symbol ==
  PASS  code "usd" (got 'USD', want 'USD')
  PASS  symbol "€" (got 'EUR', want 'EUR')
  PASS  symbol "£" (got 'GBP', want 'GBP')
  PASS  empty -> "" (got '', want '')

== 5. normalizeList: dedupe, stable order, clip, cap ==
  PASS  string split comma (got array (
  0 => 'gluten',
  1 => 'dairy',
  2 => 'egg',
), want array (
  0 => 'gluten',
  1 => 'dairy',
  2 => 'egg',
))
  PASS  semicolon/pipe/newline (got array (
  0 => 'a',
  1 => 'b',
  2 => 'c',
  3 => 'd',
), want array (
  0 => 'a',
  1 => 'b',
  2 => 'c',
  3 => 'd',
))
  PASS  dedupe case-insensitive (first wins) (got array (
  0 => 'Vegan',
), want array (
  0 => 'Vegan',
))
  PASS  trims + collapses spaces (got array (
  0 => 'spicy hot',
), want array (
  0 => 'spicy hot',
))
  PASS  array of {name:..} objects (got array (
  0 => 'nuts',
  1 => 'soy',
), want array (
  0 => 'nuts',
  1 => 'soy',
))
  PASS  drops blanks (got array (
  0 => 'a',
  1 => 'b',
), want array (
  0 => 'a',
  1 => 'b',
))
  PASS  null -> [] (got array (
), want array (
))
  PASS  empty string -> [] (got array (
), want array (
))
  PASS  element clipped to 190 (got 190, want 190)
  PASS  list capped at 50 (got 50, want 50)

== 6. normalizeMenu: nested shape ==
  PASS  menu name (got 'Bella Cucina', want 'Bella Cucina')
  PASS  item count (got 3, want 3)
  PASS  item 0 section (got 'Antipasti', want 'Antipasti')
  PASS  item 0 name (got 'Bruschetta', want 'Bruschetta')
  PASS  item 0 price cents (got 950, want 950)
  PASS  item 0 modifiers (got array (
  0 => 'Add cheese +3.00',
), want array (
  0 => 'Add cheese +3.00',
))
  PASS  item 0 allergens (got array (
  0 => 'gluten',
), want array (
  0 => 'gluten',
))
  PASS  item 0 dietary (got array (
  0 => 'vegan',
), want array (
  0 => 'vegan',
))
  PASS  item 0 sort (got 0, want 0)
  PASS  item 1 name (got 'Calamari', want 'Calamari')
  PASS  item 1 allergens (got array (
  0 => 'gluten',
  1 => 'shellfish',
), want array (
  0 => 'gluten',
  1 => 'shellfish',
))
  PASS  item 2 section (dishes key) (got 'Secondi', want 'Secondi')
  PASS  item 2 name (dish key) (got 'T-Bone', want 'T-Bone')
  PASS  item 2 price null (Market Price) (got NULL, want NULL)
  PASS  item 2 dietary (dietary key) (got array (
  0 => 'gluten-free',
), want array (
  0 => 'gluten-free',
))

== 7. normalizeMenu: flat shape + dedupe + empty-row drop ==
  PASS  flat menu name (got 'Corner Cafe', want 'Corner Cafe')
  PASS  flat dedupe+drop -> 2 items (got 2, want 2)
  PASS  flat item 0 section (category) (got 'Mains', want 'Mains')
  PASS  flat item 0 price (cost key) (got 1200, want 1200)
  PASS  flat item 1 name (item key) (got 'Fries', want 'Fries')
  PASS  flat item 1 section (got 'Sides', want 'Sides')
  PASS  flat item 1 price (got 450, want 450)
  PASS  empty raw -> no items (got array (
), want array (
))
  PASS  empty raw -> blank name (got '', want '')

== 8. normalizeMenu: clip/clamp overflow safety (MySQL strict-mode) ==
  PASS  name clipped to 255 (got 255, want 255)
  PASS  description clipped to 2000 (got 2000, want 2000)
  PASS  section clipped to 190 (got 190, want 190)
  PASS  positive price clamped +2e9 (got 2000000000, want 2000000000)
  PASS  negative price clamped -2e9 (got -2000000000, want -2000000000)

== 9. groupSections: first-appearance order ==
  PASS  group count (got 2, want 2)
  PASS  first section (got 'Mains', want 'Mains')
  PASS  second section (got 'Starters', want 'Starters')
  PASS  mains has 2 items (got 2, want 2)

== 10. sku slug ==
  PASS  sku slug (got 'starters-garlic-bread', want 'starters-garlic-bread')
  PASS  sku strips punct (got 'caf-cr-me-br-l-e', want 'caf-cr-me-br-l-e')
  PASS  sku empty -> item (got 'item', want 'item')

== 11. CSV formula-injection guard (numeric-aware) ==
  PASS  =-formula neutralized (got '\'=1+2', want '\'=1+2')
  PASS  @-formula neutralized (got '\'@SUM(A1)', want '\'@SUM(A1)')
  PASS  +-formula neutralized (non-numeric) (got '\'+cmd', want '\'+cmd')
  PASS  +1 is numeric -> KEPT (got '+1', want '+1')
  PASS  leading-TAB neutralized (got '\'	X', want '\'	X')
  PASS  leading-CR neutralized + quoted (got '"\'
X"', want '"\'
X"')
  PASS  -text neutralized (got '\'-abc', want '\'-abc')
  PASS  negative number KEPT (got '-45.00', want '-45.00')
  PASS  negative int KEPT (got '-9', want '-9')
  PASS  plain value untouched (got 'Garlic Bread', want 'Garlic Bread')
  PASS  quoting still applies to formula+comma (got '"\'=a,b"', want '"\'=a,b"')

== 12. toNestedCsv: shape + escaping + formula guard in real export ==
  PASS  nested header row (got 'Menu,Section,Item,Description,Price,Modifiers,Allergens,Dietary', want 'Menu,Section,Item,Description,Price,Modifiers,Allergens,Dietary')
  PASS  CRLF line endings
  PASS  formula item name neutralized in export
  PASS  negative price preserved unquoted in export
  PASS  list fields joined with "; "
  PASS  menu name with comma+quote is RFC-4180 quoted
  PASS  nested row cell count (got 8, want 8)

== 13. toPosCsv: flat shape + SKU + numeric price ==
  PASS  pos header row (got 'SKU,Name,Category,Price,Description', want 'SKU,Name,Category,Price,Description')
  PASS  pos has SKU slug
  PASS  pos negative price preserved
  PASS  pos row cell count (got 5, want 5)
  PASS  pos skips nameless items (got 1, want 1)

== 14. toJson: shape, decodes back, decimals + cents ==
  PASS  valid JSON
  PASS  menu key (got 'Menu, "One"', want 'Menu, "One"')
  PASS  sections is list (got 2, want 2)
  PASS  first section name (got 'Starters', want 'Starters')
  PASS  item name survives (got '=cmd', want '=cmd')
  PASS  price as decimal (got '9.50', want '9.50')
  PASS  price_cents as int (got 950, want 950)
  PASS  modifiers array (got array (
  0 => 'Add cheese',
), want array (
  0 => 'Add cheese',
))
  PASS  allergens array (got array (
  0 => 'gluten',
  1 => 'dairy',
), want array (
  0 => 'gluten',
  1 => 'dairy',
))
  PASS  dietary_flags array (got array (
  0 => 'vegan',
), want array (
  0 => 'vegan',
))
  PASS  negative price decimal (got '-45.00', want '-45.00')

== 15. Security hardening regressions ==
  PASS  dashboard month query is engine-portable (no SQLite datetime())
  PASS  settings_save blocks writes in public demo mode
  PASS  demo extract short-circuits to fixtures BEFORE reading base_url/settings
  PASS  file_serve sends X-Content-Type-Options: nosniff
  PASS  upload enforces finfo MIME allowlist
  PASS  upload caps file size
  PASS  upload stores under a random name
  PASS  csrf_check rejects empty stored token (hash_equals empty-token bypass)
  PASS  installer rejects empty install token (_it bypass)
  PASS  LLM client never follows redirects (key cannot leak to redirect target)
  PASS  LLM key read from settings, never echoed/logged
  PASS  demo session cookie is None + Secure + Partitioned
  PASS  buyer session cookie is HttpOnly + Lax + Secure-on-HTTPS
  PASS  portable UTC timestamps (no SQLite datetime/CURRENT_TIMESTAMP in inserts)
  PASS  settings view never echoes the stored API key value
  PASS  every string clip + money clamp helpers exist

== LLM truncation guard ==
  PASS  truncated (openai/ollama length) -> clear error
  PASS  truncated (anthropic max_tokens) -> same error
  PASS  error names the 8192-token ceiling
  PASS  error tells the user what to DO (split the menu)
  PASS  normal stop -> no error
  PASS  end_turn -> no error
  PASS  missing reason -> no error
  PASS  all three providers carry the raised cap (no stray literal)
  PASS  every provider path guards truncation before parsing

== PWA ==
  PASS  manifest.json is valid JSON
  PASS  manifest has required fields
  PASS  manifest URLs are RELATIVE (subpath-install safe)
  PASS  manifest icons exist on disk (192 + 512 + apple-touch)
  PASS  sw.js never caches non-GET
  PASS  sw.js never caches page navigations (auth HTML can not enter cache storage)
  PASS  sw.js cache name carries the version (old caches purged on activate)
  PASS  offline fallback page ships
  PASS  every app layout links the manifest + registers the SW

== Own It 2.0 ==
  PASS  v2/v3: tables
  PASS  v2/v3: totp_cols
  PASS  v2/v3: smtp_cols
  PASS  v2/v3: mint
  PASS  v2/v3: revoke
  PASS  v2/v3: totp
  PASS  v2/v3: recovery
  PASS  v2/v3: delivery
  PASS  v2/v3: events
  PASS  v2/v3: rate
  PASS  v2/v3: backup_tables
  PASS  v2/v3: item_json_decoded
  PASS  v2/v3: item_null_price
  PASS  v2/v3: menu_counts
  PASS  v2/v3: price_parser
  PASS  v2/v3: pos_csv
  PASS  v2/v3: json_export
  PASS  v2/v3: import_dry_run
  PASS  v2/v3: import_dry_reasons
  PASS  v2/v3: serializers_clean
  PASS  v2/v3: v3_tables
  PASS  v2/v3: audit_redacts
  PASS  v2/v3: price_percent
  PASS  v2/v3: price_charm99
  PASS  v2/v3: price_absolute
  PASS  v2/v3: price_round50
  PASS  v2/v3: price_null_stays_null
  PASS  v2/v3: price_never_negative
  PASS  v2/v3: retired_model_migrated
  PASS  v2/v3: custom_model_left_alone
  PASS  v2/v3: other_provider_left_alone
  PASS  v2/v3: qr_unpublished_is_not_served
  PASS  v2/v3: qr_publish_mints_a_token
  PASS  v2/v3: qr_unpublish_takes_it_offline
  PASS  v2/v3: qr_republish_keeps_the_printed_code
  PASS  v2/v3: qr_hidden_survives_a_review_save
  PASS  v2/v3: qr_86_hides_from_guests
  PASS  v2/v3: qr_guest_page_renders_the_menu
  PASS  v2/v3: qr_back_on_in_one_click
  PASS  v2/v3: qr_offline_page_says_so
  PASS  v2/v3: qr_exports_keep_86d_items
  PASS  v2/v3: review_keeps_item_ids
  PASS  v2/v3: review_fires_item_updated_for_a_changed_row_only
  PASS  v2/v3: review_keeps_86_and_adds_and_removes
  PASS  v2/v3: seed_buyer_publishes_nothing
  PASS  v2/v3: seed_demo_publishes_one_qr_menu
  PASS  v2/v3: seed_demo_86s_one_dish
  PASS  v2/v3: seed_dinner_menu_has_a_french_sample
  PASS  v2/v3: import_carries_modifiers_allergens_dietary
  PASS  v2: routes registered (api, 2fa, security, backup, import, healthz)
  PASS  v2: migrate() runs on boot
  PASS  v2: cli-server base guard
  PASS  v2: login is rate-limited and 2FA intercepts it
  PASS  v2: both extraction paths announce menu.processed
  PASS  v2: exports reuse the product writers (no second CSV writer)
  PASS  v2: the CSV import reuses the app price parser
  PASS  v2: dry run and commit share ONE validator
  PASS  v3: item.updated carries before AND after (from the shared edit rule)
  PASS  v3: exactly ONE item-edit rule, used by the API and MCP
  PASS  v2: notifications never break the request
  PASS  v2: upload staying UI-only is documented in the code
  PASS  v2: no undefined-key ternary re-reads in any controller
  PASS  v2: endpoints taking nested input allowlist their keys in Api::body()
  PASS  v2: dark palette + zero hardcoded white backgrounds
  PASS  v2: dark mode keeps ghost/danger buttons legible (kit truth: the kit paints them from theme tokens, and the branding override skips them)
  PASS  v2: theme.js + qr.js ship and the layout loads theme.js with a toggle
  PASS  v2: shared cores are present
  PASS  v2: .htaccess re-allows the /backup.sqlite ROUTE while denying real .sqlite files
  PASS  v2: dev router denies .sqlite FILES but not the backup route
  PASS  v3: API.md ships and the sw cache is v4.1.3
  PASS  v3: version is 4.1.3 everywhere
  PASS  v2: README is Menura's own, not another product's
  PASS  v3: no call to a function this product does not define
  PASS  C2: no sensitive handler is login-only — all are role-gated
  PASS  C2: both backup doors call the ONE shared redaction list
  PASS  C2: Api::requireKey refuses a deactivated user (u.active)
  PASS  C2: every App:: method the controllers call actually exists
  PASS  C2: Api::base() does not double an already-absolute base
  PASS  C2: allowlisted arrays reject a nested element
  PASS  nav: every sidebar link is highlighted by the page it opens
  PASS  nav: no page highlights a sidebar item that does not exist
  PASS  ip: a direct visitor's forged headers are ignored
  PASS  ip: behind a trusted proxy the forwarded visitor is used
  PASS  ip: CF-Connecting-IP wins over the X-Forwarded-For chain
  PASS  ip: the chain is read right-to-left, past trusted hops
  PASS  ip: a Cloudflare edge is not a trusted proxy by default
  PASS  ip: CIDR maths (v4 boundaries + v6, no cross-family match)

== UI kit: live POS preview + the kit layout ==
  PASS  pos preview: one line per named item (got 3, want 3)
  PASS  pos preview: SKU is the exporter's own slug (got 'starters-garlic-bread', want 'starters-garlic-bread')
  PASS  pos preview: price is the exporter's decimal (got '28.50', want '28.50')
  PASS  pos preview: a clean menu raises no issues (got array (
), want array (
))
  PASS  pos preview: every previewed SKU appears in the real POS CSV export (preview and export cannot drift)
  PASS  pos preview: a nameless row never reaches the till (got 3, want 3)
  PASS  pos preview: the dropped row is counted, not hidden (got 1, want 1)
  PASS  pos preview: dropped rows and duplicate SKUs are hard (bad) issues
  PASS  pos preview: a missing price and a missing section are warnings, not blockers
  PASS  pos preview: an empty editor previews nothing and complains about nothing (got array (
  'rows' => 
  array (
  ),
  'issues' => 
  array (
  ),
  'dropped' => 0,
), want array (
  'rows' => 
  array (
  ),
  'issues' => 
  array (
  ),
  'dropped' => 0,
))
  PASS  pos-preview: a missing menu answers 404 via App::json($data, 404) — a bare http_response_code() before App::json() is overwritten back to 200 (found 2026-08-20)
  PASS  posted rows: the blank row is skipped (got 2, want 2)
  PASS  posted rows: money goes through the same parser as a hand edit (got 2850, want 2850)
  PASS  pos preview: POST /menus/{id}/pos-preview is registered
  PASS  pos preview: the endpoint requires menu.export, checks CSRF and 404s an unknown menu (via App::json(...,404) — the status arg, not a bare http_response_code that App::json overwrites)
  PASS  pos preview: it never writes — no INSERT/UPDATE/DELETE inside the endpoint body
  PASS  kit: layout loads kit css+js, groups the nav with icons + g-keys, carries the theme toggle and a who-line
  PASS  kit: no theme.js; app css is an app layer with a dark accent; the pre-kit file is gone
  PASS  kit: App::asset() versions by mtime and the service worker revalidates code
  PASS  kit: nothing is fetched from another host
  PASS  dashboard: the first-run checklist is computed from the database, not the session
  PASS  dashboard: KPI sparkline, needs-your-action and the section strip are wired
  PASS  hero: sticky source viewer ⇄ item editor with the live POS pane wired to the endpoint
  PASS  menus list: tabs carry counts and rows link to the record
  PASS  copy: no other product's vocabulary survives in the views (this app digitizes menus)

== v3.1: the documented export columns ARE the exported columns ==
  PASS  preset: the POS CSV header is the one the README documents (got 'SKU,Name,Category,Price,Description', want 'SKU,Name,Category,Price,Description')
  PASS  preset: the nested CSV header is the one the README documents (got 'Menu,Section,Item,Description,Price,Modifiers,Allergens,Dietary', want 'Menu,Section,Item,Description,Price,Modifiers,Allergens,Dietary')
  PASS  preset: the README states the POS columns verbatim (docs cannot drift from the exporter)
  PASS  preset: the README states the nested columns verbatim
  PASS  preset: prices export as bare decimals, never with a currency symbol (a till rejects "£6.50")
  PASS  preset: the SKU is stable across exports — a re-import updates, it does not duplicate
  PASS  preset: the product still claims NO POS API (the kept limitation is not quietly dropped)
  PASS  scope: api_keys gains a scope column defaulting to full — an upgrade never loosens
  PASS  scope: requireKey reads the key scope and refuses a write on a read-only key
  PASS  scope: mintKey persists the chosen scope, and anything but read is full access
  PASS  scope: /mcp hands the key scope to the core, which refuses write tools per tool
  PASS  scope: the key form lets an administrator mint a read-only key
  PASS  license: every font ships the SIL OFL text for its own family beside it
  PASS  license: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause

== F2: fixes ==
  PASS  F2 security: the settings page never echoes the stored SMTP password into its HTML
  PASS  F2 security: a blank SMTP password keeps the stored one; only the remove box clears it
  PASS  F2 model: the Anthropic default is no longer the retired claude-3-haiku-20240307
  PASS  F2 model: a stored retired id is read as the current default (got 'claude-haiku-4-5-20251001', want 'claude-haiku-4-5-20251001')
  PASS  F2 model: a blank model is the default (got 'gpt-4o-mini', want 'gpt-4o-mini')
  PASS  F2 model: an owner-typed current model is used as typed (got 'claude-opus-5-5', want 'claude-opus-5-5')
  PASS  F2 model: Anthropic refusing the model fails with the model name and the setting to change
  PASS  F2 model: OpenAI model_not_found is recognized
  PASS  F2 model: Ollama "model not found" is recognized and says to pull it
  PASS  F2 model: an auth error is not mistaken for a model refusal
  PASS  F2 PDF: OpenAI receives a PDF as a file part (file_data), not as an image_url
  PASS  F2 PDF: OpenAI still receives photos as image_url
  PASS  F2 PDF: Ollama refuses a PDF before any call, saying to convert the pages
  PASS  F2 model: no screen, default or doc offers a retired model
  PASS  F2 model: installer, settings page and save handler read the one default (Llm::DEFAULT_MODELS)
  PASS  F2 QR: staff page, publish, 86 toggle and the guest page are all routed
  PASS  F2 QR: qr_menu_publish needs item.edit and a CSRF token
  PASS  F2 QR: qr_item_toggle needs item.edit and a CSRF token
  PASS  F2 QR: qr_menu_page needs menu.view
  PASS  F2 QR: the guest page needs no login and serves only a PUBLISHED menu by token
  PASS  F2 QR: every review row (and the add-row template) carries its 86 state, so a save keeps it
  PASS  F2 QR: a token is 24 hex characters and anything else is refused before a lookup
  PASS  F2 PDF menu: prices carry the currency the owner set (base_currency; the PDF read a setting that does not exist)
  PASS  F2 demo: the demo upload screen says a visitor's own file is not read (every upload returns a sample)
  PASS  F2 PHP: the installer refuses PHP older than 8.1 with a plain message, before anything else runs
  PASS  F2 PHP: the requirements no longer say PHP 8.0 or "PHP 8+"
  PASS  F2 security: a signed-in non-admin (viewer, member) gets 403 on every backup and restore route
  PASS  F2 security: the probe is real — an administrator is let through to the backup download
  PASS  F2 invite: with SMTP set up, the invitation is delivered over a real SMTP conversation, carrying the link
  PASS  F2 invite: with no SMTP it says so and sends nothing (the link is still shown once)
  PASS  F2 invite: a refusing mail server is reported, never thrown
  PASS  F2 invite: the invite handler emails it and still shows the link once
  PASS  F2 routes: every POST form in the views reaches a POST route (41 checked)
  PASS  F2 webhooks: the events offered in Settings (item.updated, menu.processed) are exactly the events the code sends
  PASS  F2b ribbon: the public page /m/0f3a9c2b7d1e4f5a6b8c9d0e says what it is ("this is the menu your guests open from the QR code on the table"), not "signed in as the admin"
  PASS  F2b ribbon: an admin page (/menus) keeps the admin wording
  PASS  F2b ribbon: below 600px it starts as the small "Own it" pill; a tap opens it and that choice holds for the session
  PASS  F2b ribbon: injected once, just before </body>; output with no </body> (JSON, CSV, PDF) passes untouched
  PASS  F2b ribbon: boot() installs it only behind the .demo-mode marker
  PASS  F2b security: /backup/scheduled refuses a signed-in viewer, member with no token or a wrong one (404)
  PASS  F2b backup: a value was planted and read back in every credential column (settings.llm_api_key, settings.smtp_pass, settings.backup_token_hash, settings.oidc_client_secret, users.password_hash, users.totp_secret, users.totp_recovery, api_keys.token_hash, webhooks.secret, updater_state.license_api_key, backup_state.s3_secret, invites.token_hash)
  PASS  F2b backup: the JSON download carries none of them
  PASS  F2b backup: the scheduled backup carries none of them
  PASS  F2b backup: both backup doors use the one list, menura_backup_redact()

== 3.2.0: Sonnet 5 suggested for messy or handwritten scans ==
  PASS  the Anthropic default is unchanged (claude-haiku-4-5-20251001) (got 'claude-haiku-4-5-20251001', want 'claude-haiku-4-5-20251001')
  PASS  the Anthropic model list offers claude-sonnet-5
  PASS  every provider's list starts with that provider's default
  PASS  the hint beside the model field says "Sonnet 5 for messy or handwritten scans"
  PASS  the model field offers the list (a datalist filled from Llm::SUGGESTED_MODELS)
  PASS  the model label points at the model field (one id, not two)
  PASS  the security page says the JSON backup leaves out webhook URLs

== 3.2.0: the guest's allergen and dietary filter (pure) ==
  PASS  the filter offers only what this menu lists, each once (got array (
  0 => 'egg',
  1 => 'fish',
  2 => 'gluten',
  3 => 'milk',
), want array (
  0 => 'egg',
  1 => 'fish',
  2 => 'gluten',
  3 => 'milk',
))
  PASS  ...dietary tags case-insensitively, first spelling kept (got array (
  'vegan' => 'Vegan',
  'vegetarian' => 'Vegetarian',
), want array (
  'vegan' => 'Vegan',
  'vegetarian' => 'Vegetarian',
))
  PASS  hiding "milk" hides every dish that LISTS milk, keeps the rest
  PASS  "show only vegetarian" keeps dishes carrying the tag, any case
  PASS  both together
  PASS  no filter changes nothing
  PASS  the guest page never claims a dish is free of anything, and says what the filter reads

== 3.2.0 pass B: till presets (pure) ==
  PASS  a pasted header: BOM, tabs or semicolons, blank cells dropped (got array (
  0 => 
  array (
    0 => 'Name',
    1 => 'Price',
  ),
  1 => 
  array (
    0 => 'Name',
    1 => 'Price',
    2 => 'Category',
  ),
  2 => 
  array (
  ),
), want array (
  0 => 
  array (
    0 => 'Name',
    1 => 'Price',
  ),
  1 => 
  array (
    0 => 'Name',
    1 => 'Price',
    2 => 'Category',
  ),
  2 => 
  array (
  ),
))
  PASS  what a column means (got array (
  0 => 'name',
  1 => 'name',
  2 => 'variation',
  3 => 'sku',
  4 => 'category',
  5 => 'price',
  6 => 'blank',
  7 => '',
), want array (
  0 => 'name',
  1 => 'name',
  2 => 'variation',
  3 => 'sku',
  4 => 'category',
  5 => 'price',
  6 => 'blank',
  7 => '',
))
  PASS  Vendra: a Description column is left alone (Vendra reads it as the name)
  PASS  Square: nameless rows dropped; a blank price stays blank (a variable-priced item); a formula-looking name is defused in the CSV
  PASS  Toast: rows only for sections with a group ID, numbered in order; the rest named as left out
  PASS  Lightspeed: a repeated name (any case) is warned about, not silently renamed
  PASS  another till whose columns mean nothing to Menura: no file, and it says why

== 3.2.0 pass B: translation (pure) ==
  PASS  the language: letters only, 40 at most (got array (
  0 => 'French DROP',
  1 => 'Português (Brasil)',
  2 => '',
), want array (
  0 => 'French DROP',
  1 => 'Português (Brasil)',
  2 => '',
))
  PASS  the prompt: faithful, nothing added, allergens and prices never changed, JSON only
  PASS  what is sent: ids, names, descriptions and section names; never a price, an allergen or a tag
  PASS  cleaned: unknown ids and sections dropped; no description added where there was none (got array (
  'items' => 
  array (
    7 => 
    array (
      0 => 'Hachis de poisson',
      1 => 'Haddock fumé',
    ),
    8 => 
    array (
      0 => 'Curry',
      1 => '',
    ),
  ),
  'sections' => 
  array (
    'Mains' => 'Plats',
  ),
), want array (
  'items' => 
  array (
    7 => 
    array (
      0 => 'Hachis de poisson',
      1 => 'Haddock fumé',
    ),
    8 => 
    array (
      0 => 'Curry',
      1 => '',
    ),
  ),
  'sections' => 
  array (
    'Mains' => 'Plats',
  ),
))
  PASS  applied: section renamed, names and descriptions replaced; prices, allergens and tags untouched; an empty translation keeps the original description

== 3.2.0 pass B: the demo says it did not read your file ==
  PASS  the demo branch marks a visitor's own file (not a seeded one) and names the sample in its message

== 3.2.0 pass B: the allergen matrix (pure) ==
  PASS  columns: the allergens and tags the menu lists, each once (got array (
  0 => 
  array (
    'fish' => 'Fish',
    'gluten' => 'Gluten',
    'milk' => 'milk',
  ),
  1 => 
  array (
    'vegan' => 'Vegan',
    'vegetarian' => 'vegetarian',
  ),
), want array (
  0 => 
  array (
    'fish' => 'Fish',
    'gluten' => 'Gluten',
    'milk' => 'milk',
  ),
  1 => 
  array (
    'vegan' => 'Vegan',
    'vegetarian' => 'vegetarian',
  ),
))
  PASS  rows: each dish's marks, case-insensitive; the dish with none recorded is marked none and counted (got array (
  0 => 
  array (
    0 => 
    array (
      0 => 'Fish pie',
      1 => 
      array (
        'fish' => true,
        'gluten' => false,
        'milk' => true,
      ),
      2 => false,
      3 => false,
    ),
    1 => 
    array (
      0 => 'Curry',
      1 => 
      array (
        'fish' => false,
        'gluten' => false,
        'milk' => false,
      ),
      2 => true,
      3 => false,
    ),
    2 => 
    array (
      0 => 'Tart',
      1 => 
      array (
        'fish' => false,
        'gluten' => true,
        'milk' => true,
      ),
      2 => false,
      3 => true,
    ),
  ),
  1 => 1,
), want array (
  0 => 
  array (
    0 => 
    array (
      0 => 'Fish pie',
      1 => 
      array (
        'fish' => true,
        'gluten' => false,
        'milk' => true,
      ),
      2 => false,
      3 => false,
    ),
    1 => 
    array (
      0 => 'Curry',
      1 => 
      array (
        'fish' => false,
        'gluten' => false,
        'milk' => false,
      ),
      2 => true,
      3 => false,
    ),
    2 => 
    array (
      0 => 'Tart',
      1 => 
      array (
        'fish' => false,
        'gluten' => true,
        'milk' => true,
      ),
      2 => false,
      3 => true,
    ),
  ),
  1 => 1,
))

== 3.2.0: the guest filter and table cards over HTTP (tests/v320_http_check.php) ==
  PASS  the HTTP proof ran (no PHP warning logged)
  PASS  the guest page offers the allergens and dietary tags this menu lists
  PASS  "milk" hides the three dishes listing it, says so, and flags a dish with no allergens listed
  PASS  "only vegetarian" shows the vegetarian dishes
  PASS  values the menu does not list are ignored (and never echoed)
  PASS  active chips are marked, and Show everything clears them
  PASS  the table-card sheet needs a login
  PASS  the sheet prints one card per table with the menu's link and QR
  PASS  at most 60 cards
  PASS  an offline menu says publish first, and prints no card
  PASS  the QR page offers the table cards
  PASS  pass B till: the page opens on Square and shows what the file holds
  PASS  pass B till: the Square file (Token blank, Variation "Regular", SKU, section as category, price)
  PASS  pass B till: a pasted Square header (BOM, locations, tax) is filled in its own order and remembered
  PASS  pass B till: Toast asks for a menu group ID per section and names the sections without one
  PASS  pass B till: Toast basic-template rows (CREATE, MENU_ITEM, numbered, MENU_GROUP, BASE) for the sections with an ID
  PASS  pass B till: Lightspeed needs an accounting group, then writes Type Item rows
  PASS  pass B till: the Vendra file (Name, SKU, Price, Category)
  PASS  pass B till: another till: nothing without its header; with a tab-separated header, the known columns filled
  PASS  pass B till: the review page's Export menu offers the till file
  PASS  pass B till: a viewer cannot export a till file
  PASS  pass B matrix: every dish against the allergens and tags this menu lists; an 86'd dish is marked; the gap is counted
  PASS  pass B matrix: a dish with none recorded says "ask the kitchen"; nothing is called "…-free"
  PASS  pass B matrix: an empty menu says so
  PASS  pass B matrix: the review page offers it
  PASS  API: a viewer's key gets 403 on POST /api/items/{id} and changes nothing; an admin's key writes
  PASS  pass B translation: only names/descriptions/sections are sent (no price, allergen or tag); unknown ids and invented descriptions dropped; stored as generated
  PASS  pass B translation: guests see nothing of an unapproved language
  PASS  pass B translation: once approved (with an edit), guests can pick it; allergens stay as recorded
  PASS  pass B translation: the allergen filter keeps the chosen language
  PASS  pass B translation: an unknown language in the link shows the original
  PASS  customer voice: a previewed change can be scheduled; a past date is refused; nothing changes before the date
  PASS  customer voice: on its date the change applies itself on the next request, once, recorded as scheduled by its author
  PASS  customer voice: a canceled schedule never runs
  PASS  customer voice: copying a menu brings every dish (allergens, 86 state) and its approved translations, unpublished
  PASS  customer voice: a price changed on the copy leaves the original alone
  PASS  round 4: one previewed price change applies to every menu (nothing before the Apply)
  PASS  round 4: a scheduled change can cover every menu; it shows on each menu's price page and applies to all on its date
  PASS  pass B demo: a demo extraction is labeled on the review page (your file was not read; which sample); a real one is not
  PASS  the public demo opens on its menus, not on the setup steps (a buyer's install still gets them)

== 3.2.0 customer voice: model errors say what to do ==
  PASS  a refused key, credit, a rate limit, a large file and a provider fault each get a plain reason; the provider text follows; other errors unchanged

————————————————————————————————————————————————————

== 4.0: the Update button is wired ==
  PASS  updates: the release public key is pinned
  PASS  updates: Database::migrate() creates the updater table (in code, not in a comment)
  SKIP  updates: the state row (this suite keeps no open database in its own process; update_e2e.sh installs, migrates and updates for real)
  PASS  updates: index.php clears stale compiled code before anything else loads
  PASS  updates: index.php finishes or rolls back an update before any controller loads, with this app's canary
  PASS  updates: route get /updates is updates_page()
  PASS  updates: route post /updates/key is updates_key_save()
  PASS  updates: route post /updates/check is updates_check()
  PASS  updates: route post /updates/apply is updates_apply()
  PASS  updates: updates_page() opens with the restore page's admin gate
  PASS  updates: updates_key_save() opens with the restore page's admin gate
  PASS  updates: updates_check() opens with the restore page's admin gate
  PASS  updates: updates_apply() opens with the restore page's admin gate
  PASS  updates: every POST checks the CSRF token
  PASS  updates: the page is told why updates are off here (Updater::whyOff, F5 A1 pass)
  PASS  updates: the menu links to the page
  PASS  updates: bin/update.php refuses the web and finishes in a fresh process
  PASS  updates: where updates are off (Cloud, the demo, a missing extension) the page says why and offers no button
  PASS  updates: with a key and a newer signed release the page offers "Back up and update"

== 4.0 A2: sample data, backups, health ==
  PASS  A2: the owner's-install checks ran to the end
  PASS  A2: an owner's own install (no Cloud config, no demo marker)
  PASS  A2: a fresh install's sample tables are all empty, so the installer's sample data can go in
  PASS  A2: switched off: an install without "Start with sample data" gets none
  PASS  A2: first request: the marker is used once and the sample business is there
  PASS  A2: every one of the owner's settings is theirs, not the sample's
  PASS  A2: the sample logins cannot sign in: no password matches, and switched off where the product can
  PASS  A2: the owner's own login is untouched
  PASS  A2: the load is in the audit log
  PASS  A2: a second marker on a Menura with records seeds nothing and changes nothing
  PASS  A2: real use: one of the owner's own rows among the sample
  PASS  A2: remove: the owner's own row stays, and a sample row it points at is kept
  PASS  A2: remove: the report says what went, and what was kept because the owner's records use it
  PASS  A2: remove: the rest of the sample is gone
  PASS  A2: remove: every kept sample row keeps all its own parts
  PASS  A2: remove: no row is left pointing at a row that was removed
  PASS  A2: remove: the sample logins are gone but the ones kept rows use, the owner stays, and nothing counts as sample any more
  PASS  A2: remove: the sample's files are gone from disk (0 written; one a row that stays names stays with it)
  PASS  A2: remove: the owner's own uploaded file is untouched
  PASS  A2: "Load demo data" on a Menura with records refuses and deletes nothing
  PASS  A2: backup: written, sent to the bucket (a signed PUT), and read back
  PASS  A2: backup: the bucket secret is not in the backup it protects
  PASS  A2: backup: the owner's "keep" count is honored (2 kept)
  PASS  A2: switched off: with automatic backups off, a backup is never due however old the last one
  PASS  A2: the backup lines for Backup & restore: last backup fine, offsite fine, read-back fine
  PASS  A2: an offsite endpoint must be https for an owner (plain http only in tests)
  PASS  A2: all 25 owner's-install checks reported
  PASS  A2: Database::migrate() creates the sample-data and backup tables (in code, not in a comment)
  PASS  A2: index.php loads the three cores and the wiring
  PASS  A2: the first request seeds the installer's sample, and an owner's install backs itself up when due
  PASS  A2: route post /sample-data/remove is sample_data_remove()
  PASS  A2: route post /restore/backups is backups_save()
  PASS  A2: route post /restore/backup-now is backup_now()
  PASS  A2: route get /health is health_page()
  PASS  A2: sample_data_remove() opens with the restore page's admin gate
  PASS  A2: backups_save() opens with the restore page's admin gate
  PASS  A2: backup_now() opens with the restore page's admin gate
  PASS  A2: health_page() opens with the restore page's admin gate
  PASS  A2: every POST checks the CSRF token
  PASS  A2: off an owner's install (Cloud, the demo) the backup settings and "Back up now" refuse in words
  PASS  A2: the installer offers sample data, ticked, and only leaves the first-run marker (with every table's row count)
  PASS  A2: the first run is strictly once (a marker that cannot be removed seeds nothing), and treats the installer's rows as its defaults only while nothing has changed
  PASS  A2: the scheduled-backup URL runs the same backup (offsite copy and read-back included)
  PASS  A2: "Load demo data" outside the public demo goes through SampleData (only into an empty Menura, removable)
  PASS  A2: the menu links to Health
  PASS  A2: the sample-data banner shows only to an admin, only while sample data is there, with its Remove button, and stays put (nested: the UI kit toasts a bare .main > .alert away)
  PASS  A2: Backup & restore shows the automatic backups, "Back up now" and the bucket settings (none on Cloud)
  SKIP  A2: the Health page render (this suite loads no view helpers in its own process; the owner's-install check and update_e2e.sh run the real app)
ALL 464 TESTS PASSED (0 failed)

← Back to Menura · Manual · Quickstart · API