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