Files
vyndr/tests/unit/bootResilience.test.js
T
builtbykev 17fb981f99 P0 fix: content/ not in image crashed API boot; harden garnish + preflight
ROOT CAUSE: the Dockerfile copied src/poller/scripts/supabase but NOT
content/. mediaEngine.js read content/stark-lines.json with an unguarded
module-load readFileSync; ENOENT in the image threw at require time, and
via app.js → routes/desk → deskService → mediaEngine that crashed the
ENTIRE API at boot. The Coolify healthcheck rolled back to the last
healthy image (4d2b27d), so every deploy since 219167e silently served a
14-hour-old build — S11 live tracking, S6 API code, the settlement boot
line, and SNAPSHOT_EXPECTED_INTERVAL were all merged but NOT running.

FIX (one train):
1. Dockerfile COPYs content/ into the runner image.
2. mediaEngine: stark-lines.json is OPTIONAL (garnish, never load-bearing)
   — loadStark() try/catch → {} → posts render without the Stark kicker,
   never a crash. Belt AND suspenders with #1.
3. src/preflight.js (§A4): boot prints '[preflight] OK' or 'DEGRADED'
   naming exactly what content/env is missing — before the healthcheck
   can fail silently. Run first in server.js.
4. Full fragility sweep: mediaEngine was the ONLY unguarded module-load
   file read; coachSignals (config/coaches.json) was already lazy +
   try/catch + copied. No others.

Verified: requiring app.js + deskService + mediaEngine with
stark-lines.json ABSENT now boots clean (reproduced the exact prod
failure). 2757 -> 2763 tests (tests/unit/bootResilience.test.js).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:42:18 -04:00

106 lines
4.9 KiB
JavaScript

// Session 65 (P0 deploy fix) — the API crashed at boot because a GARNISH
// file (content/stark-lines.json) was a hard module-load readFileSync and
// wasn't in the container image. These tests lock the fix: the media engine
// boots and functions with the file ABSENT, and the preflight names what's
// missing without ever throwing.
describe('mediaEngine — boots and posts with the Stark library absent (§L2)', () => {
let media;
beforeEach(() => {
jest.resetModules();
// Make EVERY stark-lines.json read throw ENOENT — simulate the file
// missing from the image (the exact production failure).
jest.doMock('fs', () => {
const real = jest.requireActual('fs');
return {
...real,
readFileSync: (p, ...rest) => {
if (String(p).includes('stark-lines.json')) {
const err = new Error('ENOENT: no such file');
err.code = 'ENOENT';
throw err;
}
return real.readFileSync(p, ...rest);
},
};
});
media = require('../../src/services/mediaEngine');
});
afterEach(() => { jest.dontMock('fs'); jest.resetModules(); });
test('requiring the module does NOT throw (this was the boot crash)', () => {
expect(media).toBeTruthy();
expect(media.__internals.STARK).toEqual({}); // empty library, not a crash
});
test('starkLine returns null with no library — the garnish is simply absent', () => {
expect(media.starkLine('morning', '2026-07-12T14:00:00Z')).toBeNull();
});
test('formats still produce valid, VOICE-clean posts without the kicker', () => {
const wire = media.morningWire({ dateIso: '2026-07-12T14:00:00Z', counts: { mlb: 15 }, loudest: null });
expect(wire).toContain('15 MLB.');
expect(wire).toContain('vyndr.app');
expect(media.lintVoice(wire)).toEqual([]);
const settle = media.theSettle({
aggregate: { settled: 5, hits: 3, misses: 2, pushes: 0, hit_pct: null, pending: 10, by_tier: {} },
misses: [{ player_name: 'A Guy', stat: 'points', line: 23.5, side: 'over' }],
dateIso: '2026-07-12T14:00:00Z',
});
expect(settle).toContain('All grades: 3-2');
expect(media.lintVoice(settle)).toEqual([]);
});
});
describe('deskService assembles with the Stark library absent', () => {
test('variants() tolerates an empty STARK (no throw, no kicker to strip)', () => {
jest.resetModules();
jest.doMock('fs', () => {
const real = jest.requireActual('fs');
return {
...real,
readFileSync: (p, ...rest) => {
if (String(p).includes('stark-lines.json')) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; }
return real.readFileSync(p, ...rest);
},
};
});
const { assembleDeskPack } = require('../../src/services/deskService');
expect(typeof assembleDeskPack).toBe('function');
jest.dontMock('fs');
jest.resetModules();
});
});
describe('preflight — names what is missing, never throws (§A4)', () => {
const { runPreflight } = require('../../src/preflight');
test('reports OK with a captured logger when content + env are present', () => {
const logs = [];
const fake = { log: (m) => logs.push(['log', m]), warn: (m) => logs.push(['warn', m]) };
const prev = { SUPABASE_URL: process.env.SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY, REDIS_URL: process.env.REDIS_URL, VYNDR_INTERNAL_KEY: process.env.VYNDR_INTERNAL_KEY };
process.env.SUPABASE_URL = 'x'; process.env.SUPABASE_SERVICE_ROLE_KEY = 'x'; process.env.REDIS_URL = 'x'; process.env.VYNDR_INTERNAL_KEY = 'x';
const res = runPreflight(fake);
// content/stark-lines.json + src/config/coaches.json both exist in the tree.
expect(res.ok).toBe(true);
expect(logs.some(([lvl, m]) => lvl === 'log' && m.includes('[preflight] OK'))).toBe(true);
Object.assign(process.env, prev);
for (const k of Object.keys(prev)) if (prev[k] === undefined) delete process.env[k];
});
test('names missing env, does not throw, returns not-ok', () => {
const logs = [];
const fake = { log: (m) => logs.push(m), warn: (m) => logs.push(m) };
const prev = { SUPABASE_URL: process.env.SUPABASE_URL, REDIS_URL: process.env.REDIS_URL, VYNDR_INTERNAL_KEY: process.env.VYNDR_INTERNAL_KEY, SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY, SUPABASE_SERVICE_KEY: process.env.SUPABASE_SERVICE_KEY };
delete process.env.SUPABASE_URL; delete process.env.REDIS_URL; delete process.env.VYNDR_INTERNAL_KEY;
delete process.env.SUPABASE_SERVICE_ROLE_KEY; delete process.env.SUPABASE_SERVICE_KEY;
const res = runPreflight(fake);
expect(res.ok).toBe(false);
expect(res.missingEnv).toEqual(expect.arrayContaining(['SUPABASE_URL', 'REDIS_URL', 'VYNDR_INTERNAL_KEY']));
expect(logs.some((m) => m.includes('DEGRADED'))).toBe(true);
Object.assign(process.env, prev);
for (const k of Object.keys(prev)) if (prev[k] === undefined) delete process.env[k];
});
});