Files
vyndr/tests/unit/newsletterService.test.js
T
builtbykev 32d7200571 S7 (a1): newsletter — THE VYNDR REPORT
Email capture + daily report assembly + operator-triggered Listmonk send.

- NewsletterCapture (dark terminal, mono) on landing (below FAQ) + /welcome
  (the real signup success surface); double-opt-in note; 'Signups open soon'
  when Listmonk env is unset.
- POST /api/newsletter/subscribe: public, 10/min IP limit, honeypot,
  server-side email validation, forwards to Listmonk subscribers API with
  preconfirm_subscriptions:false (Listmonk sends the confirmation).
  No env -> calm 200 { ok:false, reason:'not configured' }. Next proxy
  web/src/app/api/newsletter/subscribe/route.ts (S25 rule).
- newsletterService.buildDailyReport: signals from snapshot:{sport}:latest,
  STREAK WATCH via rosterLogs -> streaksService -> streakLens, THE RECORD via
  ledgerService.getModelAggregate (percentage only when hit_pct != null —
  n>=20 gate — else 'RECORD BUILDING · N pending'). RG footer (21+,
  1-800-GAMBLER, Listmonk-native {{ UnsubscribeURL }}) in html + text.
  VOICE v1.1 lint locked by tests: no '!', no banned vocabulary, numbers
  only from injected pipeline data.
- sendDailyReport: creates + starts a Listmonk campaign; env-gated no-op;
  refuses an empty report. Deliberately UNSCHEDULED — only
  POST /api/internal/newsletter/send (internal key) triggers it.
- docs/NEWSLETTER.md: box-side Listmonk runbook (install, double-opt-in
  list, API user, Coolify env, test-send).
- Spec: specs/feature-a1-s7-newsletter.md.

Tests 2398 -> 2429 (207 suites, all green); next build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:29:22 -04:00

276 lines
12 KiB
JavaScript

'use strict';
// Session S7 (a1) — THE VYNDR REPORT assembly + send. Everything injected:
// fixtures stand in for the snapshot cache, the streaks engine inputs, and
// the ledger aggregate. No network, no Redis.
const {
buildDailyReport,
sendDailyReport,
subscribe,
__internals,
} = require('../../src/services/newsletterService');
// ---------------------------------------------------------------- fixtures
const SNAPSHOT_MLB = {
sport: 'mlb',
updated_at: '2026-07-11T14:00:00Z',
grades: [
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'A+', confidence: 82, archetype: 'BOMBER' },
{ player: 'Mookie Betts', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'B', confidence: 64 },
{ player: 'Luis Castillo', stat_type: 'pitcher_strikeouts', line: 6.5, direction: 'under', grade: 'A', confidence: 71, archetype: 'WHIFF' },
],
deltas: [],
};
const ROSTER_MLB = [
{
name: 'Seiya Suzuki',
team: 'Chicago Cubs',
games: [
{ date: '2026-07-10', hits: 2, opponent: 'STL' },
{ date: '2026-07-09', hits: 1, opponent: 'STL' },
{ date: '2026-07-08', hits: 3, opponent: 'PIT' },
{ date: '2026-07-07', hits: 1, opponent: 'PIT' },
{ date: '2026-07-06', hits: 2, opponent: 'CIN' },
{ date: '2026-07-05', hits: 0, opponent: 'CIN' },
],
},
];
const AGG_BUILDING = { window_days: 30, min_sample: 20, settled: 4, hits: 3, misses: 1, pushes: 0, hit_pct: null, pending: 17, beat_close_pct: null };
const AGG_REAL = { window_days: 30, min_sample: 20, settled: 25, hits: 15, misses: 9, pushes: 1, hit_pct: 63, pending: 6, beat_close_pct: 58 };
function deps(overrides = {}) {
return {
cacheGet: async (key) => (key === 'snapshot:mlb:latest' ? SNAPSHOT_MLB : null),
loadRosterLogs: async (sport) => (sport === 'mlb' ? ROSTER_MLB : []),
computeStreaks: require('../../src/services/streaksService').computeStreaks,
applyLens: require('../../src/services/streakLens').applyLens,
getModelAggregate: async () => AGG_BUILDING,
now: () => new Date('2026-07-11T15:00:00Z'),
...overrides,
};
}
// VOICE v1.1 lint — the banned list the templates must never emit.
// "lock"/"tail"/"fade" as words (not substrings — "blocked"/"detail" are fine).
const BANNED_WORDS = /\b(lock|locks|tail|tails|fade|fades|guarantee[ds]?|free money|can't lose)\b/i;
const HYPE_EMOJI = /[\u{1F525}\u{1F4B0}\u{1F680}\u{1F4AF}\u{1F512}]/u;
describe('buildDailyReport — VOICE lint', () => {
let report;
beforeAll(async () => {
report = await buildDailyReport(['mlb'], deps());
});
test('no exclamation points anywhere — subject, html, text', () => {
expect(report.subject).not.toContain('!');
expect(report.text).not.toContain('!');
// html may contain !important? we never use it — assert none at all
expect(report.html).not.toContain('!');
});
test('no banned vocabulary, no hype emoji', () => {
for (const part of [report.subject, report.text, report.html]) {
expect(part).not.toMatch(BANNED_WORDS);
expect(part).not.toMatch(HYPE_EMOJI);
}
});
test('subject carries the pipeline signal count and the ET date', () => {
expect(report.subject).toBe('THE VYNDR REPORT — Sat, Jul 11 · 3 signals');
});
test('signals come only from the snapshot fixture, o/u for sides', () => {
expect(report.text).toContain('Aaron Judge — total bases o1.5 · A+ · BOMBER');
expect(report.text).toContain('Luis Castillo — pitcher strikeouts u6.5 · A · WHIFF');
expect(report.counts.signals).toBe(3);
});
test('A+ sorts ahead of B regardless of array order', () => {
const judge = report.text.indexOf('Aaron Judge');
const betts = report.text.indexOf('Mookie Betts');
expect(judge).toBeGreaterThan(-1);
expect(betts).toBeGreaterThan(judge);
});
test('STREAK WATCH renders the real computed streak through the lens', () => {
expect(report.text).toContain('STREAK WATCH');
expect(report.text).toContain('Seiya Suzuki');
// Lens: built-vs opponents from the game log — real, not composed.
expect(report.text).toMatch(/built vs/);
expect(report.counts.streaks).toBeGreaterThan(0);
});
test('RG footer present in html AND text: 21+, 1-800-GAMBLER, unsubscribe', () => {
for (const part of [report.text, report.html]) {
expect(part).toContain('21+');
expect(part).toContain('1-800-GAMBLER');
expect(part).toContain('{{ UnsubscribeURL }}');
}
});
});
describe('buildDailyReport — the record gate (n≥20 lives upstream)', () => {
test('hit_pct null → RECORD BUILDING with the pending count, never a %', async () => {
const report = await buildDailyReport(['mlb'], deps());
expect(report.text).toContain('RECORD BUILDING · 17 pending');
expect(report.text).not.toMatch(/\d+%.*misses included/);
});
test('hit_pct present → the real record line, misses included', async () => {
const report = await buildDailyReport(['mlb'], deps({ getModelAggregate: async () => AGG_REAL }));
expect(report.text).toContain('Last 30 days: 15-9 (63%), misses included, n=24');
expect(report.text).toContain('beat the close 58%');
expect(report.text).not.toContain('RECORD BUILDING');
});
test('aggregate throws → building line, never a crash', async () => {
const report = await buildDailyReport(['mlb'], deps({ getModelAggregate: async () => { throw new Error('supabase down'); } }));
expect(report.text).toContain('RECORD BUILDING · 0 pending');
});
});
describe('buildDailyReport — empty pipeline is a valid state', () => {
test('no snapshot, no roster → honest empty wire, zero counts', async () => {
const report = await buildDailyReport(['mlb'], deps({
cacheGet: async () => null,
loadRosterLogs: async () => [],
}));
expect(report.counts.signals).toBe(0);
expect(report.counts.streaks).toBe(0);
expect(report.text).toContain('No graded slate at send time');
expect(report.subject).toBe('THE VYNDR REPORT — Sat, Jul 11'); // no invented number
// Compliance footer still present even on an empty report.
expect(report.text).toContain('1-800-GAMBLER');
});
});
describe('sendDailyReport — env-gated, empty-refusing', () => {
const ENV = {
LISTMONK_URL: 'http://127.0.0.1:9000',
LISTMONK_USER: 'vyndr-api',
LISTMONK_TOKEN: 'tok123',
LISTMONK_LIST_ID: '3',
};
test('no env → graceful no-op, no fetch', async () => {
const fetchImpl = jest.fn();
const res = await sendDailyReport({ env: {}, fetchImpl, deps: deps() });
expect(res).toEqual({ ok: false, reason: 'not configured' });
expect(fetchImpl).not.toHaveBeenCalled();
});
test('empty report → refuses to send even with env set', async () => {
const fetchImpl = jest.fn();
const res = await sendDailyReport({
env: ENV, fetchImpl,
deps: deps({ cacheGet: async () => null, loadRosterLogs: async () => [] }),
});
expect(res.ok).toBe(false);
expect(res.reason).toBe('empty report');
expect(fetchImpl).not.toHaveBeenCalled();
});
test('creates then starts the campaign with the token auth scheme', async () => {
const calls = [];
const fetchImpl = jest.fn(async (url, opts) => {
calls.push({ url, opts });
if (url.endsWith('/api/campaigns')) {
return { ok: true, status: 200, json: async () => ({ data: { id: 42 } }) };
}
return { ok: true, status: 200, json: async () => ({}) };
});
const res = await sendDailyReport({ env: ENV, fetchImpl, deps: deps() });
expect(res.ok).toBe(true);
expect(res.campaignId).toBe(42);
const create = calls[0];
expect(create.url).toBe('http://127.0.0.1:9000/api/campaigns');
expect(create.opts.headers.Authorization).toBe('token vyndr-api:tok123');
const body = JSON.parse(create.opts.body);
expect(body.lists).toEqual([3]);
expect(body.subject).toContain('THE VYNDR REPORT');
expect(body.body).toContain('{{ UnsubscribeURL }}');
expect(body.altbody).toContain('1-800-GAMBLER');
const start = calls[1];
expect(start.url).toBe('http://127.0.0.1:9000/api/campaigns/42/status');
expect(JSON.parse(start.opts.body)).toEqual({ status: 'running' });
});
test('listmonk create failure → honest error, no start call', async () => {
const fetchImpl = jest.fn(async () => ({ ok: false, status: 500, json: async () => ({}) }));
const res = await sendDailyReport({ env: ENV, fetchImpl, deps: deps() });
expect(res.ok).toBe(false);
expect(res.reason).toBe('listmonk create 500');
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
});
describe('subscribe — double opt-in forwarding', () => {
const ENV = {
LISTMONK_URL: 'http://127.0.0.1:9000/',
LISTMONK_USER: 'vyndr-api',
LISTMONK_TOKEN: 'tok123',
LISTMONK_LIST_ID: '3',
};
test('no env → not configured, no fetch', async () => {
const fetchImpl = jest.fn();
const res = await subscribe('kev@example.com', { env: {}, fetchImpl });
expect(res).toEqual({ ok: false, reason: 'not configured' });
expect(fetchImpl).not.toHaveBeenCalled();
});
test('forwards with preconfirm_subscriptions:false (Listmonk sends the confirmation)', async () => {
const fetchImpl = jest.fn(async () => ({ ok: true, status: 200, json: async () => ({}) }));
const res = await subscribe('KEV@Example.com ', { env: ENV, fetchImpl });
expect(res).toEqual({ ok: true });
const [url, opts] = fetchImpl.mock.calls[0];
expect(url).toBe('http://127.0.0.1:9000/api/subscribers'); // trailing slash trimmed
const body = JSON.parse(opts.body);
expect(body.email).toBe('kev@example.com');
expect(body.preconfirm_subscriptions).toBe(false);
expect(body.lists).toEqual([3]);
expect(body.status).toBe('enabled');
});
test('409 already-subscribed is ok (idempotent, no enumeration)', async () => {
const fetchImpl = jest.fn(async () => ({ ok: false, status: 409, json: async () => ({}) }));
const res = await subscribe('kev@example.com', { env: ENV, fetchImpl });
expect(res).toEqual({ ok: true });
});
test('network failure → calm reason, never throws', async () => {
const fetchImpl = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
const res = await subscribe('kev@example.com', { env: ENV, fetchImpl });
expect(res.ok).toBe(false);
expect(res.reason).toBe('ECONNREFUSED');
});
});
describe('__internals', () => {
test('listmonkConfig requires all four env vars', () => {
expect(__internals.listmonkConfig({})).toBeNull();
expect(__internals.listmonkConfig({ LISTMONK_URL: 'x', LISTMONK_USER: 'u', LISTMONK_TOKEN: 't' })).toBeNull();
expect(__internals.listmonkConfig({ LISTMONK_URL: 'x', LISTMONK_USER: 'u', LISTMONK_TOKEN: 't', LISTMONK_LIST_ID: 'nope' })).toBeNull();
expect(__internals.listmonkConfig({ LISTMONK_URL: 'http://x/', LISTMONK_USER: 'u', LISTMONK_TOKEN: 't', LISTMONK_LIST_ID: '7' }))
.toEqual({ url: 'http://x', user: 'u', token: 't', listId: 7 });
});
test('recordLine gates on hit_pct, not on settled count', () => {
expect(__internals.recordLine(null)).toBe('RECORD BUILDING · 0 pending');
expect(__internals.recordLine({ hit_pct: null, pending: 12 })).toBe('RECORD BUILDING · 12 pending');
expect(__internals.recordLine({ hit_pct: 61, hits: 14, misses: 9, window_days: 30, beat_close_pct: null }))
.toBe('Last 30 days: 14-9 (61%), misses included, n=23');
});
test('statLabel humanizes stat keys', () => {
expect(__internals.statLabel('total_bases')).toBe('total bases');
expect(__internals.statLabel('PITCHER_STRIKEOUTS')).toBe('pitcher strikeouts');
});
});