← back to Dw Photo Capture

test-allshopify.js

114 lines

#!/usr/bin/env node
/**
 * End-to-end test for the "All Shopify" search + keep/replace photo paths.
 * Self-contained: creates a throwaway DRAFT product (never published — no price
 * means the activation gate can't pass it), exercises the real server endpoints,
 * verifies image counts, then DELETES the test product. No real catalog product
 * is ever touched. Run: node test-allshopify.js
 */
const https = require('https');
const fs = require('fs');
const path = require('path');

const BASE = 'http://127.0.0.1:9890';
const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
let TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || '';
if (!TOKEN) for (const l of fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8').split('\n'))
  if (l.startsWith('SHOPIFY_ADMIN_TOKEN=')) { TOKEN = l.split('=').slice(1).join('=').trim(); break; }

// 1x1 JPEG — content is irrelevant; we only assert image COUNTS change.
const JPG = '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=';
const DATAURL = 'data:image/jpeg;base64,' + JPG;

function shopify(method, p, payload) {
  return new Promise((resolve) => {
    const data = payload ? JSON.stringify(payload) : null;
    const req = https.request({ host: SHOP, path: `/admin/api/${API}${p}`, method,
      headers: Object.assign({ 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
        data ? { 'Content-Length': Buffer.byteLength(data) } : {}) }, (res) => {
      let d = ''; res.on('data', c => d += c); res.on('end', () => { let b = null; try { b = JSON.parse(d); } catch (e) {} resolve({ status: res.statusCode, body: b }); });
    });
    req.on('error', e => resolve({ status: 0, err: e.message }));
    if (data) req.write(data); req.end();
  });
}
function srv(method, p, payload) {
  return new Promise((resolve) => {
    const http = require('http'); const data = payload ? JSON.stringify(payload) : null;
    const req = http.request(BASE + p, { method, headers: Object.assign({ Authorization: AUTH, 'Content-Type': 'application/json' }, data ? { 'Content-Length': Buffer.byteLength(data) } : {}) }, (res) => {
      let d = ''; res.on('data', c => d += c); res.on('end', () => { let b = null; try { b = JSON.parse(d); } catch (e) {} resolve({ status: res.statusCode, body: b }); });
    });
    req.on('error', e => resolve({ status: 0, err: e.message }));
    if (data) req.write(data); req.end();
  });
}
const imgCount = async (pid) => ((await shopify('GET', `/products/${pid}/images.json`)).body.images || []).length;
let pass = 0, fail = 0;
const ok  = (c, m) => { (c ? pass++ : fail++); console.log(`  ${c ? '✓' : '✗ FAIL'} ${m}`); };

(async () => {
  const TAG = 'ZZTEST-PHOTOCAP-' + process.pid;
  console.log(`\n── Setup: throwaway DRAFT product (${TAG}) ──`);
  const created = await shopify('POST', '/products.json', { product: {
    title: 'ZZ TEST PhotoCapture DELETE ME', vendor: 'ZZTESTVENDOR', status: 'draft', published: false,
    variants: [{ sku: TAG, price: '0.00' }],          // price 0 → activation gate cannot pass → never goes live
    images: [{ attachment: JPG }, { attachment: JPG }] // two pre-existing "professional" images
  } });
  const pid = created.body && created.body.product && created.body.product.id;
  if (!pid) { console.log('  could not create test product:', JSON.stringify(created.body)); process.exit(1); }
  ok(await imgCount(pid) === 2, `seeded with 2 existing images`);

  try {
    console.log(`\n── Test 1: live store-wide search finds it (retry for index lag) ──`);
    // Shopify product-search is eventually consistent — a just-created product needs
    // a few seconds to enter the search index. Retry with backoff before asserting.
    let s, hit, waited = 0;
    for (let i = 0; i < 8; i++) {
      s = await srv('GET', '/api/shopify-search?q=' + encodeURIComponent(TAG));
      hit = (s.body.items || []).find(x => x.dw_sku === TAG);
      if (hit) { console.log(`  (indexed after ~${waited}s)`); break; }
      await new Promise(r => setTimeout(r, 5000)); waited += 5;
    }
    ok(!!hit, `/api/shopify-search returns the product by SKU`);
    ok(hit && hit.keep_images === true, `result is stamped keep_images:true (non-destructive default)`);
    ok(hit && hit.scope === 'shopify', `result scope = "shopify"`);

    console.log(`\n── Test 2: KEEP path (default) preserves existing imagery ──`);
    const k = await srv('POST', '/api/photo', { dw_sku: TAG, product_id: pid, dataUrl: DATAURL, keep_images: true });
    ok(k.body && k.body.ok, `/api/photo (keep) returned ok`);
    ok(await imgCount(pid) === 3, `image count 2 → 3 (new photo ADDED, originals kept)`);
    ok(k.body && k.body.live === false, `draft did NOT auto-activate (no price) — gate held`);

    console.log(`\n── Test 3: REPLACE path (armed toggle) wipes to one ──`);
    const r = await srv('POST', '/api/photo', { dw_sku: TAG, product_id: pid, dataUrl: DATAURL, keep_images: false });
    ok(r.body && r.body.ok, `/api/photo (replace) returned ok`);
    ok(await imgCount(pid) === 1, `image count 3 → 1 (replace-all wiped siblings)`);

    console.log(`\n── Test 4: MULTI add (batch /api/photos) appends, keeps featured ──`);
    const feat0 = ((await shopify('GET', `/products/${pid}/images.json`)).body.images.find(i => i.position === 1) || {}).id;
    const m = await srv('POST', '/api/photos', { dw_sku: TAG, product_id: pid, dataUrls: [DATAURL, DATAURL, DATAURL], meta: { title: 'ZZ TEST', product_id: pid } });
    ok(m.body && m.body.ok && m.body.added === 3, `/api/photos added 3 of 3 (got ${m.body && m.body.added})`);
    ok(await imgCount(pid) === 4, `image count 1 → 4 (3 appended, original kept)`);
    const feat1 = ((await shopify('GET', `/products/${pid}/images.json`)).body.images.find(i => i.position === 1) || {}).id;
    ok(feat0 && feat0 === feat1, `featured image UNCHANGED by batch add (append, not re-feature)`);
  } finally {
    console.log(`\n── Teardown: delete the throwaway product + local residue ──`);
    const del = await shopify('DELETE', `/products/${pid}.json`);
    ok(del.status >= 200 && del.status < 300, `test product deleted (HTTP ${del.status})`);
    // scrub local photo files; the progress.json key is owned by the LIVE server's
    // in-memory state, so an external file edit gets re-flushed until the server
    // reloads — the harmless ZZTEST key clears on the next dwphoto restart.
    try {
      const PF = path.join(__dirname, 'data/progress.json'); const pr = JSON.parse(fs.readFileSync(PF, 'utf8'));
      if (pr[TAG]) { delete pr[TAG]; fs.writeFileSync(PF, JSON.stringify(pr, null, 2)); }
      for (const f of fs.readdirSync(path.join(__dirname, 'photos'))) if (f.startsWith(TAG)) fs.unlinkSync(path.join(__dirname, 'photos', f));
      ok(true, `photos scrubbed (progress key clears on next server restart — harmless, not in worklist)`);
    } catch (e) { ok(false, `cleanup error: ${e.message}`); }
  }

  console.log(`\n══ ${pass} passed, ${fail} failed ══\n`);
  process.exit(fail ? 1 : 0);
})();