Home›Extracta›Test run
Extracta · Test run · run 2026-11-07

Extracta Test run, as shipped in the download

517 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.

== 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  3.3.0: date with its time (got array (
  0 => '2026-03-04',
  1 => '2026-03-04',
  2 => '2026-03-04',
  3 => '2026-03-04',
  4 => '2026-03-04',
), want array (
  0 => '2026-03-04',
  1 => '2026-03-04',
  2 => '2026-03-04',
  3 => '2026-03-04',
  4 => '2026-03-04',
))
  PASS  3.3.0: an impossible time or a bare time is still not a date (got array (
  0 => NULL,
  1 => NULL,
  2 => NULL,
), want array (
  0 => NULL,
  1 => NULL,
  2 => NULL,
))
  PASS  3.3.0: a model reply with a datetime keeps its date (got '2026-03-04', want '2026-03-04')
  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 (3.2.0: Account last) (got 'Vendor,Invoice #,Invoice Date,Due Date,Currency,Subtotal,Tax,Total,Account', want 'Vendor,Invoice #,Invoice Date,Due Date,Currency,Subtotal,Tax,Total,Account')
  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  a refused key says what to do, and keeps the provider text (3.2.0)
  PASS  out of credit, rate-limited, too large and a provider fault each get their own plain reason (3.2.0)
  PASS  anything else keeps the provider text as before (got 'LLM API error: bad param', want 'LLM API error: bad param')
  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 organization, the app, the role, the link and the 7-day expiry
  PASS  a line break in the organization 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) — since 3.2.0 through the one store both call
  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 normalizers, 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 color
  PASS  v2/kit: palette tokens are WIRED (kit css) and the app layer overrides the accent
  PASS  v3: sw cache is v4.1.3 + API.md ships
  PASS  v3: /healthz, api_ping and openapi all report 4.1.3

-- 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  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

-- 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 (41 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 (15)
  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

== 3.2.0: Sonnet 5 suggested for messy or handwritten scans ==
  PASS  the Anthropic default is unchanged (claude-haiku-4-5-20251001) (got 'claude-haiku-4-5-20251001', want 'claude-haiku-4-5-20251001')
  PASS  the Anthropic model list offers claude-sonnet-5
  PASS  every provider's list starts with that provider's default
  PASS  the hint beside the model field says "Sonnet 5 for messy or handwritten scans"
  PASS  the model field offers the list (a datalist filled from Llm::SUGGESTED_MODELS)
  PASS  the model label points at the model field (one id, not two)
  PASS  the security page says the JSON backup leaves out webhook URLs

== 3.2.0: vendor rules (pure) ==
  PASS  a vendor normalizes to upper-case words (got 'METRO OFFICE SUPPLIES LTD', want 'METRO OFFICE SUPPLIES LTD')
  PASS  a learned pattern drops the legal form (got 'METRO OFFICE SUPPLIES', want 'METRO OFFICE SUPPLIES')
  PASS  ...every trailing one (GmbH & Co KG) (got 'HARTMANN BAUER', want 'HARTMANN BAUER')
  PASS  a pattern under three letters is refused (got '', want '')
  PASS  the longest matching pattern wins (got '6420 Software', want '6420 Software')
  PASS  the general rule still catches the rest (got '6000 General', want '6000 General')
  PASS  matching is whole-word: ACME does not code ACMESOFT
  PASS  a rule codes an empty account (got array (
  0 => '6100',
  1 => 'rule',
  2 => 3,
), want array (
  0 => '6100',
  1 => 'rule',
  2 => 3,
))
  PASS  a rule re-codes an account a rule set (got array (
  0 => '6100',
  1 => 'rule',
  2 => 3,
), want array (
  0 => '6100',
  1 => 'rule',
  2 => 3,
))
  PASS  a rule NEVER overwrites an account a person set (got array (
  0 => '7000 Mine',
  1 => 'manual',
  2 => NULL,
), want array (
  0 => '7000 Mine',
  1 => 'manual',
  2 => NULL,
))
  PASS  no match leaves the account alone (got array (
  0 => '',
  1 => '',
  2 => NULL,
), want array (
  0 => '',
  1 => '',
  2 => NULL,
))

== 3.2.0: exports carry the account ==
  PASS  plain CSV: Account is the last column, filled
  PASS  QuickBooks: the Account column carries it (got '6100 Office supplies', want '6100 Office supplies')
  PASS  Xero: *AccountCode carries it (got '6100 Office supplies', want '6100 Office supplies')
  PASS  JSON: "account" is there (got '6100 Office supplies', want '6100 Office supplies')
  PASS  a record with no account exports an empty cell, not an error

== 3.2.0: the email inbox reads attachments (pure MIME) ==
  PASS  three attachments found (the bodies are not attachments) (got 3, want 3)
  PASS  the folded Content-Disposition filename wins over the type name (got 'NWL-89377.pdf', want 'NWL-89377.pdf')
  PASS  base64 decodes to the original bytes
  PASS  an RFC 2231 UTF-8 filename is decoded (got 'Reçu.png', want 'Reçu.png')
  PASS  quoted-printable decodes (got 'A=B', want 'A=B')
  PASS  the PDF is a document (type read from the bytes) (got 'application/pdf', want 'application/pdf')
  PASS  the PNG is a document (got 'image/png', want 'image/png')
  PASS  a text attachment is not
  PASS  the sender address is read from From (got 'billing@northwind.example', want 'billing@northwind.example')
  PASS  an RFC 2047 filename is decoded (got 'Facture été.pdf', want 'Facture été.pdf')
  PASS  a path in a filename is stripped (got 'x.pdf', want 'x.pdf')
  PASS  the summary line counts what happened (got '2 messages read, 3 documents added, 1 attachment ignored (not a PDF or image).', want '2 messages read, 3 documents added, 1 attachment ignored (not a PDF or image).')

== 3.2.0: the screens and routes ==
  PASS  batch upload: the picker takes several files (files[] multiple)
  PASS  batch upload: up to 50 per upload, and the old single "file" field still works
  PASS  batch extraction: the route exists and is offered on the documents list
  PASS  one extraction path for the button, the batch and the inbox
  PASS  vendor rules: page, add, apply, delete routes
  PASS  vendor rules: the review screen has the Account field and saving it teaches a rule
  PASS  vendor rules: each extraction codes a known vendor on both paths (the one store both call)
  PASS  inbox: Settings offers it switched off, with test and check buttons
  PASS  inbox: the cron script exists and refuses the web
  PASS  inbox: the public demo never reads a mailbox
  PASS  the inbox password is a secret the shareable backup leaves out

== 3.2.0: database proofs (tests/v320_db_check.php) ==
  PASS  the database proof ran cleanly
  PASS  migrate() adds the account, source and inbox columns and vendor_rules (twice, no error)
  PASS  the email inbox is off on a fresh install
  PASS  a correction teaches a rule on the vendor without its legal form
  PASS  the same vendor and account again teaches nothing new
  PASS  an extracted document from a known vendor is coded, and the rule counts it
  PASS  a rule never overwrites an account a person set
  PASS  "Apply rules" reports what it coded and what it left to people
  PASS  saving a review without changing the account keeps it a rule's
  PASS  changing the account re-points the rule and makes it the person's
  PASS  clearing the account leaves it blank
  PASS  inbox: a refused login says so without repeating the password the server echoed
  PASS  inbox: the connection test reports the unread count
  PASS  inbox: no server configured points at Settings
  PASS  demo: a register of 14+ documents and 5 rules
  PASS  demo: documents coded by rules, one by a person, one uncoded
  PASS  demo: two documents arrived by email, sender shown
  PASS  demo: documents waiting for review and for extraction
  PASS  demo: the history spans at least five months
  PASS  demo: documents carry their kind, and the ones awaiting review show a doubtful field
  PASS  demo: dated relative to today, not years ago
  PASS  round 4 demo: one reviewed invoice shows a recipient status, as recorded
  PASS  demo: every history document has its rendered image, or (without GD) no file at all — never a broken image
  PASS  "Reset all data" removes documents and vendor rules
  PASS  pass B: a file holding three documents becomes three rows on the one stored file, named "document N of 3"
  PASS  pass B: each is classified (invoice, receipt, credit note)
  PASS  pass B: split documents go through the vendor rules
  PASS  pass B: each field's confidence is stored (04/03 at 0.6, a number in prose at 0.65)
  PASS  pass B: the API carries the kind, the split and the confidence
  PASS  pass B: an ordinary file stays one row with its own name
  PASS  pass B: a person's review clears the confidence (the fields are now checked)

== 3.2.0: over HTTP (tests/v320_http_check.php) ==
  PASS  the HTTP proof ran (app, stand-in model and scripted mailbox up; no PHP warning logged)
  PASS  the upload page takes many files
  PASS  one post of three files adds the two images and skips the text file
  PASS  the documents list offers "Extract waiting documents (2)"
  PASS  "Extract waiting documents" reads both through the model
  PASS  an account set on the review screen is the person's
  PASS  ...and teaches the vendor rule
  PASS  the old single-file field still uploads and opens the review
  PASS  the vendor's next invoice arrives coded by the rule
  PASS  the review screen shows the account and says a rule set it
  PASS  the dashboard renders
  PASS  pass B: the review screen marks a field read with less certainty, and says what kind of document it is
  PASS  pass B: the documents list shows the kind and "check a field"
  PASS  "Apply rules" leaves a person's account alone
  PASS  the QuickBooks export carries the accounts
  PASS  the rules page lists the rule
  PASS  deleting a rule keeps the accounts it already set
  PASS  Settings offers the inbox, unticked
  PASS  the inbox saves, and its password is never written back into the page
  PASS  "Test the inbox connection" answers
  PASS  "Check inbox now" collects the mailed PDF and image, with the sender
  PASS  ...and extracts them on arrival when that is ticked
  PASS  ...and Settings records what it did
  PASS  ...and marks the message read
  PASS  a second check adds nothing
  PASS  the documents list marks mailed-in documents
  PASS  round 4: an agent's review with "2026-03-04T08:10:00Z" stores 2026-03-04 (it blanked the date before)
  PASS  API: a viewer's key gets 403 on POST /api/documents/{id}/review and changes nothing; an admin's key reviews
  PASS  customer voice: the drop-box is off until switched on; then a client sends a photo (a text file refused), with a name, shown as via drop-box
  PASS  customer voice: a form-filling robot adds nothing
  PASS  customer voice: the hourly cap holds
  PASS  customer voice: a new link retires the old one; off closes it
  PASS  next wave: a record whose file is not on the server shows no Open link (it was a 404), and says so
  PASS  round 4: an XRechnung UBL file and a Factur-X PDF are read from their XML with no AI key; confidence 1.0; kind invoice
  PASS  round 4: the review page says the fields came from the XML (inside the PDF), shows the seller VAT ID and the file hash
  PASS  round 4: the viewer shows an XML e-invoice as a person reads it (parties, lines, VAT, totals, IBAN), from the file not the edited working copy; below a hybrid PDF too; nothing for a photo
  PASS  round 4: the XML is served byte for byte as a download, sandboxed, never rendered in the browser
  PASS  round 4: the status reported for a received invoice (accepted, date, platform reference) is recorded; a rejection without its reason and an impossible date are refused
  PASS  round 4: the recorded status rides the JSON and CSV exports (three columns, only when one is recorded), the REST API and the MCP export
  PASS  round 4: the CSV and JSON exports carry the e-invoice VAT ID, IBAN and VAT per rate

== 3.2.0 pass B: one file, several documents; what kind; how sure (pure) ==
  PASS  a file holding several documents splits into them (non-objects dropped) (got array (
  0 => 'A Ltd',
  1 => 'B Ltd',
), want array (
  0 => 'A Ltd',
  1 => 'B Ltd',
))
  PASS  an ordinary reading is one document (got 1, want 1)
  PASS  kinds are recognized (got array (
  0 => 'invoice',
  1 => 'receipt',
  2 => 'credit_note',
  3 => 'other',
  4 => '',
  5 => '',
), want array (
  0 => 'invoice',
  1 => 'receipt',
  2 => 'credit_note',
  3 => 'other',
  4 => '',
  5 => '',
))
  PASS  a clean vendor is sure (got 1.0, want 1.0)
  PASS  a missing invoice number is doubtful, not fatal (got 0.7, want 0.7)
  PASS  04/03 could be either way (got 0.6, want 0.6)
  PASS  an ISO due date is sure (got 1.0, want 1.0)
  PASS  a clean subtotal is sure (got 1.0, want 1.0)
  PASS  a number found inside prose is less sure (got 0.65, want 0.65)
  PASS  a negative total is doubtful (got 0.4, want 0.4)
  PASS  a placeholder vendor, and a missing total and date, are flagged
  PASS  the model is asked for the kind and told how to return several documents
  PASS  the upload page offers the phone camera, several shots then one upload

== 3.3.0: e-invoices are read from their XML, not by a model (pure) ==
  PASS  UBL 2.1 invoice: seller, number, dates, currency, net / VAT / gross, both lines, and the EN 16931 extras
  PASS  UBL credit note: its kind, its lines by CreditedQuantity
  PASS  CII (ZUGFeRD / Factur-X): dates from format 102, totals from the summation, the VAT ID by its scheme
  PASS  the XML inside a Factur-X PDF is found and read; a plain PDF and a picture are not e-invoices
  PASS  a file with a DOCTYPE or an entity is refused (how XML entity attacks arrive); other XML is not an e-invoice
  PASS  the result goes through the same normalizer as a model reply

== 3.3.0: an e-invoice's VAT data reaches the exports and the API ==
  PASS  the VAT breakdown per rate, from both syntaxes (got array (
  0 => 
  array (
    0 => 
    array (
      'category' => 'S',
      'rate' => '19',
      'basis' => '200.00',
      'tax' => '38.00',
    ),
  ),
  1 => 
  array (
    0 => 
    array (
      'category' => 'S',
      'rate' => '5.5',
      'basis' => '74.00',
      'tax' => '4.07',
    ),
  ),
), want array (
  0 => 
  array (
    0 => 
    array (
      'category' => 'S',
      'rate' => '19',
      'basis' => '200.00',
      'tax' => '38.00',
    ),
  ),
  1 => 
  array (
    0 => 
    array (
      'category' => 'S',
      'rate' => '5.5',
      'basis' => '74.00',
      'tax' => '4.07',
    ),
  ),
))
  PASS  CSV: with an e-invoice in the file, three columns more at the end; the photo row leaves them empty
  PASS  ...and a file without one is exactly as before
  PASS  JSON: the e-invoice block (format, VAT ID, VAT per rate), null for other documents

== sec3 D8: a Factur-X PDF cannot inflate without limit (p3ka F4) ==
  PASS  sec3 D8: a PDF of 64 KB whose embedded stream inflates to 64 MB: no e-invoice (null), no fatal error, at 128 MB
  PASS  sec3 D8: … and reading it holds at most the cap (10 MB) beside the file, never the inflated 64 MB (grew 11.2 MB)
  PASS  sec3 D8: … and a normal Factur-X PDF still reads in the same process
  PASS  sec3 D8: a PDF of 255 KB whose embedded stream inflates to 256 MB: no e-invoice (null), no fatal error, at 128 MB
  PASS  sec3 D8: … and reading it holds at most the cap (10 MB) beside the file, never the inflated 256 MB (grew 11.1 MB)
  PASS  sec3 D8: … and a normal Factur-X PDF still reads in the same process

== 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)
  SKIP  updates: the state row (this suite keeps no open database in its own process; update_e2e.sh installs, migrates and updates for real)
  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 Extracta 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 Extracta 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 Extracta, 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)
  PASS  erase planting · sqlite: the subject is created and a marker is planted in all 4 personal columns
  PASS  erase planting · sqlite: the app's own erase runs through its route and reports no failure
  PASS  erase planting · sqlite: no planted marker survives anywhere (18 tables dumped)
  PASS  erase planting · sqlite: the amounts and the date stay (the transaction did happen)
  PASS  erase planting · sqlite: Subject search finds a drop-box upload not extracted yet, by the name typed on the drop box
  PASS  erase planting · sqlite: the erase page (/gdpr) says what an erase keeps: "Kept: the source document itself, the file as it was uploade…"
  PASS  erase planting · sqlite: MANUAL.md says the same: "Kept: the source document itself, the file as it was uploade…"
ALL 517 TESTS PASSED (0 failed)

← Back to Extracta · Manual · Quickstart · API