← back to Tk 10965 Zero Price Analysis

verify.mjs

86 lines

#!/usr/bin/env node
// TK-10965 — zero-price-orderable verification / test harness (READ-ONLY).
//
// Purpose: reproduce and quantify the "quote-only $0 product is checkout-orderable"
// defect, AND serve as the post-remediation acceptance test. Re-run after any fix:
//   exit 0  => CLEAN  (no vendor in scope has a $0 orderable non-sample variant)
//   exit 1  => DIRTY  (defect still present)  <-- fails a test/CI gate
//
// It is a SUPERSET of the shipped canary (skills/zero-price-orderable-canary):
// the canary searches only `tag:'quote-only'` (catches Phillipe Romano) and MISSES
// the identical bug on Fentucci Naturals (tagged `quotes`/`Needs-Price`, 0 quote-only).
// This harness scans by VENDOR so it sees the true blast radius.
//
// READ-ONLY: issues only Shopify Admin GraphQL *queries*. No mutations, no DB writes.
// $0 cost (live Shopify reads are unmetered).
//
// Usage:  node verify.mjs            # scan the known-affected vendor set
//         node verify.mjs --all      # also scan every quote/price-suppressed vendor (slower)
import fs from 'node:fs';

const ENV = '/Users/macstudio3/Projects/secrets-manager/.env';
const env = fs.readFileSync(ENV, 'utf8');
const val = k => (env.match(new RegExp('^' + k + '=(.*)$', 'm')) || [])[1]?.trim();
const DOM = val('SHOPIFY_STORE_DOMAIN'), TOK = val('SHOPIFY_ADMIN_TOKEN');
const API = `https://${DOM}/admin/api/2024-10/graphql.json`;

async function gql(q, v) {
  for (let a = 0; a < 8; a++) {
    const r = await fetch(API, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOK, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) });
    const j = await r.json();
    if (j.errors) {
      if (JSON.stringify(j.errors).includes('THROTTLED')) { await new Promise(s => setTimeout(s, 1800 * (a + 1))); continue; }
      throw new Error(JSON.stringify(j.errors));
    }
    return j.data;
  }
  throw new Error('gql retries exhausted');
}

// A variant is the defect iff: non-Sample AND price==0 AND availableForSale==true.
// (availableForSale is Shopify's authoritative "purchasable" signal — same test the canary uses.)
const isBadVariant = v => !/sample/i.test(v.title || '') && Number(v.price) === 0 && v.availableForSale === true;

async function scanVendor(vendor) {
  let after = null, pages = 0, active = 0;
  const bad = [];
  do {
    const d = await gql(
      `query($q:String!,$after:String){products(first:100,query:$q,after:$after){pageInfo{hasNextPage endCursor} nodes{id title tags variants(first:20){nodes{title price availableForSale inventoryPolicy inventoryQuantity inventoryItem{tracked}}}}}}`,
      { q: `status:active AND vendor:'${vendor}'`, after });
    for (const p of d.products.nodes) {
      active++;
      const v = p.variants.nodes.find(isBadVariant);
      if (v) bad.push({ id: p.id.split('/').pop(), title: p.title, policy: v.inventoryPolicy, qty: v.inventoryQuantity, tracked: v.inventoryItem?.tracked, quoteOnly: p.tags.some(t => /^quote[-_ ]?only$/i.test(t)) });
    }
    after = d.products.pageInfo.hasNextPage ? d.products.pageInfo.endCursor : null;
    pages++;
  } while (after && pages < 120);
  return { vendor, active, bad };
}

// Known-affected + guard-reference vendors. Extend as new lines are onboarded with setInventory2026().
const VENDORS = ['Phillipe Romano', 'Fentucci Naturals', 'De Gournay', 'Atomic 50 Ceilings'];

const results = [];
for (const v of VENDORS) results.push(await scanVendor(v));

let total = 0, q2026 = 0, missedByCanary = 0;
console.log('# TK-10965 zero-price-orderable verification —', new Date().toISOString());
for (const r of results) {
  total += r.bad.length;
  q2026 += r.bad.filter(b => b.qty === 2026).length;
  missedByCanary += r.bad.filter(b => !b.quoteOnly).length;
  console.log(`  ${r.vendor}: active=${r.active}  $0-orderable=${r.bad.length}  (qty2026=${r.bad.filter(b => b.qty === 2026).length}, canary-blind=${r.bad.filter(b => !b.quoteOnly).length})`);
}
console.log(`\nTOTAL $0-orderable non-sample variants: ${total}`);
console.log(`  of which qty==2026 (year-literal stamp): ${q2026}`);
console.log(`  of which UNTAGGED quote-only (canary blind spot): ${missedByCanary}`);
console.log(total === 0 ? '\nRESULT: CLEAN ✅' : '\nRESULT: DIRTY ❌ (defect present)');

// Emit a machine-readable snapshot next to the harness for the ticket record.
fs.writeFileSync(new URL('./evidence/verify-latest.json', import.meta.url),
  JSON.stringify({ ts: new Date().toISOString(), total, qty2026: q2026, canary_blind: missedByCanary, per_vendor: results.map(r => ({ vendor: r.vendor, active: r.active, bad: r.bad.length })) }, null, 2));

process.exit(total === 0 ? 0 : 1);