← back to CelebritySignatures

test/wear-accessories.test.mjs

129 lines

import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, readFile, writeFile, copyFile, rm, realpath } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawn, execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { createServer } from 'node:net';
import { once } from 'node:events';
import { buildPayload } from '../scripts/submit-pod-draft-printify.mjs';

const root = new URL('..', import.meta.url);
const templates = JSON.parse(await readFile(new URL('data/wear-templates.json', root), 'utf8'));
const catalog = JSON.parse(await readFile(new URL('verification/hat-sock-catalog.json', root), 'utf8'));
const ids = ['dad-cap', 'trucker-cap', 'bucket-hat', 'crew-socks'];
const draft = { orderId: 'fixture.0', provider: 'printify', status: 'DRAFT_UNSENT',
  signature_name: 'Fixture', design_image_url: 'https://fixture.invalid/signature.png',
  recipient_email: 'fixture@example.invalid', recipient: { name: 'Fixture Buyer',
    address1: '1 Fixture Lane', city: 'Fixture', state_code: 'CA', country_code: 'US', zip: '90001' } };
const item = id => { const g = templates.garments.find(g => g.id === id);
  return { qid: 'QFixture', garment: id, color: g.colors[0].id, size: g.sizes[0] }; };
const env = { PATH: process.env.PATH, WEAR_SALES_LIVE: '1', WEAR_POD_PROVIDER: 'printify',
  PRINTIFY_API_TOKEN: 'fixture-token', PRINTIFY_SHOP_ID: 'fixture-shop', PRINTIFY_MANUAL_APPROVAL_CONFIRMED: '1',
  STRIPE_TEST_SECRET_KEY: 'sk_test_fixture', WEAR_STRIPE_LIVE_ENABLED: '0', STRIPE_LIVE_ENABLED: '0' };

test('all four accessory payloads match real catalog variants and raster print areas', () => {
  for (const id of ids) {
    const g = templates.garments.find(g => g.id === id);
    const source = catalog.products.find(p => p.id === id);
    const payload = buildPayload(templates, { ...draft, ...item(id) }).line_items[0];
    assert.equal(payload.blueprint_id, source.blueprint.id);
    assert.equal(payload.print_provider_id, source.catalog.id);
    const variant = source.catalog.variants.find(v => v.id === payload.variant_id);
    assert.ok(variant, 'variant exists in provider response');
    assert.equal(variant.options.size, g.sizes[0]);
    assert.equal(variant.options.color, g.colors[0].label.split(' / ')[0], 'printer color matches the selected color');
    for (const [position, images] of Object.entries(payload.print_areas)) {
      const supported = variant.placeholders.find(p => p.position === position);
      assert.ok(supported, 'printer supports '+position);
      assert.ok(['dtf', 'dtg'].includes(supported.decoration_method), 'raster decoration method');
      assert.equal(images[0].src, draft.design_image_url);
    }
    assert.equal(g.saleEnabled, false, 'requires cost/mockup approval');
    assert.equal(g.priceUsd, null, 'no invented price');
  }
  const socks = buildPayload(templates, { ...draft, ...item('crew-socks') }).line_items[0];
  assert.deepEqual(Object.keys(socks.print_areas).sort(), ['left_leg', 'right_leg']);
  assert.deepEqual(socks.print_areas.left_leg, socks.print_areas.right_leg);
});

async function fixture(t) {
  const work = await realpath(await mkdtemp(join(tmpdir(), 'tk10286-accessories-')));
  t.after(() => rm(work, { recursive: true, force: true }));
  await mkdir(join(work, 'data'));
  await mkdir(join(work, 'scripts'));
  const put = (name, data) => writeFile(join(work, 'data', name), JSON.stringify(data));
  await put('wear-templates.json', templates);
  await put('wear-orders.json', []);
  await put('celebrity_signatures.json', [{ wikidata: 'https://www.wikidata.org/wiki/QFixture',
    full_name: 'Fixture', category: 'Politics', signature_image_url: draft.design_image_url,
    usable_in_commercial_collage: 'yes', risk_level: 'low', death_date: '1800-01-01' }]);
  const interceptor = join(work, 'intercept.mjs');
  await writeFile(interceptor, `
import { appendFileSync } from 'node:fs';
globalThis.fetch = async (input, options = {}) => {
  appendFileSync(${JSON.stringify(join(work, 'requests.jsonl'))}, JSON.stringify({url:String(input),method:options.method,body:options.body})+'\\n');
  if (String(input) === 'https://api.stripe.com/v1/checkout/sessions' && options.method === 'POST')
    return Response.json({id:'cs_test_fixture',url:'https://fixture.invalid/checkout'});
  throw new Error('External request blocked by fixture');
};
`);
  return { work, put, interceptor };
}

test('direct and mixed-cart requests cannot buy accessories; original shirts still checkout', { timeout: 20000 }, async t => {
  const { work, put, interceptor } = await fixture(t);
  // A held item without a mapping must not disable the three existing shirts.
  const local = structuredClone(templates);
  local.garments.filter(g => g.saleEnabled === false).forEach(g => { delete g.printify; });
  await put('wear-templates.json', local);
  await copyFile(new URL('server.js', root), join(work, 'server.mjs'));
  const socket = createServer().listen(0, '127.0.0.1');
  await once(socket, 'listening');
  const port = socket.address().port;
  await new Promise(resolve => socket.close(resolve));
  const child = spawn(process.execPath, ['--import', interceptor, join(work, 'server.mjs')],
    { env: { ...env, PORT: String(port) }, stdio: ['ignore', 'pipe', 'pipe'] });
  t.after(async () => { if (child.exitCode === null) { const stopped = once(child, 'exit'); child.kill(); await stopped; } });
  await new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('startup timeout')), 10000);
    child.stdout.on('data', c => { if (String(c).includes('CelebritySignatures →')) { clearTimeout(timer); resolve(); } });
    child.once('error', e => { clearTimeout(timer); reject(e); });
    child.once('exit', c => { clearTimeout(timer); reject(new Error('server exited '+c)); });
  });
  const buy = items => fetch(`http://127.0.0.1:${port}/api/wear-checkout`, { method: 'POST',
    headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'fixture@example.invalid', items }) });
  for (const id of ids) {
    for (const items of [[item(id)], [item('classic-tee'), item(id)]]) {
      const res = await buy(items);
      assert.equal(res.status, 503);
      assert.match((await res.json()).error, /coming soon/);
    }
  }
  assert.deepEqual(JSON.parse(await readFile(join(work, 'data/wear-orders.json'), 'utf8')), []);
  await assert.rejects(readFile(join(work, 'requests.jsonl')), { code: 'ENOENT' });
  const res = await buy([item('classic-tee')]);
  assert.equal(res.status, 200);
  assert.equal((await res.json()).url, 'https://fixture.invalid/checkout');
  const requests = (await readFile(join(work, 'requests.jsonl'), 'utf8')).trim().split('\n').map(JSON.parse);
  assert.equal(requests.length, 1);
  assert.equal(requests[0].url, 'https://api.stripe.com/v1/checkout/sessions');
  const order = JSON.parse(await readFile(join(work, 'data/wear-orders.json'), 'utf8'))[0];
  assert.equal(order.mode, 'test');
  assert.equal(order.items[0].garment, 'classic-tee');
});

test('sender holds accessories even with --apply and all account gates set', async t => {
  const { work, interceptor } = await fixture(t);
  const script = join(work, 'scripts/submit-pod-draft-printify.mjs');
  await copyFile(new URL('scripts/submit-pod-draft-printify.mjs', root), script);
  const queue = ids.map((id, i) => JSON.stringify({ ...draft, ...item(id), orderId: `fixture.${i}` })).join('\n')+'\n';
  const file = join(work, 'data/pod-order-drafts.jsonl');
  await writeFile(file, queue);
  const { stdout } = await promisify(execFile)(process.execPath, ['--import', interceptor, script, '--apply'], { env, timeout: 10000 });
  assert.equal((stdout.match(/HELD:/g) || []).length, 4, stdout);
  assert.equal(await readFile(file, 'utf8'), queue);
  await assert.rejects(readFile(join(work, 'requests.jsonl')), { code: 'ENOENT' });
});