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

Vendra Test run, as shipped in the download

479 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.

[DB: SQLite]

== 1. Weighted-average cost ==
  PASS  after 10@$2: avg = $2.00 (200000 mc) (got 200000, want 200000)
  PASS  after +10@$4: avg = $3.00 (got 300000, want 300000)
  PASS  on hand 20 (got 20, want 20)
  PASS  valuation = 20 x $3 = $60.00 (got 6000, want 6000)

== 2. Sale: pricing, tax after discount, COGS ==
  PASS  quote ok
  PASS  subtotal 5x1000+4x500 = 7000 (got 7000, want 7000)
  PASS  discount 200 line + 300 sale = 500 (got 500, want 500)
  PASS  tax = 172 (after both discounts) (got 172, want 172)
  PASS  total = 7000-500+172 = 6672 (got 6672, want 6672)
  PASS  COGS = 5x300 + 4x300 = 2700 (got 2700, want 2700)

== 3. Checkout: payments, change, stock, ledger ==
  PASS  checkout ok
  PASS  change = 328 (got 328, want 328)
  PASS  widget stock 20-5=15 (got 15, want 15)
  PASS  gadget stock 50-4=46 (got 46, want 46)
  PASS  drawer keeps total (cash net of change) = 6672 (got 6672, want 6672)
  PASS  2 sale movements stamped with sale id (got 2, want 2)
  PASS  ledger == cache (integrity) (got 0, want 0)

== 4. Oversell guard (single-process) ==
  PASS  sell the last 2 ok
  PASS  selling from empty stock rejected
  PASS  rejection names the product
  PASS  stock stays 0 (not negative) (got 0, want 0)

== 5. Payment validation ==
  PASS  underpayment rejected
  PASS  card overpayment rejected (no change from card)
  PASS  split card+cash ok
  PASS  split change = 100 (from cash) (got 100, want 100)
  PASS  split cash kept = 400 (got 400, want 400)

== 5b. Oversize guards (INT-overflow / MySQL strict-mode safety) ==
  PASS  oversize cash payment rejected (change_cents overflow)
  PASS  oversize payment error is clean
  PASS  oversize line/sale total rejected at quote
  PASS  normal sale still quotes fine

== 6. Returns ==
  PASS  return 2 widgets ok
  PASS  refund = 1912 (got 1912, want 1912)
  PASS  widget restocked 14+2=16 (got 16, want 16)
  PASS  over-return rejected (only 3 left)
  PASS  ledger == cache after returns (got 0, want 0)
  PASS  sale marked partial_ref (got 'partial_ref', want 'partial_ref')

== 7. Register reconciliation ==
  PASS  open register
  PASS  second open blocked
  PASS  register sale ok
  PASS  card sale ok
  PASS  in-session cash refund ok
  PASS  refund = 1000 (got 1000, want 1000)
  PASS  expected cash = 10000 (got 10000, want 10000)
  PASS  card total tracked = 1000 (got 1000, want 1000)
  PASS  close ok
  PASS  over/short = -50 (short) (got -50, want -50)
  PASS  double close blocked

== 8. Adjustments + negative-stock setting ==
  PASS  adjust -1 ok
  PASS  adjust below zero rejected when negative stock off
  PASS  adjust below zero allowed when setting on
  PASS  stock now -1 (got -1, want -1)

== 9. Ledger integrity end-to-end ==
  PASS  final integrity check: 0 mismatches (got 0, want 0)
  PASS  ledger populated (>= 12 movements)

== 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: tables
  PASS  v2: totp_cols
  PASS  v2: smtp_cols
  PASS  v2: mint
  PASS  v2: revoke
  PASS  v2: totp
  PASS  v2: recovery
  PASS  v2: delivery
  PASS  v2: events
  PASS  v2: rate
  PASS  v2: csv_dry
  PASS  v2: backup_tables
  PASS  v2: api_sale_ok
  PASS  v2: oversell_refused
  PASS  v2: lowstock_crossing
  PASS  v2: lowstock_no_repeat
  PASS  v2: ledger_intact
  PASS  v2: serializers_clean
  PASS  v2: routes registered (api, 2fa, security, backup, import, healthz)
  PASS  v2: migrate() runs on boot (1.x installs gain the new tables)
  PASS  v2: cli-server base guard (extension-bearing routes work under php -S)
  PASS  v2: login is rate-limited and 2FA intercepts it
  PASS  v2: the till fires the same side effects as the API (one code path)
  PASS  v2: API sales reuse Sales::checkout (never a second stock path)
  PASS  v2: API purchases reuse Inventory::receive (weighted-average costing)
  PASS  v2: notifications never break the request
  PASS  v2: dry run and commit share ONE validator
  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 filled amber buttons legible (dark on-accent ink via kit tokens)
  PASS  v2: theme.js + qr.js ship and the layout loads theme.js with a toggle
  PASS  v2: shared cores are present and unforked
  PASS  v2: .htaccess re-allows the /backup.sqlite ROUTE while still denying real .sqlite files
  PASS  v2: API.md ships + sw cache is v2.0.0 + healthz reports 2.0.0
  PASS  v3: version is 4.1.3 everywhere (sw.js, healthz, api_ping, openapi)

-- Own It 3.0 --
  PASS  v3: v3_tables
  PASS  v3: v3_columns
  PASS  v3: upgrade_from_2x
  PASS  v3: settings_singleton_ensured
  PASS  v3: settings_update_takes_effect
  PASS  v3: roles_cashier_is_pos_only
  PASS  v3: roles_viewer_reads_only
  PASS  v3: roles_manager_stops_at_admin
  PASS  v3: roles_admin_all
  PASS  v3: roles_unknown_grants_nothing
  PASS  v3: last_admin_guard
  PASS  v3: audit_redacts_secrets
  PASS  v3: audit_append_only
  PASS  v3: mcp_tools_present
  PASS  v3: mcp_record_sale_is_not_readonly
  PASS  v3: mcp_write_tool_declares_it
  PASS  v3: mcp_arg_guard
  PASS  v3: mcp_initialize
  PASS  v3: mcp_get_is_405
  PASS  v3: mcp_oversell_refused
  PASS  v3: mcp_legal_sale_decrements
  PASS  v3: ledger_agrees_with_stock
  PASS  v3: mcp_sale_is_audited
  PASS  v3: pdf_renders
  PASS  v3: pdf_money_is_the_products
  PASS  v3: branding_refuses_svg_accepts_png
  PASS  v3: branding_ink_is_AA
  PASS  v3: branding_rejects_bad_accent
  PASS  v3: gdpr_erases_identity_keeps_money
  PASS  v3: restore_refuses_other_product
  PASS  v3: restore_accepts_own
  PASS  v3: oidc_refuses_bad_token
  PASS  v3: index.php calls Database::migrate() at boot — the ONLY upgrade path for an existing install
  PASS  v3: every 3.0 route is registered
  PASS  v3: MCP record_sale reuses Sales::checkout — the oversell guard is never restated
  PASS  v3: the Z-report PDF renders _reports_data and never re-aggregates
  PASS  v3: the PDF layer formats with the product's own money() helper
  PASS  v3: bulk actions loop single-item writes inside ONE transaction (no mass UPDATE)
  PASS  v3: SSO never creates accounts, and local login is untouched
  PASS  v3: the role ladder EXTENDS vendra's existing cashier/admin (nothing loosens)
  PASS  v3: the accent ink is DERIVED at render, so a blank accent_ink can never render below AA
  PASS  v3: a branded accent survives DARK mode (the dark block outranks :root — brand both)
  PASS  v3: migrate() ensures the settings singleton (a missing row silently swallowed every save)
  PASS  v3: the barcode scanner is local-only (no CDN, no upload) and degrades gracefully
  PASS  v3: camera scans land on the SAME add path as the laser scanner
  PASS  v3: core Mcp is byte-identical to the shared core
  PASS  v3: core AuditLog is byte-identical to the shared core
  PASS  v3: core Pdf is byte-identical to the shared core
  PASS  v3: core BackupRestore is byte-identical to the shared core
  PASS  v3: core Branding is byte-identical to the shared core
  PASS  v3: core RolesKit is byte-identical to the shared core
  PASS  v3: core Gdpr is byte-identical to the shared core
  PASS  v3: core Oidc is byte-identical to the shared core
  PASS  v3/C2: the ladder is a GATE, not a word list — each role grants exactly its own seat
  PASS  v3/C2: the catalog screen is gated on catalog.view, NOT product.view (the cashier holds that one)
  PASS  v3/C2: every page the ladder promises is permission-gated, so a role is not decorative
  PASS  v3/C2: a read-only seat cannot work the till — the POS is gated, not just signed-in
  PASS  v3/C2: signing in lands you on a page your role can actually open
  PASS  v3/C2: no controller decides access by comparing the role NAME (the ladder is a map, not a string)
  PASS  v3/C2: the sidebar asks the same permission the route asks (menu and URL cannot disagree)
  PASS  v3/C2: every role the save path accepts is offered in the UI (an unassignable role is not a feature)
  PASS  v3/C2: an MCP line/payment field must be a single value — a nested one silently became qty 1
  PASS  v3/C2: v3_mcp_scalar refuses an array/object and passes a scalar through
  PASS  v3/C2: a minted invite link is actually shown to the operator, once
  PASS  C2R: api_sale_create guards scalars inside lines/payments with v3_mcp_scalar
  PASS  C2R: api_sale_create is gated on the sale.create permission, like its MCP twin
  PASS  C2R: api_purchase_create guards its nested line scalars too
  PASS  C2R: an absent payment method still defaults to cash through the guard
  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 v1.0 · POS live tender preview (/pos/quote) ==
  PASS  quote preview: happy path prices the cart
  PASS  quote preview: total matches the engine math (got 2478, want 2478)
  PASS  quote preview: discount total = line + spread sale discount (got 250, want 250)
  PASS  quote preview: zero quantity refused by the engine
  PASS  quote preview: endpoint returns 422 on a refused cart
  PASS  quote preview: endpoint requires sale.create + CSRF
  PASS  quote preview: route registered and the till calls it

== v3.1 · emailed receipt (P1) ==
  PASS  receipt: carries the code, the total and the change
  PASS  receipt: uses the operator tax name and footer
  PASS  receipt: leaks NO cost, margin or internal note
  PASS  receipt: deterministic — the same input builds the same bytes (got 'Test Store

Receipt ABC12345
2026-08-20 10:00:00 UTC
----------------------------------------
2 x Widget                        $18.90
   discount                       -$2.00
----------------------------------------
Subtotal                          $20.00
Discounts                         -$2.00
Sales Tax                          $0.90
TOTAL                             $18.90
Paid — Cash                       $20.00
Change                             $1.10

Thanks!

Keep this email as your receipt.
', want 'Test Store

Receipt ABC12345
2026-08-20 10:00:00 UTC
----------------------------------------
2 x Widget                        $18.90
   discount                       -$2.00
----------------------------------------
Subtotal                          $20.00
Discounts                         -$2.00
Sales Tax                          $0.90
TOTAL                             $18.90
Paid — Cash                       $20.00
Change                             $1.10

Thanks!

Keep this email as your receipt.
')
  PASS  receipt: every money line is the same display width (multi-byte safe) (got 1, want 1)
  PASS  receipt: notify_receipt ships OFF (got 0, want 0)
  PASS  receipt: with the toggle off nothing is sent
  PASS  receipt: a refused-by-toggle send writes no outbox row (got 0, want 0)
  PASS  receipt: with the toggle on it completes without SMTP (demo)
  PASS  receipt: the attempt is recorded in the outbox (got 1, want 1)
  PASS  receipt: a malformed address is refused
  PASS  receipt: the refusal is visible in the outbox (got 1, want 1)
  PASS  receipt: the resend endpoint demands sale.create + CSRF
  PASS  receipt: the automatic path is idempotent (already-sent short-circuit)
  PASS  receipt: an explicit resend forces a fresh send
  PASS  receipt: a mail failure never rolls back the sale — the send happens after checkout committed
  PASS  receipt: route registered
  PASS  receipt: src/Mail.php is loaded by the app bootstrap, not only by this harness

== v3.1 · day-close (Z-report) email ==
  PASS  day-close: carries the expected-vs-counted pair and the over/short verdict
  PASS  day-close: says in words whether the drawer was over, short or exact
  PASS  day-close: notify_zreport ships OFF (got 0, want 0)
  PASS  day-close: the subject is one-per-session (its own idempotency key)
  PASS  day-close: a session already reported is not reported twice
  PASS  day-close: an open (not yet closed) session is refused
  PASS  day-close: closing the register cannot be undone by a mail failure
  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 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 export: a sale line lands in the sales CSV with qty, line total and the cost the till captured
  PASS  F2 export: the date range filters (a range with no sales has no rows)
  PASS  F2 export: a product name that starts with = exports as text, not a formula
  PASS  F2 export: negative money stays a number
  PASS  F2 export: the stock CSV carries stock, average cost and value at cost
  PASS  F2 export: /sales.csv needs report.view and /stock.csv needs catalog.view (a cashier gets neither)
  PASS  F2 security: a signed-in non-admin (viewer, cashier, manager) 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 URL: with an absolute base_url the SSO redirect_uri carries the host once (it was doubled)
  PASS  F2 URL: without base_url the SSO redirect_uri is still built from the request host
  PASS  F2 routes: every POST form in the views reaches a POST route (51 checked)
  PASS  F2 webhooks: the events offered in Settings (sale.recorded, stock.low) are exactly the events the code sends
  PASS  F2b ribbon: Vendra has no public page, so its pages keep the admin wording (/, /pos)
  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, cashier, manager with no token or a wrong one (403)
  PASS  F2b backup: a value was planted and read back in every credential column (settings.smtp_pass, settings.oidc_client_secret, settings.backup_token_hash, settings.restock_api_key, settings.loyalora_api_key, 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, display_screens.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, v3_backup_redact()

== 3.2.0 · Retail Suite link: nothing happens until an app is connected ==
  PASS  not connected: syncSale sends nothing (got 0, want 0)
  PASS  not connected: earn() has nothing to do (got NULL, want NULL)
  PASS  not connected: no log rows (got 0, want 0)
  PASS  an address alone is not a connection (the key is missing)

== 3.2.0 · Restock: a sale replaces that day's units per SKU ==
  PASS  one sales-history call per sale (got 1, want 1)
  PASS  it goes to the saved address (trailing slash trimmed) (got 'https://restock.test/api/sales-history', want 'https://restock.test/api/sales-history')
  PASS  with the saved key (got 'apk_restock', want 'apk_restock')
  PASS  replace mode, so a resend counts once
  PASS  rows: that day, per SKU, units sold (got array (
  0 => 
  array (
    'date' => '2026-11-07',
    'sku' => 'MUG-01',
    'qty' => 1,
  ),
  1 => 
  array (
    'date' => '2026-11-07',
    'sku' => 'TEA-01',
    'qty' => 2,
  ),
), want array (
  0 => 
  array (
    'date' => '2026-11-07',
    'sku' => 'MUG-01',
    'qty' => 1,
  ),
  1 => 
  array (
    'date' => '2026-11-07',
    'sku' => 'TEA-01',
    'qty' => 2,
  ),
))
  PASS  a second sale sends the DAY total for its SKUs (2 + 3), not its own 3 (got array (
  0 => 
  array (
    'date' => '2026-11-07',
    'sku' => 'TEA-01',
    'qty' => 5,
  ),
), want array (
  0 => 
  array (
    'date' => '2026-11-07',
    'sku' => 'TEA-01',
    'qty' => 5,
  ),
))
  PASS  then the shelf figure for the sale's SKUs (40 - 5 = 35); a product with no SKU is never sent (got array (
  0 => 
  array (
    'sku' => 'TEA-01',
    'on_hand' => 35,
  ),
), want array (
  0 => 
  array (
    'sku' => 'TEA-01',
    'on_hand' => 35,
  ),
))

== 3.2.0 · Restock: a refund corrects the day it belongs to ==
  PASS  the return itself went through
  PASS  after returning all 3, the day is back to 2 units (got array (
  0 => 
  array (
    'date' => '2026-11-07',
    'sku' => 'TEA-01',
    'qty' => 2,
  ),
), want array (
  0 => 
  array (
    'date' => '2026-11-07',
    'sku' => 'TEA-01',
    'qty' => 2,
  ),
))
  PASS  a SKU whose units were all returned is sent as 0, so the replace clears it (got array (
  0 => 
  array (
    'date' => '2026-11-07',
    'sku' => 'MUG-01',
    'qty' => 0,
  ),
), want array (
  0 => 
  array (
    'date' => '2026-11-07',
    'sku' => 'MUG-01',
    'qty' => 0,
  ),
))
  PASS  a voided sale is not demand (got array (
  0 => 
  array (
    'date' => '2026-11-07',
    'sku' => 'TEA-01',
    'qty' => 0,
  ),
), want array (
  0 => 
  array (
    'date' => '2026-11-07',
    'sku' => 'TEA-01',
    'qty' => 0,
  ),
))

== 3.2.0 · Restock: the day is the SHOP's day, not UTC's ==
  PASS  23:30 UTC on 1 Mar is 2 Mar in Auckland (got '2026-03-02', want '2026-03-02')
  PASS  so that sale counts on 2 Mar (got array (
  0 => 
  array (
    'date' => '2026-03-02',
    'sku' => 'TEA-01',
    'qty' => 1,
  ),
), want array (
  0 => 
  array (
    'date' => '2026-03-02',
    'sku' => 'TEA-01',
    'qty' => 1,
  ),
))
  PASS  and not on 1 Mar (got array (
  0 => 
  array (
    'date' => '2026-03-01',
    'sku' => 'TEA-01',
    'qty' => 0,
  ),
), want array (
  0 => 
  array (
    'date' => '2026-03-01',
    'sku' => 'TEA-01',
    'qty' => 0,
  ),
))

== 3.2.0 · Restock down: the till loses one timeout, then leaves it alone ==
  PASS  the first failed call is logged as no answer (got 0, want 0)
  PASS  the log says why
  PASS  the next sale within 60 s does not call it at all (got 1, want 1)
  PASS  and says it skipped
  PASS  after the cooldown it is asked again
  PASS  a refused key reads as "check the API key" in the log, with Restock's own sentence
  PASS  a SKU Restock does not know is named in the log

== 3.2.0 · Restock: the first-connection catch-up ==
  PASS  backfill sends every day with sales in the window, replace mode
  PASS  ... today included
  PASS  ... and every SKU's stock (every product that has a SKU) (got array (
  0 => 'F2-SKU',
  1 => 'MUG-01',
  2 => 'TEA-01',
), want array (
  0 => 'F2-SKU',
  1 => 'MUG-01',
  2 => 'TEA-01',
))
  PASS  nothing refused (got 0, want 0)

== 3.2.0 · Loyalty at the till (Loyalora) ==
  PASS  a member's sale earns: what Loyalora answered comes back for the till (got array (
  'state' => 'earned',
  'points' => 24,
  'balance' => 962,
), want array (
  'state' => 'earned',
  'points' => 24,
  'balance' => 962,
))
  PASS  the member is looked up by the customer's email, lower-cased (got 'https://loyal.test/api/customers?q=ann%40example.com', want 'https://loyal.test/api/customers?q=ann%40example.com')
  PASS  exactly one earn call, for the EXACT email match (#12, not the LIKE hit #7) (got array (
  0 => 'https://loyal.test/api/customers/12/earn',
), want array (
  0 => 'https://loyal.test/api/customers/12/earn',
))
  PASS  the till sends what was spent and the sale code — never a points figure (got array (
  'amount_cents' => 2400,
  'ref' => 'Vendra sale EYRNWPJP',
), want array (
  'amount_cents' => 2400,
  'ref' => 'Vendra sale EYRNWPJP',
))
  PASS  no points field anywhere in the request
  PASS  a program number, when set, is sent (got 3, want 3)
  PASS  a spend too small to earn reads as too-small, not a failure (got array (
  'state' => 'too-small',
), want array (
  'state' => 'too-small',
))
  PASS  not a member: nothing is earned (got array (
  'state' => 'not-member',
), want array (
  'state' => 'not-member',
))
  PASS  ... and no earn call is made (got 0, want 0)
  PASS  a customer with no email is never guessed at (got array (
  'state' => 'no-email',
), want array (
  'state' => 'no-email',
))
  PASS  ... Loyalora is not even asked (got 0, want 0)
  PASS  a walk-in sale has nothing to earn (got NULL, want NULL)
  PASS  Loyalora down: the till is told, the sale stands (got array (
  'state' => 'unreachable',
), want array (
  'state' => 'unreachable',
))
  PASS  the sale is untouched by it (got 'completed', want 'completed')

== 3.2.0 · Loyalty at the till: the balance on screen, and enrolling ==
  PASS  a member shows their balance (got array (
  'connected' => true,
  'state' => 'member',
  'member_id' => 12,
  'balance' => 962,
  'active' => true,
), want array (
  'connected' => true,
  'state' => 'member',
  'member_id' => 12,
  'balance' => 962,
  'active' => true,
))
  PASS  no email: the till says to add one (got array (
  'connected' => true,
  'state' => 'no-email',
), want array (
  'connected' => true,
  'state' => 'no-email',
))
  PASS  not a member yet (got 'not-member', want 'not-member')
  PASS  enroll creates the member (got array (
  'ok' => true,
  'member_id' => 40,
), want array (
  'ok' => true,
  'member_id' => 40,
))
  PASS  with name, email and phone only (got array (
  'name' => 'Ann',
  'email' => 'ann@example.com',
  'phone' => '0700',
), want array (
  'name' => 'Ann',
  'email' => 'ann@example.com',
  'phone' => '0700',
))
  PASS  enroll refuses a customer with no email (got false, want false)
  PASS  already a member reads as success (got true, want true)
  PASS  with Loyalora disconnected the till shows nothing (got array (
  'connected' => false,
), want array (
  'connected' => false,
))

== 3.2.0 · Connected apps: addresses, keys and the test button ==
  PASS  an http(s) address is accepted
  PASS  blank is accepted (it disconnects)
  PASS  javascript:, ftp:, file: are refused
  PASS  an address carrying a password is refused
  PASS  Test catches a Loyalora address pasted into the Restock field
  PASS  Test names who the key acts as
  PASS  the keys' column names are credential-named, so the shareable backup leaves them out by pattern
  PASS  the log keeps at most 500 rows

== 3.2.0 · Retail Suite link: wired into every door a sale comes through ==
  PASS  sale_side_effects (till, REST and MCP sales) earns points and syncs Restock, after the webhooks
  PASS  the till hands Loyalora's answer to the done screen
  PASS  a refund re-sends its day; a delivery and a correction send the shelf figure
  PASS  Connected apps: a manager is refused (admins only) (got 403, want 403)
  PASS  Connected apps: an admin opens it (got 200, want 200)
  PASS  saving connections: a cashier is refused (got 403, want 403)
  PASS  the till's loyalty look-up: a cashier may ask (got 200, want 200)
  PASS  the till's loyalty look-up: a viewer may not (got 403, want 403)

== 3.2.0 · stock counts ==
  PASS  a count of one category lists its three products, nothing counted (got array (
  0 => 3,
  1 => 0,
), want array (
  0 => 3,
  1 => 0,
))
  PASS  jam counted: 8 (on hand when counted: 10)
  PASS  a scanned barcode finds honey (got 12, want 12)
  PASS  three scans count three (got 3, want 3)
  PASS  a SKU typed at the scanner finds tea (got 13, want 13)
  PASS  posting corrects jam and honey; tea (not counted) is left alone
  PASS  jam: counted 8 of 10 on hand → −2, and the later sale of 2 still stands → 6 (got 6, want 6)
  PASS  honey: counted 3 of 6 → 3 (got 3, want 3)
  PASS  tea untouched (got 4, want 4)
  PASS  the ledger carries "count" lines with the reason (got 'Stock count #1: Monthly count', want 'Stock count #1: Monthly count')
  PASS  ledger and stock still agree everywhere (got array (
), want array (
))
  PASS  a count posts once
  PASS  a posted count cannot be changed
  PASS  a count below zero or of a product not on it is refused

== 3.2.0 · landed cost ==
  PASS  by value: 30.00 over lines worth 60 and 40 → 18.00 + 12.00 (got array (
  0 => 1800,
  1 => 1200,
), want array (
  0 => 1800,
  1 => 1200,
))
  PASS  by value, uneven: 10.00 over three equal lines → 3.34 + 3.33 + 3.33 (sums exactly) (got array (
  0 => 334,
  1 => 333,
  2 => 333,
), want array (
  0 => 334,
  1 => 333,
  2 => 333,
))
  PASS  all free goods: by quantity instead (got array (
  0 => 100,
  1 => 300,
), want array (
  0 => 100,
  1 => 300,
))
  PASS  shares always add up exactly (200 random deliveries)
  PASS  a delivery with 20.00 freight is received
  PASS  rug: 60.00 of 100.00 goods → 12.00 freight → 36.00 a unit landed (got 3600000, want 3600000)
  PASS  lamp: 40.00 → 8.00 freight → 12.00 a unit landed (got 1200000, want 1200000)
  PASS  the purchase total is goods + freight (got array (
  0 => 12000,
  1 => 2000,
), want array (
  0 => 12000,
  1 => 2000,
))
  PASS  each line keeps its share and landed unit cost (got array (
  0 => 
  array (
    0 => 1200,
    1 => 3600000,
  ),
  1 => 
  array (
    0 => 800,
    1 => 1200000,
  ),
), want array (
  0 => 
  array (
    0 => 1200,
    1 => 3600000,
  ),
  1 => 
  array (
    0 => 800,
    1 => 1200000,
  ),
))
  PASS  the API reports the landed cost and each line's share (got array (
  0 => 2000,
  1 => 1200,
), want array (
  0 => 2000,
  1 => 1200,
))
  PASS  a second delivery averages in: (4 × 12.00 + 1 × 10.50) / 5 = 11.70 (got 1170000, want 1170000)
  PASS  a sale of the rug books its landed cost as COGS (got 3600, want 3600)

== 3.2.0 · barcode labels ==
  PASS  a product with a barcode labels its barcode (got '5012345678900', want '5012345678900')
  PASS  one without labels its SKU (got 'CNT-A', want 'CNT-A')
  PASS  one with neither has nothing to label (got '', want '')
  PASS  the sheet prints copies with names, prices and Code 128 barcodes
  PASS  a used label is left blank
  PASS  the POS camera reads Code 128 too (Vendra labels a product with no barcode by its SKU)
  PASS  the POS matches a scanned SKU exactly, not only a barcode

== 3.2.0 · recipe and bundle items ==
  PASS  a latte = 1 cup + 2 shots + 5 × 50 ml milk (got array (
  0 => true,
  1 => '',
), want array (
  0 => true,
  1 => '',
))
  PASS  the till can make as many as the scarcest part allows (min(50/1, 20/2, 30/5) = 6) (got 6, want 6)
  PASS  a product cannot be made of itself
  PASS  a part cannot itself be made of parts
  PASS  a product that is a part cannot become a recipe
  PASS  an ordinary product still holding stock cannot become a recipe
  PASS  its cost is its parts at average cost (got 0, want 0)
  PASS  selling 2 lattes takes 2 cups, 4 shots, 10 milk; the latte itself holds no stock (got array (
  0 => 48,
  1 => 16,
  2 => 20,
  3 => 0,
), want array (
  0 => 48,
  1 => 16,
  2 => 20,
  3 => 0,
))
  PASS  COGS = 2 × the parts (got 0, want 0)
  PASS  nine more are refused, naming the part that ran out
  PASS  ... and nothing moved (got array (
  0 => 48,
  1 => 16,
), want array (
  0 => 48,
  1 => 16,
))
  PASS  returning one latte puts back the parts it was MADE of (1 cup, 2 shots, 5 milk), not today's recipe (got array (
  0 => 49,
  1 => 18,
  2 => 25,
), want array (
  0 => 49,
  1 => 18,
  2 => 25,
))
  PASS  ledger and stock still agree everywhere (got array (
), want array (
))
  PASS  a recipe item never appears as low or out of stock
  PASS  the API shows what the parts can make, and the parts (got array (
  0 => 18,
  1 => 2,
  2 => false,
), want array (
  0 => 18,
  1 => 2,
  2 => false,
))
  PASS  receiving a recipe item is refused (receive its parts)
  PASS  adjusting one is refused
  PASS  a count never lists one
  PASS  Restock is told the PARTS were used (cups and shots), not a "latte" SKU

== 3.2.0 · customer display ==
  PASS  the display gets names (capped), quantities and prices — nothing else the till sends
  PASS  an unknown state becomes "cart"; negative money becomes 0 (got array (
  0 => 'cart',
  1 => 0,
), want array (
  0 => 'cart',
  1 => 0,
))
  PASS  a paired token finds its display; a wrong one does not (got array (
  0 => 1,
  1 => NULL,
  2 => NULL,
), want array (
  0 => 1,
  1 => NULL,
  2 => NULL,
))
  PASS  pairing a display: a viewer is refused (got 403, want 403)
  PASS  stock counts: a cashier cannot start one (got 403, want 403)
  PASS  stock counts: a manager opens the list (got 200, want 200)
  PASS  labels: a cashier cannot (catalog screens are the back office) (got 403, want 403)
  PASS  labels: a viewer can print them (got 200, want 200)

== 3.2.0 overflow (customer voice) · scan to receive, margin at receipt, customer and supplier exports ==
  PASS  customers CSV: two sales, $26.00 after a $4.00 refund, the voided sale left out (got array (
  0 => 2,
  1 => '26.00',
), want array (
  0 => 2,
  1 => '26.00',
))
  PASS  customers CSV: a name that starts like a formula is defused
  PASS  suppliers CSV: one delivery, $16.00 with its freight (got array (
  0 => 1,
  1 => '16.00',
), want array (
  0 => 1,
  1 => '16.00',
))
  PASS  customers CSV: a cashier is refused (it holds everyone's details) (got 403, want 403)
  PASS  customers CSV: a manager can (got 200, want 200)
  PASS  suppliers CSV: a manager can (got 200, want 200)
  PASS  receive form: a scan box that matches barcode first, then SKU, and adds to the line
  PASS  receive form: each line shows the price and the margin at that cost, flagged at or above the price
  PASS  receive form: recipe items are not offered (they keep no stock of their own)

== 3.3.0 round 4 (customer voice) · scan to adjust stock ==
  PASS  the code finds the product by barcode, then by SKU; a switched-off product is not found (got array (
  0 => 22,
  1 => 22,
  2 => NULL,
  3 => NULL,
), want array (
  0 => 22,
  1 => 22,
  2 => NULL,
  3 => NULL,
))
  PASS  a scan that adds: +2, now 7, on the ledger with its reason (got array (
  0 => true,
  1 => 2,
  2 => 7,
  3 => 'Scan: Found — behind the counter',
), want array (
  0 => true,
  1 => 2,
  2 => 7,
  3 => 'Scan: Found — behind the counter',
))
  PASS  a scan that takes off: −1 as damaged, now 6 (got array (
  0 => true,
  1 => -1,
  2 => 6,
), want array (
  0 => true,
  1 => -1,
  2 => 6,
))
  PASS  taking off more than is on hand is refused, and nothing moves (got array (
  0 => false,
  1 => true,
  2 => 6,
  3 => 2,
), want array (
  0 => false,
  1 => true,
  2 => 6,
  3 => 2,
))
  PASS  an unknown code is refused, naming it
  PASS  a recipe item is refused (it keeps no stock; adjust its parts)
  PASS  the page: a cashier is refused (it needs stock.adjust) (got 403, want 403)
  PASS  the page: a manager opens it (got 200, want 200)
  PASS  the page scans with a keyboard scanner or the camera and sends each scan at once (X-Scan)
  PASS  Inventory links to it
  PASS  the reason follows the direction: taking off starts on "Damaged", adding on "Found"

== 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)
  PASS  updates: the updater table holds exactly one state row
  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 Vendra 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 Vendra 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: under the standard keep count (14) every backup is 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: 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)

== 4.0 A3: switch from another tool ==
  PASS  A3: the switch checks ran to the end
  PASS  A3: the maps load, none refused
  PASS  A3: every shipped map has its real export as a fixture, reports exactly what is pinned beside it, imports exactly that, and a second import adds nothing (2 shipped)
  PASS  A3: the real exports kept as fixtures carry no address outside example.com (their personal data was replaced)
  PASS  A3: each variation is its own product, "Item (Variation)"; a single "Regular" keeps the name; an archived item comes across inactive
  PASS  A3: price, barcode, category path and opening stock come across; the stock goes through the ledger at the export's cost
  PASS  A3: customers: the phone without the spreadsheet's apostrophe, a company with no person named under its own name
  PASS  A3: a SKU or an email twice in one file: the report names the second line, as the import would refuse it
  PASS  A3: the plain CSV import still works and leaves import_ref empty
  PASS  A3: a waiting report whose upload was swept is dropped: no Import button on stale data
  PASS  A3: a report with nothing to import needs no file: it is shown once, then gone
  PASS  A3: each real export's report reads right on the page: every row accounted for, what does not come across, one Import button (2)
  PASS  A3: all 11 switch checks reported
  PASS  A3: index.php loads the SDK and the wiring, and POST /import/switch is switch_upload()
  PASS  A3: Database::migrate() gives products and customers their import_ref column (in code, not in a comment)
  PASS  A3: switch_upload() opens with the product import's own admin gate and the CSRF check
  PASS  A3: the import page is given the maps and the waiting report, shows the card only when a map exists, and Customers links to it for an admin
  PASS  A3: import-maps/ holds its README and the maps, nothing else; each map's real export and pinned report are in tests/fixtures
  PASS  erase planting · sqlite: the subject is created and a marker is planted in all 7 personal columns
  PASS  erase planting · sqlite: the app's own erase runs through its route and reports no failure
  PASS  erase planting · sqlite: no planted marker survives anywhere (37 tables dumped), outside the 1 column(s) kept by ruling (possible legal records)
  PASS  erase planting · sqlite: the subject-access export, taken before the erase, shows every value the erase removes (7)
  PASS  erase planting · sqlite: a read-only viewer cannot download a customer's subject-access export: HTTP 403
  PASS  erase planting · sqlite: the erase page (/customers/1/edit) says what an erase keeps: "Kept: the audit trail's lines that this customer was added o…"
  PASS  erase planting · sqlite: MANUAL.md says the same: "Kept: the audit trail's lines that this customer was added o…"

================================================
  479 passed, 0 failed
================================================

← Back to Vendra · Manual · Quickstart · API