Ownware
Home›Cargora›Test run
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

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →