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