← 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));
})();