/** * Unit test for the intake spam scorer. `node backend/intake/spam-score.test.mjs`. * * Same shape and same reasoning as `infra/cloudfront/router.test.mjs`: the real * check is a real submission, this one runs in a second and catches the branch * mistakes that a regex change makes silently. * * ⚠️ **EVERY SIGNAL SHIPS WITH A NEGATIVE FIXTURE**, which is the discipline * `CLAUDE.md` imposes on `check:claims` and applies here for the same reason: * this scorer's failure mode is not missing spam, it is labelling a real * inquiry. The pairs below are the nearest legitimate submission to each trap — * `j.k.smith@gmail.com` beside the dot trick, an extension-carrying Toronto * number beside a Russian one, ordinary commercial prose naming a company * beside a pasted link. * * ⚠️ **EACH CASE ASSERTS THE SIGNAL NAMES, NOT ONLY THE SCORE.** Asserting the * total alone lets two rules swap weights, or one rule fire in place of * another, with every case still passing — the suite would then be checking * arithmetic rather than behaviour. `expected` is the exact signal set. */ import { scoreSubmission, isPossibleSpam, SPAM_THRESHOLD, SHORT_SUMMARY_CHARS, } from './spam-score.mjs'; const SHORT = `summary under ${SHORT_SUMMARY_CHARS} characters`; const PHONE = 'phone is not a Canadian or US number'; const LINK = 'link in the dispute summary'; const GMAIL = 'Gmail address using the dot trick'; const MID = 'A construction lien dispute over a delayed fit-out. Counsel are engaged ' + 'on both sides and we want a mediator.'; const LONG = 'The parties are in dispute over a delayed fit-out on a Toronto office ' + 'tower. The subcontract was terminated in June and the holdback has not ' + 'been released. Counsel are engaged on both sides and we are looking for a ' + 'mediator with construction experience.'; /* [label, fields, expected signals] — score and labelled are DERIVED from the weights below, so a weight change fails every affected case by name rather than silently re-balancing the totals. */ const WEIGHTS = { [SHORT]: 1, [PHONE]: 1, [LINK]: 2, [GMAIL]: 1 }; const CASES = [ // ---- clean submissions, which is the half that matters most ------------- [ 'ordinary Ontario inquiry', { summary: LONG, phone: '416-555-0123', email: 'a.counsel@firm.ca' }, [], ], ['no phone given at all', { summary: LONG, email: 'counsel@firm.ca' }, []], [ '+1 with punctuation', { summary: LONG, phone: '+1 (647) 555-0188', email: 'c@firm.ca' }, [], ], [ 'ten digits, no punctuation', { summary: LONG, phone: '6475550188', email: 'c@firm.ca' }, [], ], [ 'Toronto number with an extension', { summary: LONG, phone: '416-555-0123 ext 22', email: 'c@firm.ca' }, [], ], [ 'extension written x22', { summary: LONG, phone: '(416) 555-0123 x22', email: 'c@firm.ca' }, [], ], [ 'extension written Ext:', { summary: LONG, phone: '416-555-0123 Ext: 4501', email: 'c@firm.ca' }, [], ], [ 'extension spelled out', { summary: LONG, phone: '416-555-0123 extension 22', email: 'c@firm.ca' }, [], ], [ 'extension hyphenated', { summary: LONG, phone: '416-555-0123 ext-22', email: 'c@firm.ca' }, [], ], [ 'two numbers in one field', { summary: LONG, phone: '416-555-0123 or 416-555-0124', email: 'c@firm.ca', }, [], ], [ 'ordinary gmail, one dot', { summary: LONG, phone: '416-555-0123', email: 'first.last@gmail.com' }, [], ], [ 'gmail, single initial', { summary: LONG, phone: '416-555-0123', email: 'j.smith@gmail.com' }, [], ], [ 'gmail, TWO initials and a surname', { summary: LONG, email: 'j.k.smith@gmail.com' }, [], ], /* ⚠️ NON-GMAIL, THREE DOTS — this pins the DOMAIN GUARD, which nothing did. Deleting `if (domain !== 'gmail.com' && …) return false` left all 30 cases passing: the nearest legitimate submission to a three-dot trap is a three-dot address at a firm domain, and it was the one fixture missing. */ [ 'law-firm address, three dots', { summary: LONG, email: 'j.p.van.dam@blakes.com' }, [], ], [ 'four-part real name at gmail', { summary: LONG, phone: '416-555-0123', email: 'mary.jane.o.brien@gmail.com', }, [GMAIL], ], [ 'company named in prose, no link', { summary: `${LONG} The respondent is acme.com Ltd.`, email: 'c@firm.ca' }, [], ], [ 'googlemail, one dot', { summary: LONG, email: 'first.last@googlemail.com' }, [], ], // ---- one weak signal: still clean --------------------------------------- [ 'cross-border counsel, UK number', { summary: LONG, phone: '+44 20 7946 0958', email: 'c@firm.co.uk' }, [PHONE], ], [ 'the concise summary the hint invites', { summary: 'Shareholder dispute, two directors, Ontario CBCA company.', phone: '416-555-0123', email: 'c@firm.ca', }, [SHORT], ], // ---- boundaries ---------------------------------------------------------- /* PINS THE FLOOR'S VALUE, which the two boundary cases below cannot: they derive their lengths from `SHORT_SUMMARY_CHARS`, so they move with it and a floor raised back to 140 passed them silently. This one is a literal 109-character summary of the kind the form's hint invites, and it fails the moment the floor rises above it. */ [ 'a realistic 109-character summary', { summary: MID, email: 'c@firm.ca' }, [], ], [ 'summary exactly at the floor', { summary: 'x'.repeat(SHORT_SUMMARY_CHARS), email: 'c@firm.ca' }, [], ], [ 'summary one under the floor', { summary: 'x'.repeat(SHORT_SUMMARY_CHARS - 1), email: 'c@firm.ca' }, [SHORT], ], [ 'eleven digits not starting 1', { summary: LONG, phone: '+7 912 345 6789', email: 'c@firm.ca' }, [PHONE], ], /* ⚠️ TEN DIGITS IN TOTAL, AND FOREIGN — Iceland writes +354 followed by seven. This is the ONE case that pins the country-code branch: without it the digit count reads 10 and calls this a NANP number. Every other foreign fixture here has 11+ digits, so the count agrees by accident and the branch could be deleted with the whole suite still green. */ [ 'ten-digit international number', { summary: LONG, phone: '+354 555 1234', email: 'c@firm.is' }, [PHONE], ], [ 'gmail, exactly two dots', { summary: LONG, email: 'a.b.smith@gmail.com' }, [], ], [ 'gmail, exactly three dots', { summary: LONG, email: 'a.b.c.smith@gmail.com' }, [GMAIL], ], // ---- two weak signals: labelled ------------------------------------------ [ 'foreign number and terse summary', { summary: 'Need a mediator.', phone: '+7 912 345 6789', email: 'c@firm.ru', }, [SHORT, PHONE], ], // ---- one strong signal: labelled ----------------------------------------- [ 'link in the summary', { summary: `${LONG} See https://example.com/tender`, email: 'c@firm.ca' }, [LINK], ], [ 'www link in the summary', { summary: `${LONG} See www.example.com/tender`, email: 'c@firm.ca' }, [LINK], ], [ 'dot trick, four dots', { summary: LONG, email: 'j.o.h.nsmith@gmail.com' }, [GMAIL], ], /* ⚠️ WEIGHT 1, SO IT DOES NOT LABEL ALONE. That is the whole point of the weight change, and this is the case that fails if it goes back to 2. */ [ 'dot trick alone does not label', { summary: LONG, email: 'r.a.n.d.om@gmail.com' }, [GMAIL], ], // ---- the shape the 2026-09-04 pair is described as ------------------------ // NOT a measurement of those records: their `summary` values were never read. [ 'scraped text, foreign number, link', { summary: 'Buy now at https://spam.example/offer', phone: '+7 912 345 6789', email: 'r.a.n.d.om@gmail.com', }, [SHORT, PHONE, LINK, GMAIL], ], /* The module's own worked example of a legitimate concise summary, beside a Toronto direct line. It scored 2 and shipped `[Possible spam]` while the extension strip was incomplete. */ [ 'concise summary + Toronto extension', { summary: 'Shareholder dispute, two directors, Ontario CBCA company.', phone: '416-555-0123 ext: 4501', email: 'c@firm.ca', }, [SHORT], ], // The attested signature ALONE — a non-NANP phone and nothing else known — // is one weak signal and is NOT labelled. Kept as a case so the limit of what // the observed evidence supports is asserted rather than described. [ 'attested signature alone', { summary: LONG, phone: '+7 912 345 6789', email: 'random@gmail.com' }, [PHONE], ], // ---- absent fields must not throw or score ------------------------------- ['empty object', {}, []], [ 'summary absent, phone local', { phone: '416-555-0123', email: 'c@firm.ca' }, [], ], ['email absent', { summary: LONG }, []], ['malformed email, no @', { summary: LONG, email: 'not-an-address' }, []], ['gmail with no local part', { summary: LONG, email: '@gmail.com' }, []], ]; let pass = 0; const failures = []; const seen = new Set(); for (const [label, fields, expected] of CASES) { const result = scoreSubmission(fields); expected.forEach((sig) => seen.add(sig)); const wantScore = expected.reduce((n, sig) => n + WEIGHTS[sig], 0); const wantLabelled = wantScore >= SPAM_THRESHOLD; const gotSignals = [...result.signals].sort(); const wantSignals = [...expected].sort(); const ok = result.score === wantScore && isPossibleSpam(result) === wantLabelled && JSON.stringify(gotSignals) === JSON.stringify(wantSignals); if (ok) { pass += 1; } else { failures.push( ` ${label}\n` + ` expected score ${wantScore}, labelled ${wantLabelled}, signals ${JSON.stringify(wantSignals)}\n` + ` got score ${result.score}, labelled ${isPossibleSpam(result)}, signals ${JSON.stringify(gotSignals)}`, ); } } /* COVERAGE, ASSERTED RATHER THAN ASSUMED. A rule with no positive case is a rule nobody has run, and it would still show a green suite. */ for (const sig of Object.keys(WEIGHTS)) { if (!seen.has(sig)) { failures.push(` no case exercises the "${sig}" signal — it is untested.`); } } /* The threshold is part of the contract the cases above were written against. Changing it without re-deriving them would leave every expectation a statement about a threshold that no longer exists. */ if (SPAM_THRESHOLD !== 2) { failures.push( ` SPAM_THRESHOLD is ${SPAM_THRESHOLD}, not 2 — the weights and expectations ` + 'above were written against 2. Re-derive them before changing it.', ); } if (failures.length > 0) { console.error(`spam-score: ${failures.length} FAILED of ${CASES.length}`); console.error(failures.join('\n')); process.exit(1); } console.log( `spam-score: ${pass} of ${CASES.length} cases pass; all ${Object.keys(WEIGHTS).length} signals exercised`, );