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

Restock Test run, as shipped in the download

488 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. 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  negative 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  license: every font ships the SIL OFL text for its own family beside it
  PASS  license: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause

== F2: fixes ==
  PASS  F2 security: the settings page never echoes the stored SMTP password into its HTML
  PASS  F2 security: a blank SMTP password keeps the stored one; only the remove box clears it
  PASS  F2 PHP: the installer refuses PHP older than 8.1 with a plain message, before anything else runs
  PASS  F2 PHP: the requirements no longer say PHP 8.0 or "PHP 8+"
  PASS  F2 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 (42 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 v4.1.3
  PASS  v3: version is 4.1.3 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 anonymize 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 neutralized 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 totaled
  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, updater_state.license_api_key, backup_state.s3_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

== 3.2.0 · a 0 in a replacing import clears the day ==
  PASS  stored: 6 on 1 Jul (got 6, want 6)
  PASS  a 0 row is accepted (got array (
  0 => 1,
  1 => 0,
), want array (
  0 => 1,
  1 => 0,
))
  PASS  and the day now holds nothing (got 0, want 0)
  PASS  no zero-quantity row is stored (got 0, want 0)
  PASS  a negative quantity is still refused

== 3.2.0 · stock follows sales (opt-in) ==
  PASS  off by default on every install (got 0, want 0)
  PASS  off: importing 5 sold leaves stock at 100 (got 100, want 100)
  PASS  on: a new day of 7 takes 7 off (got 93, want 93)
  PASS  ... and the result names the product (got array (
  0 => 93,
), want array (
  0 => 93,
))
  PASS  re-importing the same day takes nothing more (it replaced 7 with 7) (got 93, want 93)
  PASS  a day corrected down from 7 to 4 puts 3 back (got 96, want 96)
  PASS  a day already stored before switching on (5 = 5) moves nothing (got 96, want 96)
  PASS  a dry run reports the move without making it (got array (
  0 => 1,
  1 => 96,
), want array (
  0 => 1,
  1 => 96,
))
  PASS  stock never goes below zero (got 0, want 0)
  PASS  unitDeltas: add mode counts the whole total (got array (
  8 => 3,
), want array (
  8 => 3,
))

== 3.2.0 · supplier lead times, measured ==
  PASS  Mon 16:00 → Wed 09:00 UTC is 2 days (got 2, want 2)
  PASS  ... but in Auckland (Tue 04:00 → Wed 21:00) it is 1 (got 1, want 1)
  PASS  median of [7] is 7 (got 7, want 7)
  PASS  median of [9, 3, 30] is 9 (one lost parcel does not move it) (got 9, want 9)
  PASS  median of [4, 5] rounds up to 5 (got 5, want 5)
  PASS  Acme: three deliveries measured, actual 9 (median of 8, 9, 12) (got array (
  0 => 3,
  1 => 9,
  2 => 8,
  3 => 12,
), want array (
  0 => 3,
  1 => 9,
  2 => 8,
  3 => 12,
))
  PASS  Acme: products plan with 7 days (2 of them) (got array (
  7 => 2,
), want array (
  7 => 2,
))
  PASS  Acme differs from what is planned
  PASS  the unassigned supplier maps to the products with no supplier (got array (
  5 => 2,
  7 => 3,
  3650 => 1,
), want array (
  5 => 2,
  7 => 3,
  3650 => 1,
))
  PASS  apply sets Acme's active products to 9 days (got 2, want 2)
  PASS  ... and only theirs (got 5, want 5)
  PASS  after applying, Acme no longer differs
  PASS  the apply route re-measures instead of trusting the posted figure, needs product.edit + CSRF, and is audited

== 3.2.0 · the REST API over HTTP (php -S on a throwaway database) ==
  PASS  throwaway install seeded with admin, member and viewer keys
  PASS  the app answers under php -S
  PASS  stock-levels: 200 (got 200, want 200)
  PASS  stock-levels: one updated, one unchanged, three refused (got array (
  0 => 1,
  1 => 1,
  2 => 3,
), want array (
  0 => 1,
  1 => 1,
  2 => 3,
))
  PASS  stock-levels: the reasons name the unknown SKU and the bad figures
  PASS  stock-levels: TILL-1 now reads 12 over the API (got 12, want 12)
  PASS  stock-levels: a viewer's key is refused (403) (got 403, want 403)
  PASS  stock-levels: a read-only key is refused (403) (got 403, want 403)
  PASS  stock-levels: no key, 401 (got 401, want 401)
  PASS  stock-levels: empty rows, 400 (got 400, want 400)
  PASS  sales-history: a viewer's full key can no longer import sales (403, as in the browser and over MCP) (got 403, want 403)
  PASS  sales-history: a member imports (201) (got array (
  0 => 201,
  1 => 1,
), want array (
  0 => 201,
  1 => 1,
))
  PASS  sales-history: replace with 0 is accepted and replaces the stored day (got array (
  0 => 201,
  1 => 1,
  2 => 1,
), want array (
  0 => 201,
  1 => 1,
  2 => 1,
))
  PASS  sales-history: adding 0 (no replace) is refused with the reason
  PASS  openapi documents /api/stock-levels
  PASS  stock-levels: each changed product is audited as stock.set, by the key's user

== 3.2.0 · the demo shows the purchase-order loop and measured lead times ==
  PASS  demo: reseeding twice leaves one world (15 products and one kit) (got 16, want 16)
  PASS  demo: five suppliers have measured deliveries (got 5, want 5)
  PASS  demo: Fastwell takes 8–9 days and plans with 7–10, so it shows "differs"
  PASS  demo: one order on its way, one draft, twelve received (got array (
  'draft' => 1,
  'received' => 12,
  'sent' => 1,
), want array (
  'draft' => 1,
  'received' => 12,
  'sent' => 1,
))

== 3.2.0 · the new screens open for the right roles ==
  PASS  Purchase orders (with the lead-time card) renders for a viewer (got 200, want 200)
  PASS  applying a measured lead time: a viewer is refused (got 403, want 403)
  PASS  Settings (with stock follows sales) renders for an admin (got 200, want 200)

== 3.2.0 pass B · order rules, forecast models, events, kits, overstock, batches ==
  PASS  order rules: nothing needed → nothing ordered, whatever the minimum (got 0, want 0)
  PASS  order rules: no minimum, single units → exactly what is needed (got 13, want 13)
  PASS  order rules: 13 needed in cases of 12 → 24 (got 24, want 24)
  PASS  order rules: 5 needed, minimum 50, cases of 12 → 60 (got 60, want 60)
  PASS  weighted average: nothing sold → 0, not a division by zero (got 0.0, want 0.0)
  PASS  weighted average: recent half counts twice (got 2.3333, want 2.3333)
  PASS  events: overlapping events add (+50 then +50−50, then −50 for six days) (got array (
  0 => 1.0,
  1 => -5.0,
), want array (
  0 => 1.0,
  1 => -5.0,
))
  PASS  events: no demand → no event extra (got array (
  'lead' => 0.0,
  'total' => 0.0,
), want array (
  'lead' => 0.0,
  'total' => 0.0,
))
  PASS  events: a −100% event (closed for the season) cancels the demand it covers (got array (
  0 => 0.0,
  1 => 0,
), want array (
  0 => 0.0,
  1 => 0,
))
  PASS  planning: the probe ran through the product's own code
  PASS  model · moving average over the 60-day window (got 2, want 2)
  PASS  model · recent weighted double: (2×90 + 30) / (2×30 + 30) (got 2.3333, want 2.3333)
  PASS  model · same period last year: the next 30 days a year ago sold 6 a day (got array (
  0 => 6,
  1 => 'last_year',
), want array (
  0 => 6,
  1 => 'last_year',
))
  PASS  model · a product with no sales a year back falls back to the moving average, and says so (got array (
  0 => 'window',
  1 => true,
  2 => 0.6667,
), want array (
  0 => 'window',
  1 => true,
  2 => 0.6667,
))
  PASS  events · +100% on 5 days inside a 10-day lead time lifts the reorder point 30 → 40 (got array (
  0 => 30,
  1 => 40,
), want array (
  0 => 30,
  1 => 40,
))
  PASS  events · and the suggestion 60 → 70 (got array (
  0 => 60,
  1 => 70,
), want array (
  0 => 60,
  1 => 70,
))
  PASS  events · a past event changes nothing (got 40, want 40)
  PASS  order rules · needs 60, minimum 80, cases of 12 → orders 84 and costs 84 units (got array (
  0 => 60,
  1 => 84,
  2 => 16800,
), want array (
  0 => 60,
  1 => 84,
  2 => 16800,
))
  PASS  kits · a kit cannot contain itself
  PASS  kits · two soaps and a candle save (got array (
  0 => true,
  1 => '',
), want array (
  0 => true,
  1 => '',
))
  PASS  kits · a part cannot become a kit (one level)
  PASS  kits · kit sales count as demand on the parts (soap 60 own + 2×30; candle 30) (got array (
  0 => 120,
  1 => 30,
), want array (
  0 => 120,
  1 => 30,
))
  PASS  kits · the part page says how many of its units went out inside kits (got array (
  0 => 60,
  1 => 30,
  2 => 0,
), want array (
  0 => 60,
  1 => 30,
  2 => 0,
))
  PASS  kits · the kit itself is never suggested for reorder (got array (
  0 => true,
  1 => 'healthy',
  2 => 0,
), want array (
  0 => true,
  1 => 'healthy',
  2 => 0,
))
  PASS  kits · with stock following sales, 5 kits sold take 10 soaps and 5 candles and leave the kit's figure alone (got array (
  0 => 490,
  1 => 495,
  2 => 7,
), want array (
  0 => 490,
  1 => 495,
  2 => 7,
))
  PASS  overstock · no sales in the window: all 40 units, $80.00 tied up, marked not selling (got array (
  0 => 40,
  1 => 8000,
  2 => true,
), want array (
  0 => 40,
  1 => 8000,
  2 => true,
))
  PASS  overstock · 500 on the shelf, 60 is two covers → 440 over, $880.00 (got array (
  0 => 440,
  1 => 88000,
  2 => false,
), want array (
  0 => 440,
  1 => 88000,
  2 => false,
))
  PASS  overstock · a product within two covers and a kit are not listed
  PASS  batches · lot code, expiry or both record (got array (
  0 => true,
  1 => true,
  2 => true,
), want array (
  0 => true,
  1 => true,
  2 => true,
))
  PASS  batches · no code and no date, zero units, a day-first date are refused (got array (
  0 => false,
  1 => false,
  2 => false,
), want array (
  0 => false,
  1 => false,
  2 => false,
))
  PASS  batches · first expiry first out: 30 on the shelf = the no-expiry 10, the later 15, 5 of the earliest (got array (
  0 => 
  array (
    0 => 'A1',
    1 => 5,
  ),
  1 => 
  array (
    0 => 'B2',
    1 => 15,
  ),
  2 => 
  array (
    0 => 'C3',
    1 => 10,
  ),
), want array (
  0 => 
  array (
    0 => 'A1',
    1 => 5,
  ),
  1 => 
  array (
    0 => 'B2',
    1 => 15,
  ),
  2 => 
  array (
    0 => 'C3',
    1 => 10,
  ),
))
  PASS  batches · selling 1 a day clears both dated batches in time (got array (
  0 => 
  array (
    0 => 'A1',
    1 => 5,
    2 => 0,
  ),
  1 => 
  array (
    0 => 'B2',
    1 => 15,
    2 => 0,
  ),
), want array (
  0 => 
  array (
    0 => 'A1',
    1 => 5,
    2 => 0,
  ),
  1 => 
  array (
    0 => 'B2',
    1 => 15,
    2 => 0,
  ),
))
  PASS  batches · 50 expiring in 10 days at 1 a day: 40 at risk, $80.00 (got array (
  0 => 
  array (
    0 => 'S1',
    1 => 50,
    2 => 40,
    3 => 8000,
  ),
), want array (
  0 => 
  array (
    0 => 'S1',
    1 => 50,
    2 => 40,
    3 => 8000,
  ),
))
  PASS  batches · a batch already past its date is all at risk (got array (
  0 => 
  array (
    0 => -2,
    1 => 12,
  ),
), want array (
  0 => 
  array (
    0 => -2,
    1 => 12,
  ),
))
  PASS  batches · outside the chosen horizon nothing is listed (got 0, want 0)
  PASS  batches · receiving a purchase order line with a lot or expiry records the batch
  PASS  planning checks ran on the suite's own engine (got 'sqlite', want 'sqlite')
  PASS  margin · 40% at $10 selling price and $6 cost; nothing at cost = price; a loss below it; none without a price (got array (
  0 => 
  array (
    'cents' => 400,
    'pct' => 40,
    'loss' => false,
  ),
  1 => 
  array (
    'cents' => 0,
    'pct' => 0,
    'loss' => true,
  ),
  2 => 
  array (
    'cents' => -200,
    'pct' => -20,
    'loss' => true,
  ),
  3 => NULL,
), want array (
  0 => 
  array (
    'cents' => 400,
    'pct' => 40,
    'loss' => false,
  ),
  1 => 
  array (
    'cents' => 0,
    'pct' => 0,
    'loss' => true,
  ),
  2 => 
  array (
    'cents' => -200,
    'pct' => -20,
    'loss' => true,
  ),
  3 => NULL,
))
  PASS  margin · the reorder suggestions carry the selling price and its margin; a product without one has none (got array (
  0 => 1000,
  1 => 40,
  2 => NULL,
), want array (
  0 => 1000,
  1 => 40,
  2 => NULL,
))
  PASS  margin · raising the order keeps each line's selling price with its cost (got array (
  0 => true,
  1 => 
  array (
    0 => 
    array (
      0 => 'MG-1',
      1 => 1000,
      2 => 600,
    ),
    1 => 
    array (
      0 => 'MG-2',
      1 => 0,
      2 => 250,
    ),
  ),
), want array (
  0 => true,
  1 => 
  array (
    0 => 
    array (
      0 => 'MG-1',
      1 => 1000,
      2 => 600,
    ),
    1 => 
    array (
      0 => 'MG-2',
      1 => 0,
      2 => 250,
    ),
  ),
))
  PASS  margin · a later price change leaves the raised order as it was (got 1000, want 1000)
  PASS  margin · the order page: a Margin column, 40% on the priced line, the order sells for $100.00 with $40.00 (40%), and says one line has no price (got array (
  0 => true,
  1 => true,
  2 => true,
  3 => true,
), want array (
  0 => true,
  1 => true,
  2 => true,
  3 => true,
))
  PASS  margin · the supplier never sees it: not on the supplier CSV, not in the PDF
  PASS  margin · the products CSV takes a "selling price" column; a file without one leaves prices alone, and "price" alone is still the cost (got array (
  0 => 1250,
  1 => 500,
  2 => 350,
), want array (
  0 => 1250,
  1 => 500,
  2 => 350,
))
  PASS  margin · the API gives the selling price (got 1250, want 1250)
  PASS  demo · the Deck Starter Kit is a kit of three parts (got array (
  0 => true,
  1 => 3,
), want array (
  0 => true,
  1 => 3,
))
  PASS  demo · two planned events and three batches, reseeded twice (got array (
  0 => 2,
  1 => 3,
), want array (
  0 => 2,
  1 => 3,
))
  PASS  demo · the old engine-oil batch is the one expiring with units at risk (got array (
  0 => 
  array (
    0 => 'HTS-0013',
    1 => true,
  ),
), want array (
  0 => 
  array (
    0 => 'HTS-0013',
    1 => true,
  ),
))
  PASS  demo · overstock lists the unsold deck lights and the primer (got array (
  0 => 'HTS-0014',
  1 => 'HTS-0008',
), want array (
  0 => 'HTS-0014',
  1 => 'HTS-0008',
))
  PASS  demo · deck screws are suggested in cartons of 25, at least 200
  PASS  Planned events renders for a viewer (got 200, want 200)
  PASS  adding an event: a viewer is refused (got 403, want 403)
  PASS  removing an event: a viewer is refused (got 403, want 403)
  PASS  Overstock renders for a viewer (got 200, want 200)
  PASS  Expiring stock renders for a viewer (got 200, want 200)
  PASS  recording a batch: a viewer is refused (got 403, want 403)
  PASS  removing a batch: a viewer is refused (got 403, want 403)
  PASS  routes: events, overstock, expiring, batches wired

== 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 Restock 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 Restock 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 Restock, 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 7 personal columns
  PASS  erase planting · sqlite: the app's own erase runs through its route and reports no failure
  PASS  erase planting · sqlite: no planted marker survives anywhere (22 tables dumped), outside the 2 column(s) kept by ruling (possible legal records)
  PASS  erase planting · sqlite: the subject-access export, taken before the erase, shows every value the erase removes (5)
  PASS  erase planting · sqlite: the subject-access export carries their saved views and forecast events (the tables the erase blanks)
  PASS  erase planting · sqlite: the erase page (/gdpr) says what an erase keeps: "Kept: their address as "Raised by" on the purchase orders th…"
  PASS  erase planting · sqlite: MANUAL.md says the same: "Kept: their address as "Raised by" on the purchase orders th…"
488/488 PASS, 0 FAIL

← Back to Restock · Manual · Quickstart · API