Session 10: Internal auth refactor, prefetch cascade keys, Sentry, welcome email (1286 tests)
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// internalAuth middleware (Session 10) — tests both the new short-form
|
||||
// header (x-internal-key) and the legacy long form
|
||||
// (X-VYNDR-Internal-Key), the timing-safe compare, and the optional
|
||||
// loopback restriction.
|
||||
|
||||
const { requireInternalAuth, __internals } = require('../../src/middleware/internalAuth');
|
||||
|
||||
function fakeReqRes({ headers = {}, ip = '127.0.0.1' } = {}) {
|
||||
const req = {
|
||||
headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])),
|
||||
ip,
|
||||
socket: { remoteAddress: ip },
|
||||
get(name) {
|
||||
return this.headers[name.toLowerCase()];
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(body) { this.body = body; return this; },
|
||||
};
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
function run(mw, req, res) {
|
||||
return new Promise((resolve) => {
|
||||
mw(req, res, () => resolve('next'));
|
||||
if (res.statusCode != null) resolve('blocked');
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.VYNDR_INTERNAL_KEY;
|
||||
});
|
||||
|
||||
describe('requireInternalAuth', () => {
|
||||
describe('configuration guard', () => {
|
||||
test('returns 503 when VYNDR_INTERNAL_KEY env var is unset', async () => {
|
||||
const mw = requireInternalAuth();
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'anything' } });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('blocked');
|
||||
expect(res.statusCode).toBe(503);
|
||||
expect(res.body.error).toMatch(/not configured/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('header compatibility', () => {
|
||||
beforeEach(() => { process.env.VYNDR_INTERNAL_KEY = 'real-key-12345'; });
|
||||
|
||||
test('accepts the new short-form header (x-internal-key)', async () => {
|
||||
const mw = requireInternalAuth();
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' } });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('next');
|
||||
});
|
||||
|
||||
test('accepts the legacy long-form header (X-VYNDR-Internal-Key)', async () => {
|
||||
const mw = requireInternalAuth();
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-vyndr-internal-key': 'real-key-12345' } });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('next');
|
||||
});
|
||||
|
||||
test('returns 401 on missing header', async () => {
|
||||
const mw = requireInternalAuth();
|
||||
const { req, res } = fakeReqRes({});
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('blocked');
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test('returns 401 on wrong key (timing-safe)', async () => {
|
||||
const mw = requireInternalAuth();
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'wrong-key' } });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('blocked');
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test('returns 401 even when prefix matches but suffix differs', async () => {
|
||||
// Guards against early-exit string compare leaking timing info.
|
||||
const mw = requireInternalAuth();
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-1234X' } });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('blocked');
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test('returns 401 on length mismatch (avoids any timingSafeEqual throw)', async () => {
|
||||
const mw = requireInternalAuth();
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'short' } });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('blocked');
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loopbackOnly option', () => {
|
||||
beforeEach(() => { process.env.VYNDR_INTERNAL_KEY = 'real-key-12345'; });
|
||||
|
||||
test('passes from 127.0.0.1', async () => {
|
||||
const mw = requireInternalAuth({ loopbackOnly: true });
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '127.0.0.1' });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('next');
|
||||
});
|
||||
|
||||
test('passes from IPv6 loopback', async () => {
|
||||
const mw = requireInternalAuth({ loopbackOnly: true });
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '::1' });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('next');
|
||||
});
|
||||
|
||||
test('passes from IPv4-mapped IPv6 loopback', async () => {
|
||||
const mw = requireInternalAuth({ loopbackOnly: true });
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '::ffff:127.0.0.1' });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('next');
|
||||
});
|
||||
|
||||
test('returns 403 from off-host IP', async () => {
|
||||
const mw = requireInternalAuth({ loopbackOnly: true });
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '10.0.0.42' });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('blocked');
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body.error).toMatch(/Origin not permitted/);
|
||||
});
|
||||
|
||||
test('WITHOUT loopbackOnly, off-host IPs are accepted (n8n case)', async () => {
|
||||
const mw = requireInternalAuth({ loopbackOnly: false });
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '172.18.0.3' });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('next');
|
||||
});
|
||||
});
|
||||
|
||||
describe('timingSafeStringEqual', () => {
|
||||
const { timingSafeStringEqual } = __internals;
|
||||
|
||||
test('equal strings → true', () => {
|
||||
expect(timingSafeStringEqual('abc123', 'abc123')).toBe(true);
|
||||
});
|
||||
test('different strings same length → false', () => {
|
||||
expect(timingSafeStringEqual('abc123', 'abc124')).toBe(false);
|
||||
});
|
||||
test('different lengths → false (does not throw)', () => {
|
||||
expect(() => timingSafeStringEqual('a', 'abc')).not.toThrow();
|
||||
expect(timingSafeStringEqual('a', 'abc')).toBe(false);
|
||||
});
|
||||
test('non-string inputs → false', () => {
|
||||
expect(timingSafeStringEqual(null, 'a')).toBe(false);
|
||||
expect(timingSafeStringEqual('a', undefined)).toBe(false);
|
||||
expect(timingSafeStringEqual(123, '123')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// Sentry init wrapper — guarantees graceful no-op when SENTRY_DSN is
|
||||
// absent, scrubs PII on send when configured, and exposes a usable
|
||||
// surface to the rest of the codebase regardless of init state.
|
||||
|
||||
// Mock @sentry/node BEFORE requiring our wrapper so we control what
|
||||
// init() and setupExpressErrorHandler do.
|
||||
const mockSentryInit = jest.fn();
|
||||
const mockSentryCapture = jest.fn();
|
||||
const mockSentryHandler = jest.fn();
|
||||
jest.mock('@sentry/node', () => ({
|
||||
init: (...a) => mockSentryInit(...a),
|
||||
captureException: (...a) => mockSentryCapture(...a),
|
||||
captureMessage: jest.fn(),
|
||||
setupExpressErrorHandler: (...a) => mockSentryHandler(...a),
|
||||
addBreadcrumb: jest.fn(),
|
||||
setUser: jest.fn(),
|
||||
setTag: jest.fn(),
|
||||
setContext: jest.fn(),
|
||||
}));
|
||||
|
||||
const sentryWrapper = require('../../src/utils/sentry');
|
||||
|
||||
beforeEach(() => {
|
||||
mockSentryInit.mockReset();
|
||||
mockSentryCapture.mockReset();
|
||||
mockSentryHandler.mockReset();
|
||||
sentryWrapper.__internals.__resetForTests();
|
||||
delete process.env.SENTRY_DSN;
|
||||
});
|
||||
|
||||
describe('initSentry', () => {
|
||||
test('no-op when SENTRY_DSN is unset (sentry.init NOT called)', () => {
|
||||
sentryWrapper.initSentry();
|
||||
expect(mockSentryInit).not.toHaveBeenCalled();
|
||||
expect(sentryWrapper.isInitialized()).toBe(false);
|
||||
});
|
||||
|
||||
test('initializes when SENTRY_DSN is set', () => {
|
||||
process.env.SENTRY_DSN = 'https://abc@sentry.io/123';
|
||||
sentryWrapper.initSentry();
|
||||
expect(mockSentryInit).toHaveBeenCalledTimes(1);
|
||||
expect(sentryWrapper.isInitialized()).toBe(true);
|
||||
const cfg = mockSentryInit.mock.calls[0][0];
|
||||
expect(cfg.dsn).toBe('https://abc@sentry.io/123');
|
||||
expect(cfg.tracesSampleRate).toBe(0.1);
|
||||
expect(cfg.sendDefaultPii).toBe(false);
|
||||
expect(typeof cfg.beforeSend).toBe('function');
|
||||
});
|
||||
|
||||
test('explicit dsn arg overrides env', () => {
|
||||
process.env.SENTRY_DSN = 'env-dsn';
|
||||
sentryWrapper.initSentry({ dsn: 'arg-dsn' });
|
||||
expect(mockSentryInit.mock.calls[0][0].dsn).toBe('arg-dsn');
|
||||
});
|
||||
|
||||
test('idempotent — second call is a no-op', () => {
|
||||
process.env.SENTRY_DSN = 'https://abc@sentry.io/123';
|
||||
sentryWrapper.initSentry();
|
||||
sentryWrapper.initSentry();
|
||||
expect(mockSentryInit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('beforeSend PII scrubbing', () => {
|
||||
beforeEach(() => {
|
||||
process.env.SENTRY_DSN = 'https://abc@sentry.io/123';
|
||||
sentryWrapper.initSentry();
|
||||
});
|
||||
|
||||
test('strips user.ip_address and user.email', () => {
|
||||
const cfg = mockSentryInit.mock.calls[0][0];
|
||||
const event = { user: { id: 'u1', ip_address: '1.2.3.4', email: 'a@b.com' } };
|
||||
const scrubbed = cfg.beforeSend(event);
|
||||
expect(scrubbed.user.id).toBe('u1');
|
||||
expect(scrubbed.user.ip_address).toBeUndefined();
|
||||
expect(scrubbed.user.email).toBeUndefined();
|
||||
});
|
||||
|
||||
test('strips cookies + authorization + internal-key headers from request', () => {
|
||||
const cfg = mockSentryInit.mock.calls[0][0];
|
||||
const event = {
|
||||
request: {
|
||||
cookies: { session: 'abc' },
|
||||
headers: {
|
||||
authorization: 'Bearer secret',
|
||||
cookie: 'session=abc',
|
||||
'x-internal-key': 'leak',
|
||||
'x-vyndr-internal-key': 'leak',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
},
|
||||
};
|
||||
const scrubbed = cfg.beforeSend(event);
|
||||
expect(scrubbed.request.cookies).toBeUndefined();
|
||||
expect(scrubbed.request.headers.authorization).toBeUndefined();
|
||||
expect(scrubbed.request.headers.cookie).toBeUndefined();
|
||||
expect(scrubbed.request.headers['x-internal-key']).toBeUndefined();
|
||||
expect(scrubbed.request.headers['x-vyndr-internal-key']).toBeUndefined();
|
||||
// Non-sensitive headers are kept.
|
||||
expect(scrubbed.request.headers['content-type']).toBe('application/json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('noop client surface', () => {
|
||||
test('captureException is callable without DSN (no throw)', () => {
|
||||
sentryWrapper.initSentry();
|
||||
expect(() => sentryWrapper.Sentry.captureException(new Error('test'))).not.toThrow();
|
||||
expect(mockSentryCapture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('setupExpressErrorHandler is callable without DSN (no throw)', () => {
|
||||
sentryWrapper.initSentry();
|
||||
expect(() => sentryWrapper.Sentry.setupExpressErrorHandler({})).not.toThrow();
|
||||
expect(mockSentryHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('after init, captureException routes to real Sentry', () => {
|
||||
process.env.SENTRY_DSN = 'https://abc@sentry.io/123';
|
||||
sentryWrapper.initSentry();
|
||||
const err = new Error('boom');
|
||||
sentryWrapper.Sentry.captureException(err);
|
||||
expect(mockSentryCapture).toHaveBeenCalledWith(err);
|
||||
});
|
||||
|
||||
test('after init, setupExpressErrorHandler delegates to real Sentry', () => {
|
||||
process.env.SENTRY_DSN = 'https://abc@sentry.io/123';
|
||||
sentryWrapper.initSentry();
|
||||
const app = {};
|
||||
sentryWrapper.Sentry.setupExpressErrorHandler(app);
|
||||
expect(mockSentryHandler).toHaveBeenCalledWith(app);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
// Session 10 — prefetch's new api-football enrichment pass and the
|
||||
// CLI flags (--source, --max-players). The existing soccerDataPrefetch
|
||||
// test suite covers the legacy football-data path; this suite verifies:
|
||||
// - parseArgs handles the new flags
|
||||
// - shouldRunSource respects the source filter + defaults to 'all'
|
||||
// - enrichFromApiFootball walks finished fixtures, aggregates per-90
|
||||
// rates, and writes apifootball:player_by_name:{normalizedName}
|
||||
// - graceful skip when API_FOOTBALL_KEY is unset
|
||||
|
||||
const mockApifGetFixtures = jest.fn();
|
||||
const mockApifGetFixturePlayerStats = jest.fn();
|
||||
const mockApifHasApiKey = jest.fn(() => true);
|
||||
jest.mock('../../src/services/adapters/apiFootballAdapter', () => ({
|
||||
getFixtures: (...a) => mockApifGetFixtures(...a),
|
||||
getFixturePlayerStats: (...a) => mockApifGetFixturePlayerStats(...a),
|
||||
hasApiKey: (...a) => mockApifHasApiKey(...a),
|
||||
}));
|
||||
|
||||
const mockFootapiHasApiKey = jest.fn(() => false);
|
||||
const mockFootapiGetRefereeStatistics = jest.fn();
|
||||
jest.mock('../../src/services/adapters/footApiAdapter', () => ({
|
||||
hasApiKey: (...a) => mockFootapiHasApiKey(...a),
|
||||
getRefereeStatistics: (...a) => mockFootapiGetRefereeStatistics(...a),
|
||||
}));
|
||||
|
||||
const mockFbdHasApiKey = jest.fn(() => true);
|
||||
const mockFbdGetLeagueStandings = jest.fn(async () => []);
|
||||
const mockFbdGetLeagueScorers = jest.fn(async () => []);
|
||||
jest.mock('../../src/services/adapters/footballDataAdapter', () => ({
|
||||
hasApiKey: (...a) => mockFbdHasApiKey(...a),
|
||||
getLeagueStandings: (...a) => mockFbdGetLeagueStandings(...a),
|
||||
getLeagueScorers: (...a) => mockFbdGetLeagueScorers(...a),
|
||||
}));
|
||||
|
||||
const mockCacheSets = new Map();
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async () => null,
|
||||
cacheSet: async (k, v, ttl) => { mockCacheSets.set(k, { value: v, ttl }); return true; },
|
||||
cacheDel: async () => true,
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
const { normalizeName } = require('../../src/utils/normalize');
|
||||
const prefetch = require('../../scripts/soccer-data-prefetch');
|
||||
|
||||
beforeEach(() => {
|
||||
mockApifGetFixtures.mockReset();
|
||||
mockApifGetFixturePlayerStats.mockReset();
|
||||
mockApifHasApiKey.mockReset().mockReturnValue(true);
|
||||
mockFbdHasApiKey.mockReset().mockReturnValue(true);
|
||||
mockFbdGetLeagueStandings.mockReset().mockResolvedValue([]);
|
||||
mockFbdGetLeagueScorers.mockReset().mockResolvedValue([]);
|
||||
mockFootapiHasApiKey.mockReset().mockReturnValue(false);
|
||||
mockFootapiGetRefereeStatistics.mockReset();
|
||||
mockCacheSets.clear();
|
||||
});
|
||||
|
||||
describe('soccer-data-prefetch — Session 10 cascade enrichment', () => {
|
||||
describe('parseArgs', () => {
|
||||
test('parses --source flag with valid value', () => {
|
||||
const a = prefetch.__internals.parseArgs(['node', 'script', '--source=api-football']);
|
||||
expect(a.source).toBe('api-football');
|
||||
});
|
||||
test('invalid --source falls back to "all"', () => {
|
||||
const a = prefetch.__internals.parseArgs(['node', 'script', '--source=bogus']);
|
||||
expect(a.source).toBe('all');
|
||||
});
|
||||
test('parses --max-players', () => {
|
||||
const a = prefetch.__internals.parseArgs(['node', 'script', '--max-players=25']);
|
||||
expect(a.maxPlayers).toBe(25);
|
||||
});
|
||||
test('--max-players ignores non-numeric and zero/negative', () => {
|
||||
expect(prefetch.__internals.parseArgs(['node', 'script', '--max-players=foo']).maxPlayers).toBe(80);
|
||||
expect(prefetch.__internals.parseArgs(['node', 'script', '--max-players=0']).maxPlayers).toBe(80);
|
||||
expect(prefetch.__internals.parseArgs(['node', 'script', '--max-players=-5']).maxPlayers).toBe(80);
|
||||
});
|
||||
test('parses --season', () => {
|
||||
expect(prefetch.__internals.parseArgs(['node', 'script', '--season=2025']).season).toBe(2025);
|
||||
});
|
||||
test('defaults', () => {
|
||||
const a = prefetch.__internals.parseArgs(['node', 'script']);
|
||||
expect(a.source).toBe('all');
|
||||
expect(a.maxPlayers).toBe(80);
|
||||
expect(a.season).toBe(2026);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRunSource', () => {
|
||||
const { shouldRunSource } = prefetch.__internals;
|
||||
test('"all" matches everything', () => {
|
||||
expect(shouldRunSource({ source: 'all' }, 'api-football')).toBe(true);
|
||||
expect(shouldRunSource({ source: 'all' }, 'football-data')).toBe(true);
|
||||
expect(shouldRunSource({ source: 'all' }, 'footapi')).toBe(true);
|
||||
});
|
||||
test('explicit source matches only itself', () => {
|
||||
expect(shouldRunSource({ source: 'api-football' }, 'api-football')).toBe(true);
|
||||
expect(shouldRunSource({ source: 'api-football' }, 'football-data')).toBe(false);
|
||||
});
|
||||
test('missing args.source defaults to "all" (backwards compat)', () => {
|
||||
expect(shouldRunSource({}, 'football-data')).toBe(true);
|
||||
expect(shouldRunSource(undefined, 'api-football')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichFromApiFootball', () => {
|
||||
test('graceful skip when API_FOOTBALL_KEY is unset', async () => {
|
||||
mockApifHasApiKey.mockReturnValueOnce(false);
|
||||
const r = await prefetch.__internals.enrichFromApiFootball('WC', {
|
||||
season: 2026, maxPlayers: 80, dryRun: false,
|
||||
});
|
||||
expect(r.skipped).toBe('no_key');
|
||||
expect(r.players).toBe(0);
|
||||
expect(mockApifGetFixtures).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('skips an unmapped league code (no api-football league ID)', async () => {
|
||||
const r = await prefetch.__internals.enrichFromApiFootball('UNKNOWN', {
|
||||
season: 2026, maxPlayers: 80, dryRun: false,
|
||||
});
|
||||
expect(r.skipped).toBe('unmapped_league');
|
||||
expect(mockApifGetFixtures).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('skips when no fixtures returned', async () => {
|
||||
mockApifGetFixtures.mockResolvedValueOnce([]);
|
||||
const r = await prefetch.__internals.enrichFromApiFootball('WC', {
|
||||
season: 2026, maxPlayers: 80, dryRun: false,
|
||||
});
|
||||
expect(r.skipped).toBe('no_fixtures');
|
||||
});
|
||||
|
||||
test('aggregates per-90 stats across finished fixtures, writes cascade keys', async () => {
|
||||
mockApifGetFixtures.mockResolvedValueOnce([
|
||||
{ id: 9001, status: 'FT' },
|
||||
{ id: 9002, status: 'NS' }, // not yet played — skip
|
||||
{ id: 9003, status: 'FT' },
|
||||
]);
|
||||
// Fixture 9001: Messi 90min, 1G, 2A, 5 shots.
|
||||
mockApifGetFixturePlayerStats.mockResolvedValueOnce([
|
||||
{ name: 'Lionel Messi', team: 'Argentina', position: 'F', minutes: 90, goals: 1, assists: 2, shots_total: 5, shots_on: 3, substitute: false, rating: '8.4' },
|
||||
]);
|
||||
// Fixture 9003: Messi 88min, 0G, 1A.
|
||||
mockApifGetFixturePlayerStats.mockResolvedValueOnce([
|
||||
{ name: 'Lionel Messi', team: 'Argentina', position: 'F', minutes: 88, goals: 0, assists: 1, shots_total: 3, shots_on: 1, substitute: false, rating: '7.5' },
|
||||
]);
|
||||
|
||||
const r = await prefetch.__internals.enrichFromApiFootball('WC', {
|
||||
season: 2026, maxPlayers: 80, dryRun: false,
|
||||
});
|
||||
expect(r.players).toBe(1);
|
||||
|
||||
const key = `apifootball:player_by_name:${normalizeName('Lionel Messi')}`;
|
||||
const entry = mockCacheSets.get(key);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.value).toMatchObject({
|
||||
name: 'Lionel Messi', team: 'Argentina',
|
||||
appearances: 2,
|
||||
minutes: 178, // 90 + 88
|
||||
goals: 1, assists: 3,
|
||||
});
|
||||
// 1 goal over 178 minutes = 0.506 per 90 (3dp).
|
||||
expect(entry.value.goals_per_90).toBeCloseTo(0.506, 2);
|
||||
// 3 assists over 178 min = 1.517 per 90.
|
||||
expect(entry.value.assists_per_90).toBeCloseTo(1.517, 2);
|
||||
// Average rating across two appearances.
|
||||
expect(entry.value.avg_rating).toBeCloseTo(7.95, 1);
|
||||
expect(entry.ttl).toBe(prefetch.__internals.PLAYER_TTL_SEC);
|
||||
});
|
||||
|
||||
test('honors maxPlayers cap (limits writes per run)', async () => {
|
||||
mockApifGetFixtures.mockResolvedValueOnce([{ id: 1, status: 'FT' }]);
|
||||
mockApifGetFixturePlayerStats.mockResolvedValueOnce([
|
||||
{ name: 'A', team: 'X', minutes: 90, goals: 0, assists: 0 },
|
||||
{ name: 'B', team: 'X', minutes: 90, goals: 0, assists: 0 },
|
||||
{ name: 'C', team: 'X', minutes: 90, goals: 0, assists: 0 },
|
||||
]);
|
||||
const r = await prefetch.__internals.enrichFromApiFootball('WC', {
|
||||
season: 2026, maxPlayers: 2, dryRun: false,
|
||||
});
|
||||
expect(r.players).toBe(2);
|
||||
});
|
||||
|
||||
test('dry-run skips cacheSet but still reports a count', async () => {
|
||||
mockApifGetFixtures.mockResolvedValueOnce([{ id: 1, status: 'FT' }]);
|
||||
mockApifGetFixturePlayerStats.mockResolvedValueOnce([
|
||||
{ name: 'A', team: 'X', minutes: 90, goals: 1, assists: 0 },
|
||||
]);
|
||||
const r = await prefetch.__internals.enrichFromApiFootball('WC', {
|
||||
season: 2026, maxPlayers: 80, dryRun: true,
|
||||
});
|
||||
expect(r.players).toBe(1);
|
||||
expect(mockCacheSets.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichRefereesFromFootApi', () => {
|
||||
test('graceful skip when RAPID_API_KEY is unset', async () => {
|
||||
mockFootapiHasApiKey.mockReturnValueOnce(false);
|
||||
const r = await prefetch.__internals.enrichRefereesFromFootApi(
|
||||
[{ id: 1, name: 'X' }], { dryRun: false },
|
||||
);
|
||||
expect(r.skipped).toBe('no_key');
|
||||
expect(mockFootapiGetRefereeStatistics).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('writes footapi:referee_by_name:{name} keys when stats exist', async () => {
|
||||
mockFootapiHasApiKey.mockReturnValue(true);
|
||||
mockFootapiGetRefereeStatistics.mockResolvedValueOnce([
|
||||
{ tournamentId: 16, appearances: 6, yellowCards: 24, redCards: 1, yellowCardsPerGame: 4 },
|
||||
]);
|
||||
const r = await prefetch.__internals.enrichRefereesFromFootApi(
|
||||
[{ id: 99, name: 'Anthony Taylor' }], { dryRun: false },
|
||||
);
|
||||
expect(r.referees).toBe(1);
|
||||
const entry = mockCacheSets.get('footapi:referee_by_name:Anthony Taylor');
|
||||
expect(entry.value).toMatchObject({
|
||||
name: 'Anthony Taylor', cards_per_game: 4, appearances: 6,
|
||||
});
|
||||
expect(entry.ttl).toBe(prefetch.__internals.REFEREE_TTL_SEC);
|
||||
});
|
||||
|
||||
test('handles missing IDs in the referee list', async () => {
|
||||
mockFootapiHasApiKey.mockReturnValue(true);
|
||||
const r = await prefetch.__internals.enrichRefereesFromFootApi(
|
||||
[{ id: null, name: 'X' }, { id: 1, name: null }], { dryRun: false },
|
||||
);
|
||||
expect(r.referees).toBe(0);
|
||||
expect(mockFootapiGetRefereeStatistics).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('main — graceful skip when no source keys configured', () => {
|
||||
test('logs skip + returns {skipped: true} when nothing is available', async () => {
|
||||
mockFbdHasApiKey.mockReturnValue(false);
|
||||
mockApifHasApiKey.mockReturnValue(false);
|
||||
mockFootapiHasApiKey.mockReturnValue(false);
|
||||
const r = await prefetch.main(['node', 'script']);
|
||||
expect(r.skipped).toBe(true);
|
||||
});
|
||||
|
||||
test('proceeds when ANY source is available (api-football only)', async () => {
|
||||
mockFbdHasApiKey.mockReturnValue(false);
|
||||
mockApifHasApiKey.mockReturnValue(true);
|
||||
mockApifGetFixtures.mockResolvedValue([]);
|
||||
const r = await prefetch.main(['node', 'script', '--leagues=WC', '--dry-run']);
|
||||
// Not skipped — we have at least one configured source.
|
||||
expect(r.skipped).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user