32d7200571
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>
150 lines
5.5 KiB
JavaScript
150 lines
5.5 KiB
JavaScript
'use strict';
|
|
|
|
// Session S7 (a1) — THE VYNDR REPORT routes. Redis is mocked (app.js pulls it
|
|
// in transitively); Listmonk is a mocked global fetch. NOTE: the subscribe
|
|
// route sits behind a 10/min IP limiter with no reset hook (same lesson as
|
|
// analyze.test.js) — this file stays well under 10 subscribe requests.
|
|
|
|
const request = require('supertest');
|
|
|
|
let mockStore = {};
|
|
jest.mock('../../src/utils/redis', () => ({
|
|
getRedisClient: () => ({}),
|
|
cacheGet: async (k) => (k in mockStore ? mockStore[k] : null),
|
|
cacheSet: async (k, v) => { mockStore[k] = v; return true; },
|
|
cacheDel: async () => true,
|
|
isDegraded: () => false,
|
|
}));
|
|
|
|
const app = require('../../src/app');
|
|
|
|
const LISTMONK_ENV = {
|
|
LISTMONK_URL: 'http://127.0.0.1:9000',
|
|
LISTMONK_USER: 'vyndr-api',
|
|
LISTMONK_TOKEN: 'tok123',
|
|
LISTMONK_LIST_ID: '3',
|
|
};
|
|
const ENV_KEYS = [...Object.keys(LISTMONK_ENV), 'VYNDR_INTERNAL_KEY'];
|
|
|
|
const savedEnv = {};
|
|
beforeAll(() => { for (const k of ENV_KEYS) savedEnv[k] = process.env[k]; });
|
|
afterAll(() => {
|
|
for (const k of ENV_KEYS) {
|
|
if (savedEnv[k] === undefined) delete process.env[k];
|
|
else process.env[k] = savedEnv[k];
|
|
}
|
|
});
|
|
|
|
const realFetch = globalThis.fetch;
|
|
beforeEach(() => {
|
|
mockStore = {};
|
|
for (const k of ENV_KEYS) delete process.env[k];
|
|
});
|
|
afterEach(() => { globalThis.fetch = realFetch; });
|
|
|
|
describe('POST /api/newsletter/subscribe', () => {
|
|
test('no Listmonk env → calm HTTP 200 not-configured (never a scary error)', async () => {
|
|
globalThis.fetch = jest.fn();
|
|
const res = await request(app)
|
|
.post('/api/newsletter/subscribe')
|
|
.send({ email: 'kev@example.com' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ ok: false, reason: 'not configured' });
|
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('honeypot filled → silent ok, nothing forwarded', async () => {
|
|
Object.assign(process.env, LISTMONK_ENV);
|
|
globalThis.fetch = jest.fn();
|
|
const res = await request(app)
|
|
.post('/api/newsletter/subscribe')
|
|
.send({ email: 'bot@example.com', website: 'http://spam.example' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ ok: true });
|
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('invalid email → 400 with a plain message', async () => {
|
|
const res = await request(app)
|
|
.post('/api/newsletter/subscribe')
|
|
.send({ email: 'not-an-email' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.ok).toBe(false);
|
|
expect(res.body.error).toBe('Enter a valid email.');
|
|
});
|
|
|
|
test('env set → forwards to Listmonk with double opt-in (preconfirm false)', async () => {
|
|
Object.assign(process.env, LISTMONK_ENV);
|
|
globalThis.fetch = jest.fn(async () => ({ ok: true, status: 200, json: async () => ({}) }));
|
|
const res = await request(app)
|
|
.post('/api/newsletter/subscribe')
|
|
.send({ email: 'Kev@Example.com' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ ok: true });
|
|
|
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
const [url, opts] = globalThis.fetch.mock.calls[0];
|
|
expect(url).toBe('http://127.0.0.1:9000/api/subscribers');
|
|
expect(opts.headers.Authorization).toBe('token vyndr-api:tok123');
|
|
const body = JSON.parse(opts.body);
|
|
expect(body.email).toBe('kev@example.com');
|
|
expect(body.lists).toEqual([3]);
|
|
expect(body.preconfirm_subscriptions).toBe(false);
|
|
});
|
|
|
|
test('Listmonk 409 (already subscribed) → still ok, no enumeration', async () => {
|
|
Object.assign(process.env, LISTMONK_ENV);
|
|
globalThis.fetch = jest.fn(async () => ({ ok: false, status: 409, json: async () => ({}) }));
|
|
const res = await request(app)
|
|
.post('/api/newsletter/subscribe')
|
|
.send({ email: 'kev@example.com' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ ok: true });
|
|
});
|
|
|
|
test('Listmonk down → calm HTTP 200 unavailable', async () => {
|
|
Object.assign(process.env, LISTMONK_ENV);
|
|
globalThis.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
|
|
const res = await request(app)
|
|
.post('/api/newsletter/subscribe')
|
|
.send({ email: 'kev@example.com' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ ok: false, reason: 'unavailable' });
|
|
});
|
|
});
|
|
|
|
describe('POST /api/internal/newsletter/send', () => {
|
|
test('requires the internal key', async () => {
|
|
process.env.VYNDR_INTERNAL_KEY = 'shh';
|
|
const res = await request(app).post('/api/internal/newsletter/send').send({});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
test('with key but no Listmonk env → graceful no-op (operator sees why)', async () => {
|
|
process.env.VYNDR_INTERNAL_KEY = 'shh';
|
|
globalThis.fetch = jest.fn();
|
|
const res = await request(app)
|
|
.post('/api/internal/newsletter/send')
|
|
.set('x-internal-key', 'shh')
|
|
.send({});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ ok: false, reason: 'not configured' });
|
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('with key + env but an empty pipeline → refuses to send an empty report', async () => {
|
|
process.env.VYNDR_INTERNAL_KEY = 'shh';
|
|
Object.assign(process.env, LISTMONK_ENV);
|
|
globalThis.fetch = jest.fn();
|
|
// mockStore is empty → no snapshots, no roster logs → empty report.
|
|
const res = await request(app)
|
|
.post('/api/internal/newsletter/send')
|
|
.set('x-internal-key', 'shh')
|
|
.send({ sports: ['mlb'] });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.ok).toBe(false);
|
|
expect(res.body.reason).toBe('empty report');
|
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
});
|
|
});
|