Menura · Test run · run 2026-09-25
Menura Test run, as shipped in the download
327 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-09-25 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: 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 v3.1.6
PASS v3: version is 3.1.6 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 licence: every font ships the SIL OFL text for its own family beside it
PASS licence: 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 recognised
PASS F2 model: Ollama "model not found" is recognised 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 (28 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, 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()
————————————————————————————————————————————————————
ALL 327 TESTS PASSED (0 failed)
← Back to Menura · Manual · Quickstart · API