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