Extracta · Test run · run 2026-09-25
Extracta Test run, as shipped in the download
294 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 "1234.5" (got 123450, want 123450)
PASS thousands "1,234" (got 123400, want 123400)
PASS EU decimal "12,34" (got 1234, want 1234)
PASS "0.99" (got 99, want 99)
PASS "$0" (got 0, want 0)
PASS parens negative "(45.00)" (got -4500, want -4500)
PASS minus negative "-45.00" (got -4500, want -4500)
PASS float 1234.56 (got 123456, want 123456)
PASS int 1234 (major units) (got 123400, want 123400)
PASS "USD 89.90" (got 8990, want 8990)
PASS space thousands "1 234,56" (got 123456, want 123456)
PASS US big "1,234,567.89" (got 123456789, want 123456789)
PASS EU "€1.000,00" (got 100000, want 100000)
PASS empty -> null (got NULL, want NULL)
PASS "N/A" -> null (got NULL, want NULL)
PASS null -> null (got NULL, want NULL)
== 2. centsToDecimal round-trips ==
PASS 123456 -> "1234.56" (got '1234.56', want '1234.56')
PASS 5 -> "0.05" (got '0.05', want '0.05')
PASS -4500 -> "-45.00" (got '-45.00', want '-45.00')
PASS null -> "" (got '', want '')
== 3. normalizeDate: ISO, textual, numeric, ambiguity, invalid ==
PASS ISO passthrough (got '2026-04-03', want '2026-04-03')
PASS US m/d/Y (default) (got '2026-04-03', want '2026-04-03')
PASS d/m/Y with dayFirst (got '2026-03-04', want '2026-03-04')
PASS day>12 disambiguates (got '2026-04-13', want '2026-04-13')
PASS EU dotted 03.04.2026 (got '2026-03-04', want '2026-03-04')
PASS textual "Apr 3, 2026" (got '2026-04-03', want '2026-04-03')
PASS textual "3 April 2026" (got '2026-04-03', want '2026-04-03')
PASS year-first 2026/04/03 (got '2026-04-03', want '2026-04-03')
PASS 2-digit year 4/3/26 (got '2026-04-03', want '2026-04-03')
PASS impossible -> null (got NULL, want NULL)
PASS bad ISO -> null (got NULL, want NULL)
PASS garbage -> null (got NULL, want NULL)
PASS empty -> null (got NULL, want NULL)
== 4. currencyCode from code or symbol ==
PASS code "usd" (got 'USD', want 'USD')
PASS symbol "€" (got 'EUR', want 'EUR')
PASS symbol "£" (got 'GBP', want 'GBP')
PASS empty -> "" (got '', want '')
== 5. normalize: raw LLM JSON -> canonical record ==
PASS vendor via "supplier" (got 'Acme Supplies Ltd', want 'Acme Supplies Ltd')
PASS invoice_number via "invoice_no" (got 'INV-2026-0042', want 'INV-2026-0042')
PASS invoice_date normalized (got '2026-04-03', want '2026-04-03')
PASS due_date from textual (got '2026-04-17', want '2026-04-17')
PASS currency from "$" (got 'USD', want 'USD')
PASS subtotal cents (got 100000, want 100000)
PASS tax via "vat" (got 8500, want 8500)
PASS total via "grand_total" (got 108500, want 108500)
PASS two line items (got 2, want 2)
PASS line 1 desc (got 'Widget A', want 'Widget A')
PASS line 1 qty (float) (got 2.0, want 2.0)
PASS line 1 amount cents (got 60000, want 60000)
PASS line 2 desc via "name" (got 'Service B', want 'Service B')
PASS empty raw -> blank vendor (got '', want '')
PASS empty raw -> null date (got NULL, want NULL)
PASS empty raw -> [] items (got array (
), want array (
))
== 6. reconcile: arithmetic cross-checks ==
PASS clean invoice reconciles (no warnings)
PASS subtotal+tax != total is flagged
PASS line items not summing to subtotal is flagged
PASS within-tolerance rounding does not warn
PASS nothing to check -> ok
== 7. toCsv: RFC-4180 escaping round-trips ==
PASS header row present (got 'Vendor,Invoice #,Invoice Date,Due Date,Currency,Subtotal,Tax,Total', want 'Vendor,Invoice #,Invoice Date,Due Date,Currency,Subtotal,Tax,Total')
PASS vendor with comma/quote/newline round-trips
PASS CRLF line endings
== 8. toJson: valid, decodes back, decimals ==
PASS valid JSON
PASS vendor survives (got 'Acme Supplies Ltd', want 'Acme Supplies Ltd')
PASS total as decimal (got '1085.00', want '1085.00')
PASS line item unit price decimal (got '300.00', want '300.00')
== CSV formula-injection guard ==
PASS Extraction =-formula neutralized (got '\'=1+2', want '\'=1+2')
PASS Extraction @-formula neutralized (got '\'@SUM(A1)', want '\'@SUM(A1)')
PASS Extraction leading-TAB neutralized (got '\' X', want '\' X')
PASS Extraction -text neutralized (got '\'-abc', want '\'-abc')
PASS Extraction negative number kept (got '-45.00', want '-45.00')
PASS Extraction plain value untouched (got 'Safe Name', want 'Safe Name')
PASS Extraction quoting still applies (got '"\'=a,b"', want '"\'=a,b"')
== Security hardening regressions ==
PASS dashboard week query is engine-portable (no SQLite datetime())
PASS settings_save blocks writes in public demo mode
PASS file_serve sends X-Content-Type-Options: nosniff
PASS installer rejects empty install token (hash_equals empty-token bypass)
PASS LLM client never follows redirects (key cannot leak to a redirect target)
PASS settings view never echoes the stored API key value
== LLM truncation guard ==
PASS truncated (openai/ollama length) -> clear error
PASS truncated (anthropic max_tokens) -> same error
PASS error names the 8192-token ceiling
PASS error tells the user what to DO (split the document)
PASS normal stop -> no error
PASS end_turn -> no error
PASS missing reason -> no error
PASS all three providers carry the raised cap (no stray literal)
PASS every provider path guards truncation before parsing
== 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-5-sonnet (the old accuracy hint) 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 the upload page says, on the public demo only, that extraction there is a canned sample
== Possible duplicate invoices are flagged ==
PASS same vendor (any case, punctuation) and invoice number -> flagged with the other document (got array (
0 => 7,
1 => 'same vendor and invoice number',
), want array (
0 => 7,
1 => 'same vendor and invoice number',
))
PASS no invoice number, same vendor + total + date -> flagged (got array (
0 => 9,
1 => 'same vendor, total and invoice date',
), want array (
0 => 9,
1 => 'same vendor, total and invoice date',
))
PASS same invoice number from a DIFFERENT vendor is not a duplicate (got NULL, want NULL)
PASS same vendor, same total, different date and number -> not flagged (monthly retainers) (got NULL, want NULL)
PASS no vendor read -> nothing to compare (got NULL, want NULL)
PASS the review page compares against every other extracted document and passes the verdict to the view
PASS the review view shows the warning with a link to the other document
== 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_upload (403)
PASS a signed-in member is refused restore_upload (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: mint
PASS v2: revoke
PASS v2: totp
PASS v2: recovery
PASS v2: delivery
PASS v2: rate
PASS v2: backup_tables
PASS v2: reconciling_doc_needs_no_review
PASS v2: non_reconciling_doc_needs_review
PASS v2: needs_review_is_reconcile_verdict
PASS v2: money_is_cents_and_decimal
PASS v2: line_items_surfaced
PASS v2: extract_hook_fires_event
PASS v2: review_hook_fires_event
PASS v2: export_includes_reviewed
PASS v2: backup_redacts_llm_key
PASS v2: db_files_cleaned
PASS v2: routes registered (api, 2fa, security, backup, healthz)
PASS v2: migrate() runs on every boot (Extracta had NO migrate() before 2.0)
PASS v2: 2FA intercepts login + login is rate-limited
PASS v2: BOTH extraction paths fire the hook (demo + real)
PASS v2: the review hook fires from the UI too, not just the API
PASS v2: needs_review reuses Extraction::reconcile, it does not re-derive a rule
PASS v2: API corrections use the app own normalisers, not ad-hoc parsing
PASS v2: the fields object is declared nested, so the documented payload is accepted
PASS v2: the API knows the product REAL status vocabulary (incl. reviewed)
PASS v3: backup redacts every reusable credential, incl. the buyer's own LLM key
PASS v3: both backup callers share ONE redaction list
PASS v2: no PHP-8 ternary key re-reads (PLAN item 10)
PASS v2: cli-server base guard (PLAN item 9)
PASS v2: core modules stay dependency-free (no product helpers inside them)
PASS v2: clip() matches the family implementation (copied code must not drift)
PASS v2: light accent is dark enough for white button text (WCAG AA)
PASS v2/kit: dark palette (kit + app layer) + kit js + qr.js + layout wiring
PASS v2: no themed background paired with an un-overridden hardcoded colour
PASS v2/kit: palette tokens are WIRED (kit css) and the app layer overrides the accent
PASS v3: sw cache is v3.1.5 + API.md ships
PASS v3: /healthz, api_ping and openapi all report 3.1.5
-- Own It 3.0 --
PASS v3: v3_tables
PASS v3: v3_columns
PASS v3: upgrade_never_loosens
PASS v3: settings_singleton_ensured
PASS v3: upgrade_from_2x
PASS v3: migrate_called_at_boot
PASS v3: roles
PASS v3: whitelist_drops_undeclared
PASS v3: stored_keys_are_exactly_declared
PASS v3: values_go_through_the_parsers
PASS v3: ambiguous_date_is_parsed
PASS v3: db_matches_and_marks_reviewed
PASS v3: reconcile_is_the_products
PASS v3: line_items_narrowed
PASS v3: mcp_tools
PASS v3: mcp_write_tools_declare_it
PASS v3: mcp_arg_guard
PASS v3: mcp_get_is_405
PASS v3: mcp_never_returns_file_path
PASS v3: reject_keeps_values
PASS v3: approve_marks_reviewed
PASS v3: template_narrows_only
PASS v3: template_null_is_full_set
PASS v3: template_empty_falls_back
PASS v3: pdf_renders
PASS v3: gdpr_search_finds_values
PASS v3: gdpr_search_is_case_insensitive
PASS v3: gdpr_search_misses_non_matches
PASS v3: audit_redacts_llm_key
PASS v3: audit_records_mcp_writes
PASS v3: branding_content_decides
PASS v3: restore_refuses_other_product
PASS v3: routes registered, LITERALS before {id} patterns
PASS v3: MCP writes go through the SAME whitelist builder as the review form
PASS v3: the record builder NAMES every field — it never copies keys from its input
PASS v3: every path parses currency AND dates with Extraction (one field, one meaning)
PASS v3: a template can only NARROW the declared field set
PASS v3: the extraction report renders Extraction::reconcile, never a second arithmetic check
PASS v3: subject search is gated the SAME way as the nav link that offers it
PASS v3: GDPR redaction keeps the money and never logs the search term
PASS v3: core Mcp is byte-identical to the shared core
PASS v3: core AuditLog is byte-identical to the shared core
PASS v3: core Pdf is byte-identical to the shared core
PASS v3: core BackupRestore is byte-identical to the shared core
PASS v3: core Branding is byte-identical to the shared core
PASS v3: core RolesKit is byte-identical to the shared core
PASS v3: core Oidc is byte-identical to the shared core
PASS v3/C2: a nested value where a value belongs is REFUSED, never cast to the string "Array"
PASS v3/C2: _rec_from_input runs the guard over every declared field AND over line items
PASS v3/C2: every path that writes extracted values refuses a nested value
PASS v3/C2: the viewer seat is read-only in the map and cannot spend the buyer's AI credits
PASS v3/C2: the pre-3.0 screens are role-gated, not merely signed-in
PASS v3/C2: minting an API key and downloading the register are admin acts
PASS v3/C2: no route outside the user's own account is left on a bare requireLogin
PASS v3/C2: /backup.sqlite is an app ROUTE and the dev router does not 403 it
PASS v3/C2: bin/demo.php can run — migrate() calls AuditLog::ensure()
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 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: /review/1 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 (24 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: document.reviewed is sent by the action where it happens [302]
PASS webhooks: every event the Settings page offers is sent somewhere
PASS webhooks: approving several documents at once announces each one
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, v3_backup_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)
————————————————————————————————————————————————————
== v3.1 P6: accounting export presets ==
PASS P6: QuickBooks preset carries the documented header and one row PER LINE ITEM (2 items + 1 summary = 3 data rows)
PASS P6: Xero preset carries the starred header, quantity 1, Tax Exclusive, currency passthrough
PASS P6: unknown preset falls back to the generic CSV (never errors, never invents a format)
PASS P6: the route accepts only the named presets
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 294 TESTS PASSED (0 failed)