Ownware
Home›Restock›Test run
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

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →