Packora · Test run · run 2026-11-07
Packora Test run, as shipped in the download
505 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. 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 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
----------------------------------------------------
== 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 v4.1.3
PASS v3: version is 4.1.3 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, backup_state.s3_secret, invites.token_hash, settings.smtp_pass, settings.oidc_client_secret, settings.backup_token_hash, settings.ical_token_hash, updater_state.license_api_key, 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
== 3.2.0 · weights that change from a year keep earlier years as filed ==
PASS componentApplies: open row counts every year
PASS componentApplies: from 2026 counts 2026 on, not 2025
PASS componentApplies: until 2025 counts 2025, not 2026
PASS yearsLabel covers the four shapes (got array (
0 => 'every year',
1 => 'from 2026',
2 => 'until 2025',
3 => '2024–2025',
4 => 'in 2025',
), want array (
0 => 'every year',
1 => 'from 2026',
2 => 'until 2025',
3 => '2024–2025',
4 => 'in 2025',
))
PASS before any change: 2025 and 2026 both weigh 20 g × 100 = 2,000,000 mg (got array (
0 => 2000000,
1 => 2000000,
), want array (
0 => 2000000,
1 => 2000000,
))
PASS a lighter carton from 2026: 2025 still reports 2,000,000 mg (as filed), 2026 reports 1,500,000 mg (got array (
0 => 2000000,
1 => 1500000,
), want array (
0 => 2000000,
1 => 1500000,
))
PASS the old row now stops at 2025 and the new row starts at 2026 (got array (
0 =>
array (
0 => 20000,
1 => 0,
2 => 2025,
),
1 =>
array (
0 => 15000,
1 => 2026,
2 => 0,
),
), want array (
0 =>
array (
0 => 20000,
1 => 0,
2 => 2025,
),
1 =>
array (
0 => 15000,
1 => 2026,
2 => 0,
),
))
PASS the SKU-level detail feed for 2025 carries the 2025 row only
PASS versionFrom: a failed insert rolls back the end year (both writes or neither)
PASS CSV with from_year 2028: one new row from 2028, 2026–2027 keeps 15 g (got array (
0 => 1,
1 =>
array (
0 =>
array (
0 => 20000,
1 => 0,
2 => 2025,
),
1 =>
array (
0 => 15000,
1 => 2026,
2 => 2027,
),
2 =>
array (
0 => 12000,
1 => 2028,
2 => 0,
),
),
), want array (
0 => 1,
1 =>
array (
0 =>
array (
0 => 20000,
1 => 0,
2 => 2025,
),
1 =>
array (
0 => 15000,
1 => 2026,
2 => 2027,
),
2 =>
array (
0 => 12000,
1 => 2028,
2 => 0,
),
),
))
PASS the same CSV again changes nothing (counted as updated, no new row) (got array (
0 => 0,
1 => 1,
2 => 3,
), want array (
0 => 0,
1 => 1,
2 => 3,
))
PASS a CSV without from_year corrects the open-ended row only; the filed years keep their weights (got array (
0 => 1,
1 =>
array (
0 => 20000,
1 => 15000,
2 => 11500,
),
), want array (
0 => 1,
1 =>
array (
0 => 20000,
1 => 15000,
2 => 11500,
),
))
PASS CSV: a from_year that is not a year is refused with its line number
PASS SKU page form: "6.5 g from 2027" splits the row (8 g until 2026, 6.5 g from 2027) and redirects back
PASS SKU page form: the change is in the audit trail with both weights
PASS refused: a year not after the row's start, another SKU's row, and a year past the row's end — nothing changes
PASS the SKU page shows each row's years and the unit weight for the default reporting year
PASS deleting a component is audited, and deleting one that does not exist is not (it was the other way round)
PASS API upsert: a year-blind replace of a SKU with dated rows is refused with 409, history intact
PASS API upsert: sending from_year / to_year replaces the rows with their years
PASS API sku record: each component carries from_year / to_year, and the total is the default year's
== 3.2.0 · sales in, not typed in: Shopify / WooCommerce / Amazon orders exports ==
PASS dry run: nothing is written
PASS Shopify: units counted per SKU × state × year (OR 2026: 2 + 5 via "Oregon"; FIX-A inherits #1001's state and date; CO 2025: partially refunded still counts) (got array (
0 => 7,
1 => 1,
2 => 2,
), want array (
0 => 7,
1 => 1,
2 => 2,
))
PASS Shopify: the report counts each kind of line (got array (
0 => 9,
1 => 11,
2 => 2,
3 => 2,
4 => 1,
5 => 1,
), want array (
0 => 9,
1 => 11,
2 => 2,
3 => 2,
4 => 1,
5 => 1,
))
PASS Shopify: a state not on your States page is reported with its units (WA 6)
PASS Shopify: an unknown SKU is imported and flagged, never dropped
PASS dry run and real run agree on what they would add
PASS the same file again adds nothing: every counted line is recognized
PASS Amazon: a tab-separated report is read; shipped counts, canceled and pending do not
PASS a file without the expected columns is refused, naming the missing ones
PASS custom column names: the same file imports once the names are given (a US date still yields the year)
PASS orderMap: an empty override keeps the preset's name; unknown presets fall back to Shopify (got array (
0 => 'sku',
1 => 'shopify',
), want array (
0 => 'sku',
1 => 'shopify',
))
PASS clearing a year, deleting a units row or a SKU also forgets its imported order lines (so they can be imported again)
PASS the orders import is staged as a dry run and committed like the others (import.run + CSRF)
== 3.2.0 · registration, recyclability, eco-modulation, PRO formats ==
PASS recyclability: 40 g recyclable of 50 g = 80%; recycled content 20 g of 50 g = 40% (got array (
0 => 80.0,
1 => 40.0,
), want array (
0 => 80.0,
1 => 40.0,
))
PASS recyclability: no weight → no percentage, never 0% (got array (
0 => NULL,
1 => NULL,
), want array (
0 => NULL,
1 => NULL,
))
PASS recyclability: units weight the shares (3 × 1 g recyclable vs 1 × 1 g not = 75%)
PASS modulation: 0/0 is the flat fee exactly (got 800, want 800)
PASS modulation: 30 kg recyclable at 20¢/kg −10% = 540¢; 10 kg other +25% = 250¢; total 790¢ (got 790, want 790)
PASS modulation: no rate → no estimate, still null
PASS format units: 1,000,000 mg = 1.000 kg = 2.205 lb = 0.001000 t = 1000 g (got array (
0 => '1.000',
1 => '2.205',
2 => '0.001000',
3 => '1000',
), want array (
0 => '1.000',
1 => '2.205',
2 => '0.001000',
3 => '1000',
))
PASS format CSV: semicolon separator, a separator inside a value is quoted, a formula is neutralized (got 'Material;Weight
"Paper; board";1.5
\'=HYPERLINK();2
', want 'Material;Weight
"Paper; board";1.5
\'=HYPERLINK();2
')
PASS components: the recycled % is saved, clamped to 0–100 (got array (
0 => 60,
1 => 100,
), want array (
0 => 60,
1 => 100,
))
PASS SKU page: recyclable 75% · recycled content 45% by weight (30 g board at 60%, 10 g film)
PASS register: the state and year shares follow the units sold
PASS registration: a state sold into with nothing recorded is a gap; one with no sales is not
PASS registration: recorded → no gap; renewal within 60 days is listed (got array (
0 =>
array (
),
1 =>
array (
0 => 'OR',
),
), want array (
0 =>
array (
),
1 =>
array (
0 => 'OR',
),
))
PASS registration: a bad date is refused and nothing changes
PASS registration: the dashboard lists the renewal due
PASS estimate: board 30 kg at 20¢ −10% = 540¢, film 10 kg at 50¢ +25% = 625¢, total 1165¢ against 1100¢ flat (got array (
0 => 1165,
1 => 1100,
2 => true,
), want array (
0 => 1165,
1 => 1100,
2 => true,
))
PASS fees page: the modulated line shows the flat fee and both adjustments
PASS format: saved per state; a field the level does not have is dropped; an empty header takes the field's name
PASS format export: the PRO layout, pounds to 2 decimals, semicolons, the modulated fee (got 'Material Category;Total Weight (lbs);Fee estimate ($)
Board;66.14;5.40
Film;22.05;6.25
', want 'Material Category;Total Weight (lbs);Fee estimate ($)
Board;66.14;5.40
Film;22.05;6.25
')
PASS format export: rows per SKU component, tab-separated (got 'Item Part Qty g/unit PCR %
B-1 Box 1000 30 60
B-1 Wrap 1000 10 0
', want 'Item Part Qty g/unit PCR %
B-1 Box 1000 30 60
B-1 Wrap 1000 10 0
')
PASS the disclaimer's bold lead-in uses the ink color, not the on-accent color
── customer voice: normalized state names, and weights that look like the wrong unit ──
PASS state names as typed map to their code (got array (
0 => 'CA',
1 => 'CA',
2 => 'CA',
3 => 'CA',
4 => 'CA',
5 => 'NY',
6 => 'NY',
7 => 'WA',
8 => 'DC',
9 => 'DC',
10 => 'TX',
), want array (
0 => 'CA',
1 => 'CA',
2 => 'CA',
3 => 'CA',
4 => 'CA',
5 => 'NY',
6 => 'NY',
7 => 'WA',
8 => 'DC',
9 => 'DC',
10 => 'TX',
))
PASS ...and an unrecognized code stays as typed (upper-cased), so an owner's own code still matches (got array (
0 => 'XQ9',
1 => '',
), want array (
0 => 'XQ9',
1 => '',
))
PASS weights: 20 kg or more per unit, and under 1 g, are flagged; ordinary weights are not (got array (
0 => true,
1 => true,
2 => true,
3 => true,
4 => true,
), want array (
0 => true,
1 => true,
2 => true,
3 => true,
4 => true,
))
PASS ...the message names the kilograms
PASS units import: "Calif.", "california", "N.Y." and "Cal" land on CA and NY; the second California line updates the first
PASS component import: a 0.54 g carton and a 25 kg wrap are imported as given but listed to check; a 12 g insert is not
PASS the dashboard lists the weight to check, and the SKU page marks that component only
── customer voice round 4: fee per unit sold ──
PASS per unit: dollars to a tenth of a cent; no units, no figure (got array (
0 => '$0.050',
1 => '$0.100',
2 => '$0.003',
3 => NULL,
), want array (
0 => '$0.050',
1 => '$0.100',
2 => '$0.003',
3 => NULL,
))
PASS every state side by side, highest fee per unit first: CO $0.100, OR $0.050 (units of SKUs on file only), CA with no rate has nothing per unit (got array (
0 =>
array (
0 => 'CO',
1 => 500,
2 => 5000,
3 => '$0.100',
4 => 0,
),
1 =>
array (
0 => 'OR',
1 => 1000,
2 => 5000,
3 => '$0.050',
4 => 0,
),
2 =>
array (
0 => 'CA',
1 => 200,
2 => 0,
3 => NULL,
4 => 1,
),
), want array (
0 =>
array (
0 => 'CO',
1 => 500,
2 => 5000,
3 => '$0.100',
4 => 0,
),
1 =>
array (
0 => 'OR',
1 => 1000,
2 => 5000,
3 => '$0.050',
4 => 0,
),
2 =>
array (
0 => 'CA',
1 => 200,
2 => 0,
3 => NULL,
4 => 1,
),
))
PASS the fee estimator shows the estimate per unit sold and the table of every state, with missing rates marked
== 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 Packora 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 Packora 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: under the standard keep count (14) every backup is 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 Packora, 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)
505/505 PASS, 0 FAIL
← Back to Packora · Manual · Quickstart · API