← back to Dw Signup Fulfillment

scripts/selftest.js

162 lines

'use strict';
// Self-test for dw-signup-fulfillment. Everything runs in DRY_RUN — no live
// Shopify write, no real email, no webhook registration. It prints the exact
// Admin API calls + emails the service WOULD make, then exits 0 on success.
//
//   node scripts/selftest.js
//
// Exercises:
//   (a) the customers/create webhook with a fake payload + a VALID HMAC computed
//       from a test secret (proves HMAC verify accepts a correct sig and the
//       retail gift-card path fires),
//   (b) an invalid-HMAC webhook (proves it is rejected),
//   (c) /trade/apply then /admin/trade/:id/approve (proves the moderated flow +
//       tag + metafield + rep-notify + applicant email),
//   (d) round-robin across the whole rep roster (proves cursor rotation).

// Force DRY_RUN on, pin a known webhook secret, use a throwaway data dir so we
// never touch real trade-applications.jsonl / rep-cursor.json.
const os = require('os');
const fs = require('fs');
const path = require('path');

const TEST_SECRET = 'selftest-webhook-secret-abc123';
const TEST_SHARED_CODE = 'DWSAMPLES3';
process.env.DRY_RUN = '1';
process.env.SHOPIFY_WEBHOOK_SECRET = TEST_SECRET;
// Pin the shared retail code so the retail path asserts it emails exactly that code
// (in prod this is the code of the admin-created "DW Free Samples" discount).
process.env.RETAIL_SHARED_CODE = TEST_SHARED_CODE;
// Isolate persisted state to a temp dir so the roster is real but the cursor +
// applications are throwaway. We copy the seed reps.json in.
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dwsf-selftest-'));

const REPO = path.join(__dirname, '..');
// Seed the temp data dir with the real reps.json so round-robin uses the roster.
const dataDir = path.join(REPO, 'data');
fs.copyFileSync(path.join(dataDir, 'reps.json'), path.join(TMP, 'reps.json'));

// Point the libs at the temp data dir by monkey-patching module paths is fragile;
// instead we run against the real repo but reset the mutable files afterward.
// Simpler + honest: snapshot then restore data/rep-cursor.json + trade-applications.jsonl.
const cursorPath = path.join(dataDir, 'rep-cursor.json');
const appsPath = path.join(dataDir, 'trade-applications.jsonl');
const snap = {
  cursor: fs.existsSync(cursorPath) ? fs.readFileSync(cursorPath) : null,
  apps: fs.existsSync(appsPath) ? fs.readFileSync(appsPath) : null,
};
function restore() {
  if (snap.cursor === null) { try { fs.unlinkSync(cursorPath); } catch {} } else fs.writeFileSync(cursorPath, snap.cursor);
  if (snap.apps === null) { try { fs.unlinkSync(appsPath); } catch {} } else fs.writeFileSync(appsPath, snap.apps);
  try { fs.rmSync(TMP, { recursive: true, force: true }); } catch {}
}

const config = require('../lib/config');
const retailWebhook = require('../lib/retail-webhook'); // WIRED webhook handler (secret-less re-fetch auth)
const shopify = require('../lib/shopify');
const giftcard = require('../lib/giftcard');      // WIRED retail path (FINAL — gift card, memo §2)
const retailCode = require('../lib/retail-code'); // alternate (shared function code — needs admin discount)
const trade = require('../lib/trade');
const reps = require('../lib/reps');

function hr(title) { console.log('\n' + '='.repeat(72) + '\n' + title + '\n' + '='.repeat(72)); }
function ok(msg) { console.log('  ✔ ' + msg); }
function fail(msg) { console.log('  x FAIL: ' + msg); failures++; }
let failures = 0;

async function main() {
  console.log('DW signup fulfillment — SELFTEST (DRY_RUN=' + config.DRY_RUN + ')');
  console.log('Store: ' + config.SHOP_DOMAIN + '  API ' + config.SHOPIFY_API_VERSION);
  console.log('Retail path (WIRED): unique GIFT CARD (' + config.FREE_SAMPLE_COUNT + ' × $' + config.SAMPLE_PRICE + ' = $' + config.SAMPLE_GIFT_VALUE + ') emailed per signup');
  if (!config.DRY_RUN) { fail('DRY_RUN is OFF — refusing to run selftest that would make live writes'); return; }

  // ---------------------------------------------------------------------------
  hr('(a) webhook — secret-less re-fetch auth issues gift card to the REAL on-file email');
  // Monkeypatch the Shopify client so the handler sees a "found" customer whose REAL
  // on-file email DIFFERS from the (attacker-controlled) payload email.
  const REAL = { id: 8675309, email: 'real-customer@onfile.com', first_name: 'Dana', created_at: new Date().toISOString() };
  const _gc = shopify.getCustomer, _gm = shopify.getCustomerMetafield;
  shopify.getCustomer = async () => ({ ok: true, json: { customer: REAL } });
  shopify.getCustomerMetafield = async () => null; // no gift flag yet
  const res1 = await retailWebhook.handleCustomerCreate({ id: 8675309, email: 'ATTACKER@evil.com' });
  console.log('  result: ' + JSON.stringify(res1, null, 2));
  if (res1.ok && res1.email === REAL.email) ok('issued to REAL on-file email (' + res1.email + '), NOT the payload/attacker email'); else fail('used the wrong email: ' + JSON.stringify(res1));
  if (res1.issued && res1.issued.path === 'gift_card' && res1.issued.value === 12.75) ok('issued a $12.75 gift card (WOULD POST gift_cards)'); else fail('gift card not issued: ' + JSON.stringify(res1.issued));
  if (res1.issued && res1.issued.email && res1.issued.email.dryRun) ok('WOULD email the gift code (dry-run, no real send)'); else fail('gift email not dry-run');

  // ---------------------------------------------------------------------------
  hr('(a3) freshness gate — an OLD existing customer is NOT gifted (anti mass-mint)');
  const OLD = { id: 7000001, email: 'old-customer@onfile.com', first_name: 'Pat', created_at: '2024-01-01T00:00:00Z' };
  shopify.getCustomer = async () => ({ ok: true, json: { customer: OLD } });
  shopify.getCustomerMetafield = async () => null;
  const resOld = await retailWebhook.handleCustomerCreate({ id: 7000001, email: 'attacker@evil.com' });
  if (!resOld.ok && resOld.reason === 'stale_customer') ok('old customer (created 2024) rejected as stale — cannot mass-mint the customer base'); else fail('stale gate did not reject old customer: ' + JSON.stringify(resOld));
  shopify.getCustomer = async () => ({ ok: true, json: { customer: REAL } }); // restore fresh

  // ---------------------------------------------------------------------------
  hr('(b) forged / unknown customer id → REJECTED (the re-fetch IS the auth)');
  shopify.getCustomer = async () => ({ ok: true, json: {} }); // customer not found
  const res2 = await retailWebhook.handleCustomerCreate({ id: 999999, email: 'x@y.com' });
  if (!res2.ok && /not_found/.test(res2.reason || '')) ok('unknown id rejected (' + res2.reason + ')'); else fail('forged id was not rejected: ' + JSON.stringify(res2));

  // ---------------------------------------------------------------------------
  hr('(b2) already-issued customer → skipped (idempotent — one gift ever)');
  shopify.getCustomer = async () => ({ ok: true, json: { customer: REAL } });
  shopify.getCustomerMetafield = async () => 'true'; // flag already set
  const res3 = await retailWebhook.handleCustomerCreate({ id: 8675309 });
  if (res3.ok && res3.skipped === 'already_issued') ok('replay/second event is a no-op (' + res3.skipped + ')'); else fail('idempotency failed: ' + JSON.stringify(res3));
  shopify.getCustomer = _gc; shopify.getCustomerMetafield = _gm; // restore

  // ---------------------------------------------------------------------------
  hr('(b3) gift email clearly offers the 3 free samples + includes the code');
  const gtpl = require('../lib/email').retailGiftEmail({ firstName: 'Dana', code: 'DEMO-CODE-1234', value: 12.75, count: 3 });
  const blob = (gtpl.subject || '') + ' ' + (gtpl.html || '');
  if (/\b3\b/.test(blob) && /sample/i.test(blob)) ok('email references "3" and "sample"'); else fail('email does not clearly offer 3 free samples');
  if (/DEMO-CODE-1234/.test(blob)) ok('email includes the gift code'); else fail('email missing the code');

  // ---------------------------------------------------------------------------
  hr('(c) trade application → moderated approve');
  const created = trade.apply({ email: 'Studio@BigDesignCo.com', business_name: 'Big Design Co', resale_cert: 'CA-RESALE-99887', phone: '310-555-0142', shopify_customer_id: 5551234 });
  console.log('  applied: ' + JSON.stringify(created));
  if (created.status === 'pending' && created.created_at) ok('application stored pending with created_at ' + created.created_at);
  else fail('apply() did not produce a pending record with created_at');

  const pendingBefore = trade.listPending().length;
  const approve = await trade.approve(created.id);
  console.log('  approve result: ' + JSON.stringify(approve, null, 2));
  if (approve.ok && approve.status === 'approved') ok('approved; assigned rep = ' + approve.rep.name + ' <' + approve.rep.email + '>');
  else fail('approve failed');
  const tagStep = approve.steps.find(s => s.step === 'tag_trade');
  if (tagStep && tagStep.result && tagStep.result.WOULD) ok('WOULD tag customer `trade`: ' + tagStep.result.WOULD);
  else fail('no trade-tag step');
  const mfStep = approve.steps.find(s => s.step === 'set_metafield');
  if (mfStep && mfStep.result && mfStep.result.WOULD) ok('WOULD set custom.assigned_rep metafield: ' + mfStep.result.WOULD);
  else fail('no metafield step');
  const repMailStep = approve.steps.find(s => s.step === 'email_rep');
  if (repMailStep && repMailStep.dryRun) ok('WOULD email rep ' + repMailStep.to + ' (dry-run)'); else fail('no rep email');
  const appMailStep = approve.steps.find(s => s.step === 'email_applicant');
  if (appMailStep && appMailStep.dryRun) ok('WOULD email applicant ' + appMailStep.to + ' (dry-run)'); else fail('no applicant email');
  if (trade.listPending().length === pendingBefore - 1) ok('application moved out of pending queue');
  else fail('application still pending after approve');

  // ---------------------------------------------------------------------------
  hr('(d) fixed assignment to the DW House Account');
  const picks = [];
  for (let i = 0; i < 5; i++) picks.push(reps.assignRep());
  const house = reps.houseAccount();
  console.log('  house account = ' + house.name + ' <' + house.email + '>');
  console.log('  5 consecutive assignments: ' + picks.map(p => p.name).join(', '));
  // Every assignment must be the SAME single house account (no rotation).
  if (picks.every(p => p.name === house.name && p.email === house.email)) ok('all assignments go to the single DW House Account (no round-robin)');
  else fail('assignment was not the fixed house account');
  if (house.id === 'dw-house') ok('house account id = dw-house'); else fail('unexpected house id');

  hr('SUMMARY');
  if (failures === 0) { console.log('  ALL CHECKS PASSED — DRY_RUN, nothing written to Shopify, no email sent.'); }
  else { console.log('  ' + failures + ' CHECK(S) FAILED.'); }
}

main()
  .then(() => { restore(); process.exit(failures === 0 ? 0 : 1); })
  .catch(e => { console.error('SELFTEST ERROR:', e); restore(); process.exit(2); });