'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(); }); });