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