← back to Goldleafwallpaper

scripts/ig-poster/test-cncp-alert.js

112 lines

'use strict';
/*
 * test-cncp-alert.js — NEGATIVE-TEST-FIRST proof for the IG-poster alert arm.
 *
 * CLAUDE.md TK-11431 amendment 3: "a check ships with a negative test proving it
 * goes RED on an injected fault, or it does not ship. A positive-only test on a
 * detector proves nothing." The defect this replaces was invisible to exactly a
 * positive-only test, because fetch() RESOLVES on HTTP 404/500 — so the ONLY
 * case that mattered (non-2xx) looked identical to success.
 *
 * Runs four cases against LOCAL STUB servers — it never touches the real CNCP
 * board, never posts a real card, and never loads post-next.js (so it cannot
 * post to Instagram):
 *   1. NEGATIVE  non-2xx  (stub returns 404)          -> MUST report delivered:false
 *   2. NEGATIVE  non-2xx  (stub returns 500)          -> MUST report delivered:false
 *   3. NEGATIVE  transport (nothing listening)        -> MUST report delivered:false
 *   4. POSITIVE  2xx      (stub returns 200)          -> MUST report delivered:true
 * Plus: the shared sender's delivery RECEIPT is asserted for both a failed and a
 * successful send, since that receipt is the durable, producer-unsuppressable
 * signal the whole design rests on.
 *
 * The seam is CNCP_URL (honoured by _shared/cncp_post.sh). No plist sets it, so
 * a scheduled run always targets the real board.
 *
 * Run:  node scripts/ig-poster/test-cncp-alert.js        ($0 local)
 */
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const { postCncpCard } = require('./cncp-alert');

// The stub MUST live in its own process: postCncpCard is synchronous
// (execFileSync), so an in-process http server would never get a chance to
// answer and every case — including the 200 control — would fail as a timeout.
// That is itself a measuring-the-wrong-thing trap, so it is designed out.
function stub(status) {
  return new Promise((resolve, reject) => {
    const code = `const http=require('http');const s=http.createServer((q,r)=>{r.writeHead(${status});r.end('{}')});`
      + `s.listen(0,'127.0.0.1',()=>console.log(s.address().port));`;
    const p = spawn(process.execPath, ['-e', code], { stdio: ['ignore', 'pipe', 'inherit'] });
    let buf = '';
    p.stdout.on('data', (d) => {
      buf += d;
      if (buf.includes('\n')) resolve({ srv: { close: () => p.kill() }, port: Number(buf.trim()) });
    });
    p.on('error', reject);
  });
}
function freePort() {
  return new Promise((resolve) => {
    const srv = http.createServer();
    srv.listen(0, '127.0.0.1', () => { const p = srv.address().port; srv.close(() => resolve(p)); });
  });
}

// Receipts go to a THROWAWAY skills-shaped dir so the real shared receipt log is
// not polluted with test rows (alert_receipt.sh prefers $SKILL when it is a dir
// whose parent is named "skills").
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'goldleaf-alert-test-'));
const SKILL = path.join(TMP, 'skills', 'goldleaf-ig-poster-test');
fs.mkdirSync(SKILL, { recursive: true });
process.env.SKILL = SKILL;
const RECEIPTS = path.join(SKILL, 'data', 'alert-delivery.jsonl');
const receipts = () => (fs.existsSync(RECEIPTS)
  ? fs.readFileSync(RECEIPTS, 'utf8').split('\n').filter(Boolean).map(JSON.parse) : []);

let failures = 0;
function check(name, cond, detail) {
  console.log(`${cond ? 'PASS' : 'FAIL'}  ${name}${detail ? '  ::  ' + detail : ''}`);
  if (!cond) failures++;
}

(async () => {
  console.log(`receipts -> ${RECEIPTS}\n`);

  // --- 1 & 2: NEGATIVE, non-2xx. The case the old code got wrong. ---
  for (const code of [404, 500]) {
    const { srv, port } = await stub(code);
    process.env.CNCP_URL = `http://127.0.0.1:${port}`;
    const r = postCncpCard('alert://test', 'injected fault: stub returns ' + code);
    srv.close();
    check(`NEGATIVE non-2xx ${code} reports NOT delivered`, r.delivered === false, JSON.stringify(r));
    check(`NEGATIVE non-2xx ${code} names the HTTP code in its reason`,
      String(r.error).includes(String(code)), r.error);
  }
  const afterFail = receipts().slice(-1)[0];
  check('NEGATIVE non-2xx writes a receipt with ok:false',
    !!afterFail && afterFail.ok === false && afterFail.channel === 'cncp', JSON.stringify(afterFail));

  // --- 3: NEGATIVE, transport failure (nothing listening). ---
  const dead = await freePort();
  process.env.CNCP_URL = `http://127.0.0.1:${dead}`;
  const r3 = postCncpCard('alert://test', 'injected fault: nothing listening');
  check('NEGATIVE transport failure reports NOT delivered', r3.delivered === false, JSON.stringify(r3));
  check('NEGATIVE transport failure explains itself', /transport|reaching|CNCP/i.test(String(r3.error)), r3.error);

  // --- 4: POSITIVE, 2xx. The success path must still report success. ---
  const { srv: okSrv, port: okPort } = await stub(200);
  process.env.CNCP_URL = `http://127.0.0.1:${okPort}`;
  const r4 = postCncpCard('alert://test', 'control: stub returns 200');
  okSrv.close();
  check('POSITIVE 2xx reports delivered', r4.delivered === true, JSON.stringify(r4));
  const afterOk = receipts().slice(-1)[0];
  check('POSITIVE 2xx writes a receipt with ok:true',
    !!afterOk && afterOk.ok === true && afterOk.channel === 'cncp', JSON.stringify(afterOk));

  console.log(`\n${failures === 0 ? 'ALL PASS' : failures + ' FAILURE(S)'} · receipts written: ${receipts().length} · cost: $0 (local)`);
  process.exit(failures === 0 ? 0 : 1);
})();