17fb981f99
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 since219167esilently 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>
106 lines
4.9 KiB
JavaScript
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];
|
|
});
|
|
});
|