← back to Dw Signup Fulfillment

scripts/trade-approval-test.js

282 lines

#!/usr/bin/env node
'use strict';
// Real trade + Shopify modules, actual retained temp JSONL store, deterministic
// transport adapter. Real config is NEVER imported; no network module is exposed.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const vm = require('node:vm');
const crypto = require('node:crypto');
const repo = path.resolve(__dirname, '..');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tk11285-flow-'));
const checks = [];
function fixture(name, fault = null, settings = {}) {
  const dir = path.join(root, name);
  fs.mkdirSync(path.join(dir, 'lib'), { recursive: true });
  const calls = [], mails = [], state = { fault, customer: { id: 123, tags: 'existing', email: 'fixture@example.invalid' } };
  const config = { DRY_RUN: false, SHOP_DOMAIN: 'fixture.invalid', SHOPIFY_API_VERSION: 'fixture', SHOPIFY_FULFILLMENT_TOKEN: 'fixture-token', ...settings };
  const response = (json, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => structuredClone(json), headers: { get: () => null } });
  async function fetchAdapter(url, options) {
    assert.equal(new URL(url).hostname, 'fixture.invalid');
    const method = options.method;
    calls.push(method);
    const currentFault = state.fault;
    if (currentFault === 'throw' && method === 'PUT') throw Error('fixture transport failed');
    if ((currentFault === 'http500' && method === 'PUT') || (currentFault === 'mf500' && method === 'POST') || (currentFault === 'read500' && method === 'GET')) return response({}, 500);
    if (method === 'PUT') {
      if (currentFault === 'graphql_top') return response({ errors: [{ message: 'Denied' }] });
      if (currentFault === 'graphql_user') return response({ data: { tagsAdd: { node: { id: 'gid://shopify/Customer/123' }, userErrors: [{ message: 'Denied' }] } } });
      if (currentFault === 'malformed') return response(null);
      if (currentFault === 'empty') return response({});
      state.customer.tags = JSON.parse(options.body).customer.tags;
      return response({ customer: state.customer });
    }
    if (method === 'POST') {
      if (currentFault === 'mf_user') return response({ data: { metafieldsSet: { userErrors: [{ message: 'Denied' }] } } });
      return response({ metafield: { id: 789, owner_id: 123, ...JSON.parse(options.body).metafield } }, 201);
    }
    const customer = { ...state.customer };
    // These faults affect the final readback only, leaving tag-merge intact.
    if (calls.includes('POST') && currentFault === 'missing_tag') customer.tags = 'trade';
    if (calls.includes('POST') && currentFault === 'wrong_id') customer.id = 999;
    if (calls.includes('POST') && currentFault === 'missing_customer') return response({ customer: null });
    return response({ customer });
  }
  const email = {
    repNotifyEmail: () => ({ subject: 'Fixture rep', html: 'Fixture' }),
    tradeApprovedEmail: () => ({ subject: 'Fixture approval', html: 'Fixture' }),
    tradeRejectedEmail: () => ({ subject: 'Fixture rejection', html: 'Fixture' }),
    async sendEmail(payload) {
      mails.push(payload.source);
      if (state.fault === 'mail503' && payload.source === 'trade-approved') return { ok: false, status: 503 };
      if (state.fault === 'mail_throw' && payload.source === 'trade-approved') throw Error('ambiguous delivery');
      return { ok: true, status: 200 };
    },
  };
  const reps = { assignRep: () => ({ id: 'fixture-rep', name: 'Fixture Rep', email: 'rep@example.invalid' }) };
  const compile = (name, deps) => {
    const module = { exports: {} };
    vm.runInNewContext(fs.readFileSync(path.join(repo, 'lib', name + '.js'), 'utf8'), {
      module, exports: module.exports, __dirname: path.join(dir, 'lib'), Buffer,
      console: { log() {} }, setTimeout, clearTimeout, fetch: fetchAdapter,
      require(specifier) { if (!(specifier in deps)) throw Error('Blocked dependency: ' + specifier); return deps[specifier]; },
    }, { filename: name + '.js' });
    return module.exports;
  };
  const shopify = compile('shopify', { './config': config });
  const storeFs = { ...fs, writeFileSync(file, data, ...options) {
    if (state.fault === 'final_checkpoint' && String(data).includes('"status":"approved"')) {
      state.fault = null;
      throw Error('fixture one-shot final persistence failure');
    }
    return fs.writeFileSync(file, data, ...options);
  } };
  function loadTrade() { return compile('trade', { fs: storeFs, path, crypto, './config': config, './reps': reps, './email': email, './shopify': shopify }); }
  let trade = loadTrade();
  const app = trade.apply({ email: state.customer.email, business_name: 'Fixture', shopify_customer_id: 123 });
  return { email, state, mails, calls, shopify, config, app, trade, reload: loadTrade, persisted: () => JSON.parse(fs.readFileSync(trade.APPS_PATH, 'utf8').trim()), store: trade.APPS_PATH };
}
async function check(name, fn) {
  try { const detail = await fn(); checks.push({ name, verdict: 'PASS', ...detail }); }
  catch (error) { checks.push({ name, verdict: 'FAIL', error: error.stack }); process.exitCode = 1; }
}
(async () => {
  if (process.argv.includes('--baseline')) {
    await check('original HTTP500 falsely approves and sends both emails', async () => {
      const f = fixture('baseline', 'http500');
      const result = await f.trade.approve(f.app.id);
      assert.equal(result.ok, true);
      assert.equal(f.persisted().status, 'approved');
      assert.equal(f.mails.length, 2);
      return { result, persisted: f.persisted(), calls: f.calls, emails: f.mails, store: f.store };
    });
  } else {
    for (const fault of ['http500', 'mf500', 'read500', 'graphql_top', 'graphql_user', 'mf_user', 'malformed', 'empty', 'missing_tag', 'wrong_id', 'missing_customer', 'throw']) {
      await check(fault + ' fails closed', async () => {
        const f = fixture(fault, fault), result = await f.trade.approve(f.app.id);
        assert.equal(result.ok, false);
        assert.equal(f.persisted().status, 'pending');
        assert.equal(f.mails.length, 0);
        assert.equal(f.reload().get(f.app.id).status, 'pending');
        return { result, calls: f.calls, store: f.store };
      });
    }
    await check('verified entitlement then persisted approval and reload', async () => {
      const f = fixture('happy'), result = await f.trade.approve(f.app.id);
      assert.equal(result.ok, true);
      assert.equal(f.persisted().status, 'approved');
      assert.equal(f.reload().get(f.app.id).status, 'approved');
      assert.match(f.state.customer.tags, /existing/);
      assert.match(f.state.customer.tags, /trade_approved/);
      assert.equal(f.mails.length, 2);
      assert.ok(f.persisted().approval_progress.entitlement_verified_at);
      await f.reload().approve(f.app.id);
      assert.equal(f.mails.length, 2);
      return { result, persisted: f.persisted(), calls: f.calls, emails: f.mails, store: f.store };
    });
    await check('failed mutation can retry after reload', async () => {
      const f = fixture('retry', 'mf500');
      assert.equal((await f.trade.approve(f.app.id)).ok, false);
      assert.equal(f.mails.length, 0);
      f.state.fault = null;
      assert.equal((await f.reload().approve(f.app.id)).ok, true);
      assert.equal(f.mails.length, 2);
      return { persisted: f.persisted(), store: f.store };
    });
    await check('email partial success survives restart without duplicate rep send', async () => {
      const f = fixture('mail-retry', 'mail503');
      assert.equal((await f.trade.approve(f.app.id)).ok, false);
      assert.equal(f.persisted().status, 'pending');
      assert.equal(f.persisted().approval_progress.emails.rep.state, 'sent');
      f.state.fault = null;
      assert.equal((await f.reload().approve(f.app.id)).ok, true);
      assert.equal(f.mails.filter(x => x === 'trade-rep-notify').length, 1);
      assert.equal(f.mails.filter(x => x === 'trade-approved').length, 2);
      return { persisted: f.persisted(), emails: f.mails, store: f.store };
    });
    await check('ambiguous email delivery blocks resend across restart', async () => {
      const f = fixture('ambiguous-mail', 'mail_throw');
      assert.equal((await f.trade.approve(f.app.id)).ok, false);
      f.state.fault = null;
      const result = await f.reload().approve(f.app.id);
      assert.equal(result.ok, false);
      assert.match(result.error, /delivery_unknown/);
      assert.equal(f.mails.length, 2);
      return { result, persisted: f.persisted(), store: f.store };
    });
    await check('final checkpoint failure stays pending and retry skips completed emails', async () => {
      const f = fixture('checkpoint-failure', 'final_checkpoint');
      const first = await f.trade.approve(f.app.id);
      assert.equal(first.ok, false);
      assert.equal(f.persisted().status, 'pending');
      assert.equal(f.persisted().decision, null);
      assert.equal(f.mails.length, 2);
      assert.equal((await f.reload().approve(f.app.id)).ok, true);
      assert.equal(f.mails.length, 2);
      return { first, persisted: f.persisted(), store: f.store };
    });
    await check('same-process concurrent duplicate does not duplicate emails', async () => {
      const f = fixture('concurrent');
      const results = await Promise.all([f.trade.approve(f.app.id), f.trade.approve(f.app.id)]);
      assert.equal(results.filter(r => r.ok).length, 1);
      assert.equal(f.mails.length, 2);
      return { results, store: f.store };
    });
    await check('intake during approval await survives checkpoint rewrites', async () => {
      const f = fixture('intake-race');
      const original = f.shopify.addTags;
      let newer;
      f.shopify.addTags = async (...args) => {
        newer = f.trade.apply({ email: 'newer@example.invalid', business_name: 'Newer fixture' });
        return original(...args);
      };
      assert.equal((await f.trade.approve(f.app.id)).ok, true);
      assert.equal(f.reload().get(newer.id).status, 'pending');
      assert.equal(f.reload().readAll().length, 2);
      return { store: f.store, preservedApplication: newer.id };
    });
    function holdMail(f, source) {
      let release, entered;
      const ready = new Promise(resolve => { entered = resolve; });
      const held = new Promise(resolve => { release = resolve; });
      const original = f.email.sendEmail;
      f.email.sendEmail = async payload => {
        if (payload.source === source) { entered(); await held; }
        return original(payload);
      };
      return { ready, release };
    }
    await check('reject first blocks approval until rejection completes', async () => {
      const f = fixture('reject-first'), gate = holdMail(f, 'trade-rejected');
      const rejecting = f.trade.reject(f.app.id);
      await gate.ready;
      const approval = await f.trade.approve(f.app.id);
      assert.equal(approval.ok, false);
      assert.equal(f.calls.length, 0);
      gate.release();
      assert.equal((await rejecting).ok, true);
      assert.equal(f.persisted().status, 'rejected');
      assert.deepEqual(f.mails, ['trade-rejected']);
      return { approval, persisted: f.persisted(), store: f.store };
    });
    await check('approve first blocks rejection and preserves approval receipts', async () => {
      const f = fixture('approve-first'), gate = holdMail(f, 'trade-rep-notify');
      const approving = f.trade.approve(f.app.id);
      await gate.ready;
      const rejection = await f.trade.reject(f.app.id);
      assert.equal(rejection.ok, false);
      gate.release();
      assert.equal((await approving).ok, true);
      assert.equal(f.persisted().status, 'approved');
      assert.equal(f.persisted().approval_progress.emails.applicant.state, 'sent');
      assert.equal(f.mails.includes('trade-rejected'), false);
      return { rejection, persisted: f.persisted(), store: f.store };
    });
    await check('concurrent rejection attempts send only one rejection', async () => {
      const f = fixture('reject-reject'), gate = holdMail(f, 'trade-rejected');
      const first = f.trade.reject(f.app.id);
      await gate.ready;
      const duplicate = await f.trade.reject(f.app.id);
      assert.equal(duplicate.ok, false);
      gate.release();
      assert.equal((await first).ok, true);
      assert.deepEqual(f.mails, ['trade-rejected']);
      return { duplicate, persisted: f.persisted(), store: f.store };
    });
    await check('intake during rejection survives final persistence', async () => {
      const f = fixture('reject-intake'), gate = holdMail(f, 'trade-rejected');
      const rejecting = f.trade.reject(f.app.id);
      await gate.ready;
      const newer = f.trade.apply({ email: 'newer@example.invalid' });
      gate.release();
      assert.equal((await rejecting).ok, true);
      assert.equal(f.reload().get(newer.id).status, 'pending');
      assert.equal(f.reload().get(f.app.id).status, 'rejected');
      assert.equal(f.reload().readAll().length, 2);
      return { newerId: newer.id, store: f.store };
    });
    for (const failure of ['template', 'send']) {
      await check('rejection ' + failure + ' exception releases shared decision lock', async () => {
        const f = fixture('reject-fail-' + failure);
        if (failure === 'template') f.email.tradeRejectedEmail = () => { throw Error('fixture rejection template failed'); };
        else {
          const original = f.email.sendEmail;
          f.email.sendEmail = async payload => {
            if (payload.source === 'trade-rejected') throw Error('fixture rejection send failed');
            return original(payload);
          };
        }
        await assert.rejects(f.trade.reject(f.app.id), /fixture rejection/);
        assert.equal(f.persisted().status, 'pending');
        assert.equal((await f.trade.approve(f.app.id)).ok, true);
        assert.equal(f.persisted().status, 'approved');
        return { persisted: f.persisted(), store: f.store };
      });
    }
    await check('dry run reports simulated pending without external side effects', async () => {
      const f = fixture('dryrun', null, { DRY_RUN: true });
      const result = await f.trade.approve(f.app.id);
      assert.equal(result.ok, false); // Existing one-click caller must not say approved.
      assert.equal(result.simulated, true);
      assert.equal(result.dryRun, true);
      assert.equal(f.persisted().status, 'pending');
      assert.equal(f.mails.length, 0);
      assert.equal(f.calls.length, 0);
      return { result, store: f.store };
    });
    await check('live missing token cannot be treated as approval', async () => {
      const f = fixture('no-token', null, { SHOPIFY_FULFILLMENT_TOKEN: '' });
      assert.equal((await f.trade.approve(f.app.id)).ok, false);
      assert.equal(f.persisted().status, 'pending');
      assert.equal(f.mails.length, 0);
      assert.equal(f.calls.length, 0);
      return { store: f.store };
    });
  }
  const report = { timestamp: new Date().toISOString(), repo, environment: 'Real trade and Shopify modules in VM; real retained temp JSONL; config entirely stubbed; fetch replaced; require allowlisted; no external network', retainedRoot: root, sourceSha256: crypto.createHash('sha256').update(fs.readFileSync(path.join(repo, 'lib/trade.js'))).digest('hex'), checks };
  const output = process.env.TRADE_TEST_REPORT || path.join(root, 'report.json');
  fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n');
  console.log(JSON.stringify({ report: output, checks: checks.map(({ name, verdict, error }) => ({ name, verdict, error })) }, null, 2));
})();