Restock · Test run · run 2026-09-25
Restock Test run, as shipped in the download
306 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-09-25 with php tests/run.php. Run it yourself after you download.
[DB: SQLite]
== 1. avgDaily: window total → avg/day ==
PASS 90 units / 90 days = 1.0 (got 1, want 1)
PASS 45 units / 90 days = 0.5 (got 0.5, want 0.5)
PASS 20 units / 10 days = 2.0 (got 2, want 2)
PASS no sales → 0.0 (got 0, want 0)
PASS 0-day window → 0.0 (no ÷0) (got 0, want 0)
== 2. stats: daily series mean/std-dev, zero-fill, window boundary ==
PASS series 1..5 avg = 3.0 (got 3, want 3)
PASS series 1..5 std = sqrt(2) (got 1.4142135623731, want 1.4142135623731)
PASS series 1..5 total = 15 (got 15, want 15)
PASS zero-filled days count: avg 10/5 = 2.0 (got 2, want 2)
PASS zero-filled std = 4.0 (got 4, want 4)
PASS sale ON window start day (today-6) counted (got 7, want 7)
PASS sale one day BEFORE window excluded (got 0, want 0)
PASS future-dated sale excluded (got 0, want 0)
PASS no history → avg 0, std 0, no errors
PASS windowStart 90d from 2026-06-30 (got '2026-04-02', want '2026-04-02')
PASS windowStart 1d = same day (got '2026-06-30', want '2026-06-30')
== 3. safetyStock: days × avg, manual override wins ==
PASS 3 days × 2.0/day = 6.0 (got 6, want 6)
PASS manual override 50 beats days (got 50, want 50)
PASS manual override 0 = 0 (explicit) (got 0, want 0)
PASS negative manual clamped to 0 (got 0, want 0)
PASS zero demand → derived safety 0 (got 0, want 0)
== 4. reorderPoint = avg × lead + safety ==
PASS 2.0/day × 5d lead + 6 safety = 16 (got 16, want 16)
PASS lead time 0 → safety only (got 6, want 6)
PASS zero demand + manual safety 10 (got 10, want 10)
PASS all zero → 0 (got 0, want 0)
== 5. daysLeft = stock ÷ avg (null when no demand) ==
PASS 10 units at 2.0/day = 5.0 days (got 5, want 5)
PASS 0 stock = 0 days (got 0, want 0)
PASS zero demand → null (∞), not ÷0
PASS negative stock treated as 0 (got 0, want 0)
== 6. suggestedQty = ceil(target × avg − stock), floor 0 ==
PASS 2.0/day × 30d − 10 = 50 (got 50, want 50)
PASS 1.5/day × 30d − 10 = 35 (got 35, want 35)
PASS fractional need rounds UP (0.7×30−10=11) (got 11, want 11)
PASS well-stocked → 0 (never negative) (got 0, want 0)
PASS zero demand → 0 (got 0, want 0)
PASS zero stock full cover (got 60, want 60)
== 7. status: below / near / healthy ==
PASS stock == ROP → below (got 'below', want 'below')
PASS stock just above ROP → near (got 'near', want 'near')
PASS near boundary 12.5 → near at 12 (got 'near', want 'near')
PASS above 1.25×ROP → healthy (got 'healthy', want 'healthy')
PASS zero stock → below (got 'below', want 'below')
PASS no demand, no safety (ROP 0) → healthy (got 'healthy', want 'healthy')
== 8. metrics: end-to-end product fixture ==
PASS fixture avg = 1.0/day (got 1, want 1)
PASS fixture safety = 3.0 (got 3, want 3)
PASS fixture ROP = 10.0 (got 10, want 10)
PASS fixture days-left = 8.0 (got 8, want 8)
PASS fixture suggested = 22 (got 22, want 22)
PASS fixture status = below (got 'below', want 'below')
PASS fixture est cost = 5500¢ (int cents, no float money) (got 5500, want 5500)
PASS override safety = 50.0 (got 50, want 50)
PASS override ROP = 57.0 (got 57, want 57)
PASS no-history: avg 0, days-left null, suggested 0
PASS no-history status (safety derives to 0, ROP 0) = healthy (got 'healthy', want 'healthy')
PASS lead 0 → ROP = safety only (3.0) (got 3, want 3)
== 9. toCsv: RFC-4180 + numeric-aware formula guard ==
PASS header row from keys (got 'Sku,Product,Suggested Qty', want 'Sku,Product,Suggested Qty')
PASS quotes/commas escaped
PASS CRLF endings
PASS missing key → blank cell (got '', want '')
PASS = formula neutralized (got '\'=1+2', want '\'=1+2')
PASS + formula neutralized (got '\'+HYPERLINK("x")', want '\'+HYPERLINK("x")')
PASS @ formula neutralized (got '\'@SUM(A1)', want '\'@SUM(A1)')
PASS leading TAB neutralized (got '\' Tabbed', want '\' Tabbed')
PASS -DDE neutralized (got '\'-DDE', want '\'-DDE')
PASS negative int NOT corrupted (got '-5', want '-5')
PASS negative decimal NOT corrupted (got '-12.50', want '-12.50')
PASS numeric +7 NOT corrupted (got '+7', want '+7')
PASS date untouched (got '2026-06-30', want '2026-06-30')
PASS plain value untouched (got 'plain', want 'plain')
== 10. CSRF empty-token guard + clip/clamp helpers ==
PASS EMPTY stored token never validates (hash_equals("","") trap)
PASS empty stored vs non-empty given rejected
PASS matching tokens validate
PASS mismatched tokens rejected
PASS str_cap trims to width (got 190, want 190)
PASS str_cap multibyte-safe (got 100, want 100)
PASS str_cap leaves short strings (got 'HTS-0001', want 'HTS-0001')
PASS clamp_int caps 15-digit qty (got 2000000000, want 2000000000)
PASS clamp_int caps negative overflow (got -2000000000, want -2000000000)
PASS clamp_int non-numeric → 0 (got 0, want 0)
PASS clamp_int trims + parses (got 42, want 42)
PASS clamp_days caps at 3650 (got 3650, want 3650)
PASS clamp_days floors at 0 (got 0, want 0)
PASS clamp_window floor 7 (got 7, want 7)
PASS clamp_window cap 365 (got 365, want 365)
PASS clamp_window passthrough (got 90, want 90)
PASS fmt_money integer cents (got '$1,234.56', want '$1,234.56')
PASS fmt_money sub-dollar (got '$0.05', want '$0.05')
PASS fmt_money negative (got '-$2.50', want '-$2.50')
== 11. Importer::importProducts — header mapping, upsert, adversarial input ==
PASS 3 products imported (got 3, want 3)
PASS 0 skipped (got 0, want 0)
PASS name stored (got 'Alpha Widget', want 'Alpha Widget')
PASS stock stored (got 140, want 140)
PASS lead stored (got 14, want 14)
PASS dollar cost → integer cents (4.25 → 425) (got 425, want 425)
PASS active flag "0" → inactive (got 0, want 0)
PASS re-import updates, not inserts (got 1, want 1)
PASS re-import imported 0 (got 0, want 0)
PASS updated stock landed (got 99, want 99)
PASS still exactly one HTS-A row (got 1, want 1)
PASS reordered/aliased headers import cleanly (got 1, want 1)
PASS reordered: stock landed in stock column (got 33, want 33)
PASS reordered: supplier landed (got 'Northway', want 'Northway')
PASS good row imported (got 1, want 1)
PASS 2 malformed skipped (got 2, want 2)
PASS skip reasons reference the line numbers
PASS adversarial row imported (clipped, not crashed) (got 1, want 1)
PASS oversize SKU clipped to 100 chars
PASS oversize name clipped to 190 (got 190, want 190)
PASS 15-digit stock clamped to 2e9 (got 2000000000, want 2000000000)
PASS huge cost clamped to 2e9¢ (got 2000000000, want 2000000000)
PASS missing sku/name headers → 0 imported (got 0, want 0)
PASS header error reported
== 12. Importer::importSales — validation, unknown SKUs, clamping ==
PASS 3 sales imported (got 3, want 3)
PASS sales rows in DB for HTS-A (got 2, want 2)
PASS qty summed correctly (got 10, want 10)
PASS reordered headers: 1 good row in (got 1, want 1)
PASS 4 bad rows skipped (got 4, want 4)
PASS non-numeric qty reason
PASS bad date reason
PASS unknown SKU reason
PASS zero qty reason
PASS huge qty row imported (got 1, want 1)
PASS huge qty clamped to 2e9 (got 2000000000, want 2000000000)
PASS validDate normalizes 2026-2-3 (got '2026-02-03', want '2026-02-03')
PASS validDate rejects month 13
PASS validDate rejects Feb 30
PASS validDate rejects garbage
== 13. DB → forecast: imported sales drive the numbers ==
PASS window total from DB = 15 (got 15, want 15)
PASS imported-data avg = 1.0/day (got 1, want 1)
PASS imported-data ROP = 10.0 (got 10, want 10)
PASS stock 7 vs ROP 10 → below (got 'below', want 'below')
PASS suggested = ceil(30×1.0 − 7) = 23 (got 23, want 23)
== 14. Auth + settings write-path clamps ==
PASS correct password authenticates
PASS session uid set on login
PASS wrong password rejected
PASS unknown email rejected
PASS password_hash is not plaintext
PASS business name clipped to 190 (got 190, want 190)
PASS currency symbol clipped to 8 (got 8, want 8)
PASS window clamped to 365 (got 365, want 365)
PASS cover floored to 1 (got 1, want 1)
PASS capped oversize product inserts without error
PASS stored capped name exactly 190 (got 190, want 190)
PASS stored stock clamped (got 2000000000, want 2000000000)
PASS licence: every font ships the SIL OFL text for its own family beside it
PASS licence: 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 security: a signed-in non-admin (viewer, member) gets 403 on every backup and restore route
PASS F2 security: the probe is real — an administrator is let through to the backup download
PASS F2 invite: with SMTP set up, the invitation is delivered over a real SMTP conversation, carrying the link
PASS F2 invite: with no SMTP it says so and sends nothing (the link is still shown once)
PASS F2 invite: a refusing mail server is reported, never thrown
PASS F2 invite: the invite handler emails it and still shows the link once
PASS F2 routes: every POST form in the views reaches a POST route (31 checked)
PASS F2 webhooks: the events offered in Settings (sku.reorder_point) are exactly the events the code sends
----------------------------------------------------
== 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: products_dry_run
PASS v2: products_commit_matches_dry_run
PASS v2: products_reimport_updates
PASS v2: sales_dry_run
PASS v2: sales_dry_run_reasons
PASS v2: sales_commit
PASS v2: serializers_clean
PASS v2: forecast_passthrough
PASS v2: routes registered (api, 2fa, security, backup, 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: imports are two-phase — staged, dry-run, then an explicit confirm
PASS v2: the API reuses the report engine (no second copy of the forecast maths)
PASS v3: sales ingestion reuses the CSV date validator (shared rule)
PASS v3: exactly ONE sales-ingest rule, used by the API and MCP
PASS v3: exactly ONE stock writer, used by the form, the API, MCP and PO receiving
PASS v2: reorder alerts fire on the CROSSING, not on every already-low SKU
PASS v2: notifications never break the request
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 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 v3.1.6
PASS v3: version is 3.1.6 everywhere (healthz, api_ping, openapi)
PASS v3: every column the controllers write exists in the schema
PASS v3: every write route is permission-gated
PASS v3: no stale names on the gate-exemption list
PASS v3: no route is shadowed by an earlier one
PASS v3: no call to a function this product does not define
PASS v3: the /backup.json download is actually produced (a dead subprocess must not read as clean)
PASS v3: the /backup.json download redacts every secret column
PASS v3: the scheduled backup file is actually produced (a dead subprocess must not read as clean)
PASS v3: the scheduled backup file redacts every secret column
PASS v3: GET /mcp is routed to the unauthenticated probe (a 401 hides that we speak MCP)
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 status check ran clean (no PHP warning, no fatal)
PASS v3: the MCP status enum names exactly the states the forecast computes
PASS v3: every advertised status returns its own products (none silently empty)
PASS v3: below_reorder returns only stock at or under the reorder point
PASS v3: the MCP instructions teach the vocabulary the enum enforces
PASS v3: a viewer key is refused adjust_stock and ingest_sales
PASS v3: the refused write changed nothing
PASS v3: the restore page states the JSON-vs-.sqlite trade-off (redacted copy vs whole install)
PASS v3: GDPR check ran clean (no PHP warning, no fatal)
PASS v3: subject access returns account + invitation + their audit entries
PASS v3: every column the anonymise map names really exists
PASS v3: erasure blanks name and email
PASS v3: erasure clears the password and 2FA secrets
PASS v3: erasure revokes their API keys
PASS v3: erasure blanks a pending invitation to that address
PASS v3: erasure destroys no audit rows
PASS v3: erasure does NOT rewrite the append-only audit actor
PASS v3: no controller can UPDATE or DELETE audit_log at all
PASS v3: stock and sales records survive an erasure
PASS v3: the page states WHY suppliers are out of scope
PASS v3: the deploy kit ships both files
PASS v3: every deploy-kit placeholder is filled
PASS v3: the deploy kit names THIS product (slug, port, volume)
PASS v3: the deploy healthcheck decodes healthz rather than grepping it (re-copied core fix)
PASS v3: the healthcheck escapes its PHP variables for compose interpolation
PASS C2: no sensitive handler is login-only — all are role-gated
PASS C2: both backup doors call the ONE shared redaction list
PASS C2: Api::requireKey refuses a deactivated user (u.active)
PASS C2: every App:: method the controllers call actually exists
PASS C2: Api::base() does not double an already-absolute base
PASS C2: sales rows reject a nested value where a scalar belongs
== UI kit v1.0 · reorder board tuning ==
PASS tune: lead 5→20 lifts ROP 10 → 25 (got 25, want 25)
PASS tune: stock 7 under the new ROP stays below (got 'below', want 'below')
PASS tune: suggested still targets 30-day cover (got 23, want 23)
PASS tune: negative lead time clamps to 0 (got 0, want 0)
PASS tune: absurd lead time clamps to 3650 (got 3650, want 3650)
PASS tune: out-of-range dials are REFUSED with a sentence (422), never silently clamped — a negative lead time zeroed the ROP and read "healthy" (found 2026-08-20)
PASS tune: requires product.edit + CSRF
PASS tune: the change is written to the audit trail
PASS tune: route registered and the board posts to it
== v3.1 · export presets ==
PASS presets: supplier headers are the documented contract (got array (
0 => 'PO Number',
1 => 'Supplier',
2 => 'Part Number',
3 => 'Description',
4 => 'Quantity',
5 => 'Unit Price',
6 => 'Line Total',
7 => 'Required By',
), want array (
0 => 'PO Number',
1 => 'Supplier',
2 => 'Part Number',
3 => 'Description',
4 => 'Quantity',
5 => 'Unit Price',
6 => 'Line Total',
7 => 'Required By',
))
PASS presets: QuickBooks headers match what QBO reads on a PO import (got array (
0 => 'RefNumber',
1 => 'Vendor',
2 => 'TxnDate',
3 => 'Item(Product/Service)',
4 => 'Description',
5 => 'Qty',
6 => 'Rate',
7 => 'Amount',
), want array (
0 => 'RefNumber',
1 => 'Vendor',
2 => 'TxnDate',
3 => 'Item(Product/Service)',
4 => 'Description',
5 => 'Qty',
6 => 'Rate',
7 => 'Amount',
))
PASS presets: the CSV starts with the header row, CRLF-terminated
PASS presets: a line carries the part number, quantity and a plain decimal price
PASS presets: line total is qty x unit, computed in integer cents (got '1835.40', want '1835.40')
PASS presets: money carries no symbol and no thousands separator (a spreadsheet must read a number)
PASS presets: an unknown key builds no rows (got array (
), want array (
))
PASS presets: has() rejects an unknown key
PASS presets: the endpoint answers 422 for an unknown preset instead of guessing
PASS presets: the export demands po.view
PASS presets: an order with no lines exports headers alone, not an empty file (got 'po_number,supplier,status,raised_on,sku,product,quantity,unit_cost,line_total,received_qty
', want 'po_number,supplier,status,raised_on,sku,product,quantity,unit_cost,line_total,received_qty
')
PASS presets: a formula-shaped cell is neutralised before it reaches the supplier spreadsheet
PASS presets: a negative NUMBER is still exported as a number, not quoted into text
PASS presets: route registered and the order page offers every preset
PASS F2 on-order: 1/day, ROP 15, stock 5, nothing on order → below, suggest 25
PASS F2 on-order: the same product with 25 on a sent order → not below, suggest 0, cover unchanged
PASS F2 on-order: suggestedQty nets what is on order (got 15, want 15)
PASS F2 import: the same sales file imported twice counts once (it used to double)
PASS F2 import: the dry run says how many stored SKU-days the file replaces
PASS F2 import: an overlapping export replaces the overlap; two rows on one day are totalled
PASS F2 import: REST/MCP rows still add by default (unchanged contract)
PASS F2 import: REST/MCP "replace": true replaces the SKU-day (documented, now read)
PASS F2 on-order: a draft purchase order is not stock on order
PASS F2 on-order: a SENT order is netted from status and suggestion (the live forecast path)
PASS F2 on-order: a product covered by a sent order is not suggested for a second order
PASS F2 on-order: once received it is stock, not on order
PASS F2b ribbon: Restock has no public page, so its pages keep the admin wording (/, /products)
PASS F2b ribbon: below 600px it starts as the small "Own it" pill; a tap opens it and that choice holds for the session
PASS F2b ribbon: injected once, just before </body>; output with no </body> (JSON, CSV, PDF) passes untouched
PASS F2b ribbon: boot() installs it only behind the .demo-mode marker
PASS F2b security: /backup/scheduled refuses a signed-in viewer, member with no token or a wrong one (404)
PASS F2b backup: a value was planted and read back in every credential column (settings.smtp_pass, settings.backup_token_hash, settings.oidc_client_secret, users.password_hash, users.totp_secret, users.totp_recovery, api_keys.token_hash, webhooks.secret, invites.token_hash)
PASS F2b backup: the JSON download carries none of them
PASS F2b backup: the scheduled backup carries none of them
PASS F2b backup: both backup doors use the one list, restock_backup_redact()
————————————————————————————————————————————————————
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
306/306 PASS, 0 FAIL
← Back to Restock · Manual · Quickstart · API