Ownware
Home›Specta›Test run
Specta · Test run · run 2026-09-25

Specta Test run, as shipped in the download

527 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. reused money engine (Extraction) ==
  PASS  US "$1,299.00" (got 129900, want 129900)
  PASS  EU "1.299,00 €" (got 129900, want 129900)
  PASS  plain "59" (got 5900, want 5900)
  PASS  empty -> null (got NULL, want NULL)
  PASS  cents->decimal (got '1299.00', want '1299.00')
  PASS  null cents -> "" (got '', want '')
  PASS  currency from "€" (got 'EUR', want 'EUR')
  PASS  currency code "usd" (got 'USD', want 'USD')

== 2. columnKey: label -> safe snake key ==
  PASS  "Width (mm)" (got 'width_mm', want 'width_mm')
  PASS  "SKU / Part #" (got 'sku_part', want 'sku_part')
  PASS  "Weight, g" (got 'weight_g', want 'weight_g')
  PASS  "Product Name" (got 'product_name', want 'product_name')
  PASS  leading digit (got 'c_3d_model', want 'c_3d_model')
  PASS  symbols only -> col (got 'col', want 'col')

== 3. detectRole heuristics ==
  PASS  "Product Name" -> name (got 'name', want 'name')
  PASS  "Part Number" -> sku (got 'sku', want 'sku')
  PASS  "Unit Price" -> price (got 'price', want 'price')
  PASS  "Material" -> attribute (got 'attribute', want 'attribute')

== 4. parseColumnsInput: free-text schema definition ==
  PASS  5 columns parsed (got 5, want 5)
  PASS  name role auto (got 'name', want 'name')
  PASS  sku role auto (got 'sku', want 'sku')
  PASS  price role auto (got 'price', want 'price')
  PASS  key derived (got 'width_mm', want 'width_mm')
  PASS  role override "| sku" (got 'sku', want 'sku')
  PASS  role override "| attribute" beats auto (got 'attribute', want 'attribute')
  PASS  duplicate keys collapsed (got 1, want 1)
  PASS  empty definition throws

== 5. normalizeColumns: validation + single-role enforcement ==
  PASS  only one name role (got 1, want 1)
  PASS  only one price role (got 1, want 1)
  PASS  empty column list throws
  PASS  columnOfRole finds name (got 'name', want 'name')

== 6. mapProduct / mapProducts: raw LLM -> canonical records ==
  PASS  two products (empty skipped) (got 2, want 2)
  PASS  name promoted (got 'Aurora Oak Table', want 'Aurora Oak Table')
  PASS  sku promoted (got 'TBL-1', want 'TBL-1')
  PASS  price parsed to cents (got 129900, want 129900)
  PASS  currency default USD (got 'USD', want 'USD')
  PASS  attribute captured (got 'Oak', want 'Oak')
  PASS  attribute width (got '1800', want '1800')
  PASS  tolerant label-keyed name (got 'Fjord Bench', want 'Fjord Bench')
  PASS  tolerant label-keyed attr (got 'Walnut', want 'Walnut')
  PASS  EU price + currency (got 129900, want 129900)
  PASS  currency detected EUR (got 'EUR', want 'EUR')
  PASS  bare top-level array maps (got 'Solo', want 'Solo')
  PASS  array attribute flattened (got 'Steel, Glass', want 'Steel, Glass')

== 7. attributes_json round-trip ==
  PASS  round-trip material (got 'Oak', want 'Oak')
  PASS  round-trip description (got 'Six-seat table', want 'Six-seat table')
  PASS  key set preserved (got array (
  0 => 'material',
  1 => 'width_mm',
  2 => 'description',
), want array (
  0 => 'material',
  1 => 'width_mm',
  2 => 'description',
))

== 8. cellValue: per-column display for the review table + plain export ==
  PASS  name cell (got 'Aurora Oak Table', want 'Aurora Oak Table')
  PASS  sku cell (got 'TBL-1', want 'TBL-1')
  PASS  attr cell (got 'Oak', want 'Oak')
  PASS  price cell decimal (got '1299.00', want '1299.00')

== 9. plain CSV: labels as headers, values in column order ==
  PASS  plain header = labels (got 'Product Name,SKU,Material,Width (mm),Description,Price', want 'Product Name,SKU,Material,Width (mm),Description,Price')
  PASS  plain row 1 (got 'Aurora Oak Table,TBL-1,Oak,1800,Six-seat table,1299.00', want 'Aurora Oak Table,TBL-1,Oak,1800,Six-seat table,1299.00')
  PASS  CRLF line endings

== 10. RFC-4180 escaping of commas / quotes / newlines ==
  PASS  comma/quote/newline value round-trips
  PASS  comma value quoted

== 10b. CSV formula/injection neutralization ==
  PASS  =formula neutralized (got '\'=1+1', want '\'=1+1')
  PASS  +number NOT quoted (got '+1', want '+1')
  PASS  +formula neutralized (got '\'+1+1', want '\'+1+1')
  PASS  -formula neutralized (got '\'-1+1', want '\'-1+1')
  PASS  @formula neutralized (got '\'@SUM(A1)', want '\'@SUM(A1)')
  PASS  TAB-lead neutralized (got '\'	=x', want '\'	=x')
  PASS  safe cell untouched (got 'Aurora Oak', want 'Aurora Oak')
  PASS  empty cell untouched (got '', want '')
  PASS  injection: header label neutralized
  PASS  injection: product name neutralized
  PASS  injection: attribute value neutralized
  PASS  injection: woo name cell neutralized
  PASS  injection: shopify vendor neutralized

== 11. WooCommerce CSV preset ==
  PASS  woo core headers (got array (
  0 => 'Type',
  1 => 'SKU',
  2 => 'Name',
  3 => 'Published',
  4 => 'Regular price',
  5 => 'Description',
), want array (
  0 => 'Type',
  1 => 'SKU',
  2 => 'Name',
  3 => 'Published',
  4 => 'Regular price',
  5 => 'Description',
))
  PASS  woo has Attribute 1 name
  PASS  woo has Attribute 1 value(s)
  PASS  woo has Attribute 2 name (width)
  PASS  woo Type=simple (got 'simple', want 'simple')
  PASS  woo SKU mapped (got 'TBL-1', want 'TBL-1')
  PASS  woo Name mapped (got 'Aurora Oak Table', want 'Aurora Oak Table')
  PASS  woo Regular price (got '1299.00', want '1299.00')
  PASS  woo Description mapped (got 'Six-seat table', want 'Six-seat table')
  PASS  woo Attribute 1 name = Material (got 'Material', want 'Material')
  PASS  woo Attribute 1 value = Oak (got 'Oak', want 'Oak')
  PASS  woo Attribute 1 visible = 1 (got '1', want '1')

== 12. Shopify CSV preset ==
  PASS  shopify header row (got array (
  0 => 'Handle',
  1 => 'Title',
  2 => 'Body (HTML)',
  3 => 'Vendor',
  4 => 'Type',
  5 => 'Tags',
  6 => 'Published',
  7 => 'Variant SKU',
  8 => 'Variant Price',
), want array (
  0 => 'Handle',
  1 => 'Title',
  2 => 'Body (HTML)',
  3 => 'Vendor',
  4 => 'Type',
  5 => 'Tags',
  6 => 'Published',
  7 => 'Variant SKU',
  8 => 'Variant Price',
))
  PASS  shopify Handle slug (got 'aurora-oak-table', want 'aurora-oak-table')
  PASS  shopify Title (got 'Aurora Oak Table', want 'Aurora Oak Table')
  PASS  shopify Body has spec
  PASS  shopify Vendor (got 'Nordic Furniture Co', want 'Nordic Furniture Co')
  PASS  shopify Published TRUE (got 'TRUE', want 'TRUE')
  PASS  shopify Variant SKU (got 'TBL-1', want 'TBL-1')
  PASS  shopify Variant Price (got '1299.00', want '1299.00')
  PASS  first handle (got 'chair', want 'chair')
  PASS  second handle deduped (got 'chair-2', want 'chair-2')

== 13. JSON export shape ==
  PASS  valid JSON, 2 products
  PASS  json name (got 'Aurora Oak Table', want 'Aurora Oak Table')
  PASS  json sku (got 'TBL-1', want 'TBL-1')
  PASS  json price decimal (got '1299.00', want '1299.00')
  PASS  json currency (got 'USD', want 'USD')
  PASS  json attribute nested (got 'Oak', want 'Oak')

== 14. slug helper ==
  PASS  slug spaces (got 'oak-dining-table', want 'oak-dining-table')
  PASS  slug symbols (got '12-panel-v2', want '12-panel-v2')
  PASS  slug empty -> product (got 'product', want 'product')

== 15. catalogStatusFrom: pure status roll-up ==
  PASS  no products, not extracted -> pending (got 'pending', want 'pending')
  PASS  no products, extracted -> extracted (got 'extracted', want 'extracted')
  PASS  some mapped -> extracted (got 'extracted', want 'extracted')
  PASS  all reviewed -> reviewed (got 'reviewed', want 'reviewed')

== 16. buildPrompt reflects the schema ==
  PASS  prompt names each column key
  PASS  prompt asks for products array
  PASS  prompt mentions multi-page

== 17. column metadata: type / required / unit flags ==
  PASS  name role auto-required (got true, want true)
  PASS  sku optional by default (got false, want false)
  PASS  default type is text (got 'text', want 'text')
  PASS  required flag parsed (got true, want true)
  PASS  number flag parsed (got 'number', want 'number')
  PASS  unit:mm parsed (got 'mm', want 'mm')
  PASS  flags in any order (number+required+unit) (got array (
  0 => 'number',
  1 => true,
  2 => 'kg',
), want array (
  0 => 'number',
  1 => true,
  2 => 'kg',
))
  PASS  price column always numeric (got 'number', want 'number')
  PASS  1.0 stored column gets defaults (got array (
  0 => 'text',
  1 => false,
  2 => '',
), want array (
  0 => 'text',
  1 => false,
  2 => '',
))
  PASS  9000-char label clipped to 190 (got 190, want 190)

== 18. confidence heuristics (hand-computed fixtures) ==
  PASS  required + empty -> 0.0 (got 0.0, want 0.0)
  PASS  optional + empty -> 1.0 (got 1.0, want 1.0)
  PASS  placeholder "n/a" -> 0.3 (got 0.3, want 0.3)
  PASS  placeholder "TBD" -> 0.3 (got 0.3, want 0.3)
  PASS  clean text -> 1.0 (got 1.0, want 1.0)
  PASS  501-char text -> 0.7 (got 0.7, want 0.7)
  PASS  clean number, expected unit absent -> 0.9 (got 0.9, want 0.9)
  PASS  clean number + unit -> 1.0 (got 1.0, want 1.0)
  PASS  clean money "$1,299.00" -> 1.0 (got 1.0, want 1.0)
  PASS  clean EU money "1.299,00 €" -> 1.0 (got 1.0, want 1.0)
  PASS  edge currency code "USD 89.90" -> 1.0 (got 1.0, want 1.0)
  PASS  coerced "approx 42 kg total" -> 0.65 (got 0.65, want 0.65)
  PASS  unparseable "forty-two" -> 0.2 (got 0.2, want 0.2)
  PASS  unparseable price "POA" -> 0.2 (got 0.2, want 0.2)
  PASS  negative "-5 kg" capped -> 0.4 (got 0.4, want 0.4)
  PASS  implausible magnitude capped -> 0.4 (got 0.4, want 0.4)

== 19. scored mapping + review thresholds ==
  PASS  scored: empty row skipped, 2 kept (got 2, want 2)
  PASS  scored: record mapping intact (got 'Clean Widget', want 'Clean Widget')
  PASS  scored: clean row all 1.0 (got 1.0, want 1.0)
  PASS  scored: placeholder material 0.3 (got 0.3, want 0.3)
  PASS  scored: coerced width (no unit) 0.65 (got 0.65, want 0.65)
  PASS  scored: required weight empty 0.0 (got 0.0, want 0.0)
  PASS  scored: unparseable price 0.2 (got 0.2, want 0.2)
  PASS  scored: min = the floor (got 0.0, want 0.0)
  PASS  needsReview: below warn flagged
  PASS  needsReview: AT threshold not flagged
  PASS  needsReview: clean row passes
  PASS  decodeConfidence round-trip (got 0.0, want 0.0)
  PASS  decodeConfidence junk -> [] (got array (
), want array (
))

== 20. mapping presets: default == legacy, custom rename/reorder/exclude ==
  PASS  default woo mapping == legacy csvWoo (byte-for-byte) (got 'Type,SKU,Name,Published,Regular price,Description,Attribute 1 name,Attribute 1 value(s),Attribute 1 visible,Attribute 2 name,Attribute 2 value(s),Attribute 2 visible
simple,TBL-1,Aurora Oak Table,1,1299.00,Six-seat table,Material,Oak,1,Width (mm),1800,1
simple,BNC-2,Fjord Bench,1,1299.00,Bench,Material,Walnut,1,Width (mm),1600,1
', want 'Type,SKU,Name,Published,Regular price,Description,Attribute 1 name,Attribute 1 value(s),Attribute 1 visible,Attribute 2 name,Attribute 2 value(s),Attribute 2 visible
simple,TBL-1,Aurora Oak Table,1,1299.00,Six-seat table,Material,Oak,1,Width (mm),1800,1
simple,BNC-2,Fjord Bench,1,1299.00,Bench,Material,Walnut,1,Width (mm),1600,1
')
  PASS  default shopify mapping == legacy csvShopify (byte-for-byte) (got 'Handle,Title,Body (HTML),Vendor,Type,Tags,Published,Variant SKU,Variant Price,Variant Inventory Policy,Variant Fulfillment Service,Variant Requires Shipping,Variant Taxable,Status
aurora-oak-table,Aurora Oak Table,<p>Six-seat table</p><ul><li><strong>Material:</strong> Oak</li><li><strong>Width (mm):</strong> 1800</li></ul>,Nordic Furniture Co,,,TRUE,TBL-1,1299.00,deny,manual,TRUE,TRUE,active
fjord-bench,Fjord Bench,<p>Bench</p><ul><li><strong>Material:</strong> Walnut</li><li><strong>Width (mm):</strong> 1600</li></ul>,Nordic Furniture Co,,,TRUE,BNC-2,1299.00,deny,manual,TRUE,TRUE,active
', want 'Handle,Title,Body (HTML),Vendor,Type,Tags,Published,Variant SKU,Variant Price,Variant Inventory Policy,Variant Fulfillment Service,Variant Requires Shipping,Variant Taxable,Status
aurora-oak-table,Aurora Oak Table,<p>Six-seat table</p><ul><li><strong>Material:</strong> Oak</li><li><strong>Width (mm):</strong> 1800</li></ul>,Nordic Furniture Co,,,TRUE,TBL-1,1299.00,deny,manual,TRUE,TRUE,active
fjord-bench,Fjord Bench,<p>Bench</p><ul><li><strong>Material:</strong> Walnut</li><li><strong>Width (mm):</strong> 1600</li></ul>,Nordic Furniture Co,,,TRUE,BNC-2,1299.00,deny,manual,TRUE,TRUE,active
')
  PASS  custom header row exact (got 'Name,Type,GTIN,Price', want 'Name,Type,GTIN,Price')
  PASS  custom value row exact (got 'Aurora Oak Table,simple,TBL-1,1299.00', want 'Aurora Oak Table,simple,TBL-1,1299.00')
  PASS  attr + currency sources (got 'Oak,USD', want 'Oak,USD')
  PASS  custom handle deduped (got array (
  0 => 'chair',
  1 => 'chair-2',
), want array (
  0 => 'chair',
  1 => 'chair-2',
))
  PASS  mapping: 9000-char header clipped to 190 (got 190, want 190)
  PASS  mapping: invalid sources dropped (got 1, want 1)
  PASS  mappings_json round-trip (got 'GTIN', want 'GTIN')
  PASS  decodeMappings junk -> [] (got array (
), want array (
))

== 20b. numeric-aware CSV formula guard on the mapping path ==
  PASS  negative number NOT prefixed (got '-5.00', want '-5.00')
  PASS  plain formula prefixed (got '\'=1+1', want '\'=1+1')
  PASS  minus-formula prefixed (got '\'-1+1', want '\'-1+1')
  PASS  @ prefixed (got '\'@SUM(A1)', want '\'@SUM(A1)')
  PASS  mapping export: evil header neutralized
  PASS  mapping export: formula name neutralized
  PASS  mapping export: @attr neutralized
  PASS  mapping export: negative price kept numeric

== 21. batch roll-ups (pure) ==
  PASS  no files -> empty (got 'empty', want 'empty')
  PASS  queued -> processing (got 'processing', want 'processing')
  PASS  extracting -> processing (got 'processing', want 'processing')
  PASS  failed (none pending) -> attention (got 'attention', want 'attention')
  PASS  all reviewed -> reviewed (got 'reviewed', want 'reviewed')
  PASS  extracted mix -> extracted (got 'extracted', want 'extracted')
  PASS  summary counts (got array (
  0 => 2,
  1 => 1,
  2 => 1,
  3 => 1,
  4 => 1,
), want array (
  0 => 2,
  1 => 1,
  2 => 1,
  3 => 1,
  4 => 1,
))
  PASS  summary total/done/remaining (got array (
  0 => 6,
  1 => 2,
  2 => 3,
), want array (
  0 => 6,
  1 => 2,
  2 => 3,
))

== 22. clip / clamp overflow safety ==
  PASS  clip 9000 -> 255 (got 255, want 255)
  PASS  clip short untouched (got 'abc', want 'abc')
  PASS  clip multibyte safe (got 'ééé', want 'ééé')
  PASS  clamp_int overflow (got 2000000000, want 2000000000)
  PASS  clamp_int negative (got -2000000000, want -2000000000)
  PASS  clamp_int junk -> 0 (got 0, want 0)
  PASS  clamp_cents null kept (got NULL, want NULL)
  PASS  clamp_cents pass-through (got 1250, want 1250)

== 23. batch state machine end-to-end (sqlite :memory:) ==
  PASS  fresh install has 1.1 schema
  PASS  migrate on fresh install is a no-op (no throw)
  PASS  3 files queued in order (got array (
  0 => 'queued',
  1 => 'queued',
  2 => 'queued',
), want array (
  0 => 'queued',
  1 => 'queued',
  2 => 'queued',
))
  PASS  positions preserved (got array (
  0 => 'a.pdf',
  1 => 'b.pdf',
  2 => 'c.pdf',
), want array (
  0 => 'a.pdf',
  1 => 'b.pdf',
  2 => 'c.pdf',
))
  PASS  pass 1 ok, 2 products (got array (
  0 => true,
  1 => 2,
), want array (
  0 => true,
  1 => 2,
))
  PASS  pass 1 file extracted (got 'extracted', want 'extracted')
  PASS  pass 1 created a catalog
  PASS  products stored (got 2, want 2)
  PASS  clean row min_confidence 100 (got 100, want 100)
  PASS  messy row min_confidence 0 (required weight empty) (got 0, want 0)
  PASS  per-field map persisted (got 0.2, want 0.2)
  PASS  pass 2 fails in isolation (got array (
  0 => false,
  1 => 'simulated provider failure',
), want array (
  0 => false,
  1 => 'simulated provider failure',
))
  PASS  pass 2 file failed + error stored (got array (
  0 => 'failed',
  1 => 'simulated provider failure',
), want array (
  0 => 'failed',
  1 => 'simulated provider failure',
))
  PASS  pass 3 continues past the failure
  PASS  pass 4: queue drained -> null
  PASS  batch state with a failure -> attention (got 'attention', want 'attention')
  PASS  retryFailed re-queues 1 (got 1, want 1)
  PASS  failed file back to queued, error cleared (got array (
  0 => 'queued',
  1 => NULL,
), want array (
  0 => 'queued',
  1 => NULL,
))
  PASS  retried file processes fine
  PASS  batch now fully extracted (got 'extracted', want 'extracted')
  PASS  catalog rolls up to reviewed (got 'reviewed', want 'reviewed')
  PASS  batch file synced to reviewed (got 'reviewed', want 'reviewed')
  PASS  un-review syncs back to extracted (got 'extracted', want 'extracted')

== 24. migrate: 1.0-shape database -> 1.1, data intact ==
  PASS  pre-migrate: no conf_warn column
  PASS  post-migrate: conf_warn exists
  PASS  post-migrate: batch tables exist
  PASS  post-migrate: schemas gained mappings_json + is_sample
  PASS  post-migrate: products gained confidence columns
  PASS  old settings row intact + new default (got array (
  0 => 'Old Corp',
  1 => 70,
), want array (
  0 => 'Old Corp',
  1 => 70,
))
  PASS  old product data intact (got array (
  0 => 'Old Product',
  1 => 999,
  2 => 100,
), want array (
  0 => 'Old Product',
  1 => 999,
  2 => 100,
))
  PASS  old schema row intact (got 'Old Schema', want 'Old Schema')
  PASS  migrate is idempotent (second run, no throw)
  PASS  1.0 catalog listable after migrate

== 25. CSRF: empty-token rejection (subprocess) ==
  PASS  empty stored + empty posted token REJECTED
  PASS  empty posted token rejected
  PASS  matching token accepted

== 26. source hardening: every new POST handler is CSRF-checked, uploads finfo'd ==
  PASS  POST handler batch_new_do calls csrf_check()
  PASS  POST handler batch_new_do is authenticated
  PASS  POST handler batch_new_do is role-gated (3.0)
  PASS  POST handler batch_process_do calls csrf_check()
  PASS  POST handler batch_process_do is authenticated
  PASS  POST handler batch_process_do is role-gated (3.0)
  PASS  POST handler batch_run_start calls csrf_check()
  PASS  POST handler batch_run_start is authenticated
  PASS  POST handler batch_run_start is role-gated (3.0)
  PASS  POST handler batch_retry_do calls csrf_check()
  PASS  POST handler batch_retry_do is authenticated
  PASS  POST handler batch_retry_do is role-gated (3.0)
  PASS  POST handler batch_delete calls csrf_check()
  PASS  POST handler batch_delete is authenticated
  PASS  POST handler batch_delete is role-gated (3.0)
  PASS  POST handler mapping_save calls csrf_check()
  PASS  POST handler mapping_save is authenticated
  PASS  POST handler mapping_save is role-gated (3.0)
  PASS  POST handler product_field_save calls csrf_check()
  PASS  POST handler product_field_save is authenticated
  PASS  POST handler product_field_save is role-gated (3.0)
  PASS  multi-upload: per-file finfo MIME sniff
  PASS  multi-upload: is_uploaded_file guard
  PASS  multi-upload: per-file size cap
  PASS  multi-upload: file count cap
  PASS  auto-advance GET is session-token-guarded

== 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-sonnet-4-6', want 'claude-sonnet-4-6')
  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-5-sonnet-latest (the old default) is replaced (got 'claude-sonnet-4-6', want 'claude-sonnet-4-6')
  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-sonnet-4-6', want 'claude-sonnet-4-6')
  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_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: batch_tables_intact
  PASS  v2: mint
  PASS  v2: revoke
  PASS  v2: totp
  PASS  v2: recovery
  PASS  v2: delivery
  PASS  v2: rate
  PASS  v2: backup_tables
  PASS  v2: no_event_mid_run
  PASS  v2: event_on_completion
  PASS  v2: no_duplicate_event
  PASS  v2: failed_batch_still_completes
  PASS  v2: retry_rearms_the_alert
  PASS  v2: payload_carries_summary
  PASS  v2: payload_lists_files
  PASS  v2: single_upload_allowlist
  PASS  v2: backup_redacts_llm_key
  PASS  v2: db_files_cleaned
  PASS  v2: routes registered (api, 2fa, security, backup, healthz)
  PASS  v2: 2FA intercepts login + login is rate-limited
  PASS  v2: BOTH batch-advance paths hook the completion edge
  PASS  v2: completion uses Batch::statusFrom, not a second definition of "done"
  PASS  v2: completion is idempotent via a remembered notified_status, not caller trust
  PASS  v2: the export API reuses the same mapping precedence as the export button
  PASS  v2: the upload allow-list is ONE shared list (form, batch, API)
  PASS  v2: the API reads MIME from the BYTES, never the browser-supplied type
  PASS  the backup redacts through the shared list, not a local literal
  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: dark palette + theme.js + qr.js + layout wiring
  PASS  v2: the confidence heat-map re-tints in dark via kit theme tokens (kit truth: --warnbg/--badbg flip with the theme)
  PASS  v2: no themed background paired with an un-overridden hardcoded colour
  PASS  v2: palette tokens are WIRED, not just declared (generic wiring lives in the kit; the app layer wires its accent ink)
  PASS  sw cache is v3.1.5 + API.md ships
  PASS  /healthz, api_ping and openapi all report 3.1.5
  PASS  no superseded version string survives in sw.js or the API layer
  PASS  v3 db proof ran clean — no fatal, no PHP warning
  PASS  migrate() adds the role column
  PASS  migrate() adds every 3.0 settings column
  PASS  migrate() creates audit_log + invites + saved_views
  PASS  migrate() guarantees the settings singleton
  PASS  UPDATE settings ... WHERE id = 1 actually lands (the settings no-op relay)
  PASS  the singleton re-inserts at id=1, not the next autoincrement (invora's lesson)
  PASS  the 3.0 tables were really dropped first (the upgrade test is not vacuous)
  PASS  ...and migrate() took its EARLY-RETURN path on that database
  PASS  ...and rebuilt them anyway (the v3 block sits ABOVE the early return)
  PASS  the completeness report runs
  PASS  it finds every required column the schema declares
  PASS  a complete product is not listed
  PASS  a missing attribute is caught
  PASS  a whitespace-only value counts as missing
  PASS  ⭐ ZERO counts as SUPPLIED, not missing (the empty() trap)
  PASS  a required SKU is checked in its own column, not attributes_json
  PASS  ...and so is a required price
  PASS  the counts add up
  PASS  the roll-up puts the worst column first
  PASS  an OPTIONAL column is never counted against anyone
  PASS  the report never edits anything
  PASS  a schema with nothing required has nothing to be incomplete against
  PASS  a viewer reads and exports
  PASS  a viewer cannot edit products, catalogues or schemas
  PASS  ⭐ a viewer cannot RUN an extraction (that spends the owner's AI credits)
  PASS  a member can run and correct
  PASS  a member cannot reach settings or the team
  PASS  an admin can
  PASS  an unknown role normalizes to the MOST restrictive
  PASS  the last active admin cannot be demoted
  PASS  the shared field save writes the value
  PASS  ...and carries the confidence contract (human-confirmed = 1.0)
  PASS  ...and refuses a column the schema does not have
  PASS  bulk would SKIP a product that already has a value
  PASS  a filled product drops out of the incomplete list
  PASS  MCP exposes exactly the six tools the row asked for
  PASS  ⭐ MCP has NO tool that starts an extraction
  PASS  ...and every tool is declared read-only
  PASS  ...and the instructions say so in words
  PASS  an unknown argument is refused BY NAME
  PASS  a nested value is refused by the scalar guard
  PASS  an out-of-enum preset lists the legal options
  PASS  GET /mcp is 405, not 404
  PASS  catalog_detail carries the completeness summary
  PASS  list_items can return only the incomplete ones
  PASS  ...each with what it is missing
  PASS  item_detail names the missing required attribute
  PASS  a viewer key can read
  PASS  an export over MCP is audited
  PASS  the spec-sheet PDF renders
  PASS  audit detail redacts the llm_api_key
  PASS  the audit viewer exports CSV
  PASS  a scheduled backup writes and names the product
  PASS  a backup never carries the AI provider key
  PASS  a backup carries no bcrypt hash (the 2.0 list redacted none)
  PASS  both backup callers share ONE redaction list
  PASS  a backup parses back
  PASS  another product's backup is refused
  PASS  the dry-run diff is zero against itself
  PASS  a restore brings a deleted row back
  PASS  a restore does not overwrite a live secret with [REDACTED]
  PASS  prune deletes exactly the overflow
  PASS  accent() validates hex and rejects junk
  PASS  the ink on an accent is derived and AA-safe
  PASS  OIDC state is unguessable per request
  PASS  every 3.0 core is required in index.php (a missing one is fatal on the first MCP call)
  PASS  Ical.php and Gdpr.php are deliberately NOT shipped (P6 + P8 N/A here)
  PASS  the migrate() catch-all now LOGS instead of swallowing silently
  PASS  the v3 migration sits ABOVE migrate()'s early return
  PASS  bulk loops the shared field-save, never a mass UPDATE
  PASS  bulk FILLS only — it checks the value is absent before writing
  PASS  the inline grid delegates to the same shared field-save
  PASS  no MCP handler writes to products or catalogs
  PASS  GET /mcp is routed and answered before the key check
  PASS  no POST /catalogs/{id} route exists that could shadow /catalogs/{id}/bulk
  PASS  every controller handler is role-gated (no bare requireLogin left in app.php)
  PASS  the layout brands the accent in BOTH :root and the dark selector
  PASS  ...and overrides this product's own --on-accent variable rather than inventing a rival
  PASS  the layout derives the ink rather than trusting a stored column
  PASS  the catalogue view has no nested <form>
  PASS  ...and the bulk checkboxes use the HTML5 form attribute
  PASS  README documents "New in 3.0"
  PASS  API.md documents the MCP endpoint and its six tools
  PASS  API.md states plainly that no tool can start an extraction
  PASS  the deploy kit ships both files
  PASS  no {PLACEHOLDER} survives anywhere in the deploy kit
  PASS  compose.yml names specta, its port and its own volume
  PASS  the healthcheck PARSES the healthz JSON instead of grepping its formatting
  PASS  core Mcp.php is byte-identical to the shared core
  PASS  core AuditLog.php is byte-identical to the shared core
  PASS  core Pdf.php is byte-identical to the shared core
  PASS  core BackupRestore.php is byte-identical to the shared core
  PASS  core Branding.php is byte-identical to the shared core
  PASS  core Oidc.php is byte-identical to the shared core
  PASS  core RolesKit.php is byte-identical to the shared core
  PASS  v3/C2: minting or revoking an API key, and the backups, are admin acts
  PASS  v3/C2: no route outside the user's own account is left on a bare requireLogin
  PASS  v3/C2: the gate that keeps a viewer out does not lock the ADMIN out
  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)

== UI kit: accept-row endpoint + layout on the kit ==
  PASS  accept: returns reviewed with min 1.0
  PASS  accept: row is reviewed, min_confidence 100, every field confidence 1.0
  PASS  accept: values are NEVER changed — accept means "the extraction was right"
  PASS  accept: catalog does NOT roll up while a sibling row is still unreviewed
  PASS  accept: catalog rolls up to reviewed once every row is signed off
  PASS  accept: route POST /products/{id}/accept is registered
  PASS  accept: endpoint requires product.edit, checks CSRF, 404s an unknown product and audits the act
  PASS  accept: a schema with no columns is refused (422), never silently confirmed
  PASS  kit: layout loads kit css+js, groups the nav with icons + g-keys, carries theme toggle and who-line
  PASS  kit: no theme.js; app css is an app layer with a dark accent; pre-kit file deleted
  PASS  kit: App::asset() versions by mtime and the service worker revalidates code
  PASS  kit: no font or script fetched from another host
  PASS  dashboard: first-run checklist is computed from the database, not the session
  PASS  dashboard: KPI sparkline series + needs-your-action list are wired
  PASS  hero: sticky source viewer ⇄ review grid with per-row Accept hitting the new endpoint
  PASS  record pages: product and batch records carry the kit rail

== v3.1: verify a mapping against the store's real header row ==
  PASS  verify: an exact match is called exact (got 'exact', want 'exact')
  PASS  verify: nothing missing (got array (
), want array (
))
  PASS  verify: nothing extra (got array (
), want array (
))
  PASS  verify: an UNCHECKED mapping row is not counted as exported (it writes no column)
  PASS  verify: columns the store wants and the export lacks are named (got array (
  0 => 'Stock',
  1 => 'Weight (kg)',
), want array (
  0 => 'Stock',
  1 => 'Weight (kg)',
))
  PASS  verify: that is a gaps verdict (got 'gaps', want 'gaps')
  PASS  verify: a column the export adds that the store does not list is "extra", not a failure (got array (
  0 => 'Regular price',
), want array (
  0 => 'Regular price',
))
  PASS  verify: covered means nothing missing, something extra (got 'covered', want 'covered')
  PASS  verify: an empty paste is refused with a sentence, not an empty match
  PASS  verify: prose is refused rather than read as one giant column
  PASS  verify: BOM + semicolons + case + doubled spaces still match (Excel exports look like this) (got 'exact', want 'exact')
  PASS  verify: the delimiter is reported back so the operator can see how it was read (got ';', want ';')
  PASS  verify: a tab-separated paste (straight out of a spreadsheet) is read as tabs (got 'tab', want 'tab')
  PASS  verify: a comma INSIDE quotes does not split a column (got 4, want 4)
  PASS  verify: a duplicated header in the paste is reported
  PASS  verify: pasting the whole file still reads only the header line (got 'exact', want 'exact')
  PASS  verify: repeating the check gives an identical report (got array (
  'ok' => true,
  'delimiter' => ',',
  'store' => 
  array (
    0 => 'Type',
    1 => 'SKU',
    2 => 'Name',
    3 => 'Regular price',
    4 => 'Stock',
  ),
  'export' => 
  array (
    0 => 'Type',
    1 => 'SKU',
    2 => 'Name',
    3 => 'Regular price',
  ),
  'matched' => 
  array (
    0 => 'Type',
    1 => 'SKU',
    2 => 'Name',
    3 => 'Regular price',
  ),
  'missing' => 
  array (
    0 => 'Stock',
  ),
  'extra' => 
  array (
  ),
  'duplicates' => 
  array (
  ),
  'order_differs' => false,
  'verdict' => 'gaps',
), want array (
  'ok' => true,
  'delimiter' => ',',
  'store' => 
  array (
    0 => 'Type',
    1 => 'SKU',
    2 => 'Name',
    3 => 'Regular price',
    4 => 'Stock',
  ),
  'export' => 
  array (
    0 => 'Type',
    1 => 'SKU',
    2 => 'Name',
    3 => 'Regular price',
  ),
  'matched' => 
  array (
    0 => 'Type',
    1 => 'SKU',
    2 => 'Name',
    3 => 'Regular price',
  ),
  'missing' => 
  array (
    0 => 'Stock',
  ),
  'extra' => 
  array (
  ),
  'duplicates' => 
  array (
  ),
  'order_differs' => false,
  'verdict' => 'gaps',
))
  PASS  verify: checking never edits the mapping it was given (got array (
  0 => 
  array (
    'header' => 'Type',
    'source' => 'const:simple',
    'on' => true,
  ),
  1 => 
  array (
    'header' => 'SKU',
    'source' => 'sku',
    'on' => true,
  ),
  2 => 
  array (
    'header' => 'Name',
    'source' => 'name',
    'on' => true,
  ),
  3 => 
  array (
    'header' => 'Regular price',
    'source' => 'price',
    'on' => true,
  ),
  4 => 
  array (
    'header' => 'Internal note',
    'source' => 'const:x',
    'on' => false,
  ),
), want array (
  0 => 
  array (
    'header' => 'Type',
    'source' => 'const:simple',
    'on' => true,
  ),
  1 => 
  array (
    'header' => 'SKU',
    'source' => 'sku',
    'on' => true,
  ),
  2 => 
  array (
    'header' => 'Name',
    'source' => 'name',
    'on' => true,
  ),
  3 => 
  array (
    'header' => 'Regular price',
    'source' => 'price',
    'on' => true,
  ),
  4 => 
  array (
    'header' => 'Internal note',
    'source' => 'const:x',
    'on' => false,
  ),
))
  PASS  verify: POST /schemas/{id}/mapping/verify is registered
  PASS  verify: the endpoint requires schema.edit and checks CSRF
  PASS  verify: it resolves the mapping exactly as the editor and exporter do (saved ?? built-in preset)
  PASS  verify: it never writes — a check is a read
  PASS  verify: a bad paste answers 422 and an unknown template 404
  PASS  scope: api_keys gains a scope column defaulting to full — an upgrade never loosens
  PASS  scope: requireKey reads the key scope and refuses a write on a read-only key
  PASS  scope: mintKey persists the chosen scope, and anything but read is full access
  PASS  scope: /mcp hands the key scope to the core, which refuses write tools per tool
  PASS  scope: the key form lets an administrator mint a read-only key
  PASS  verify: the shipped WooCommerce preset covers a documented Woo header row (got array (
), want array (
))
  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: /batches keeps the admin wording
  PASS  demo card: /catalogs/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 (37 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: catalog.extracted is sent by the action where it happens [302]
  PASS  webhooks: batch.completed is sent by the action where it happens [302]
  PASS  webhooks: every event the Settings page offers is sent somewhere
  PASS  webhooks: a catalogue read inside a batch is announced as extracted too
  PASS  webhooks: the announcement carries the catalogue and its product count
  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)

————————————————————————————————————————————————————
ALL 527 TESTS PASSED (0 failed)

← Back to Specta · 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 →