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

Specta Test run, as shipped in the download

727 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. 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  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  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 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: 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 color
  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 v4.1.3 + API.md ships
  PASS  /healthz, api_ping and openapi all report 4.1.3
  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, catalogs 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 catalog 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  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: /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 (54 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 catalog read inside a batch is announced as extracted too
  PASS  webhooks: the announcement carries the catalog and its product count
  PASS  backup redaction: a credential was planted in every credential column (13)
  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)

== 3.2.0: Sonnet 5 suggested for messy or handwritten scans ==
  PASS  the Anthropic default is unchanged (claude-sonnet-4-6) (got 'claude-sonnet-4-6', want 'claude-sonnet-4-6')
  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: variant proposals (pure) ==
  PASS  option columns are the size and color attributes (got array (
  0 => 'colour',
  1 => 'size',
), want array (
  0 => 'colour',
  1 => 'size',
))
  PASS  a base name drops the option values and separators (got 'crew tee', want 'crew tee')
  PASS  ...whole words only (M does not eat "Merino")
  PASS  one proposal: the three cotton tees (the polyester one differs in material) (got 1, want 1)
  PASS  ...its members (got array (
  0 => 1,
  1 => 2,
  2 => 3,
), want array (
  0 => 1,
  1 => 2,
  2 => 3,
))
  PASS  ...both options vary, in template order (got array (
  0 => 'colour',
  1 => 'size',
), want array (
  0 => 'colour',
  1 => 'size',
))
  PASS  ...titled from the first row as printed (got 'Crew Tee', want 'Crew Tee')
  PASS  two identical rows are duplicates, not variants (the aprons)
  PASS  a dismissed proposal is not offered again
  PASS  a row already grouped is never proposed again
  PASS  no size/colour columns → no proposals
  PASS  parent SKU: the members' common prefix (got 'TEE', want 'TEE')
  PASS  ...never an SKU already in the file (got 'TEE-P', want 'TEE-P')
  PASS  ...or the title when the SKUs share too little (got 'CREW-TEE', want 'CREW-TEE')

== 3.2.0: import-ready exports (pure) ==
  PASS  Shopify: option and image columns are appended after the existing ones
  PASS  Shopify: the group is ONE handle over three rows, then the stool (got array (
  0 => 'Handle',
  1 => 'crew-tee',
  2 => 'crew-tee',
  3 => 'crew-tee',
  4 => 'oak-stool',
), want array (
  0 => 'Handle',
  1 => 'crew-tee',
  2 => 'crew-tee',
  3 => 'crew-tee',
  4 => 'oak-stool',
))
  PASS  Shopify: the lead row names the options and the product; variant rows carry only values
  PASS  Shopify: each variant keeps its own SKU and price
  PASS  Shopify: the group body leaves out the option values
  PASS  Shopify: a single product reads Title / Default Title
  PASS  Shopify: images carried with their position within the handle
  PASS  WooCommerce: one variable parent, three variations, then the simple stool (got array (
  0 => 'Type',
  1 => 'variable',
  2 => 'variation',
  3 => 'variation',
  4 => 'variation',
  5 => 'simple',
), want array (
  0 => 'Type',
  1 => 'variable',
  2 => 'variation',
  3 => 'variation',
  4 => 'variation',
  5 => 'simple',
))
  PASS  WooCommerce: the parent lists every option value and has no price of its own
  PASS  WooCommerce: each variation names its parent and its own values
  PASS  WooCommerce: images in the Images column
  PASS  with no variants and no images, both presets are exactly as before (no new columns)
  PASS  JSON carries the image URL and the variant group
  PASS  a saved mapping can take the image URL as a source

== 3.2.0: database proofs (tests/v320_db_check.php) ==
  PASS  the database proof ran cleanly
  PASS  the 3.2.0 columns and table arrive on an install that is already current (not only on old ones)
  PASS  demo: the apparel sheet's five tees are proposed as one product with Sizes and Color
  PASS  pass B demo: Bright Lighting's October list shows two repriced, one new, one gone; two supplier profiles
  PASS  "Reset all data" also removes variant groups; supplier profiles stay, like templates
  PASS  pass B: a supplier's list, fetched, is filed as a catalog waiting for extraction
  PASS  pass B: an unchanged list files nothing
  PASS  pass B: a changed list is filed again; a web page instead of a file is refused and named
  PASS  pass B demo: the Halo Pendant has a written text waiting for approval (from its facts, never the price)
  PASS  "Color Temperature" is a spec, not a variant option

== 3.2.0: variants and images over HTTP (tests/v320_http_check.php) ==
  PASS  the HTTP proof ran (no PHP warning logged)
  PASS  a viewer sees the proposal but no Group button
  PASS  a viewer cannot confirm a group (403)
  PASS  the catalog page proposes the tees and the totes
  PASS  Group these groups the three rows under the title the reviewer typed
  PASS  a group is not confirmed twice
  PASS  Not variants keeps the totes separate and stops proposing them
  PASS  an image pattern that is not an http(s) address is refused
  PASS  the pattern fills empty image URLs only; a typed one is kept; no SKU, no URL
  PASS  a product's image URL that is not an address is refused
  PASS  a typed image URL is saved
  PASS  Shopify export: one handle, three variants, the options named, the image carried
  PASS  WooCommerce export: one variable parent and three variations
  PASS  JSON export: the group and the image URL
  PASS  Ungroup restores separate products
  PASS  pass B: the catalog sheet is a PDF of the reviewed range, and says what it left out
  PASS  pass B: a confirmed group is one section with a table of its variants
  PASS  pass B: the spec sheet and the catalog sheet carry the approved text, not the generated one
  PASS  pass B: a viewer can download the sheet (read access)
  PASS  pass B feeds: no feed without a https:// product-page address
  PASS  pass B feeds: the Google file, tab-separated, one row per ready product, price with its currency, the supplier as brand
  PASS  pass B feeds: a group's variants share an item_group_id and the group's page, with color and size
  PASS  pass B feeds: the settings are remembered for the catalog
  PASS  pass B feeds: the catalog page names the product left out and why
  PASS  pass B feeds: the Meta CSV uses Meta's availability wording and the chosen condition and brand
  PASS  pass B feeds: a viewer can download, but not change the saved settings
  PASS  pass B suppliers: the page offers a supplier already on a catalog, with a prefilled new-catalogue link
  PASS  pass B suppliers: an http://, loopback or cloud-metadata address is refused
  PASS  pass B suppliers: saving under the same name (any case or spacing) updates the one profile
  PASS  pass B suppliers: "New catalog" arrives with the supplier and template chosen
  PASS  pass B suppliers: the next list shows prices changed, products new and gone since the last one
  PASS  pass B suppliers: the first list from a supplier shows no comparison
  PASS  round 4: a Shopify export against a catalog: products not in the store yet, the store's products this list dropped, by SKU else name, one vendor only
  PASS  round 4: the check stores nothing; a file with no SKU or name column and a post without the CSRF token are refused
  PASS  pass B suppliers: a viewer sees the profiles but cannot change or fetch them
  PASS  customer voice: a typed image description is saved (spaces tidied) and becomes the Shopify Image Alt Text
  PASS  customer voice: "reviewed only" CSV, JSON and API exports leave out an unreviewed product; the page offers them with the count
  PASS  API: a viewer's key gets 403 on POST /api/catalogs and adds nothing; an admin's key gets 201
  PASS  pass B: the catalog offers the writer, in British English by default
  PASS  pass B: another language is cleaned, remembered, and named in the prompt
  PASS  pass B: "Write titles and descriptions" writes for the reviewed products
  PASS  pass B: the product shows the text, the facts it came from, and flags the invented "15"
  PASS  pass B: generated text is not exported until approved
  PASS  pass B: the approved (edited) text leads the Shopify export
  PASS  pass B: Discard clears the text

== 3.2.0 pass B: titles and descriptions from confirmed facts (pure) ==
  PASS  facts: the name and non-empty attributes — never the price, never a placeholder (got array (
  'Name' => 'Organic Crew Tee',
  'Material' => '100% organic cotton',
  'Weight' => '210 g',
), want array (
  'Name' => 'Organic Crew Tee',
  'Material' => '100% organic cotton',
  'Weight' => '210 g',
))
  PASS  the prompt forbids adding anything not in the facts, in British English, JSON only
  PASS  the language: letters only, British English when blank (got array (
  0 => 'British English',
  1 => 'Deutsch DROP',
  2 => 'Português (Brasil)',
), want array (
  0 => 'British English',
  1 => 'Deutsch DROP',
  2 => 'Português (Brasil)',
))
  PASS  ...and the prompt names it
  PASS  the facts are passed as a list
  PASS  the title is one line, capped at 80; the description capped at 90 words
  PASS  a number not in the facts is flagged (250 g invented; 210 and 100 are in them) (got array (
  0 => '250',
), want array (
  0 => '250',
))
  PASS  the demo writes from the facts only, no call
  PASS  a unit is shown once, even when the label carries it (got array (
  0 => 'Weight (g)',
  1 => 'Weight (g)',
  2 => 'Weight',
), want array (
  0 => 'Weight (g)',
  1 => 'Weight (g)',
  2 => 'Weight',
))
  PASS  an APPROVED text leads the Shopify title and body
  PASS  ...and the WooCommerce name and description
  PASS  a variant group keeps its confirmed title; the lead member's approved description leads on both presets
  PASS  API and MCP rows carry the image, the group, and the written text only once approved

== 3.2.0 customer voice: image alt text (pure) ==
  PASS  Shopify alt text: a variant gets its group title and its own options; a single product its name (got array (
  0 => 'Crew Tee — Black, M',
  1 => 'Crew Tee — White, M',
  2 => 'Oak Stool',
), want array (
  0 => 'Crew Tee — Black, M',
  1 => 'Crew Tee — White, M',
  2 => 'Oak Stool',
))
  PASS  typed alt text wins; otherwise the approved written title
  PASS  the JSON export and a saved mapping carry it too

== 3.2.0 pass B: the catalog sheet (pure) ==
  PASS  reviewed only: the group, then the stool; the unreviewed apron is counted as left out (got array (
  0 => 
  array (
    0 => 'Crew Tee',
    1 => 'Oak Stool, hand-finished',
  ),
  1 => 1,
), want array (
  0 => 
  array (
    0 => 'Crew Tee',
    1 => 'Oak Stool, hand-finished',
  ),
  1 => 1,
))
  PASS  a group: its variants with their own option values and prices (got array (
  0 => 
  array (
    0 => 'SKU',
    1 => 'Color',
    2 => 'Size',
    3 => 'Price',
  ),
  1 => 
  array (
    0 => 
    array (
      0 => 'TEE-BLK-M',
      1 => 'Black',
      2 => 'M',
      3 => 'GBP 12.00',
    ),
    1 => 
    array (
      0 => 'TEE-BLK-L',
      1 => 'Black',
      2 => 'L',
      3 => 'GBP 13.00',
    ),
    2 => 
    array (
      0 => 'TEE-WHT-M',
      1 => 'White',
      2 => 'M',
      3 => 'GBP 12.00',
    ),
  ),
), want array (
  0 => 
  array (
    0 => 'SKU',
    1 => 'Color',
    2 => 'Size',
    3 => 'Price',
  ),
  1 => 
  array (
    0 => 
    array (
      0 => 'TEE-BLK-M',
      1 => 'Black',
      2 => 'M',
      3 => 'GBP 12.00',
    ),
    1 => 
    array (
      0 => 'TEE-BLK-L',
      1 => 'Black',
      2 => 'L',
      3 => 'GBP 13.00',
    ),
    2 => 
    array (
      0 => 'TEE-WHT-M',
      1 => 'White',
      2 => 'M',
      3 => 'GBP 12.00',
    ),
  ),
))
  PASS  ...its shared specifications once, never the option columns or the description
  PASS  a single product: approved title and description, SKU and price, specifications (got array (
  'title' => 'Oak Stool, hand-finished',
  'caption' => 'SKU STL-1 · GBP 45.00',
  'description' => 'A stool.',
  'specs' => 
  array (
    0 => 
    array (
      0 => 'Color',
      1 => 'Natural',
    ),
    1 => 
    array (
      0 => 'Material',
      1 => 'Oak',
    ),
  ),
  'variant_head' => 
  array (
  ),
  'variants' => 
  array (
  ),
), want array (
  'title' => 'Oak Stool, hand-finished',
  'caption' => 'SKU STL-1 · GBP 45.00',
  'description' => 'A stool.',
  'specs' => 
  array (
    0 => 
    array (
      0 => 'Color',
      1 => 'Natural',
    ),
    1 => 
    array (
      0 => 'Material',
      1 => 'Oak',
    ),
  ),
  'variant_head' => 
  array (
  ),
  'variants' => 
  array (
  ),
))
  PASS  without an approved text, the supplier's description is used; a needed value that is missing is printed as not specified

== 3.2.0 pass B: marketplace feeds (pure) ==
  PASS  options: an address must start with http(s)://; the supplier is the default brand; new and in stock by default (got array (
  0 => 
  array (
    'link' => '',
    'brand' => 'Acme',
    'condition' => 'new',
    'in_stock' => true,
  ),
  1 => 
  array (
    'link' => 'https://s.example/{sku}',
    'brand' => 'Own Brand',
    'condition' => 'used',
    'in_stock' => false,
  ),
), want array (
  0 => 
  array (
    'link' => '',
    'brand' => 'Acme',
    'condition' => 'new',
    'in_stock' => true,
  ),
  1 => 
  array (
    'link' => 'https://s.example/{sku}',
    'brand' => 'Own Brand',
    'condition' => 'used',
    'in_stock' => false,
  ),
))
  PASS  a template with an EAN column adds gtin, digits only
  PASS  the ready rows only, with their reasons for the rest (reviewed only; unreviewed not even counted) (got array (
  0 => 
  array (
    0 => 'STL-1',
    1 => 'P12',
  ),
  1 => 
  array (
    0 => 
    array (
      0 => 'No Photo',
      1 => 'no image URL',
    ),
    1 => 
    array (
      0 => 'No Price',
      1 => 'no price',
    ),
    2 => 
    array (
      0 => 'Oak Stool again',
      1 => 'SKU already used by another row',
    ),
  ),
  2 => 5,
), want array (
  0 => 
  array (
    0 => 'STL-1',
    1 => 'P12',
  ),
  1 => 
  array (
    0 => 
    array (
      0 => 'No Photo',
      1 => 'no image URL',
    ),
    1 => 
    array (
      0 => 'No Price',
      1 => 'no price',
    ),
    2 => 
    array (
      0 => 'Oak Stool again',
      1 => 'SKU already used by another row',
    ),
  ),
  2 => 5,
))
  PASS  the approved title and description; the currency falls back to the base one; Google's availability wording
  PASS  TSV: a tab or line break inside a value becomes a space; a formula-looking title is defused
  PASS  Meta: its own availability wording, CSV, the same rows
  PASS  no page address: every row is left out with that reason

== 3.2.0 pass B: repeat imports per supplier (pure) ==
  PASS  due: never checked → due; weekly → 7 days later (an hour of slack), not before; off or no address → never
  PASS  an address: https only, never a private or reserved network
  PASS  a redirect, a 404 and an empty file are refused with the reason
  PASS  changes: by SKU (any case), else by name; a price that appears counts as changed (got array (
  'added' => 
  array (
    0 => 
    array (
      0 => 'Cap',
      1 => 'CAP-1',
      2 => 'GBP 8.00',
    ),
  ),
  'removed' => 
  array (
    0 => 
    array (
      0 => 'Old Mug',
      1 => 'MUG-1',
      2 => 'GBP 5.00',
    ),
  ),
  'price' => 
  array (
    0 => 
    array (
      0 => 'Crew Tee (new name)',
      1 => 'tee-1',
      2 => 'GBP 12.00',
      3 => 'GBP 13.50',
    ),
    1 => 
    array (
      0 => 'Apron',
      1 => 'APR-1',
      2 => '—',
      3 => 'GBP 7.00',
    ),
  ),
  'same' => 1,
), want array (
  'added' => 
  array (
    0 => 
    array (
      0 => 'Cap',
      1 => 'CAP-1',
      2 => 'GBP 8.00',
    ),
  ),
  'removed' => 
  array (
    0 => 
    array (
      0 => 'Old Mug',
      1 => 'MUG-1',
      2 => 'GBP 5.00',
    ),
  ),
  'price' => 
  array (
    0 => 
    array (
      0 => 'Crew Tee (new name)',
      1 => 'tee-1',
      2 => 'GBP 12.00',
      3 => 'GBP 13.50',
    ),
    1 => 
    array (
      0 => 'Apron',
      1 => 'APR-1',
      2 => '—',
      3 => 'GBP 7.00',
    ),
  ),
  'same' => 1,
))
  PASS  store export (Shopify): title and vendor carried down by Handle, image-only rows skipped (got array (
  0 => 
  array (
    0 => 
    array (
      0 => 'Crew Tee',
      1 => 'TEE-L',
      2 => 'ACME',
    ),
    1 => 
    array (
      0 => 'Crew Tee',
      1 => 'TEE-M',
      2 => 'ACME',
    ),
    2 => 
    array (
      0 => 'Rib Beanie',
      1 => '',
      2 => 'acme',
    ),
    3 => 
    array (
      0 => 'Mug',
      1 => 'MUG-7',
      2 => 'Other',
    ),
  ),
  1 => 5,
  2 => 'Variant SKU',
  3 => 'Vendor',
), want array (
  0 => 
  array (
    0 => 
    array (
      0 => 'Crew Tee',
      1 => 'TEE-L',
      2 => 'ACME',
    ),
    1 => 
    array (
      0 => 'Crew Tee',
      1 => 'TEE-M',
      2 => 'ACME',
    ),
    2 => 
    array (
      0 => 'Rib Beanie',
      1 => '',
      2 => 'acme',
    ),
    3 => 
    array (
      0 => 'Mug',
      1 => 'MUG-7',
      2 => 'Other',
    ),
  ),
  1 => 5,
  2 => 'Variant SKU',
  3 => 'Vendor',
))
  PASS  store export (WooCommerce, semicolons): SKU, Name and Brands found
  PASS  a file with no SKU or name column says so
  PASS  compare: by SKU (any case), else by name; one vendor only; duplicates once (got array (
  'matched_sku' => 1,
  'matched_name' => 1,
  'not_in_store' => 
  array (
    0 => 
    array (
      0 => 'Canvas Tote',
      1 => 'TOTE-N',
    ),
  ),
  'not_in_list' => 
  array (
    0 => 
    array (
      0 => 'Crew Tee',
      1 => 'TEE-M',
    ),
  ),
  'store_items' => 3,
  'not_in_store_n' => 1,
  'not_in_list_n' => 1,
), want array (
  'matched_sku' => 1,
  'matched_name' => 1,
  'not_in_store' => 
  array (
    0 => 
    array (
      0 => 'Canvas Tote',
      1 => 'TOTE-N',
    ),
  ),
  'not_in_list' => 
  array (
    0 => 
    array (
      0 => 'Crew Tee',
      1 => 'TEE-M',
    ),
  ),
  'store_items' => 3,
  'not_in_store_n' => 1,
  'not_in_list_n' => 1,
))
  PASS  demo: the sample store against the Bright Lighting list: 2 by SKU, 1 by name, 3 to add, the Aura lamp dropped
  PASS  compare without a vendor counts every product in the store
  PASS  a large store: the lists keep the first 500, the counts stay whole
  PASS  an upload is read from disk row by row: BOM skipped, semicolons, a Windows-1252 cell converted
  PASS  the cron script is CLI only and fetches only what is due
  PASS  without an approved text the export is exactly as before

————————————————————————————————————————————————————

== 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)
  PASS  updates: the updater table holds exactly one state row
  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 Specta 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 Specta 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 Specta, 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)
ALL 727 TESTS PASSED (0 failed)

← Back to Specta · Manual · Quickstart · API