← back to Sanderson Onboard
tk10873/test_reprice.mjs
89 lines
#!/usr/bin/env node
// test_reprice.mjs — zero-dependency unit tests for the pure functions in
// reprice_lib.mjs (mutation-payload builder + band/sanity classifier + stats).
// No DB, no network. Exit nonzero on any failure.
import {
buildWouldRun, restoreMapSchema, restoreMapTemplate, classifyPrice, priceStats,
ourPriceDeviationWarn, ID_RESOLUTION_DISCLOSURE,
SAMPLE_PRICE, VARIANT_ID_PLACEHOLDER, BAND_MIN, BAND_MAX,
} from './reprice_lib.mjs';
let pass = 0, fail = 0;
function ok(cond, msg) { if (cond) { pass++; } else { fail++; console.error(`FAIL: ${msg}`); } }
// --- buildWouldRun: mutation shape, placeholder id, price formatting, sku echo ---
const wr = buildWouldRun('DWZF-187228', 148);
ok(wr.mutation === 'productVariantsBulkUpdate', 'buildWouldRun mutation name');
ok(wr.variables.variants.length === 1, 'buildWouldRun one variant');
ok(wr.variables.variants[0].id === VARIANT_ID_PLACEHOLDER, 'buildWouldRun uses variant-id placeholder (no live read)');
ok(wr.variables.variants[0].price === '148.00', 'buildWouldRun formats price to 2 decimals');
ok(wr.variables.variants[0].inventoryItem.sku === 'DWZF-187228', 'buildWouldRun echoes sku unchanged (identity)');
ok(/userErrors/.test(wr.returns), 'buildWouldRun selection set includes userErrors');
// no send path: buildWouldRun returns a plain object, never a promise / network call
ok(typeof wr === 'object' && typeof wr.then !== 'function', 'buildWouldRun returns a plain object, not a promise/send');
const wr2 = buildWouldRun('X', 1073.3);
ok(wr2.variables.variants[0].price === '1073.30', 'buildWouldRun rounds max price to 2dp');
// --- restoreMapSchema: the 4 required snapshot fields ---
const rm = restoreMapSchema();
ok('product_id' in rm && 'variant_id' in rm && 'old_price' in rm && 'new_price' in rm, 'restoreMapSchema has product_id/variant_id/old_price/new_price');
// --- classifyPrice: PASS / WARN / FAIL logic ---
// PASS: in-band, > sample, >= trade floor
ok(classifyPrice(148, SAMPLE_PRICE, null).verdict === 'PASS', 'classifyPrice PASS in-band no-trade');
ok(classifyPrice(345.70, SAMPLE_PRICE, 191).verdict === 'PASS', 'classifyPrice PASS above trade floor');
// FAIL: nonpositive
ok(classifyPrice(0, SAMPLE_PRICE, null).verdict === 'FAIL', 'classifyPrice FAIL zero');
ok(classifyPrice(-5, SAMPLE_PRICE, null).verdict === 'FAIL', 'classifyPrice FAIL negative');
ok(classifyPrice(null, SAMPLE_PRICE, null).verdict === 'FAIL', 'classifyPrice FAIL null');
ok(classifyPrice('abc', SAMPLE_PRICE, null).verdict === 'FAIL', 'classifyPrice FAIL non-numeric');
// FAIL: <= sample
ok(classifyPrice(4.25, SAMPLE_PRICE, null).verdict === 'FAIL', 'classifyPrice FAIL equal to sample');
ok(classifyPrice(3.00, SAMPLE_PRICE, null).verdict === 'FAIL', 'classifyPrice FAIL below sample');
// FAIL: below cost/trade floor
ok(classifyPrice(150, SAMPLE_PRICE, 200).verdict === 'FAIL', 'classifyPrice FAIL below trade floor');
// WARN: out-of-band low (but still > sample, positive) -> WARN not FAIL, exits 0-worthy
const low = classifyPrice(15, SAMPLE_PRICE, null);
ok(low.verdict === 'WARN' && /below band/.test(low.reasons[0]), 'classifyPrice WARN below band');
// WARN: out-of-band high
const high = classifyPrice(2500, SAMPLE_PRICE, null);
ok(high.verdict === 'WARN' && /above band/.test(high.reasons[0]), 'classifyPrice WARN above band');
// boundary: exactly BAND_MIN and BAND_MAX are IN band (PASS)
ok(classifyPrice(BAND_MIN, SAMPLE_PRICE, null).verdict === 'PASS', 'classifyPrice PASS at band min boundary');
ok(classifyPrice(BAND_MAX, SAMPLE_PRICE, null).verdict === 'PASS', 'classifyPrice PASS at band max boundary');
// a below-cost price that is ALSO out of band -> FAIL wins (hard invariant first)
ok(classifyPrice(10, SAMPLE_PRICE, 50).verdict === 'FAIL', 'classifyPrice FAIL (below cost) beats WARN (out of band)');
// --- priceStats ---
const st = priceStats([100, 176, 1073.3, 148, 200]);
ok(st.count === 5, 'priceStats count');
ok(st.min === 100, 'priceStats min');
ok(st.max === 1073.3, 'priceStats max');
ok(st.median === 176, 'priceStats median (sorted middle)');
const empty = priceStats([]);
ok(empty.count === 0 && empty.min === null, 'priceStats empty -> null stats');
// --- restoreMapTemplate: it is a TEMPLATE, NOT a populated map (Cody cycle-3) ---
const tmpl = restoreMapTemplate();
ok(tmpl._template === true, 'restoreMapTemplate is flagged _template:true');
ok(typeof tmpl.old_price === 'string' && !Number.isFinite(Number(tmpl.old_price)), 'restoreMapTemplate.old_price is a TEMPLATE marker, NOT a real number (dry-run cannot read live price)');
ok(/TEMPLATE/.test(tmpl.variant_id), 'restoreMapTemplate.variant_id is a template marker, not a real id');
ok(/NOT a populated/.test(tmpl._warning), 'restoreMapTemplate carries the not-a-populated-map warning');
ok(restoreMapSchema === restoreMapTemplate, 'restoreMapSchema is a back-compat alias of restoreMapTemplate');
// --- ourPriceDeviationWarn: scraper-unit-error signal (Cody cycle-3) ---
ok(ourPriceDeviationWarn(176, 176) === null, 'ourPriceDeviationWarn: identical -> no warn');
ok(ourPriceDeviationWarn(190, 176) === null, 'ourPriceDeviationWarn: within 15% -> no warn (~8%)');
ok(/deviates/.test(ourPriceDeviationWarn(300, 176) || ''), 'ourPriceDeviationWarn: >15% (~70%) -> warns');
ok(ourPriceDeviationWarn(176, null) === null, 'ourPriceDeviationWarn: no our_price -> no warn (inert)');
ok(ourPriceDeviationWarn(176, 0) === null, 'ourPriceDeviationWarn: zero our_price -> no warn (guard)');
ok(ourPriceDeviationWarn('abc', 176) === null, 'ourPriceDeviationWarn: non-numeric proposed -> no warn');
// half-price (unit error) is well outside 15%
ok(/deviates/.test(ourPriceDeviationWarn(88, 176) || ''), 'ourPriceDeviationWarn: half-price unit-error -> warns');
// --- id-resolution disclosure exists + is honest about the placeholder ---
ok(typeof ID_RESOLUTION_DISCLOSURE === 'string' && /UNPROVEN|unproven/.test(ID_RESOLUTION_DISCLOSURE), 'ID_RESOLUTION_DISCLOSURE states id-resolution is unproven by the dry-run');
console.log(`\ntest_reprice: ${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);