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

Consigna Test run, as shipped in the download

546 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. split(): penny-exact integer split ==
  PASS  999c at 6000bp -> consignor 599 (got array (
  0 => 599,
  1 => 400,
), want array (
  0 => 599,
  1 => 400,
))
  PASS  999c at 5000bp rounds half up (got array (
  0 => 500,
  1 => 499,
), want array (
  0 => 500,
  1 => 499,
))
  PASS  997c (prime) at 3333bp (got array (
  0 => 332,
  1 => 665,
), want array (
  0 => 332,
  1 => 665,
))
  PASS  9999c at 5000bp (got array (
  0 => 5000,
  1 => 4999,
), want array (
  0 => 5000,
  1 => 4999,
))
  PASS  7919c (prime) at 6667bp (got array (
  0 => 5280,
  1 => 2639,
), want array (
  0 => 5280,
  1 => 2639,
))
  PASS  1c at 5000bp -> penny to consignor (got array (
  0 => 1,
  1 => 0,
), want array (
  0 => 1,
  1 => 0,
))
  PASS  1c at 4999bp -> penny to shop (got array (
  0 => 0,
  1 => 1,
), want array (
  0 => 0,
  1 => 1,
))
  PASS  0% split: shop keeps everything (got array (
  0 => 0,
  1 => 12345,
), want array (
  0 => 0,
  1 => 12345,
))
  PASS  100% split: consignor gets everything (got array (
  0 => 12345,
  1 => 0,
), want array (
  0 => 12345,
  1 => 0,
))
  PASS  zero-price sale splits to zeros (got array (
  0 => 0,
  1 => 0,
), want array (
  0 => 0,
  1 => 0,
))
  PASS  bp above 10000 is clamped (got array (
  0 => 1000,
  1 => 0,
), want array (
  0 => 1000,
  1 => 0,
))
  PASS  negative bp is clamped to 0 (got array (
  0 => 0,
  1 => 1000,
), want array (
  0 => 0,
  1 => 1000,
))
  PASS  large sale: 19999999c at 3333bp (got array (
  0 => 6666000,
  1 => 13333999,
), want array (
  0 => 6666000,
  1 => 13333999,
))
  PASS  invariant: consignor + shop == sale across 110 prime/bp combos

== 2. effectiveBp: override falls back to consignor default ==
  PASS  no override -> consignor default (got 6000, want 6000)
  PASS  override wins over default (got 7000, want 7000)
  PASS  override of 0 is a real override (got 0, want 0)
  PASS  override clamped to 10000 (got 10000, want 10000)
  PASS  negative default clamped to 0 (got 0, want 0)

== 3. cents parsing / formatting (never floats) ==
  PASS  parse "24.99" (got 2499, want 2499)
  PASS  parse "1,250" (got 125000, want 125000)
  PASS  parse "$8" (got 800, want 800)
  PASS  parse "0.5" = 50c (got 50, want 50)
  PASS  parse "-3.25" (got -325, want -325)
  PASS  parse garbage -> null (got NULL, want NULL)
  PASS  parse empty -> null (got NULL, want NULL)
  PASS  fmt_cents 1234567 (got '12,345.67', want '12,345.67')
  PASS  fmt_cents 5 (got '0.05', want '0.05')
  PASS  fmt_cents negative (got '-3.25', want '-3.25')
  PASS  fmt_bp 6000 -> 60% (got '60%', want '60%')
  PASS  fmt_bp 6250 -> 62.5% (got '62.5%', want '62.5%')
  PASS  round-trip: fmt then parse (got 987654321, want 987654321)

== 4. CSV export: formula-injection guard, numeric-aware ==
  PASS  header row (got 'A,B', want 'A,B')
  PASS  formula =SUM is quoted with apostrophe
  PASS  negative money -5.00 NOT corrupted
  PASS  +1234x (non-numeric) is guarded
  PASS  -42 (numeric) NOT corrupted
  PASS  @cmd is guarded
  PASS  leading TAB is guarded
  PASS  comma cell is RFC-4180 quoted
  PASS  embedded quotes doubled
  PASS  CRLF line endings

== 5. CSRF: empty stored token must never validate ==
  PASS  empty stored + empty given -> reject (got false, want false)
  PASS  empty stored + any given -> reject (got false, want false)
  PASS  mismatched tokens -> reject (got false, want false)
  PASS  matching non-empty tokens -> accept (got true, want true)

== 6. Oversize-input guards (MySQL-strict safety) ==
  PASS  clip trims to width (got 190, want 190)
  PASS  clip leaves short strings (got 'Walnut desk', want 'Walnut desk')
  PASS  clip is multibyte-safe (got 120, want 120)
  PASS  clamp_int caps 15-digit price (got 2000000000, want 2000000000)
  PASS  clamp_int floors huge negative (got -2000000000, want -2000000000)
  PASS  clamp_int zeroes garbage (got 0, want 0)
  PASS  clamp_bp caps at 10000 (got 10000, want 10000)
  PASS  clamp_bp floors at 0 (got 0, want 0)
  PASS  clamp_bp keeps sane values (got 6000, want 6000)
  PASS  clamp_cents floors at 0 (got 0, want 0)
  PASS  valid_ymd accepts real date (got true, want true)
  PASS  valid_ymd rejects Feb 30 (got false, want false)
  PASS  valid_ymd rejects garbage (got false, want false)

== 7. Sales state machine (SQLite throwaway DB) ==
  PASS  nextSku follows highest tail (got 'T-0006', want 'T-0006')
  PASS  sale records on available item
  PASS  sale used consignor default 6000bp (got 6000, want 6000)
  PASS  999c @6000bp -> consignor 599 (got 599, want 599)
  PASS  999c @6000bp -> shop 400 (got 400, want 400)
  PASS  penny-exact: 599 + 400 == 999
  PASS  item flipped to sold (got 'sold', want 'sold')
  PASS  double sale refused (got false, want false)
  PASS  sale price may differ from list
  PASS  override bp snapshotted (7500) (got 7500, want 7500)
  PASS  4200c @7500bp -> consignor 3150 (got 3150, want 3150)
  PASS  4200c @7500bp -> shop 1050 (got 1050, want 1050)
  PASS  cannot sell a returned item (got false, want false)
  PASS  negative sale price refused (got false, want false)
  PASS  item 3 still available after refusals (got 'available', want 'available')
  PASS  bad sale date refused (got false, want false)

== 8. Payout ledger: balance can never go negative ==
  PASS  Ann earned across two sales (got 3749, want 3749)
  PASS  Ann balance == earned (no payouts yet) (got 3749, want 3749)
  PASS  payout exceeding balance by 1c is refused (got false, want false)
  PASS  zero payout refused (got false, want false)
  PASS  negative payout refused (got false, want false)
  PASS  payout within balance accepted
  PASS  balance after 2000c payout (got 1749, want 1749)
  PASS  second over-payout (1750 > 1749) refused (got false, want false)
  PASS  payout of exactly the full balance accepted
  PASS  balance drained to zero (got 0, want 0)
  PASS  1c payout on zero balance refused (got false, want false)
  PASS  third sale recorded
  PASS  10000c @6000bp -> consignor 6000 (got 6000, want 6000)
  PASS  balance rises after new sale (got 6000, want 6000)
  PASS  paid total is tracked (got 3749, want 3749)
  PASS  9999c @5000bp -> consignor 5000 (round half up) (got 5000, want 5000)
  PASS  9999c @5000bp -> shop 4999 (got 4999, want 4999)
  PASS  total owed across shop == Ann 6000 + Bob 5000 (got 11000, want 11000)

== 9. Undo sale ==
  PASS  undo unpaid sale accepted
  PASS  item back to available after undo (got 'available', want 'available')
  PASS  Bob balance back to zero (got 0, want 0)
  PASS  undoing a gone sale refused (got false, want false)
  PASS  drain payout accepted
  PASS  undo refused when consignor already paid out against it (got false, want false)

== 10. Item status transitions ==
  PASS  available -> returned allowed
  PASS  status stored (got 'returned', want 'returned')
  PASS  returned -> available allowed
  PASS  available -> expired allowed
  PASS  expired -> donated allowed
  PASS  'sold' cannot be set directly (got false, want false)
  PASS  sold item status is locked (got false, want false)
  PASS  unknown status refused (got false, want false)

== 11. Statement: date-range filtering ==
  PASS  March statement has 2 sales (got 2, want 2)
  PASS  March consignor shares sum (got 3749, want 3749)
  PASS  April statement has 1 sale (got 1, want 1)
  PASS  range boundaries are inclusive (got 1, want 1)
  PASS  day before is excluded (got 0, want 0)
  PASS  March payouts count (got 2, want 2)
  PASS  March payouts sum (got 3749, want 3749)
  PASS  payouts outside range excluded (got 0, want 0)

== 12. Expiry policy ==
  PASS  90-day expiry from intake (got '2026-04-01', want '2026-04-01')
  PASS  expiry disabled at 0 days (got NULL, want NULL)
  PASS  month-end rollover (got '2026-03-02', want '2026-03-02')

== 13. Auth ==
  PASS  password hash verifies
  PASS  wrong password does not verify
  PASS  hash is not the plaintext

== 14. Settings + clamped writes land safely ==
  PASS  9000-char shop name landed clipped to 190 (got 190, want 190)
  PASS  default_split_bp clamped to 10000 (got 10000, want 10000)
  PASS  expiry_days clamped to 3650 (got 3650, want 3650)
  PASS  adversarial oversize item inserts without error
  PASS  9000-char title stored clipped to 190 (got 190, want 190)
  PASS  40-char SKU clip (got 40, want 40)
  PASS  15-digit price clamped to 2e9 (got 2000000000, want 2000000000)
  PASS  oversize override clamped to 10000 (got 10000, want 10000)
  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

----------------------------------------------------

== 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: settings_singleton
  PASS  v2: mint
  PASS  v2: revoke
  PASS  v2: totp
  PASS  v2: recovery
  PASS  v2: delivery
  PASS  v2: events
  PASS  v2: rate
  PASS  v2: backup_tables
  PASS  v2: split_exact_sweep
  PASS  v2: split_round_half_up
  PASS  v2: sale_ok
  PASS  v2: sold_once
  PASS  v2: payout_over_refused
  PASS  v2: payout_exact_allowed
  PASS  v2: payout_on_zero_refused
  PASS  v2: next_sku
  PASS  v2: serializers_clean
  PASS  v2: ledger_reconciles
  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 screen fires the same side effects as the API (one code path)
  PASS  v2: API sales and payouts reuse Consign:: (never a second money path)
  PASS  v2: API intake reuses the auto-SKU and expiry helpers
  PASS  v2: the ledger endpoint reuses the statement queries
  PASS  v2: multi-entity import shares ONE staged dry-run/commit driver
  PASS  v2: dry run and commit share ONE validator per entity
  PASS  v2: notifications never break the request
  PASS  v2: no undefined-key ternary re-reads in any controller
  PASS  v2: dark palette + zero hardcoded white backgrounds
  PASS  v2: dark mode carries its own accent with dark on-accent ink (kit tokens)
  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 still 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 (healthz, api_ping, openapi)
  PASS  v3: the 3.0 check ran clean (no PHP warning, no fatal)
  PASS  v3: PENNY-EXACT SPLITS SURVIVE MCP — 1c/3c/3333c/9999c round-trip 1=>1+0 3=>2+1 3333=>2000+1333 9999=>5999+4000 100000=>60000+40000 7=>4+3
  PASS  v3: no penny is created or lost across the whole ledger
  PASS  v3: a viewer key is refused record_sale
  PASS  v3: the refused sale wrote nothing
  PASS  v3: MCP exposes NO payout tool (cash leaving the till stays human)
  PASS  v3: settlement_preview reports the ledger balance, not a period sum
  PASS  v3: previewing a settlement records no payout
  PASS  v3: the portal token is stored hashed, never in the clear
  PASS  v3: a portal token verifies and a wrong one does not
  PASS  v3: every column the anonymize map names really exists
  PASS  v3: erasure touches no money column
  PASS  v3: the iCal feed uses the description key the core reads
  PASS  v3: a sale through MCP is recorded as rung up by the key's owner (3.3.0)
  PASS  v3: index.php calls Database::migrate() at boot
  PASS  v3: no literal route is shadowed by an earlier {id} route
  PASS  v3: all 9 shared cores are byte-identical to the reference copy
  PASS  v3: the deploy kit ships both files, fully filled
  PASS  v3: the deploy kit names THIS product and carries the fixed healthcheck
  PASS  v3: the brand accent is applied in :root AND html[data-theme=dark]
  PASS  v3: an uploaded logo and the brand name reach the app chrome
  PASS  v3: GET /mcp is routed to the unauthenticated probe
  PASS  v3: the GET probe runs no key check and exposes no tools
  PASS  v3: GET reaches the core and gets 405 + Allow: POST
  PASS  v3: MCP records sales through Consign::recordSale(), never its own INSERT
  PASS  v3: no new surface re-implements the split (only Consign::split may do that arithmetic)
  PASS  v3: the settlement PDF reads the ledger balance rather than summing its own rows
  PASS  v3: the public portal contains no write statement at all
  PASS  v3: the portal resolves a real accent even when branding is unset
  PASS  v3: both backup doors use one redaction list
  PASS  v3: the redaction list covers every credential, including the portal token
  PASS  v3: every write handler is permission-gated
  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

== UI kit v1.0 · payout run ==
  PASS  run fixture: consignor one owed 60% of 10000 (got 6000, want 6000)
  PASS  run fixture: consignor two owed 50% of 5000 (got 2500, want 2500)
  PASS  payout run: both lines accepted
  PASS  payout run: consignor one settled (got 0, want 0)
  PASS  payout run: consignor two settled (got 0, want 0)
  PASS  payout run: two rows written with the shared note (got 2, want 2)
  PASS  payout run: a settled consignor cannot be paid again
  PASS  payout run: an over-balance line aborts the whole run before writing
  PASS  payout run: every line goes through the guarded writer, not a raw INSERT
  PASS  payout run: requires payout.record + CSRF
  PASS  payout run: the run is written to the audit trail
  PASS  payout run: route registered and the hero form posts to it

== v3.1 · portal statement PDF ==
  PASS  statement: one builder serves both doors
  PASS  statement: the portal door renders no PDF of its own
  PASS  statement: the output is a PDF
  PASS  statement: it names the consignor it belongs to
  PASS  statement: a period filter narrows the same builder
  PASS  statement: a consignor with no sales still gets a valid PDF
  PASS  statement: one resolver serves every portal door
  PASS  statement: an empty token resolves to nobody
  PASS  statement: a garbage token resolves to nobody
  PASS  statement: a live token resolves to its own consignor (got 1, want 1)
  PASS  statement: a revoked token stops resolving
  PASS  statement: the portal refusal is a 404 that does not say which case it was
  PASS  statement: the staff download still demands consignor.view
  PASS  statement: the portal URL carries no consignor id — only the token decides whose data it is
  PASS  statement: the portal statement is marked noindex and never cached by a robot
  PASS  statement: both doors are audited, the portal one distinguishably
  PASS  statement: route registered BEFORE the catch-all token route, and the portal offers it

== fix wave: every REST write checks the key holder's role ==
  PASS  api: the viewer key is refused POST /api/items (403, needs item.edit)
  PASS  api: the member key passes the role check on POST /api/items
  PASS  api: the admin key passes the role check on POST /api/items
  PASS  api: the viewer key is refused POST /api/sales (403, needs sale.record)
  PASS  api: the member key passes the role check on POST /api/sales
  PASS  api: the viewer key is refused POST /api/payouts (403, needs payout.record)
  PASS  api: the member key is refused POST /api/payouts (403, needs payout.record)
  PASS  api: the admin key passes the role check on POST /api/payouts
  PASS  api: every /api/* write route checks the key holder's role (no key-only writes)

== fix wave: a viewer cannot read the audit trail ==
  PASS  audit: the viewer is refused /audit (403)
  PASS  audit: an admin still opens /audit
  PASS  audit: the viewer is refused /audit.csv (403)
  PASS  audit: an admin still opens /audit.csv
  PASS  audit: the sidebar shows the viewer no audit link
  PASS  audit: the viewer still opens /
  PASS  audit: the viewer still opens /consignors
  PASS  audit: the viewer still opens /items
  PASS  audit: the viewer still opens /sales
  PASS  audit: the viewer still opens /payouts
  PASS  audit: every *.view permission the code asks for is granted to the viewer, except audit.view

== fix wave: every backup and restore route is administrator-only ==
  PASS  backup: a signed-in viewer gets 403 on GET /backup.json
  PASS  backup: a signed-in viewer gets 403 on GET /backup.sqlite
  PASS  backup: a signed-in viewer gets 403 on GET /restore
  PASS  backup: a signed-in viewer gets 403 on POST /restore/dry-run
  PASS  backup: a signed-in viewer gets 403 on POST /restore/commit
  PASS  backup: a signed-in viewer gets 403 on POST /restore/token
  PASS  backup: a signed-in viewer without the token gets nothing from GET /backup/scheduled
  PASS  backup: a signed-in member gets 403 on GET /backup.json
  PASS  backup: a signed-in member gets 403 on GET /backup.sqlite
  PASS  backup: a signed-in member gets 403 on GET /restore
  PASS  backup: a signed-in member gets 403 on POST /restore/dry-run
  PASS  backup: a signed-in member gets 403 on POST /restore/commit
  PASS  backup: a signed-in member gets 403 on POST /restore/token
  PASS  backup: a signed-in member without the token gets nothing from GET /backup/scheduled
  PASS  backup: an admin still opens the restore page

== fix wave: the SMTP password is never written into the page ==
  PASS  smtp: a typed password is saved
  PASS  smtp: the settings page does not contain the stored password
  PASS  smtp: saving with the password field left blank keeps the stored password

== fix wave: consignors hear about their sales and payouts ==
  PASS  consignor switches exist and start off
  PASS  consignor note is off by default
  PASS  consignor note skips a consignor with no address
  PASS  consignor note a dead mail server breaks nothing
  PASS  consignor hears a sale with price share and balance
  PASS  consignor hears a payout with the remaining balance
  PASS  consignor with no address still sells
  PASS  agent sale gets the same side effects
  PASS  settings: the two consignor switches are on the settings page
  PASS  ui: the sidebar brand cannot be shrunk to nothing (flex-shrink:0)

== fix wave: the webhook buttons work ==
  PASS  webhooks: Add creates the endpoint (no 404)
  PASS  webhooks: Pause switches it off (no 404)
  PASS  webhooks: pressing it again switches it back on
  PASS  webhooks: the page offers Pause and Delete for the endpoint, at those same addresses
  PASS  webhooks: Delete removes it (no 404)

-- F2b: the demo ribbon's wording and size; the JSON backup's secrets --
  PASS  demo ribbon: on the consignor portal (/portal/…) it says what the page is, not "signed in as the admin"
  PASS  demo ribbon: an administrator's page keeps the admin wording
  PASS  demo ribbon: below 600px it starts as the small "Own it" pill; a tap opens it and the choice holds for the session
  PASS  demo ribbon: registered only behind the .demo-mode marker, and non-HTML output passes through untouched
  PASS  backup: the JSON download carries none of the 11 credentials this schema holds (settings.smtp_pass, settings.backup_token_hash, settings.oidc_client_secret, users.password_hash, users.totp_secret, users.totp_recovery, consignors.portal_token_hash, api_keys.token_hash, webhooks.secret, backup_state.s3_secret, invites.token_hash)

————————————————————————————————————————————————————
  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)
  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

== 3.2.0 · counter tax maths (pure) ==
  PASS  no tax: net = price (got array (
  0 => 1999,
  1 => 0,
), want array (
  0 => 1999,
  1 => 0,
))
  PASS  20% added on 10.00: tax 2.00 (got array (
  0 => 1000,
  1 => 200,
), want array (
  0 => 1000,
  1 => 200,
))
  PASS  20% added on 0.05: 0.01 (half up) (got array (
  0 => 5,
  1 => 1,
), want array (
  0 => 5,
  1 => 1,
))
  PASS  20% included in 12.00: net 10.00, tax 2.00 (got array (
  0 => 1000,
  1 => 200,
), want array (
  0 => 1000,
  1 => 200,
))
  PASS  7.5% included in 9.99: net 9.29, tax 0.70 (net + tax = the tag) (got array (
  0 => 929,
  1 => 70,
), want array (
  0 => 929,
  1 => 70,
))
  PASS  included: net + tax is always the price paid (0..20,000 cents at 17.5%)

== 3.2.0 · a basket at the counter ==
  PASS  quote ok
  PASS  quote: tag prices unless overridden (20.00 + 15.50 + 8.00) (got array (
  0 => 4350,
  1 => 4350,
), want array (
  0 => 4350,
  1 => 4350,
))
  PASS  quote: each line split with its own share, on its own price (got array (
  0 => 
  array (
    0 => 1200,
    1 => 800,
  ),
  1 => 
  array (
    0 => 775,
    1 => 775,
  ),
  2 => 
  array (
    0 => 560,
    1 => 240,
  ),
), want array (
  0 => 
  array (
    0 => 1200,
    1 => 800,
  ),
  1 => 
  array (
    0 => 775,
    1 => 775,
  ),
  2 => 
  array (
    0 => 560,
    1 => 240,
  ),
))
  PASS  the same item twice is refused
  PASS  an empty basket is refused
  PASS  a negative price is refused
  PASS  cash sale of two items recorded
  PASS  20% added: subtotal 35.50, tax 7.10, total 42.60, paid 50.00, change 7.40 (got array (
  0 => 3550,
  1 => 710,
  2 => 4260,
  3 => 5000,
  4 => 740,
), want array (
  0 => 3550,
  1 => 710,
  2 => 4260,
  3 => 5000,
  4 => 740,
))
  PASS  both items are sold (got array (
  0 => 'sold',
  1 => 'sold',
), want array (
  0 => 'sold',
  1 => 'sold',
))
  PASS  each item has its own sale row: net price, split, tax, pointing at the receipt (got array (
  0 => 
  array (
    0 => 2000,
    1 => 1200,
    2 => 800,
    3 => 400,
    4 => '2026-09-20',
  ),
  1 => 
  array (
    0 => 1550,
    1 => 775,
    2 => 775,
    3 => 310,
    4 => '2026-09-20',
  ),
), want array (
  0 => 
  array (
    0 => 2000,
    1 => 1200,
    2 => 800,
    3 => 400,
    4 => '2026-09-20',
  ),
  1 => 
  array (
    0 => 1550,
    1 => 775,
    2 => 775,
    3 => 310,
    4 => '2026-09-20',
  ),
))
  PASS  tax belongs to no consignor: Carla's balance rose by 12.00 exactly (got 1200, want 1200)
  PASS  a receipt code was issued
  PASS  cash short of the total is refused, and nothing is sold
  PASS  card: paid = total, no change; 20% included in 12.00 → net 10.00 split, tax 2.00 (got array (
  0 => 1200,
  1 => 1200,
  2 => 0,
  3 => 200,
  4 => 1000,
  5 => 600,
), want array (
  0 => 1200,
  1 => 1200,
  2 => 0,
  3 => 200,
  4 => 1000,
  5 => 600,
))

== 3.2.0 · all or nothing: one sold item refuses the whole basket ==
  PASS  refused, naming the item
  PASS  ... and the other item is still on the floor, no receipt written (got array (
  0 => 'available',
  1 => 2,
), want array (
  0 => 'available',
  1 => 2,
))
  PASS  lost race mid-basket: refused and rolled back
  PASS  ... the first item is back on the floor (rolled back) (got 'available', want 'available')

== 3.2.0 · void a counter sale; a single line cannot be undone on its own ==
  PASS  undoing one line of a receipt is refused, naming the receipt
  PASS  void refused once a consignor was paid out against it
  PASS  void goes through
  PASS  its items are back on the floor, its sale rows gone, the receipt marked void (got array (
  0 => 'available',
  1 => 'available',
  2 => 0,
  3 => true,
), want array (
  0 => 'available',
  1 => 'available',
  2 => 0,
  3 => true,
))
  PASS  Carla's balance is back to what it was (got 600, want 600)
  PASS  voiding twice is refused

== 3.2.0 · Code 128 barcodes for the tags ==
  PASS  "A" = START B, 33, checksum 34, STOP (got array (
  0 => 104,
  1 => 33,
  2 => 34,
  3 => 106,
), want array (
  0 => 104,
  1 => 33,
  2 => 34,
  3 => 106,
))
  PASS  "C-0043" checksum = (104 + Σ i×v) mod 103 (got 79, want 79)
  PASS  "A" is 46 modules (3 × 11 + 13) (got 46, want 46)
  PASS  every symbol is 11 modules wide
  PASS  printable ASCII only: a tab, an accent or 41 characters are refused
  PASS  the SVG is a row of bars with quiet zones and an accessible name

== 3.2.0 · the counter and the tags, through the front controller ==
  PASS  counter: a viewer is refused (got 403, want 403)
  PASS  counter: a member opens it, with the scan box and the script
  PASS  lookup: a scanned SKU comes back exact
  PASS  lookup: words find the title
  PASS  tags: a viewer can choose and print (both untagged items listed)
  PASS  tag sheet: two tags with barcodes, prices, consignor number — never the consignor's name
  PASS  tag sheet: the two skipped labels are left blank
  PASS  marking as tagged: a viewer is refused (got 403, want 403)
  PASS  marked items leave the "not yet tagged" list
  PASS  ... and come back with "include items already tagged"

== 3.2.0 · ringing a basket up over HTTP (php -S, real login, JSON + CSRF) ==
  PASS  the app answers under php -S
  PASS  signed in as a member through the login form
  PASS  the counter page hands the script its CSRF token
  PASS  quote over HTTP: 45.00 + 10.00, VAT 10% added = 5.50, total 60.50 (got array (
  0 => 200,
  1 => 5500,
  2 => 550,
  3 => 6050,
), want array (
  0 => 200,
  1 => 5500,
  2 => 550,
  3 => 6050,
))
  PASS  a sale without the right CSRF token is refused (419) (got 419, want 419)
  PASS  sale over HTTP: recorded, change 9.50 (got array (
  0 => 200,
  1 => true,
  2 => 950,
), want array (
  0 => 200,
  1 => true,
  2 => 950,
))
  PASS  the receipt shows both items, VAT, the total, the change and the footer
  PASS  a member sees no Void button (voiding is an administrator's)
  PASS  the same item cannot be sold twice
  PASS  the sale is in the audit trail as a counter sale

== 3.2.0 pass B · consignor messages ==
  PASS  messages: the probe ran
  PASS  messages: both new switches start off
  PASS  messages: due = Ann's one item inside 14 days and Di's week (not sold, past, later, no-address or opted-out items) (got array (
  0 => 
  array (
    0 => 'expiry',
    1 => 'Ann Payne',
    2 => 1,
  ),
  1 => 
  array (
    0 => 'digest',
    1 => 'Di Seller',
    2 => 1,
  ),
), want array (
  0 => 
  array (
    0 => 'expiry',
    1 => 'Ann Payne',
    2 => 1,
  ),
  1 => 
  array (
    0 => 'digest',
    1 => 'Di Seller',
    2 => 1,
  ),
))
  PASS  messages: the reminder names the item, the collect-by date and the shop's policy
  PASS  messages: the weekly note lists this week's sale with the share and the balance, not the older one
  PASS  messages: switched off, nothing is sent or recorded
  PASS  messages: a mail server that refuses leaves it to be tried again
  PASS  messages: the reminder reaches Ann over SMTP, once
  PASS  messages: moving the expiry date announces the item again
  PASS  messages: the weekly note reaches Di over SMTP, once a week
  PASS  messages: ISO weeks (2027-01-01 belongs to 2026-W53) (got array (
  0 => '2026-W39',
  1 => '2026-W53',
), want array (
  0 => '2026-W39',
  1 => '2026-W53',
))
  PASS  messages: an opted-out consignor gets no sale note either
  PASS  messages: the checks ran on the suite's own engine (got 'sqlite', want 'sqlite')
  PASS  portal by email: a refused message leaves no link behind
  PASS  portal by email: the emailed link opens that consignor's page and only its hash is stored
  PASS  portal by email: audited without the link
  PASS  messages page: a viewer can read it
  PASS  messages: a viewer cannot send (got 403, want 403)
  PASS  portal by email: a viewer is refused (got 403, want 403)
  PASS  the consignor form saves the opt-out (got 1, want 1)
  PASS  settings save the reminder switch, its days and the policy line (got array (
  0 => '1',
  1 => '0',
  2 => '21',
  3 => 'Donated.',
), want array (
  0 => '1',
  1 => '0',
  2 => '21',
  3 => 'Donated.',
))

== 3.2.0 pass B · the payout bank file ==
  PASS  IBAN: the ISO 13616 examples pass, one wrong digit fails
  PASS  routing numbers: the ABA checksum (021000021, 011000015 pass; 021000022 fails)
  PASS  BIC: 8 or 11 characters
  PASS  NACHA: every record is 94 characters and the file is blocked by 10 (6 records + 4 of nines)
  PASS  NACHA: file header — destination, origin, created 2609301405, 094, blocking 10
  PASS  NACHA: batch header — credits only (220), PPD, effective 261001, ODFI 02100002
  PASS  NACHA: entries — checking 22 / savings 32, RDFI + check digit, amount in cents, name in plain capitals, trace
  PASS  NACHA: batch and file control — 2 entries, entry hash 0003200003, credits 60.50, 1 block
  PASS  SEPA: well-formed pain.001.001.03 with 2 transfers, control sum 60.50, execution 2026-10-01
  PASS  SEPA: each transfer in euro to its IBAN, the BIC only where known, the name in the SEPA character set
  PASS  CSV: the bulk-payment columns, 1.05 as a decimal, a formula in a name defused
  PASS  a consignor's IBAN that fails its check is refused, the rest saved
  PASS  bank file page: a viewer sees who is in the file (account masked) and who has no details
  PASS  bank file: a viewer cannot download it (got 403, want 403)
  PASS  bank file: refused until the shop's own bank details are in
  PASS  bank file: the ACH file carries Bea's savings credit of 25.75 and not Tess's cash payout
  PASS  bank file: the payout is marked with its file and the download is audited
  PASS  bank file: downloading again leaves out what is already in a file
  PASS  bank file: asked to, it includes them again (CSV here)

== 3.2.0 pass B · drop-off booking ==
  PASS  windows: a window that closes before it opens is refused (got 'Each window opens before it closes, as HH:MM.', want 'Each window opens before it closes, as HH:MM.')
  PASS  windows: Monday 10–11 in 30-minute slots for two, Wednesday 14–15 for one (got array (
  0 => true,
  1 => '',
), want array (
  0 => true,
  1 => '',
))
  PASS  slots: Monday 10:00 and 10:30, Wednesday 14:00, nothing on Tuesday (got array (
  '2030-01-07' => 
  array (
    0 => '10:00',
    1 => '10:30',
  ),
  '2030-01-09' => 
  array (
    0 => '14:00',
  ),
), want array (
  '2030-01-07' => 
  array (
    0 => '10:00',
    1 => '10:30',
  ),
  '2030-01-09' => 
  array (
    0 => '14:00',
  ),
))
  PASS  slots: a slot closes an hour before it starts (got array (
  0 => '2030-01-09',
), want array (
  0 => '2030-01-09',
))
  PASS  capacity: two people take the 10:00 slot for two; the third is told it has just filled up
  PASS  capacity: the slot shows nobody left (got 0, want 0)
  PASS  refusals: no name, a bad address, a time that is not open
  PASS  limits: one address holds at most two upcoming bookings
  PASS  limits: one connection makes at most five bookings a day
  PASS  cancel link: a wrong token does nothing; the right one frees the seat once
  PASS  cancel link: a booking that has passed cannot be canceled by its link
  PASS  the shop: canceling gives the seat back, marking arrived does not
  PASS  a booking by a known consignor's address is linked to them
  PASS  backups leave out the cancel link and the network address
  PASS  booking page: closed until switched on (404) (got 404, want 404)
  PASS  booking settings: a member is refused (got 403, want 403)
  PASS  booking page: open to anyone, with times and the shop's note
  PASS  booking page: the hidden field stops a form-filling robot
  PASS  booking: confirmed on the page with a cancel link
  PASS  booking: the one-person slot is refused to the next visitor
  PASS  the shop's list: a viewer sees who is coming and what they bring
  PASS  the shop's list: a viewer cannot mark a booking (got 403, want 403)
  PASS  cancel link: asks before canceling (GET changes nothing)
  PASS  cancel link: cancels, audited

== 3.2.0 pass B · item entry from a photo (the shop's own AI provider) ==
  PASS  Anthropic: the Messages API with your key, the image as a base64 block, a retired model replaced by the default
  PASS  the prompt asks for what the photo shows and forbids a price
  PASS  the draft: the shop's own category spelling, color and condition as one notes line (got array (
  'title' => 'Oak dresser',
  'category' => 'Furniture',
  'notes' => 'Color: light oak. Condition: good. Three drawers.',
), want array (
  'title' => 'Oak dresser',
  'category' => 'Furniture',
  'notes' => 'Color: light oak. Condition: good. Three drawers.',
))
  PASS  OpenAI: chat completions with a Bearer key, JSON mode, the image as a data URL, your model kept
  PASS  Ollama: your own machine, no key needed, the image in the images array
  PASS  refused before any call: switched off, no key, not an image, over 5 MB
  PASS  a refused key: the provider's reason, never the key
  PASS  a model the provider does not know: says which and where to change it
  PASS  an answer that is not the JSON asked for is an error, not a blank draft
  PASS  normalize: a long title is clipped to the column, "null" strings dropped (got array (
  'title' => 'éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé',
  'category' => 'Rugs',
  'notes' => '',
), want array (
  'title' => 'éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé',
  'category' => 'Rugs',
  'notes' => '',
))
  PASS  the handler: a photo in, the draft out as JSON, the type taken from the bytes (PNG sent as .jpg), nothing saved
  PASS  the handler: audited with the provider and size, never the key
  PASS  the handler: a text file named .jpg is refused and never sent
  PASS  photo entry: a viewer is refused (got 403, want 403)
  PASS  settings: the key is kept when the field is left blank, and never written into the page
  PASS  the intake form offers "Fill from a photo" once a provider is chosen
  PASS  backups and the audit trail leave out the AI key

== 3.2.0 overflow (customer voice) · reports and bulk price changes ==
  PASS  reports: two sales, $100.00, the shop's half, 16.7% below list, 7.5 days to sell on average (got array (
  0 => 2,
  1 => 10000,
  2 => 5000,
  3 => 16.7,
  4 => 7.5,
), want array (
  0 => 2,
  1 => 10000,
  2 => 5000,
  3 => 16.7,
  4 => 7.5,
))
  PASS  reports: sell-through 2 of 3 — sold before the period and returned items are left out of the floor (got array (
  0 => 3,
  1 => 66.7,
), want array (
  0 => 3,
  1 => 66.7,
))
  PASS  reports: by category (sold / on the floor) (got array (
  'Books' => 
  array (
    0 => 1,
    1 => 1,
  ),
  'Furniture' => 
  array (
    0 => 1,
    1 => 2,
  ),
), want array (
  'Books' => 
  array (
    0 => 1,
    1 => 1,
  ),
  'Furniture' => 
  array (
    0 => 1,
    1 => 2,
  ),
))
  PASS  reports: the unsold item counts as over 90 days on the floor
  PASS  reports: the page opens for a viewer with the figures
  PASS  reports: the categories CSV
  PASS  bulk price: a viewer is refused (got 403, want 403)
  PASS  bulk price: 20% off the two on the floor (whole cents, half up); the returned vase untouched (got array (
  0 => 1600,
  1 => 7999,
  2 => 1500,
), want array (
  0 => 1600,
  1 => 7999,
  2 => 1500,
))
  PASS  bulk price: it says what it did
  PASS  bulk price: set a price (got 1250, want 1250)
  PASS  bulk price: 100% off is refused (use a price instead) (got 1250, want 1250)
  PASS  bulk price: audited
  PASS  the items list carries the bulk bar (status and price)

== 3.3.0 round 4 (customer voice) · average sale, sales by staff member, sales by hour ==
  PASS  average sale: a counter basket is one sale — 4 sales, $120.00, $30.00 each, 1.3 items each (got array (
  0 => 4,
  1 => 12000,
  2 => 3000,
  3 => 1.3,
), want array (
  0 => 4,
  1 => 12000,
  2 => 3000,
  3 => 1.3,
))
  PASS  by staff member: named from the team (email matched in any case), else the email kept, else not recorded; the biggest takings first (got array (
  'Sam Staff' => 
  array (
    'txn' => 2,
    'items' => 3,
    'gross' => 9000,
    'avg' => 4500,
  ),
  'gone@kpi.test' => 
  array (
    'txn' => 1,
    'items' => 1,
    'gross' => 2000,
    'avg' => 2000,
  ),
  '(not recorded)' => 
  array (
    'txn' => 1,
    'items' => 1,
    'gross' => 1000,
    'avg' => 1000,
  ),
), want array (
  'Sam Staff' => 
  array (
    'txn' => 2,
    'items' => 3,
    'gross' => 9000,
    'avg' => 4500,
  ),
  'gone@kpi.test' => 
  array (
    'txn' => 1,
    'items' => 1,
    'gross' => 2000,
    'avg' => 2000,
  ),
  '(not recorded)' => 
  array (
    'txn' => 1,
    'items' => 1,
    'gross' => 1000,
    'avg' => 1000,
  ),
))
  PASS  by hour: when it was rung up; the sale entered on a later day is left out and counted (got array (
  0 => 
  array (
    9 => 
    array (
      0 => 1,
      1 => 1,
      2 => 2000,
    ),
    14 => 
    array (
      0 => 1,
      1 => 1,
      2 => 5000,
    ),
    15 => 
    array (
      0 => 1,
      1 => 2,
      2 => 4000,
    ),
  ),
  1 => 1,
), want array (
  0 => 
  array (
    9 => 
    array (
      0 => 1,
      1 => 1,
      2 => 2000,
    ),
    14 => 
    array (
      0 => 1,
      1 => 1,
      2 => 5000,
    ),
    15 => 
    array (
      0 => 1,
      1 => 2,
      2 => 4000,
    ),
  ),
  1 => 1,
))
  PASS  by hour, in the shop's timezone (New York, UTC−5 in March 2002) (got array (
  0 => 4,
  1 => 9,
  2 => 10,
), want array (
  0 => 4,
  1 => 9,
  2 => 10,
))
  PASS  the sale form records who rang it up
  PASS  the page shows the average sale, the staff table and the hour table
  PASS  the REST API records the key's owner (the MCP tool: tests/v3_check.php, through the real handler)

== 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 Consigna 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 Consigna 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 Consigna, 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)
  PASS  erase planting · sqlite: the subject is created and a marker is planted in all 15 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 (24 tables dumped)
546/546 PASS, 0 FAIL

← Back to Consigna · Manual · Quickstart · API