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