Packora · Test run · run 2026-09-25
Packora Test run, as shipped in the download
374 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. grams_to_mg: exact decimal parse, no float drift ==
PASS "40" g = 40000 mg (got 40000, want 40000)
PASS "12.5" g = 12500 mg (got 12500, want 12500)
PASS "3.2" g = 3200 mg (got 3200, want 3200)
PASS "0.001" g = 1 mg (got 1, want 1)
PASS "1.2344" rounds down → 1234 (got 1234, want 1234)
PASS "1.2345" rounds half-up → 1235 (got 1235, want 1235)
PASS "0.1" g = 100 mg (classic float trap value, exact here) (got 100, want 100)
PASS whitespace tolerated (got 7250, want 7250)
PASS garbage → 0 (got 0, want 0)
PASS negative → 0 (got 0, want 0)
PASS empty → 0 (got 0, want 0)
PASS huge grams clamped to 2e9 mg (got 2000000000, want 2000000000)
== 2. mg → display: grams string + kg at 3dp (half-up, integer space) ==
PASS 12500 mg → "12.5" g (got '12.5', want '12.5')
PASS 40000 mg → "40" g (got '40', want '40')
PASS 1 mg → "0.001" g (got '0.001', want '0.001')
PASS 0 mg → "0" g (got '0', want '0')
PASS 40,000,000 mg → "40.000" kg (got '40.000', want '40.000')
PASS 12,500,000 mg → "12.500" kg (got '12.500', want '12.500')
PASS 3,125,000 mg → "3.125" kg (got '3.125', want '3.125')
PASS 1,234,499 mg → "1.234" kg (rounds down) (got '1.234', want '1.234')
PASS 1,234,500 mg → "1.235" kg (half-up) (got '1.235', want '1.235')
PASS 499 mg → "0.000" kg (got '0.000', want '0.000')
PASS 500 mg → "0.001" kg (got '0.001', want '0.001')
PASS 1,000,000,000 mg → "1,000.000" kg (thousands separator) (got '1,000.000', want '1,000.000')
== 3. Register::lineMg + feeCents: exact integer arithmetic ==
PASS 40000 mg × 1000 units = 40,000,000 mg (got 40000000, want 40000000)
PASS 12500 mg × 250 units = 3,125,000 mg (got 3125000, want 3125000)
PASS 0 units → 0 (got 0, want 0)
PASS negative units floored → 0 (got 0, want 0)
PASS negative weight floored → 0 (got 0, want 0)
PASS 40 kg × 12¢/kg = 480¢ (got 480, want 480)
PASS 12.5 kg × 9¢/kg = 112.5¢ → 113¢ (half-up) (got 113, want 113)
PASS 112.4¢ case rounds down (12.48889 kg × 9) (got 112, want 112)
PASS rate 0 → 0¢ (explicit zero is a claim) (got 0, want 0)
PASS BLANK rate → null, not 0 (no fake $0 estimate)
PASS 0 mg with a rate → 0¢ (got 0, want 0)
PASS big-but-sane stays integer-exact: 2,000,000 kg × $2.00/kg (got 400000000, want 400000000)
== 4. Register::deadlineBadge vs a fixed 'today' ==
PASS 19 days out → soon (≤60)
PASS 6 days past → overdue, days = -6
PASS 153 days out → ok
PASS exactly 60 days out → soon (boundary inclusive)
PASS due today → soon, days = 0
PASS empty date → none ("verify with your PRO")
PASS garbage date → none
== 5. Hand-computed register: 40g PET + 12.5g corrugated × 1,000 OR / 250 CO ==
PASS OR PET total = 40,000,000 mg exactly (got 40000000, want 40000000)
PASS OR PET display = "40.000" kg (got '40.000', want '40.000')
PASS OR corrugated total = 12,500,000 mg (got 12500000, want 12500000)
PASS OR corrugated display = "12.500" kg (got '12.500', want '12.500')
PASS OR recyclable split: PET fully recyclable-flagged (got 40000000, want 40000000)
PASS OR recyclable split: carton flag off → 0 (got 0, want 0)
PASS CO PET total = 10,000,000 mg (got 10000000, want 10000000)
PASS CO PET display = "10.000" kg (got '10.000', want '10.000')
PASS CO corrugated total = 3,125,000 mg (got 3125000, want 3125000)
PASS CO corrugated display = "3.125" kg (got '3.125', want '3.125')
PASS OR grand total mg = 52,500,000 (got 52500000, want 52500000)
PASS CO grand total mg = 13,125,000 (got 13125000, want 13125000)
PASS multi-SKU: OR PET aggregates to 41,000,000 mg (got 41000000, want 41000000)
PASS multi-SKU: OR PET display "41.000" kg (got '41.000', want '41.000')
PASS multi-SKU: corrugated untouched (got 12500000, want 12500000)
PASS CA (zero sales) → empty materialTotals
PASS statesWithSales(2026) = OR + CO only, CA absent
PASS wrong year → empty register
PASS OR detail has 3 SKU-component lines (got 3, want 3)
PASS detail line FIX-A/Clamshell = 40,000,000 mg (got 40000000, want 40000000)
PASS detail line FIX-A/Carton = 12,500,000 mg (got 12500000, want 12500000)
PASS detail line FIX-B/Blister = 1,000,000 mg (got 1000000, want 1000000)
== 6. Fee estimate: kg × operator rate = exact cents; blank rate → no estimate ==
PASS OR PET fee = 41 kg × 12¢ = 492¢ exact (got 492, want 492)
PASS OR corrugated: no rate → fee null (not 0)
PASS OR missing_rates counts the unrated material (got 1, want 1)
PASS OR total covers rated materials only = 492¢ (got 492, want 492)
PASS CO (no rates at all): total 0, all lines null (got 0, want 0)
PASS CO both lines null
== 7. dollars_to_cents_or_null: operator rate input parse ==
PASS blank → null (no rate, not $0)
PASS "0.12" → 12¢ (got 12, want 12)
PASS "1.2" → 120¢ (got 120, want 120)
PASS "$2.50" → 250¢ (got 250, want 250)
PASS "0" → 0¢ (explicit zero allowed) (got 0, want 0)
PASS "0.125" rounds half-up → 13¢ (got 13, want 13)
PASS garbage → 0 (never a crash) (got 0, want 0)
PASS huge clamped to 2e9¢ (got 2000000000, want 2000000000)
== 8. Unknown-SKU sales flagged (never dropped) + component-less SKUs ==
PASS one unmatched sales row detected (got 1, want 1)
PASS unmatched row keeps its code (got 'GHOST-1', want 'GHOST-1')
PASS unmatched row keeps its units (stored, not dropped) (got 777, want 777)
PASS register EXCLUDES unmatched units (PET unchanged) (got 41000000, want 41000000)
PASS after adding the SKU, unmatched list is empty
PASS its units now weigh in: film = 5g × 777 = 3,885,000 mg (got 3885000, want 3885000)
PASS component-less active SKU flagged
PASS SKUs with components not flagged
== 9. toCsv: RFC-4180 + numeric-aware formula guard ==
PASS header row from keys (got 'Sku,Product,Total Kg', want 'Sku,Product,Total Kg')
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 'CG-TENT-2P', want 'CG-TENT-2P')
PASS clamp_int caps 15-digit units (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_year passthrough (got 2026, want 2026)
PASS clamp_year rejects 1899 → current year (got 2026, want 2026)
PASS clamp_year rejects garbage → current year (got 2026, want 2026)
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::importSkus — header mapping, upsert, adversarial input ==
PASS 3 SKUs imported (got 3, want 3)
PASS 0 skipped (got 0, want 0)
PASS name stored (got 'Alpha Product', want 'Alpha Product')
PASS category stored (got 'Drinkware', want 'Drinkware')
PASS active flag "inactive" → 0 (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 name landed (got 'Alpha Product v2', want 'Alpha Product v2')
PASS still exactly one IMP-A row (got 1, want 1)
PASS reordered/aliased headers import cleanly (got 1, want 1)
PASS reordered: category landed (got 'Hardware', want 'Hardware')
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 missing sku/name headers → 0 imported (got 0, want 0)
PASS header error reported
== 12. Importer::importSales — validation, unknown SKU flagged, upsert, clamping ==
PASS 3 sales rows imported (got 3, want 3)
PASS 0 unmatched (got 0, want 0)
PASS units landed (got 1200, want 1200)
PASS reordered headers: 1 good row (upsert-updated) (got 1, want 1)
PASS 4 bad rows skipped (got 4, want 4)
PASS non-numeric units reason
PASS bad year reason
PASS unknown state reason
PASS negative units reason
PASS upsert replaced IMP-A OR 2026 units with 5 (got 5, want 5)
PASS still exactly one IMP-A × OR × 2026 row (got 1, want 1)
PASS unknown-SKU row imported (got 1, want 1)
PASS unknown-SKU row counted as unmatched (got 1, want 1)
PASS flag reason mentions the SKU list
PASS row really stored (got 8, want 8)
PASS and it surfaces in unmatchedSales()
PASS huge units row imported (got 1, want 1)
PASS huge units clamped to 2e9 (got 2000000000, want 2000000000)
PASS lowercase state code matches (got 1, want 1)
== 13. 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 company name clipped to 190 (got 190, want 190)
PASS contact name clipped to 120 (got 120, want 120)
PASS absurd year → current year (got 2026, want 2026)
PASS capped oversize SKU inserts without error
PASS stored capped name exactly 190 (got 190, want 190)
PASS oversize component weight clamps to 2e9 mg and inserts
PASS upsert refuses weight_g scientific notation ("1e3") instead of zero-filling
PASS upsert refuses an over-cap weight_mg (9e99) instead of overflow-to-zero
PASS upsert refuses an over-cap weight_g instead of the silent 2e9 clamp
PASS upsert still accepts a plain decimal exactly (18.4 g → 18400 mg)
PASS no zero-weight component was written by any refused probe
PASS v3: requireKey refuses a deactivated user at the SQL layer (u.active = 1)
PASS v3: no privileged route is left on the bare login guard
PASS v3: the admin can still administer (no perm gates the owner out)
PASS v3: no REST handler writes on the bare key guard
PASS v3: GET /mcp reaches the transport hint WITHOUT the key check in front of it
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
----------------------------------------------------
== 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: deadline_latch_col
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: register_totals
PASS v2: recyclable_split
PASS v2: detail_reconciles
PASS v2: fee_math
PASS v2: deadline_badge
PASS v2: components_dry_run
PASS v2: components_dry_reasons
PASS v2: components_commit_matches_dry_run
PASS v2: grams_to_mg_exact
PASS v2: components_reimport_updates
PASS v2: skus_dry_run
PASS v2: serializers_clean
PASS v2: routes registered (api, 2fa, security, backup, components import, healthz)
PASS v2: migrate() runs on boot (1.x installs gain the new tables)
PASS v2: cli-server base guard (extension-bearing routes work under php -S)
PASS v2: login is rate-limited and 2FA intercepts it
PASS v2: every importer is staged dry-run then explicit confirm
PASS v2: exporting a filing announces report.generated
PASS v2: the deadline alert rides a page load, latched so it cannot repeat
PASS v2: the API reuses Register:: (no second copy of the tonnage maths)
PASS v2: an unknown material is refused rather than invented
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)
-- Own It 3.0 --
PASS v3: role_cols
PASS v3: v3_settings_cols
PASS v3: v3_tables
PASS v3: v3_columns_typed
PASS v3: upgrade_restores_v3
PASS v3: upgrade_keeps_admin
PASS v3: roles_viewer_readonly
PASS v3: roles_member_scope
PASS v3: roles_admin_all
PASS v3: last_admin_guard
PASS v3: audit_redacts_at_depth
PASS v3: audit_csv
PASS v3: audit_append_only
PASS v3: pdf_renders
PASS v3: ical_token
PASS v3: ical_feed
PASS v3: branding_refuses_svg
PASS v3: branding_accent
PASS v3: branding_ink_is_aa
PASS v3: restore_refuses_other_app
PASS v3: mcp_initialize
PASS v3: mcp_tools_list
PASS v3: mcp_call
PASS v3: mcp_scalar_guard
PASS v3: mcp_unknown_arg
PASS v3: mcp_allowlist
PASS v3: mcp_domain_refusal
PASS v3: mcp_get_405
PASS v3: readiness_names_gaps
PASS v3: readiness_not_zero_filled
PASS v3: fresh install · install_from_nothing
PASS v3: fresh install · schema_exists
PASS v3: fresh install · migrate_is_idempotent
PASS v3: fresh install · audit_table_after_boot
PASS v3: fresh install did not fatal
PASS v3: migrate() runs at boot, after the schemaExists guard
PASS v3: every core in src/ is REQUIRED by index.php (no autoloader here)
PASS v3: routes registered (mcp both verbs, audit, pdf, branding, ical, restore, users)
PASS v3: /skus/bulk is registered before /skus/{id}
PASS v3: MCP and the PDF reuse the register engine (no second tonnage maths)
PASS v3: upsert_sku goes through the SAME path as POST /api/skus
PASS v3: the shared upsert exists and the REST endpoint is a thin wrapper
PASS v3: every core is byte-identical to the shared core
PASS v3: Gdpr.php is deliberately absent (packora stores products, not people)
PASS v3: every UI mutation is audited and role-gated
PASS v3: branding accent reaches the page and the audit link is role-gated
PASS v3: README documents the 3.0 release
PASS v3: API.md documents the MCP surface
PASS v3: deploy kit ships
== UI kit v1.0 · report builder preview ==
PASS preview: the engine the preview calls still totals OR PET exactly (got 41000000, want 41000000)
PASS preview: an unsaved rate of 12¢/kg prices 41 kg at 492¢ (got 492, want 492)
PASS preview: a material with no rate stays null, never a fake $0
PASS preview: the endpoint prices through Register::feeCents, not its own maths
PASS preview: an unknown state is refused with 422
PASS preview: an out-of-range year is refused with 422
PASS preview: a negative rate is refused with 422
PASS preview: a non-numeric rate is refused rather than guessed
PASS preview: the endpoint never writes a rate row
PASS preview: requires a session + CSRF
PASS preview: route registered and the builder posts to it
== v3.1 · deadline reminders ==
PASS reminder: a normal ladder parses in descending order (got array (
0 => 60,
1 => 30,
2 => 7,
), want array (
0 => 60,
1 => 30,
2 => 7,
))
PASS reminder: whitespace, duplicates and stray separators survive (got array (
0 => 60,
1 => 30,
2 => 7,
), want array (
0 => 60,
1 => 30,
2 => 7,
))
PASS reminder: nonsense is dropped rather than stored (got array (
0 => 14,
), want array (
0 => 14,
))
PASS reminder: an empty ladder is empty, not a silent default (got array (
), want array (
))
PASS reminder: 65 days out is below every rung (got NULL, want NULL)
PASS reminder: 60 days out hits the first rung (got 60, want 60)
PASS reminder: 31 days out is still the 60 rung (got 60, want 60)
PASS reminder: 30 days out tightens to 30 (got 30, want 30)
PASS reminder: 7 days out tightens to 7 (got 7, want 7)
PASS reminder: overdue maps to rung 0 (got 0, want 0)
PASS reminder: a fresh deadline fires
PASS reminder: the same rung does not fire twice
PASS reminder: the ladder tightening fires again
PASS reminder: a re-run at the same rung stays silent
PASS reminder: going overdue fires the final rung
PASS reminder: an overdue state does not keep firing every page load
PASS reminder: a CHANGED deadline date starts the ladder over
PASS reminder: only valid addresses survive, case-insensitively deduplicated (got array (
0 => 'Owner@Example.com',
1 => 'consultant@example.com',
), want array (
0 => 'Owner@Example.com',
1 => 'consultant@example.com',
))
PASS reminder: no addresses at all means nobody is mailed (got array (
), want array (
))
PASS reminder: the toggle ships ON but sends nowhere until an address exists (got 1, want 1)
PASS reminder: the counterparty list ships EMPTY (got '', want '')
PASS reminder: an unknown toggle is treated as OFF — new mail never starts sending itself
PASS reminder: the body names the state, the date and the days left
PASS reminder: it says plainly that Packora does not file anything
PASS reminder: it leaks no credential or SMTP detail
PASS reminder: an overdue filing says how late it is and defers to the PRO
PASS reminder: the watcher can never break a page
PASS reminder: each rung is latched AFTER the attempt, so a dead mail server cannot loop it
PASS reminder: sends go through the guarded family sender, not a raw socket
PASS reminder: the ladder is re-parsed on save, so nonsense can never be stored
PASS reminder: settings are permission-gated + CSRF like every other write here
PASS reminder: the watcher reports the rung it fired (25 days out = the 30-day rung, one recipient)
PASS reminder: a second run (cron after a page load) sends nothing — one latch for both paths
PASS reminder: without SMTP the rung is recorded as a demo send, never counted as delivered
PASS reminder: a cron route runs the ladder, guarded by the backup token like the scheduled backup
PASS reminder: the settings page tells the owner how to schedule it
PASS filing: recording freezes the rows, their exact total and a SHA-256 of them
PASS filing: straight after recording, it matches what the register computes
PASS filing: after a packaging weight changes, the recording still holds what was filed
PASS filing: ...and the report page is told the register now computes a different total
PASS filing: only an administrator can record one (viewer and member cannot)
PASS filing: routes registered; recording is permission-gated and CSRF-checked
PASS filing: the exports and the recording build their rows in one place
PASS sparkline: years run oldest first and end at the year shown
PASS sparkline: the last two points are last year and this year (300 units vs 100 → exactly 3×)
PASS sparkline: a year with no sales still ends the series, at zero
PASS sparkline: the dashboard draws the yearly series, not one point per state
PASS backup guard: the harness made one signed-in account per role (viewer,member,admin)
PASS backup guard: GET /backup.json — 403 for every non-admin role, open to admin
PASS backup guard: GET /backup.sqlite — 403 for every non-admin role, open to admin
PASS backup guard: GET /restore — 403 for every non-admin role, open to admin
PASS backup guard: POST /restore/upload — 403 for every non-admin role, open to admin
PASS backup guard: POST /restore/commit — 403 for every non-admin role, open to admin
PASS backup guard: POST /restore/token — 403 for every non-admin role, open to admin
PASS backup guard: GET /backup/scheduled without its token refuses every signed-in role, admin included
PASS backup guard: GET /reminders/run without its token refuses every signed-in role, admin included
PASS webhooks: "Add" reaches its route and saves the endpoint (was a 404)
PASS webhooks: "Pause" reaches its route and flips the endpoint
PASS webhooks: "Delete" reaches its route and removes the endpoint
PASS demo card: no page here is public, so every page keeps "signed in as the admin"
PASS demo card: below 600px it starts as the small "Own it" pill; a tap opens it and the choice holds for the session
PASS demo card: boot() hooks it only behind the .demo-mode marker, and a page with no </body> passes through untouched
PASS backup redaction: a canary sits in every credential column of the schema (api_keys.token_hash, invites.token_hash, settings.smtp_pass, settings.oidc_client_secret, settings.backup_token_hash, settings.ical_token_hash, users.password_hash, users.totp_secret, users.totp_recovery, webhooks.secret)
PASS backup redaction: each arrives as [REDACTED] and no canary is anywhere in the JSON backup
PASS settings: a typed SMTP password is saved
PASS settings: the Settings page never carries the stored SMTP password (it was printed into the form)
PASS settings: saving the form with the password field left empty keeps the stored password
PASS settings: typing a new password replaces it
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
PASS backup: the download and the scheduled backup redact through the one PACKORA_BACKUP_REDACT list
374/374 PASS, 0 FAIL
← Back to Packora · Manual · Quickstart · API