Cargora · Test run · run 2026-09-25
Cargora Test run, as shipped in the download
455 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.
== 1. moneyToCents: US, EU, symbols, negatives, raw numbers ==
PASS US "$1,234.56" (got 123456, want 123456)
PASS EU "1.234,56 €" (got 123456, want 123456)
PASS plain "18.00" (got 1800, want 1800)
PASS thousands "1,234" (got 123400, want 123400)
PASS EU decimal "12,34" (got 1234, want 1234)
PASS "$0.85" (got 85, want 85)
PASS parens negative "(45.00)" (got -4500, want -4500)
PASS float 3.5 (got 350, want 350)
PASS int 18 (major units) (got 1800, want 1800)
PASS empty -> null (got NULL, want NULL)
PASS "N/A" -> null (got NULL, want NULL)
== 2. centsToDecimal round-trips ==
PASS 42000 -> "420.00" (got '420.00', want '420.00')
PASS 85 -> "0.85" (got '0.85', want '0.85')
PASS -4500 -> "-45.00" (got '-45.00', want '-45.00')
PASS null -> "" (got '', want '')
== 3. qtyToNum + qtyToString ==
PASS "120" -> 120.0 (got 120.0, want 120.0)
PASS "1,000" -> 1000.0 (got 1000.0, want 1000.0)
PASS "2.5" -> 2.5 (got 2.5, want 2.5)
PASS "3 units" -> 3.0 (got 3.0, want 3.0)
PASS "" -> null (got NULL, want NULL)
PASS 120.0 -> "120" (got '120', want '120')
PASS 2.5 -> "2.5" (got '2.5', want '2.5')
PASS null -> "" (got '', want '')
== 4. normalizeDate ==
PASS ISO passthrough (got '2026-02-10', want '2026-02-10')
PASS US m/d/Y (default) (got '2026-02-10', want '2026-02-10')
PASS d/m/Y with dayFirst (got '2026-02-10', want '2026-02-10')
PASS textual "Feb 10, 2026" (got '2026-02-10', want '2026-02-10')
PASS impossible -> null (got NULL, want NULL)
PASS garbage -> null (got NULL, want NULL)
== 5. currencyCode + docType coercion + labels ==
PASS code "usd" (got 'USD', want 'USD')
PASS symbol "€" (got 'EUR', want 'EUR')
PASS empty -> "" (got '', want '')
PASS "purchase order" -> po (got 'po', want 'po')
PASS "packing slip" -> packing_slip (got 'packing_slip', want 'packing_slip')
PASS "delivery note" -> delivery_note (got 'delivery_note', want 'delivery_note')
PASS "despatch" -> delivery_note (got 'delivery_note', want 'delivery_note')
PASS unknown falls back to default (got 'packing_slip', want 'packing_slip')
PASS label po (got 'Purchase Order', want 'Purchase Order')
== 6. line-total computation + mismatch flagging ==
PASS 120 x 350 = 42000 (got 42000, want 42000)
PASS 2.5 x 620 = 1550 (got 1550, want 1550)
PASS missing qty -> null (got NULL, want NULL)
PASS clean row not flagged
PASS wrong printed total IS flagged
PASS missing total -> nothing to flag
== 7. status transitions ==
PASS pending + extract -> extracted (got 'extracted', want 'extracted')
PASS extracted + save -> reviewed (got 'reviewed', want 'reviewed')
PASS reviewed + save stays reviewed (got 'reviewed', want 'reviewed')
PASS any + fail -> failed (got 'failed', want 'failed')
PASS unknown event keeps status (got 'reviewed', want 'reviewed')
PASS unknown status defaults pending (got 'pending', want 'pending')
== 8. normalize: raw LLM JSON -> canonical document (header + line items) ==
PASS doc_type via "purchase order" (got 'po', want 'po')
PASS ref via "po_number" (got 'PO-2026-0142', want 'PO-2026-0142')
PASS supplier via "vendor" (got 'Northwind Wholesale Ltd', want 'Northwind Wholesale Ltd')
PASS ship_to via "consignee" (got 'Harbor Point Warehouse', want 'Harbor Point Warehouse')
PASS date normalized (got '2026-02-10', want '2026-02-10')
PASS currency from "$" (got 'USD', want 'USD')
PASS two line items (got 2, want 2)
PASS line 1 sku (got 'NW-4471', want 'NW-4471')
PASS line 1 qty (got 120.0, want 120.0)
PASS line 1 unit cents (got 350, want 350)
PASS line 1 total cents (got 42000, want 42000)
PASS line 2 sku via "sku" (got 'NW-2203', want 'NW-2203')
PASS forceType overrides guess (got 'packing_slip', want 'packing_slip')
PASS empty raw -> [] items (got array (
), want array (
))
PASS empty raw -> default po type (got 'po', want 'po')
== 9. Reconcile::normalizeSku (trim / case-insensitive / whitespace) ==
PASS trim + upper (got 'NW-4471', want 'NW-4471')
PASS case-insensitive (got 'NW-4471', want 'NW-4471')
PASS spaces ignored entirely (got 'ABC123', want 'ABC123')
PASS "AB 12" and "AB12" are one SKU (got 'AB12', want 'AB12')
PASS a tab or non-breaking run inside a code is ignored too (got 'AB12', want 'AB12')
PASS punctuation still counts: AB-12 is not AB12
PASS a PO line "WID 100" and a slip line "wid100" reconcile as one clean line, not missing + unexpected
PASS empty -> "" (got '', want '')
== 10. Reconcile::aggregate (per-SKU rollup, dupes summed) ==
PASS one aggregated SKU (empty skipped) (got 1, want 1)
PASS dupe quantities summed (got 15.0, want 15.0)
PASS first unit price kept (got 100, want 100)
PASS display SKU preserved (got 'NW-1', want 'NW-1')
== 11. reconcile: qty deltas, price mismatch, missing + extra ==
PASS 2 matched SKUs (got 2, want 2)
PASS 1 missing on slip (got 1, want 1)
PASS 1 extra on slip (got 1, want 1)
PASS 1 qty mismatch (got 1, want 1)
PASS 1 price mismatch (got 1, want 1)
PASS not clean
PASS total discrepancies (got 4, want 4)
PASS NW-4471 matched despite case
PASS NW-4471 qty delta -30 (got -30.0, want -30.0)
PASS NW-4471 qty status short (got 'short', want 'short')
PASS NW-4471 price matches
PASS NW-8890 qty status match (got 'match', want 'match')
PASS NW-8890 price status mismatch (got 'mismatch', want 'mismatch')
PASS missing SKU is NW-2203 (got 'NW-2203', want 'NW-2203')
PASS extra SKU is NW-7001 (got 'NW-7001', want 'NW-7001')
== 12. reconcile: identical documents are clean ==
PASS identical docs reconcile clean
PASS all matched (got 3, want 3)
PASS zero discrepancies (got 0, want 0)
PASS over-ship delta +2 (got 2.0, want 2.0)
PASS over-ship status over (got 'over', want 'over')
== 13. document CSV / JSON export ==
PASS CSV has header row
PASS CSV quotes comma+quote
PASS CSV flags mismatch OK/MISMATCH
PASS CSV CRLF endings
PASS JSON decodes
PASS JSON doc_type (got 'po', want 'po')
PASS JSON line1 unit price decimal (got '3.50', want '3.50')
PASS JSON line1 computed total (got '420.00', want '420.00')
PASS JSON line1 arithmetic ok
PASS JSON line2 arithmetic not ok
== 14. reconciliation CSV export ==
PASS recon CSV header line
PASS recon CSV section col
PASS recon CSV has matched
PASS recon CSV has missing
PASS recon CSV has extra
PASS recon CSV CRLF
== 15. CSV formula-injection neutralization (document + reconciliation) ==
PASS neutralizes leading '='
PASS neutralizes leading '@'
PASS neutralizes leading '+'
PASS neutralizes '=' in SKU (quoted)
PASS neutralizes leading TAB
PASS negative money NOT mangled (-45.00 kept)
PASS recon CSV neutralizes '=' SKU
PASS recon CSV neutralizes '@' desc
== 16. oversize field / value clamping (no MySQL strict-mode 500) ==
PASS ref_number capped to 190 (got 190, want 190)
PASS supplier capped to 255 (got 255, want 255)
PASS sku capped to 190 (got 190, want 190)
PASS clip() no-op under limit (got 'short', want 'short')
PASS huge money clamps to +2e9
PASS huge negative clamps to -2e9
PASS normal money unaffected by clamp (got 1800, want 1800)
== AI model: current default, retired ids replaced, a refused model named ==
PASS the Anthropic default is the id Anthropic names as the replacement (read 2026-09-24) (got 'claude-haiku-4-5-20251001', want 'claude-haiku-4-5-20251001')
PASS the OpenAI and Ollama defaults are unchanged (got array (
0 => 'gpt-4o-mini',
1 => 'llama3.2-vision',
), want array (
0 => 'gpt-4o-mini',
1 => 'llama3.2-vision',
))
PASS a saved claude-3-haiku-20240307 (the old default) is replaced (got 'claude-haiku-4-5-20251001', want 'claude-haiku-4-5-20251001')
PASS a saved claude-3-haiku-20240307 becomes claude-haiku-4-5-20251001 (got 'claude-haiku-4-5-20251001', want 'claude-haiku-4-5-20251001')
PASS a saved claude-3-5-sonnet becomes claude-sonnet-4-6 (got 'claude-sonnet-4-6', want 'claude-sonnet-4-6')
PASS a current id is left as the owner typed it (got 'claude-sonnet-5', want 'claude-sonnet-5')
PASS claude-opus-4-5 is current and not caught by the retired opus-4-0/4-1 entries (got 'claude-opus-4-5-20251101', want 'claude-opus-4-5-20251101')
PASS an empty model falls back to the provider default (got 'claude-haiku-4-5-20251001', want 'claude-haiku-4-5-20251001')
PASS OpenAI ids are never rewritten (got 'gpt-4.1-mini', want 'gpt-4.1-mini')
PASS Anthropic refusing a model: the message names it, Settings and the current default
PASS OpenAI model_not_found is named the same way
PASS Ollama without the model: the message says to pull it
PASS any other provider error keeps the provider text (got 'LLM API error: invalid x-api-key', want 'LLM API error: invalid x-api-key')
PASS a reply that opens with a thinking block still yields its text (got '{"a":1}', want '{"a":1}')
PASS a refusal gets a plain message, not a JSON parse error
PASS every provider call resolves its model through resolveModel (no inline default left)
PASS provider errors pass through apiErrorMessage with the provider and model
PASS the Anthropic reply is read through anthropicText, not content[0]
PASS no retired Claude 3 id is offered by the installer, Settings or the settings save
PASS installer and settings save both resolve the model through Llm (retired ids stored as the replacement)
PASS Settings reads the one default list and says when a saved model was retired
PASS truncated (openai/ollama length) -> clear error
PASS truncated (anthropic max_tokens) -> the same error
PASS the error names the 8192-token ceiling and says to split
PASS normal stops -> no error
PASS every provider path guards truncation before parsing, at one ceiling
PASS the upload page says, on the public demo only, that extraction there is a canned sample
== Load demo data keeps the owner's settings ==
PASS the seed check ran cleanly (no .demo-mode marker in the tree)
PASS loading demo data on a real install leaves the owner's AI provider, model, company name and currency as they were
PASS ...and still loads the demo records
== Invitations by email through your own SMTP ==
PASS the invite check ran cleanly
PASS the email names the organisation, the app, the role, the link and the 7-day expiry
PASS a line break in the organisation name cannot add a mail header
PASS with no SMTP server set, the box is not offered
PASS ...and a send is refused with a reason, not attempted
PASS with SMTP set, the box is offered
PASS an unreachable mail server comes back as a reported failure, never an exception
PASS a real send goes out through the owner's SMTP server
PASS the invitee's message carries their address, the link and the expiry
PASS the invite handler emails only when "Email the link" is ticked, and still shows the link once
PASS the public demo never sends an invitation
PASS the invite form offers "Email the link", ticked only when SMTP is set
== Backups and restore are for an administrator only ==
PASS the backup guard check ran cleanly
PASS a signed-in viewer is refused backup_json (403)
PASS a signed-in member is refused backup_json (403)
PASS a signed-in viewer is refused backup_sqlite (403)
PASS a signed-in member is refused backup_sqlite (403)
PASS a signed-in viewer is refused restore_page (403)
PASS a signed-in member is refused restore_page (403)
PASS a signed-in viewer is refused restore_preview (403)
PASS a signed-in member is refused restore_preview (403)
PASS a signed-in viewer is refused restore_commit (403)
PASS a signed-in member is refused restore_commit (403)
PASS a signed-in viewer is refused backup_token_mint (403)
PASS a signed-in member is refused backup_token_mint (403)
PASS an administrator still gets the backup download
PASS the scheduled backup answers to its token only, even for a signed-in administrator
PASS the Security page shows the Backups card only to someone allowed to download it
== 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: review_cols
PASS v2: settings_singleton_exists_after_migrate
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: reconcile_engine
PASS v2: summary_passthrough
PASS v2: has_discrepancies_from_engine
PASS v2: discrepant_lines
PASS v2: clean_pair_is_clean
PASS v2: status_machine
PASS v2: serializers_clean
PASS v2: routes registered (api, 2fa, security, backup, healthz)
PASS v2: migrate() runs on boot
PASS v2: cli-server base guard
PASS v2: login is rate-limited and 2FA intercepts it
PASS v2: both extraction paths announce document.extracted
PASS v2: creating a reconciliation announces discrepancies
PASS v2: the API reuses Reconcile:: (no second discrepancy engine)
PASS v2: review reuses the product status machine
PASS v2: document status filter uses the product list, not a hard-coded one
PASS v2: a clean reconciliation stays silent
PASS v2: notifications never break the request
PASS v2: upload staying UI-only is documented in the code
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 keeps ghost/danger buttons legible (kit truth: they read --ink/--bad, which flip with the theme, and branding only moves accent variables — it never paints a button colour)
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 denying real .sqlite files
PASS v2: dev router denies .sqlite FILES but not the backup route
PASS v2: API.md ships (the sw cache version is asserted in the 3.0 block)
PASS the 2.0 version stamps were carried forward, not left behind
== Own It 3.0: completeness engine (pure, offline) ==
PASS received is cumulative across slips (90 + 20) (got 110.0, want 110.0)
PASS remaining is ordered minus received (got 10.0, want 10.0)
PASS a part-received line is "outstanding" (got 'outstanding', want 'outstanding')
PASS a fully-received line is "complete" (got 'complete', want 'complete')
PASS an undelivered line is "not_received" (got 'not_received', want 'not_received')
PASS an undelivered line still owes the full qty (got 500.0, want 500.0)
PASS SKU matching stays case-insensitive across documents (nw-4471 == NW-4471)
PASS each delivery is named with what it brought
PASS a price gap is attributed to the delivery that charged it, not averaged
PASS delivered-but-never-ordered is reported with its slip
PASS the order is not finished while anything is outstanding
PASS an over-delivery leaves nothing outstanding (got 0.0, want 0.0)
PASS the surplus is reported separately (got 5.0, want 5.0)
PASS and the line reads "over" (got 'over', want 'over')
PASS the order closes once every ordered line has landed
PASS an over-delivery still leaves the order un-clean
PASS a PO with no deliveries owes everything
PASS a PO delivered exactly once, in full, is clean
PASS completeness leaves the pairwise report untouched (both still answer their own question)
== Own It 3.0: MCP · roles · audit · branding · restore · backup redaction ==
PASS migrate() creates audit_log + invites + saved_views
PASS users gain role; settings gain branding, SSO and backup-token columns
PASS install() ALONE produces the current schema (no migrate() recursion)
PASS migrate() creates the settings singleton row
PASS so "UPDATE settings ... WHERE id = 1" is no longer a silent no-op
PASS UPGRADE PATH: drop every v3 table, migrate, they come back
PASS a member uploads, extracts, corrects lines and reconciles
PASS but a member cannot reach the AI key or delete a document
PASS a viewer reads documents and reconciliations and cannot edit
PASS a viewer canNOT read the audit trail or the team roster
PASS an admin holds the API key and the deletions
PASS AN UPGRADE NEVER LOOSENS: the existing user stays an admin
PASS the last admin cannot be demoted into a lockout
PASS received is summed across every slip matched to the PO
PASS and what is still outstanding per line
PASS SKUs match across documents regardless of case
PASS a line delivered in full reads "complete"
PASS a line never delivered reads "not received"
PASS a SKU delivered but never ordered is named with its slip
PASS a price gap names the DELIVERY that charged it
PASS the PO reads outstanding while anything is missing
PASS **RECONCILING THE SAME SLIP TWICE DOES NOT RECEIVE IT TWICE**
PASS an over-delivery reports a surplus, never a negative balance
PASS the PO closes when every ordered line has arrived
PASS MCP exposes exactly the row's six tools, in order
PASS there is deliberately NO extraction tool (it spends the buyer's AI credits)
PASS every tool description declares READ-ONLY or WRITES
PASS GET /mcp is refused with 405, not 404
PASS a nested argument that is not allowlisted is refused
PASS an unknown argument is refused BY NAME
PASS a value outside the enum is refused
PASS **match_status EQUALS Reconcile::completeness() — never re-derived**
PASS and the per-slip answer equals Reconcile::reconcile()
PASS asking for the completeness of a packing slip is refused, not guessed
PASS discrepancies can be narrowed to price gaps
PASS line_item_update recomputes the total, as the edit form does
PASS and an agent correcting a quantity lands in the audit trail
PASS an explicitly given line total is NOT silently recomputed
PASS export_rows returns the product's own CSV, byte for byte
PASS naming both a document and a reconciliation is refused
PASS a reconciliation exports through Reconcile::toCsv()
PASS MCP role gate matches the UI: a viewer key cannot edit
PASS and a viewer key can still read
PASS the match report renders as a real PDF
PASS inkOn() clears 4.5:1 on both a very light and a very dark accent
PASS a PHP script named .png is refused
PASS another product's backup is refused by name stamp
PASS **THE SHARED BACKUP DOES NOT CARRY THE LLM API KEY**
PASS nor any bcrypt password hash
PASS nor any API-key hash
PASS nor the SMTP password or the SSO client secret
PASS while still carrying the documents themselves
PASS restoring a redacted backup KEEPS the live key rather than blanking it
PASS old scheduled backups are pruned
PASS P8 N/A is a column fact: no table carries a data-subject key
PASS OIDC refuses alg=none
PASS mutations write audit entries
PASS the trail exports as CSV
PASS a product-declared secret key is redacted at write time
PASS and the SSO save never puts the secret in the payload at all
== Own It 3.0: wiring (static) ==
Warning: preg_replace(): Unknown modifier '[' in tests/run.php on line 630
Warning: preg_replace(): Unknown modifier '[' in tests/run.php on line 630
Warning: preg_replace(): Unknown modifier '[' in tests/run.php on line 630
Warning: preg_replace(): Unknown modifier '[' in tests/run.php on line 630
Warning: preg_replace(): Unknown modifier '[' in tests/run.php on line 630
PASS POST and GET /mcp are both routed
PASS src includes are idempotent (a shared class cannot be declared twice)
PASS install() reaches the v3 schema without recursing through migrate()
PASS the bulk literals are registered BEFORE /documents/{id}
PASS there is ONE line-item write path, and the form, the API and MCP all use it
PASS the MCP layer owns no line-item SQL of its own
PASS the MCP layer computes no matching of its own — it calls Reconcile::
PASS completeness dedupes slips by document before summing
PASS bulk actions loop the single-item paths inside one transaction
PASS no bulk action mass-updates rows behind the product's back
PASS destructive + spending actions are role-gated
PASS the API surface is role-gated too
PASS mutations write audit entries
PASS the shared backup redaction list covers the LLM key and every credential
PASS BOTH the download and the scheduled backup redact through that one list
PASS no backup path is left with the 2.0 smtp-only list
PASS the PDF footer cannot outgrow the page (company clipped, fixed text short)
PASS filled controls take their ink from the branding contrast helper, never a hardcoded white (kit truth: .btn reads --accent-ink, and the branding block sets BOTH ink variables from Branding::inkOn)
PASS the uploaded logo is actually RENDERED in the layout (not merely stored)
PASS the branding accent overrides BOTH themes (dark re-declares --accent and would win)
PASS P5 is reachable: the documents list really exposes checkboxes, bulk actions and saved views
PASS the completeness view is reachable from the navigation
PASS src/Mcp.php is byte-identical to the shared core copy of Mcp.php
PASS src/AuditLog.php is byte-identical to the shared core copy of AuditLog.php
PASS src/Pdf.php is byte-identical to the shared core copy of Pdf.php
PASS src/BackupRestore.php is byte-identical to the shared core copy of BackupRestore.php
PASS src/Branding.php is byte-identical to the shared core copy of Branding.php
PASS src/RolesKit.php is byte-identical to the shared core copy of RolesKit.php
PASS src/Oidc.php is byte-identical to the shared core copy of Oidc.php
PASS src/Gdpr.php is byte-identical to the shared core copy of Gdpr.php
PASS src/Ical.php is byte-identical to the shared core copy of Ical.php
PASS every /api-settings handler is role-gated, not merely logged-in
PASS creating or deleting a webhook is ADMIN-only (it is an off-box data feed)
PASS revoking an API key is ADMIN-only (it takes every integration down)
PASS the settings page itself is role-gated, not merely logged-in
PASS the nav never links a role to a page it will be refused
PASS every /api/* WRITE route role-checks the key, not just its existence
PASS the roles map keeps admin able to administer, and keeps settings.manage away from the rest
PASS an MCP tool refusal THROWS McpRefusal — never a result the agent reads as success
PASS wiping the register is admin-only, not merely logged-in
PASS a switched-off person's API key stops working (u.active in requireKey)
PASS the shared line-item write path refuses a negative or absurd quantity
PASS computeLineTotalCents refuses to overflow instead of wrapping to a negative
PASS all three write paths surface the refusal instead of 500ing
PASS sw.js cache is static-v3.1.5
PASS healthz + api_ping + openapi report 3.1.5
PASS API.md documents the MCP endpoint AND why extraction is absent
PASS README documents "New in 3.0"
PASS README states the GDPR position rather than leaving it unsaid
PASS the deploy kit is filled in
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)
== UI kit: resolving a disputed line + the kit layout ==
PASS resolve: the engine sees two disputed lines to begin with (got 2, want 2)
PASS resolve: case and stray spacing do not split one SKU into two (normalization folds those; punctuation is deliberately significant)
PASS resolve/accept_delivery: the short line now agrees (got 0, want 0)
PASS resolve/accept_delivery: the PO line really holds the delivered figure (got 7.0, want 7.0)
PASS resolve/accept_delivery: the line total was recomputed, not left stale (got 3500, want 3500)
PASS resolve/accept_delivery: the delivery document was NOT touched (got 7.0, want 7.0)
PASS resolve/accept_order: the price disagreement is gone (got 0, want 0)
PASS resolve/accept_order: the whole report is now clean
PASS resolve: a negative quantity is refused by the shared write path (a delivery of minus five is not a correction)
PASS resolve: the refused write left the line untouched (got 2.0, want 2.0)
PASS resolve: an absurd quantity is refused too (it once overflowed the line total to a negative)
PASS resolve: POST /reconcile/{id}/resolve is registered
PASS resolve: the endpoint requires documents.edit, checks CSRF and refuses an unknown action
PASS resolve: it writes ONLY through the shared validated patch — no raw UPDATE of its own
PASS resolve: a line present on only one document is refused (409), never silently invented
PASS resolve: the act is audited against the reconciliation
PASS kit: layout loads kit css+js, groups the nav with icons + g-keys, carries the theme toggle and a who-line
PASS kit: no theme.js; app css is an app layer with a dark accent; the pre-kit file is gone
PASS kit: App::asset() versions by mtime and the service worker revalidates code
PASS kit: nothing is fetched from another host
PASS dashboard: the first-run checklist is computed from the database, not the session
PASS dashboard: the disputed-line count is recomputed by the engine, never read from a stale column
PASS dashboard: KPI sparkline and needs-your-action are wired
PASS hero: the three-column match renders ordered / delivered / difference with inline resolve buttons
PASS record: the document page carries a sticky source viewer and a rail
PASS documents list: tabs carry counts and rows link to the record
== v3.1: importable export presets ==
PASS preset goods_received: header is row 1, exactly as documented (got 'PO reference,Delivery reference,Supplier,Delivery date,SKU,Description,Qty received,Unit price,Line total,Currency', want 'PO reference,Delivery reference,Supplier,Delivery date,SKU,Description,Qty received,Unit price,Line total,Currency')
PASS preset goods_received: one row per line ACTUALLY delivered (2 matched + 1 unexpected) (got 3, want 3)
PASS preset goods_received: a line ordered and never delivered is not a receiving line
PASS preset goods_received: the delivered quantity and its line total are priced from the agreed unit
PASS preset discrepancies: header is row 1, exactly as documented (got 'PO reference,Delivery reference,Supplier,SKU,Description,Issue,Qty ordered,Qty delivered,Qty difference,Unit ordered,Unit delivered,Value difference,Currency', want 'PO reference,Delivery reference,Supplier,SKU,Description,Issue,Qty ordered,Qty delivered,Qty difference,Unit ordered,Unit delivered,Value difference,Currency')
PASS preset discrepancies: only the lines that disagree (short, price, missing, unexpected) (got 4, want 4)
PASS preset discrepancies: a short delivery says so, with the money actually at stake
PASS preset discrepancies: a pure OVERCHARGE carries its real value (4 x 12.50 vs 4 x 10.00 = +10.00)
PASS preset: value difference is delivered value minus ordered value, both variances at once (got 2000, want 2000)
PASS preset: a short delivery at the agreed price is the value that never arrived (got -1500, want -1500)
PASS preset discrepancies: a price-only difference is named as one, not as a quantity problem
PASS preset discrepancies: nothing that matched cleanly appears in the claim list
PASS preset flat: header is row 1, exactly as documented (got 'PO reference,Delivery reference,Supplier,SKU,Description,State,Qty ordered,Qty delivered,Qty difference,Unit ordered,Unit delivered,Qty status,Price status,Currency', want 'PO reference,Delivery reference,Supplier,SKU,Description,State,Qty ordered,Qty delivered,Qty difference,Unit ordered,Unit delivered,Qty status,Price status,Currency')
PASS preset flat: every compared line appears exactly once (2 matched + 1 missing + 1 extra) (got 4, want 4)
PASS preset flat: no preamble — an import reads row 1 as the header
PASS preset: the claim list is exactly as long as the engine's discrepancy count
PASS preset: the flat file is exactly matched + not-delivered + not-ordered
PASS preset: an unknown id falls back to flat, never to an empty or headerless file (got 'PO reference,Delivery reference,Supplier,SKU,Description,State,Qty ordered,Qty delivered,Qty difference,Unit ordered,Unit delivered,Qty status,Price status,Currency', want 'PO reference,Delivery reference,Supplier,SKU,Description,State,Qty ordered,Qty delivered,Qty difference,Unit ordered,Unit delivered,Qty status,Price status,Currency')
PASS preset: isPreset() refuses anything not offered
PASS preset: a formula-looking SKU is neutralised by the shared CSV guard
PASS preset: money is a bare decimal — no currency symbol inside a numeric cell
PASS preset: a missing price stays EMPTY rather than becoming a fabricated 0.00 (got NULL, want NULL)
PASS preset: exporting twice produces identical bytes (got 'PO reference,Delivery reference,Supplier,SKU,Description,Issue,Qty ordered,Qty delivered,Qty difference,Unit ordered,Unit delivered,Value difference,Currency
PO-1,SL-9,Northwind,NW-1,Bracket,short delivery,10,7,-3,5.00,5.00,-15.00,USD
PO-1,SL-9,Northwind,NW-2,Strap,price differs,4,4,0,10.00,12.50,10.00,USD
PO-1,SL-9,Northwind,NW-3,Pallet,"ordered, not delivered",2,0,-2,3.00,,-6.00,USD
PO-1,SL-9,Northwind,NW-9,Filler,"delivered, not ordered",0,6,6,,0.85,5.10,USD
', want 'PO reference,Delivery reference,Supplier,SKU,Description,Issue,Qty ordered,Qty delivered,Qty difference,Unit ordered,Unit delivered,Value difference,Currency
PO-1,SL-9,Northwind,NW-1,Bracket,short delivery,10,7,-3,5.00,5.00,-15.00,USD
PO-1,SL-9,Northwind,NW-2,Strap,price differs,4,4,0,10.00,12.50,10.00,USD
PO-1,SL-9,Northwind,NW-3,Pallet,"ordered, not delivered",2,0,-2,3.00,,-6.00,USD
PO-1,SL-9,Northwind,NW-9,Filler,"delivered, not ordered",0,6,6,,0.85,5.10,USD
')
PASS preset: exporting never mutates the report it was given (got array (
'matched' =>
array (
0 =>
array (
'sku' => 'NW-1',
'norm' => 'NW-1',
'description' => 'Bracket',
'po_qty' => 10.0,
'slip_qty' => 7.0,
'qty_delta' => -3.0,
'qty_match' => false,
'qty_status' => 'short',
'po_unit_cents' => 500,
'slip_unit_cents' => 500,
'price_match' => true,
'price_status' => 'match',
'discrepancy' => true,
),
1 =>
array (
'sku' => 'NW-2',
'norm' => 'NW-2',
'description' => 'Strap',
'po_qty' => 4.0,
'slip_qty' => 4.0,
'qty_delta' => 0.0,
'qty_match' => true,
'qty_status' => 'match',
'po_unit_cents' => 1000,
'slip_unit_cents' => 1250,
'price_match' => false,
'price_status' => 'mismatch',
'discrepancy' => true,
),
),
'missing_on_slip' =>
array (
0 =>
array (
'sku' => 'NW-3',
'norm' => 'NW-3',
'description' => 'Pallet',
'qty' => 2.0,
'unit_cents' => 300,
),
),
'extra_on_slip' =>
array (
0 =>
array (
'sku' => 'NW-9',
'norm' => 'NW-9',
'description' => 'Filler',
'qty' => 6.0,
'unit_cents' => 85,
),
),
'summary' =>
array (
'po_skus' => 3,
'slip_skus' => 3,
'matched' => 2,
'missing_on_slip' => 1,
'extra_on_slip' => 1,
'qty_mismatches' => 1,
'price_mismatches' => 1,
'discrepancies' => 4,
'clean' => false,
),
), want array (
'matched' =>
array (
0 =>
array (
'sku' => 'NW-1',
'norm' => 'NW-1',
'description' => 'Bracket',
'po_qty' => 10.0,
'slip_qty' => 7.0,
'qty_delta' => -3.0,
'qty_match' => false,
'qty_status' => 'short',
'po_unit_cents' => 500,
'slip_unit_cents' => 500,
'price_match' => true,
'price_status' => 'match',
'discrepancy' => true,
),
1 =>
array (
'sku' => 'NW-2',
'norm' => 'NW-2',
'description' => 'Strap',
'po_qty' => 4.0,
'slip_qty' => 4.0,
'qty_delta' => 0.0,
'qty_match' => true,
'qty_status' => 'match',
'po_unit_cents' => 1000,
'slip_unit_cents' => 1250,
'price_match' => false,
'price_status' => 'mismatch',
'discrepancy' => true,
),
),
'missing_on_slip' =>
array (
0 =>
array (
'sku' => 'NW-3',
'norm' => 'NW-3',
'description' => 'Pallet',
'qty' => 2.0,
'unit_cents' => 300,
),
),
'extra_on_slip' =>
array (
0 =>
array (
'sku' => 'NW-9',
'norm' => 'NW-9',
'description' => 'Filler',
'qty' => 6.0,
'unit_cents' => 85,
),
),
'summary' =>
array (
'po_skus' => 3,
'slip_skus' => 3,
'matched' => 2,
'missing_on_slip' => 1,
'extra_on_slip' => 1,
'qty_mismatches' => 1,
'price_mismatches' => 1,
'discrepancies' => 4,
'clean' => false,
),
))
PASS preset: the export route still requires a login
PASS preset: ?preset= selects a shape and no preset keeps the original report (no route renamed, nothing dropped)
PASS preset: every offered preset is documented in the README with its exact columns
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
-- F2b: the demo card, the webhook buttons and events, the JSON backup --
PASS demo card: /documents keeps the admin wording
PASS demo card: /reconcile keeps the admin wording
PASS demo card: /login keeps the admin wording
PASS demo card: below 600px the script starts it as the small "Own it" pill unless the visitor opened it this session
PASS demo card: output without </body> (JSON, CSV, a PDF) passes through untouched
PASS forms: every POST form on a screen reaches a registered route (32 checked)
PASS webhooks: the Settings page answers and lists every event it offers
PASS webhooks: the Add, Pause, Resume and Delete buttons each reach a route and do what they say
PASS webhooks: Delete also removes that webhook's delivery log, as its confirmation says
PASS webhooks: a paused webhook is sent nothing
PASS webhooks: document.extracted is sent by the action where it happens [302]
PASS webhooks: discrepancy.found is sent by the action where it happens [302]
PASS webhooks: every event the Settings page offers is sent somewhere
PASS backup redaction: a credential was planted in every credential column (10)
PASS backup redaction: /backup.json and the scheduled backup both use the one list, CG_REDACT
PASS backup redaction: /backup.json (downloaded by an administrator) carries none of the planted credentials
PASS backup redaction: /backup.json (downloaded by an administrator) shows [REDACTED] in each credential column and leaves none unredacted
PASS backup redaction: the scheduled backup file carries none of the planted credentials
PASS backup redaction: the scheduled backup file shows [REDACTED] in each credential column and leaves none unredacted
PASS backup redaction: the administrator got the download (HTTP 200)
————————————————————————————————————————————————————
PASS reconcile: JSON refusals carry their real HTTP status via App::json($data, $code) — a bare http_response_code() before App::json() is silently reset to 200 (found 2026-08-20)
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
ALL 455 TESTS PASSED (0 failed)
← Back to Cargora · Manual · Quickstart · API