Session 9: api-football + FootApi + Tank01 adapters, grace period middleware, cookie consent, /pricing page, OOM fix documented (1240 tests)
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
// apiFootballAdapter — PRIMARY soccer source. Tests the auth header
|
||||
// shape (x-apisports-key, NOT RapidAPI), the rate-limit bookkeeping,
|
||||
// the graceful-degradation paths, and the per-endpoint projection.
|
||||
|
||||
const mockAxiosGet = jest.fn();
|
||||
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) }));
|
||||
|
||||
const mockCacheStore = new Map();
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null),
|
||||
cacheSet: async (k, v) => { mockCacheStore.set(k, v); return true; },
|
||||
cacheDel: async (k) => { mockCacheStore.delete(k); return true; },
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
const adapter = require('../../src/services/adapters/apiFootballAdapter');
|
||||
|
||||
beforeEach(async () => {
|
||||
mockAxiosGet.mockReset();
|
||||
mockCacheStore.clear();
|
||||
await adapter.__internals.resetCounterForTests();
|
||||
// Re-clear so the counter set above doesn't persist into the next test.
|
||||
mockCacheStore.clear();
|
||||
});
|
||||
|
||||
describe('apiFootballAdapter', () => {
|
||||
describe('graceful degradation when API_FOOTBALL_KEY missing', () => {
|
||||
const original = process.env.API_FOOTBALL_KEY;
|
||||
beforeAll(() => { delete process.env.API_FOOTBALL_KEY; });
|
||||
afterAll(() => { if (original !== undefined) process.env.API_FOOTBALL_KEY = original; });
|
||||
|
||||
test('hasApiKey reports false', () => {
|
||||
expect(adapter.hasApiKey()).toBe(false);
|
||||
});
|
||||
|
||||
test('all endpoints return null without touching axios', async () => {
|
||||
expect(await adapter.getFixtures({ league: 1, season: 2026 })).toBeNull();
|
||||
expect(await adapter.getFixtureLineups(42)).toBeNull();
|
||||
expect(await adapter.getFixturePlayerStats(42)).toBeNull();
|
||||
expect(await adapter.getFixtureEvents(42)).toBeNull();
|
||||
expect(await adapter.getPlayerSeasonStats(100, 2026)).toBeNull();
|
||||
expect(await adapter.getStandings(1, 2026)).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with key configured', () => {
|
||||
beforeAll(() => { process.env.API_FOOTBALL_KEY = 'test-apisports-key'; });
|
||||
|
||||
test('auth header is x-apisports-key — NOT RapidAPI', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { response: [] } });
|
||||
await adapter.getFixtures({ league: 1, season: 2026 });
|
||||
const [, opts] = mockAxiosGet.mock.calls[0];
|
||||
expect(opts.headers['x-apisports-key']).toBe('test-apisports-key');
|
||||
// RapidAPI headers must NOT be present.
|
||||
expect(opts.headers['x-rapidapi-key']).toBeUndefined();
|
||||
expect(opts.headers['x-rapidapi-host']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getFixtures projects to the unified shape', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
response: [
|
||||
{
|
||||
fixture: { id: 9001, date: '2026-06-11T20:00:00+00:00', status: { short: 'NS' }, venue: { name: 'Estadio Azteca' }, referee: 'Daniele Orsato' },
|
||||
league: { name: 'World Cup', season: 2026, round: 'Group Stage - 1' },
|
||||
teams: { home: { id: 26, name: 'Mexico' }, away: { id: 6, name: 'USA' } },
|
||||
score: { fulltime: { home: null, away: null } },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const fixtures = await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' });
|
||||
expect(fixtures).toHaveLength(1);
|
||||
expect(fixtures[0]).toMatchObject({
|
||||
id: 9001,
|
||||
homeTeam: 'Mexico',
|
||||
awayTeam: 'USA',
|
||||
venue: 'Estadio Azteca',
|
||||
referee: 'Daniele Orsato',
|
||||
league: 'World Cup',
|
||||
});
|
||||
});
|
||||
|
||||
test('getFixtures with no date works (whole season)', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { response: [{ fixture: { id: 1 }, teams: { home: { name: 'A' }, away: { name: 'B' } } }] } });
|
||||
const fixtures = await adapter.getFixtures({ league: 1, season: 2026 });
|
||||
expect(fixtures).toHaveLength(1);
|
||||
const [url] = mockAxiosGet.mock.calls[0];
|
||||
expect(url).not.toMatch(/date=/);
|
||||
});
|
||||
|
||||
test('null params bounce without touching axios', async () => {
|
||||
expect(await adapter.getFixtures({})).toBeNull();
|
||||
expect(await adapter.getFixtureLineups(null)).toBeNull();
|
||||
expect(await adapter.getPlayerSeasonStats(null, 2026)).toBeNull();
|
||||
expect(await adapter.getStandings(1, null)).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('getFixturePlayerStats flattens per-team rosters into one list', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
response: [
|
||||
{
|
||||
team: { name: 'Argentina' },
|
||||
players: [
|
||||
{
|
||||
player: { id: 1, name: 'Messi' },
|
||||
statistics: [{
|
||||
games: { minutes: 88, position: 'F', rating: '8.4', substitute: false },
|
||||
goals: { total: 1, assists: 1, saves: null },
|
||||
shots: { total: 5, on: 3 },
|
||||
passes: { total: 47, accuracy: 89 },
|
||||
tackles: { total: 1, blocks: 0, interceptions: 2 },
|
||||
cards: { yellow: 0, red: 0 },
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
team: { name: 'France' },
|
||||
players: [
|
||||
{
|
||||
player: { id: 2, name: 'Mbappe' },
|
||||
statistics: [{
|
||||
games: { minutes: 90, position: 'F', rating: '7.9', substitute: false },
|
||||
goals: { total: 2, assists: 0, saves: null },
|
||||
shots: { total: 7, on: 4 },
|
||||
passes: { total: 28, accuracy: 71 },
|
||||
tackles: { total: 0 },
|
||||
cards: { yellow: 1, red: 0 },
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const stats = await adapter.getFixturePlayerStats(9001);
|
||||
expect(stats).toHaveLength(2);
|
||||
const messi = stats.find((p) => p.name === 'Messi');
|
||||
expect(messi.team).toBe('Argentina');
|
||||
expect(messi.goals).toBe(1);
|
||||
expect(messi.shots_on).toBe(3);
|
||||
const mbappe = stats.find((p) => p.name === 'Mbappe');
|
||||
expect(mbappe.team).toBe('France');
|
||||
expect(mbappe.goals).toBe(2);
|
||||
expect(mbappe.yellow).toBe(1);
|
||||
});
|
||||
|
||||
test('getFixtureLineups projects formation + startXI + bench', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
response: [
|
||||
{
|
||||
team: { id: 26, name: 'Mexico' },
|
||||
coach: { name: 'Some Coach' },
|
||||
formation: '4-3-3',
|
||||
startXI: [
|
||||
{ player: { id: 10, name: 'GK', number: 1, pos: 'G', grid: '1:1' } },
|
||||
],
|
||||
substitutes: [{ player: { id: 11, name: 'Sub', number: 22, pos: 'M' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const lineups = await adapter.getFixtureLineups(9001);
|
||||
expect(lineups[0].formation).toBe('4-3-3');
|
||||
expect(lineups[0].startXI[0].name).toBe('GK');
|
||||
expect(lineups[0].substitutes[0].name).toBe('Sub');
|
||||
});
|
||||
|
||||
test('getStandings flattens group standings into a flat list', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
response: [{
|
||||
league: {
|
||||
standings: [
|
||||
[ // Group A
|
||||
{ rank: 1, team: { id: 26, name: 'Mexico' }, all: { played: 3, win: 2, draw: 1, lose: 0, goals: { for: 5, against: 2 } }, points: 7, group: 'Group A' },
|
||||
],
|
||||
[ // Group B
|
||||
{ rank: 1, team: { id: 5, name: 'Argentina' }, all: { played: 3, win: 3, draw: 0, lose: 0, goals: { for: 6, against: 1 } }, points: 9, group: 'Group B' },
|
||||
],
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
});
|
||||
const standings = await adapter.getStandings(1, 2026);
|
||||
expect(standings).toHaveLength(2);
|
||||
expect(standings.find((s) => s.team === 'Mexico')?.points).toBe(7);
|
||||
expect(standings.find((s) => s.team === 'Argentina')?.points).toBe(9);
|
||||
});
|
||||
|
||||
test('cache hit on repeat call (axios not re-invoked)', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { response: [{ fixture: { id: 1 }, teams: { home: { name: 'A' }, away: { name: 'B' } } }] } });
|
||||
await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' });
|
||||
await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' });
|
||||
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('axios throw → null + stale-while-revalidate', async () => {
|
||||
mockCacheStore.set('apifootball:fixtures:1:2026:2026-06-11:stale', { response: [{ fixture: { id: 999 }, teams: { home: { name: 'X' }, away: { name: 'Y' } } }] });
|
||||
mockAxiosGet.mockRejectedValueOnce(new Error('upstream 500'));
|
||||
const fixtures = await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' });
|
||||
expect(fixtures[0].id).toBe(999);
|
||||
});
|
||||
|
||||
test('axios throw with no stale → returns null', async () => {
|
||||
mockAxiosGet.mockRejectedValueOnce(new Error('network down'));
|
||||
const fixtures = await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' });
|
||||
expect(fixtures).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('daily rate-limit accounting', () => {
|
||||
beforeAll(() => { process.env.API_FOOTBALL_KEY = 'test-apisports-key'; });
|
||||
|
||||
test('bumps the counter on a successful network call', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { response: [] } });
|
||||
await adapter.getFixtures({ league: 1, season: 2026 });
|
||||
const count = await adapter.__internals.readDailyCount();
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test('does NOT bump on cache hit', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { response: [{ fixture: { id: 1 }, teams: { home: { name: 'A' }, away: { name: 'B' } } }] } });
|
||||
await adapter.getFixtures({ league: 1, season: 2026 });
|
||||
await adapter.getFixtures({ league: 1, season: 2026 }); // cached
|
||||
const count = await adapter.__internals.readDailyCount();
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test('at SOFT_LIMIT, refuses network + serves stale if present', async () => {
|
||||
// Prime the counter to SOFT_LIMIT (90).
|
||||
const { cacheSet } = require('../../src/utils/redis');
|
||||
await cacheSet(adapter.__internals.DAILY_COUNTER_KEY, adapter.__internals.SOFT_LIMIT);
|
||||
mockCacheStore.set('apifootball:fixtures:1:2026:stale', { response: [{ fixture: { id: 7 }, teams: { home: { name: 'X' }, away: { name: 'Y' } } }] });
|
||||
|
||||
const fixtures = await adapter.getFixtures({ league: 1, season: 2026 });
|
||||
expect(fixtures[0].id).toBe(7);
|
||||
// Network NOT called — the bucket stopped us.
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('at SOFT_LIMIT with no stale → null', async () => {
|
||||
const { cacheSet } = require('../../src/utils/redis');
|
||||
await cacheSet(adapter.__internals.DAILY_COUNTER_KEY, adapter.__internals.SOFT_LIMIT);
|
||||
const fixtures = await adapter.getFixtures({ league: 1, season: 2026 });
|
||||
expect(fixtures).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
// footApiAdapter — BACKUP soccer source via RapidAPI. Tests the
|
||||
// RapidAPI auth header shape, the per-endpoint projection, and the
|
||||
// graceful-degradation paths.
|
||||
|
||||
const mockAxiosGet = jest.fn();
|
||||
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) }));
|
||||
|
||||
const mockCacheStore = new Map();
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null),
|
||||
cacheSet: async (k, v) => { mockCacheStore.set(k, v); return true; },
|
||||
cacheDel: async (k) => { mockCacheStore.delete(k); return true; },
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
const adapter = require('../../src/services/adapters/footApiAdapter');
|
||||
|
||||
beforeEach(async () => {
|
||||
mockAxiosGet.mockReset();
|
||||
mockCacheStore.clear();
|
||||
});
|
||||
|
||||
describe('footApiAdapter', () => {
|
||||
describe('graceful degradation when RAPID_API_KEY missing', () => {
|
||||
const original = process.env.RAPID_API_KEY;
|
||||
beforeAll(() => { delete process.env.RAPID_API_KEY; });
|
||||
afterAll(() => { if (original !== undefined) process.env.RAPID_API_KEY = original; });
|
||||
|
||||
test('hasApiKey reports false', () => {
|
||||
expect(adapter.hasApiKey()).toBe(false);
|
||||
});
|
||||
|
||||
test('all endpoints return null without touching axios', async () => {
|
||||
expect(await adapter.getMatchLineups(123)).toBeNull();
|
||||
expect(await adapter.getMatchIncidents(123)).toBeNull();
|
||||
expect(await adapter.getRefereeStatistics(7)).toBeNull();
|
||||
expect(await adapter.getWorldCupSchedule(11, 6, 2026)).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with key configured', () => {
|
||||
beforeAll(() => { process.env.RAPID_API_KEY = 'test-rapid-key'; });
|
||||
|
||||
test('auth headers are RapidAPI shape — NOT x-apisports-key', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: {} });
|
||||
await adapter.getMatchLineups(42);
|
||||
const [url, opts] = mockAxiosGet.mock.calls[0];
|
||||
expect(url).toMatch(/^https:\/\/footapi7\.p\.rapidapi\.com/);
|
||||
expect(opts.headers['x-rapidapi-key']).toBe('test-rapid-key');
|
||||
expect(opts.headers['x-rapidapi-host']).toBe('footapi7.p.rapidapi.com');
|
||||
// Primary adapter's header MUST NOT appear.
|
||||
expect(opts.headers['x-apisports-key']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('FOOTAPI_HOST override is honored', async () => {
|
||||
const originalHost = process.env.FOOTAPI_HOST;
|
||||
process.env.FOOTAPI_HOST = 'mirror.rapidapi.com';
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: {} });
|
||||
await adapter.getMatchLineups(7);
|
||||
const [url, opts] = mockAxiosGet.mock.calls[0];
|
||||
expect(url).toMatch(/^https:\/\/mirror\.rapidapi\.com/);
|
||||
expect(opts.headers['x-rapidapi-host']).toBe('mirror.rapidapi.com');
|
||||
if (originalHost !== undefined) process.env.FOOTAPI_HOST = originalHost;
|
||||
else delete process.env.FOOTAPI_HOST;
|
||||
});
|
||||
|
||||
test('getMatchLineups flattens home + away into one player list', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
home: {
|
||||
formation: '4-3-3',
|
||||
players: [
|
||||
{
|
||||
player: { id: 1, name: 'Saka' },
|
||||
position: 'F',
|
||||
shirtNumber: 7,
|
||||
statistics: { minutesPlayed: 88, rating: 8.1, goals: 1, goalAssist: 1, totalShots: 4, shotOnTarget: 2, totalPass: 35, accuratePass: 30, totalTackle: 2, yellowCards: 0, redCards: 0, keyPass: 3 },
|
||||
},
|
||||
],
|
||||
},
|
||||
away: {
|
||||
formation: '4-2-3-1',
|
||||
players: [
|
||||
{
|
||||
player: { id: 2, name: 'Mbappe' },
|
||||
position: 'F',
|
||||
substitute: false,
|
||||
statistics: { minutesPlayed: 90, rating: 7.7, goals: 0, totalShots: 5, shotOnTarget: 1, totalPass: 22, accuratePass: 18, yellowCards: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const lineups = await adapter.getMatchLineups(101);
|
||||
expect(lineups).toHaveLength(2);
|
||||
const saka = lineups.find((p) => p.name === 'Saka');
|
||||
expect(saka.side).toBe('home');
|
||||
expect(saka.goals).toBe(1);
|
||||
expect(saka.shotsOnTarget).toBe(2);
|
||||
expect(saka.assists).toBe(1);
|
||||
const mbappe = lineups.find((p) => p.name === 'Mbappe');
|
||||
expect(mbappe.side).toBe('away');
|
||||
expect(mbappe.yellow).toBe(1);
|
||||
});
|
||||
|
||||
test('getMatchIncidents projects time + addedTime + player + type', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
incidents: [
|
||||
{ incidentType: 'goal', time: 43, addedTime: 0, isHome: true, player: { name: 'A' }, assist1: { name: 'B' }, text: '1-0' },
|
||||
{ incidentType: 'card', incidentClass: 'yellow', time: 90, addedTime: 4, player: { name: 'C' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
const events = await adapter.getMatchIncidents(101);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({ type: 'goal', minute: 43, player: 'A', assist: 'B' });
|
||||
expect(events[1]).toMatchObject({ type: 'card', minute: 90, addedTime: 4 });
|
||||
});
|
||||
|
||||
test('getRefereeStatistics computes per-game rates', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
statistics: [
|
||||
{ tournament: { id: 16, name: 'World Cup' }, season: { year: 2022 }, appearances: 6, yellowCards: 24, redCards: 1 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const refs = await adapter.getRefereeStatistics(99);
|
||||
expect(refs).toHaveLength(1);
|
||||
// 24/6 = 4.00 cards/game; 1/6 = ~0.167 red/game.
|
||||
expect(refs[0].yellowCardsPerGame).toBeCloseTo(4.0);
|
||||
expect(refs[0].redCardsPerGame).toBeCloseTo(0.167, 2);
|
||||
});
|
||||
|
||||
test('getRefereeStatistics handles zero appearances gracefully', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: { statistics: [{ tournament: { id: 16 }, appearances: 0, yellowCards: 0, redCards: 0 }] },
|
||||
});
|
||||
const refs = await adapter.getRefereeStatistics(99);
|
||||
expect(refs[0].yellowCardsPerGame).toBeNull();
|
||||
expect(refs[0].redCardsPerGame).toBeNull();
|
||||
});
|
||||
|
||||
test('getWorldCupSchedule maps events with venue + referee', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
events: [
|
||||
{
|
||||
id: 5, startTimestamp: 1749672000, status: { type: 'notstarted' },
|
||||
homeTeam: { id: 26, name: 'Mexico' }, awayTeam: { id: 6, name: 'USA' },
|
||||
homeScore: { current: 0 }, awayScore: { current: 0 },
|
||||
venue: { name: 'Estadio Azteca' }, referee: { name: 'Daniele Orsato' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const matches = await adapter.getWorldCupSchedule(11, 6, 2026);
|
||||
expect(matches[0]).toMatchObject({
|
||||
id: 5, homeTeam: 'Mexico', awayTeam: 'USA', venue: 'Estadio Azteca', referee: 'Daniele Orsato',
|
||||
});
|
||||
});
|
||||
|
||||
test('cache hit on repeat call (axios not re-invoked)', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { home: { players: [] }, away: { players: [] } } });
|
||||
await adapter.getMatchLineups(101);
|
||||
await adapter.getMatchLineups(101);
|
||||
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('null IDs bounce without touching axios', async () => {
|
||||
expect(await adapter.getMatchLineups(null)).toBeNull();
|
||||
expect(await adapter.getMatchIncidents(null)).toBeNull();
|
||||
expect(await adapter.getRefereeStatistics(null)).toBeNull();
|
||||
expect(await adapter.getWorldCupSchedule(null, 6, 2026)).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('daily rate-limit (50 budget, soft 45)', () => {
|
||||
beforeAll(() => { process.env.RAPID_API_KEY = 'test-rapid-key'; });
|
||||
beforeEach(async () => { await adapter.__internals.resetCounterForTests(); mockCacheStore.clear(); });
|
||||
|
||||
test('bumps counter on successful network call', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: {} });
|
||||
await adapter.getMatchLineups(42);
|
||||
expect(await adapter.__internals.readDailyCount()).toBe(1);
|
||||
});
|
||||
|
||||
test('at SOFT_LIMIT refuses network + serves stale', async () => {
|
||||
const { cacheSet } = require('../../src/utils/redis');
|
||||
await cacheSet(adapter.__internals.DAILY_COUNTER_KEY, adapter.__internals.SOFT_LIMIT);
|
||||
mockCacheStore.set('footapi:match:42:lineups:stale', { home: { players: [{ player: { id: 1, name: 'Stale' }, statistics: {} }] }, away: { players: [] } });
|
||||
|
||||
const lineups = await adapter.getMatchLineups(42);
|
||||
expect(lineups[0].name).toBe('Stale');
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
// Grace-period middleware tests. We mock the supabase client at the
|
||||
// module boundary so the middleware's calls land on a controllable
|
||||
// chainable fake. No real DB / network.
|
||||
|
||||
const mockSupabaseUpdates = [];
|
||||
const supabaseFake = {
|
||||
from(table) {
|
||||
const ctx = { table, filters: [], action: null };
|
||||
const proxy = {
|
||||
update(patch) {
|
||||
ctx.patch = patch;
|
||||
ctx.action = 'update';
|
||||
mockSupabaseUpdates.push(ctx);
|
||||
return proxy;
|
||||
},
|
||||
eq(col, val) {
|
||||
ctx.filters.push([col, val]);
|
||||
// The middleware awaits the result of .eq() after .update() —
|
||||
// return a resolved promise (no error).
|
||||
return Promise.resolve({ error: null });
|
||||
},
|
||||
};
|
||||
return proxy;
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('../../src/utils/supabase', () => ({
|
||||
getSupabaseServiceClient: () => supabaseFake,
|
||||
}));
|
||||
|
||||
const { checkGracePeriod } = require('../../src/middleware/gracePeriod');
|
||||
|
||||
beforeEach(() => {
|
||||
mockSupabaseUpdates.length = 0;
|
||||
});
|
||||
|
||||
function runMiddleware(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const res = {
|
||||
status: (code) => ({ json: (b) => reject(new Error(`unexpected ${code}: ${JSON.stringify(b)}`)) }),
|
||||
};
|
||||
checkGracePeriod(req, res, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
describe('checkGracePeriod', () => {
|
||||
test('passes through when no user (unauth route slip)', async () => {
|
||||
await runMiddleware({});
|
||||
expect(mockSupabaseUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('passes through when user has no grace_period_until', async () => {
|
||||
await runMiddleware({ user: { id: 'u1', tier: 'analyst', grace_period_until: null } });
|
||||
expect(mockSupabaseUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('passes through when grace is still in the future', async () => {
|
||||
const future = new Date(Date.now() + 12 * 3600 * 1000).toISOString();
|
||||
const req = { user: { id: 'u1', tier: 'analyst', grace_period_until: future } };
|
||||
await runMiddleware(req);
|
||||
expect(mockSupabaseUpdates).toHaveLength(0);
|
||||
expect(req.user.tier).toBe('analyst'); // unchanged
|
||||
});
|
||||
|
||||
test('expired grace → downgrades both tables + rewrites req.user', async () => {
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
const req = { user: { id: 'u1', tier: 'desk', grace_period_until: past } };
|
||||
await runMiddleware(req);
|
||||
|
||||
expect(mockSupabaseUpdates).toHaveLength(2);
|
||||
|
||||
const usersUpdate = mockSupabaseUpdates.find((u) => u.table === 'users');
|
||||
expect(usersUpdate.patch).toEqual({ tier: 'free', grace_period_until: null });
|
||||
expect(usersUpdate.filters).toEqual([['id', 'u1']]);
|
||||
|
||||
const profileUpdate = mockSupabaseUpdates.find((u) => u.table === 'user_profiles');
|
||||
expect(profileUpdate.patch.tier).toBe('free');
|
||||
expect(profileUpdate.patch.subscription_status).toBe('expired');
|
||||
expect(profileUpdate.patch.grace_period_until).toBeNull();
|
||||
|
||||
// req.user reflects the downgrade so the downstream route sees it.
|
||||
expect(req.user.tier).toBe('free');
|
||||
expect(req.user.grace_period_until).toBeNull();
|
||||
});
|
||||
|
||||
test('expired but already free → still scrubs the grace timestamp', async () => {
|
||||
// Edge case — Stripe set grace, then user got downgraded by some
|
||||
// other path. We should still null the timestamp so the row is clean.
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
const req = { user: { id: 'u2', tier: 'free', grace_period_until: past } };
|
||||
await runMiddleware(req);
|
||||
const usersUpdate = mockSupabaseUpdates.find((u) => u.table === 'users');
|
||||
expect(usersUpdate.patch.grace_period_until).toBeNull();
|
||||
expect(req.user.grace_period_until).toBeNull();
|
||||
});
|
||||
|
||||
test('malformed grace timestamp → treated as no grace (no downgrade)', async () => {
|
||||
const req = { user: { id: 'u3', tier: 'analyst', grace_period_until: 'not-a-date' } };
|
||||
await runMiddleware(req);
|
||||
expect(mockSupabaseUpdates).toHaveLength(0);
|
||||
expect(req.user.tier).toBe('analyst');
|
||||
});
|
||||
|
||||
test('exactly-at-boundary timestamp is treated as expired', async () => {
|
||||
// The middleware does `> Date.now()`; an exact match counts as
|
||||
// expired. We test that policy stays explicit.
|
||||
const req = { user: { id: 'u4', tier: 'analyst', grace_period_until: new Date(Date.now() - 1).toISOString() } };
|
||||
await runMiddleware(req);
|
||||
expect(mockSupabaseUpdates.length).toBeGreaterThan(0);
|
||||
expect(req.user.tier).toBe('free');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// Source-cascade tests for soccerFeatureExtractor (Session 9). The
|
||||
// pre-existing soccerFeatureExtractor.test.js covers the legacy
|
||||
// football-data path; this suite verifies that:
|
||||
// - api-football data wins when the prefetch alias exists
|
||||
// - footapi wins when api-football is missing but footapi alias exists
|
||||
// - football-data is still served when only the legacy key is set
|
||||
// - The `meta.sources` map attributes correctly per lookup
|
||||
//
|
||||
// We mock cacheGet to inspect which key the cascade asked for.
|
||||
|
||||
const mockCacheStore = new Map();
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null),
|
||||
cacheSet: async (k, v) => { mockCacheStore.set(k, v); return true; },
|
||||
cacheDel: async (k) => { mockCacheStore.delete(k); return true; },
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
const { normalizeName } = require('../../src/utils/normalize');
|
||||
const extractor = require('../../src/services/intelligence/soccerFeatureExtractor');
|
||||
|
||||
beforeEach(() => { mockCacheStore.clear(); });
|
||||
|
||||
describe('soccerFeatureExtractor — source cascade (Session 9)', () => {
|
||||
test('api-football wins when its alias is populated', async () => {
|
||||
const n = normalizeName('Lionel Messi');
|
||||
// Only the apifootball alias is populated — others should not be consulted.
|
||||
mockCacheStore.set(`apifootball:player_by_name:${n}`, {
|
||||
team: 'Argentina', goals_per_90: 0.92, minutes_per_game: 88,
|
||||
});
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Lionel Messi', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.goals_per_90).toBe(0.92);
|
||||
expect(r.meta.sources.player).toBe('api-football');
|
||||
});
|
||||
|
||||
test('footapi wins when api-football is empty but footapi is populated', async () => {
|
||||
const n = normalizeName('Harry Kane');
|
||||
mockCacheStore.set(`footapi:player_by_name:${n}`, {
|
||||
team: 'England', goals_per_90: 0.81,
|
||||
});
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Harry Kane', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.goals_per_90).toBe(0.81);
|
||||
expect(r.meta.sources.player).toBe('footapi');
|
||||
});
|
||||
|
||||
test('football-data legacy key is the final fallback', async () => {
|
||||
const n = normalizeName('Bukayo Saka');
|
||||
mockCacheStore.set(`soccer:player:${n}`, {
|
||||
team: 'England', goals_per_90: 0.4,
|
||||
});
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Bukayo Saka', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.goals_per_90).toBe(0.4);
|
||||
expect(r.meta.sources.player).toBe('football-data');
|
||||
});
|
||||
|
||||
test('all-miss case → null source + errors populated', async () => {
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Unknown', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.meta.sources.player).toBeNull();
|
||||
expect(r.meta.errors).toContain('player_not_found_in_cache');
|
||||
});
|
||||
|
||||
test('nextMatch cascade — api-football preferred', async () => {
|
||||
const n = normalizeName('Vinicius Junior');
|
||||
mockCacheStore.set(`apifootball:player_by_name:${n}`, { team: 'Brazil' });
|
||||
mockCacheStore.set('apifootball:nextmatch:Brazil', {
|
||||
opponent: 'Argentina', venue: 'MetLife Stadium', isHome: true, referee: 'A. Taylor',
|
||||
});
|
||||
mockCacheStore.set('soccer:nextmatch:Brazil', {
|
||||
opponent: 'STALE', venue: 'old', isHome: false, referee: 'STALE',
|
||||
});
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Vinicius Junior', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.meta.opponentAbbr).toBe('Argentina'); // api-football won
|
||||
expect(r.meta.sources.nextMatch).toBe('api-football');
|
||||
});
|
||||
|
||||
test('referee cascade falls through to legacy key when richer sources empty', async () => {
|
||||
const n = normalizeName('Anyone');
|
||||
mockCacheStore.set(`apifootball:player_by_name:${n}`, { team: 'X' });
|
||||
mockCacheStore.set('apifootball:nextmatch:X', {
|
||||
opponent: 'Y', venue: 'V', isHome: true, referee: 'Bjorn',
|
||||
});
|
||||
mockCacheStore.set('soccer:referee:Bjorn', {
|
||||
cards_per_game: 4.2, penalties_per_game: 0.3,
|
||||
});
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Anyone', stat_type: 'cards', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.referee_cards_per_game).toBe(4.2);
|
||||
expect(r.meta.sources.referee).toBe('football-data');
|
||||
});
|
||||
|
||||
test('multiple sources active → independent attribution per lookup', async () => {
|
||||
const n = normalizeName('Mixed');
|
||||
mockCacheStore.set(`apifootball:player_by_name:${n}`, { team: 'France', goals_per_90: 1.1 });
|
||||
// Match data only in legacy.
|
||||
mockCacheStore.set('soccer:nextmatch:France', {
|
||||
opponent: 'Italy', venue: 'AT&T Stadium', isHome: false, referee: 'X',
|
||||
});
|
||||
// Referee only in footapi.
|
||||
mockCacheStore.set('footapi:referee_by_name:X', { cards_per_game: 5.5 });
|
||||
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Mixed', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.meta.sources).toEqual({
|
||||
player: 'api-football',
|
||||
nextMatch: 'football-data',
|
||||
lastFixture: null,
|
||||
referee: 'footapi',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
const mockAxiosGet = jest.fn();
|
||||
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) }));
|
||||
|
||||
const mockCacheStore = new Map();
|
||||
const mockCacheTtls = new Map();
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null),
|
||||
cacheSet: async (k, v, ttl) => { mockCacheStore.set(k, v); mockCacheTtls.set(k, ttl); return true; },
|
||||
cacheDel: async (k) => { mockCacheStore.delete(k); return true; },
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
const adapter = require('../../src/services/adapters/tank01MlbAdapter');
|
||||
|
||||
beforeEach(() => {
|
||||
mockAxiosGet.mockReset();
|
||||
mockCacheStore.clear();
|
||||
mockCacheTtls.clear();
|
||||
});
|
||||
|
||||
describe('tank01MlbAdapter', () => {
|
||||
describe('graceful degradation (no RAPID_API_KEY)', () => {
|
||||
const original = process.env.RAPID_API_KEY;
|
||||
beforeAll(() => { delete process.env.RAPID_API_KEY; });
|
||||
afterAll(() => { if (original !== undefined) process.env.RAPID_API_KEY = original; });
|
||||
|
||||
test('hasApiKey false', () => { expect(adapter.hasApiKey()).toBe(false); });
|
||||
|
||||
test('all endpoints return null without touching axios', async () => {
|
||||
expect(await adapter.getMLBBoxScore('20260611_ATL_NYM')).toBeNull();
|
||||
expect(await adapter.getMLBBatterVsPitcher('B1', 'P1')).toBeNull();
|
||||
expect(await adapter.getMLBDailyScoreboard('20260611')).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with key configured', () => {
|
||||
beforeAll(() => { process.env.RAPID_API_KEY = 'test-rapid-key'; });
|
||||
|
||||
test('RapidAPI host header is the MLB-specific Tank01 host', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } });
|
||||
await adapter.getMLBDailyScoreboard('20260611');
|
||||
const [url, opts] = mockAxiosGet.mock.calls[0];
|
||||
expect(url).toMatch(/^https:\/\/tank01-mlb-live-in-game-real-time-statistics\.p\.rapidapi\.com/);
|
||||
expect(opts.headers['x-rapidapi-host']).toBe('tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com');
|
||||
});
|
||||
|
||||
test('TANK01_MLB_HOST override is honored', async () => {
|
||||
const original = process.env.TANK01_MLB_HOST;
|
||||
process.env.TANK01_MLB_HOST = 'alt-mlb.rapidapi.com';
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } });
|
||||
await adapter.getMLBDailyScoreboard('20260611');
|
||||
const [url] = mockAxiosGet.mock.calls[0];
|
||||
expect(url).toMatch(/^https:\/\/alt-mlb\.rapidapi\.com/);
|
||||
if (original !== undefined) process.env.TANK01_MLB_HOST = original;
|
||||
else delete process.env.TANK01_MLB_HOST;
|
||||
});
|
||||
|
||||
test('getMLBBoxScore tags batters and pitchers with role', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
body: {
|
||||
gameStatus: 'Final',
|
||||
playerStats: {
|
||||
batting: { 'B1': { longName: 'Ronald Acuña', teamAbv: 'ATL' } },
|
||||
pitching: { 'P1': { longName: 'Spencer Strider', teamAbv: 'ATL' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const stats = await adapter.getMLBBoxScore('GAME-1');
|
||||
expect(stats.find((s) => s.role === 'batter').name).toBe('Ronald Acuña');
|
||||
expect(stats.find((s) => s.role === 'pitcher').name).toBe('Spencer Strider');
|
||||
// Final → 24h TTL.
|
||||
expect(mockCacheTtls.get('tank01:mlb:boxscore:GAME-1')).toBe(adapter.__internals.TTL.boxScoreFinal);
|
||||
});
|
||||
|
||||
test('In-progress game keeps 5-min TTL on the box score', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: { body: { gameStatus: 'InProgress', playerStats: { batting: {}, pitching: {} } } },
|
||||
});
|
||||
await adapter.getMLBBoxScore('GAME-2');
|
||||
expect(mockCacheTtls.get('tank01:mlb:boxscore:GAME-2')).toBe(adapter.__internals.TTL.boxScoreLive);
|
||||
});
|
||||
|
||||
test('getMLBBatterVsPitcher projects single-object payload', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
body: { batterID: 'B1', pitcherID: 'P1', PA: 18, AB: 16, H: 5, HR: 1, RBI: 3, SO: 4, AVG: '.313', OPS: '.857' },
|
||||
},
|
||||
});
|
||||
const bvp = await adapter.getMLBBatterVsPitcher('B1', 'P1');
|
||||
expect(bvp).toMatchObject({
|
||||
batterId: 'B1', pitcherId: 'P1',
|
||||
plateAppearances: 18, atBats: 16, hits: 5, homeRuns: 1, rbi: 3, strikeouts: 4,
|
||||
avg: '.313', ops: '.857',
|
||||
});
|
||||
});
|
||||
|
||||
test('getMLBBatterVsPitcher handles array of matchups', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
body: [
|
||||
{ batterID: 'B1', pitcherID: 'P1', PA: 12, H: 3, SO: 5 },
|
||||
{ batterID: 'B1', pitcherID: 'P1', PA: 6, H: 2, SO: 1 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const bvp = await adapter.getMLBBatterVsPitcher('B1', 'P1');
|
||||
expect(Array.isArray(bvp)).toBe(true);
|
||||
expect(bvp).toHaveLength(2);
|
||||
expect(bvp[0].plateAppearances).toBe(12);
|
||||
});
|
||||
|
||||
test('null IDs return null without touching axios', async () => {
|
||||
expect(await adapter.getMLBBoxScore(null)).toBeNull();
|
||||
expect(await adapter.getMLBBatterVsPitcher(null, 'P1')).toBeNull();
|
||||
expect(await adapter.getMLBBatterVsPitcher('B1', null)).toBeNull();
|
||||
expect(await adapter.getMLBDailyScoreboard(null)).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('getMLBDailyScoreboard projects both array + map shapes', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: { body: { 'GAME-X': { gameID: 'GAME-X', home: 'NYY', away: 'BOS', homePts: 5, awayPts: 4, gameStatus: 'Final' } } },
|
||||
});
|
||||
const games = await adapter.getMLBDailyScoreboard('20260611');
|
||||
expect(games).toHaveLength(1);
|
||||
expect(games[0]).toMatchObject({ gameId: 'GAME-X', homeTeam: 'NYY', awayTeam: 'BOS', homeScore: 5, awayScore: 4 });
|
||||
});
|
||||
|
||||
test('cache hit on repeat call', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { body: { batterID: 'B', PA: 1 } } });
|
||||
await adapter.getMLBBatterVsPitcher('B', 'P');
|
||||
await adapter.getMLBBatterVsPitcher('B', 'P');
|
||||
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('axios throw → stale fallback', async () => {
|
||||
mockCacheStore.set('tank01:mlb:scoreboard:20260611:stale', { body: [{ gameID: 'STALE' }] });
|
||||
mockAxiosGet.mockRejectedValueOnce(new Error('upstream 500'));
|
||||
const games = await adapter.getMLBDailyScoreboard('20260611');
|
||||
expect(games[0].gameId).toBe('STALE');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
const mockAxiosGet = jest.fn();
|
||||
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) }));
|
||||
|
||||
const mockCacheStore = new Map();
|
||||
const mockCacheTtls = new Map();
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null),
|
||||
cacheSet: async (k, v, ttl) => { mockCacheStore.set(k, v); mockCacheTtls.set(k, ttl); return true; },
|
||||
cacheDel: async (k) => { mockCacheStore.delete(k); return true; },
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
const adapter = require('../../src/services/adapters/tank01NbaAdapter');
|
||||
|
||||
beforeEach(() => {
|
||||
mockAxiosGet.mockReset();
|
||||
mockCacheStore.clear();
|
||||
mockCacheTtls.clear();
|
||||
});
|
||||
|
||||
describe('tank01NbaAdapter', () => {
|
||||
describe('graceful degradation (no RAPID_API_KEY)', () => {
|
||||
const original = process.env.RAPID_API_KEY;
|
||||
beforeAll(() => { delete process.env.RAPID_API_KEY; });
|
||||
afterAll(() => { if (original !== undefined) process.env.RAPID_API_KEY = original; });
|
||||
|
||||
test('hasApiKey false', () => {
|
||||
expect(adapter.hasApiKey()).toBe(false);
|
||||
});
|
||||
|
||||
test('all endpoints return null without touching axios', async () => {
|
||||
expect(await adapter.getNBABoxScore('20260611_LAL_BOS')).toBeNull();
|
||||
expect(await adapter.getNBAGamesForDate('20260611')).toBeNull();
|
||||
expect(await adapter.getNBABettingOdds('20260611')).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with key configured', () => {
|
||||
beforeAll(() => { process.env.RAPID_API_KEY = 'test-rapid-key'; });
|
||||
|
||||
test('RapidAPI auth headers wired correctly', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } });
|
||||
await adapter.getNBAGamesForDate('20260611');
|
||||
const [url, opts] = mockAxiosGet.mock.calls[0];
|
||||
expect(url).toMatch(/^https:\/\/tank01-fantasy-stats\.p\.rapidapi\.com/);
|
||||
expect(opts.headers['x-rapidapi-key']).toBe('test-rapid-key');
|
||||
expect(opts.headers['x-rapidapi-host']).toBe('tank01-fantasy-stats.p.rapidapi.com');
|
||||
});
|
||||
|
||||
test('TANK01_NBA_HOST override is honored', async () => {
|
||||
const original = process.env.TANK01_NBA_HOST;
|
||||
process.env.TANK01_NBA_HOST = 'alt-tank01.rapidapi.com';
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } });
|
||||
await adapter.getNBAGamesForDate('20260611');
|
||||
const [url, opts] = mockAxiosGet.mock.calls[0];
|
||||
expect(url).toMatch(/^https:\/\/alt-tank01\.rapidapi\.com/);
|
||||
expect(opts.headers['x-rapidapi-host']).toBe('alt-tank01.rapidapi.com');
|
||||
if (original !== undefined) process.env.TANK01_NBA_HOST = original;
|
||||
else delete process.env.TANK01_NBA_HOST;
|
||||
});
|
||||
|
||||
test('getNBABoxScore projects playerStats map into a flat list', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
body: {
|
||||
gameStatus: 'InProgress',
|
||||
playerStats: {
|
||||
'NBA-123': { longName: 'Jaylen Brown', teamAbv: 'BOS', mins: '34', pts: 27, reb: 5, ast: 4 },
|
||||
'NBA-456': { longName: 'Jayson Tatum', teamAbv: 'BOS', mins: '36', pts: 31, reb: 8, ast: 6, tptfgm: 5 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const stats = await adapter.getNBABoxScore('20260611_LAL_BOS');
|
||||
expect(stats).toHaveLength(2);
|
||||
const tatum = stats.find((p) => p.name === 'Jayson Tatum');
|
||||
expect(tatum.team).toBe('BOS');
|
||||
expect(tatum.pts).toBe(31);
|
||||
expect(tatum.threes).toBe(5);
|
||||
expect(tatum._final).toBe(false);
|
||||
});
|
||||
|
||||
test('Final game upgrades the cache TTL to 24h', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: { body: { gameStatus: 'Final', playerStats: {} } },
|
||||
});
|
||||
await adapter.getNBABoxScore('GAME-1');
|
||||
const ttl = mockCacheTtls.get('tank01:nba:boxscore:GAME-1');
|
||||
expect(ttl).toBe(adapter.__internals.TTL.boxScoreFinal);
|
||||
});
|
||||
|
||||
test('In-progress game stays on 5-min TTL', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: { body: { gameStatus: 'InProgress', playerStats: {} } },
|
||||
});
|
||||
await adapter.getNBABoxScore('GAME-2');
|
||||
const ttl = mockCacheTtls.get('tank01:nba:boxscore:GAME-2');
|
||||
expect(ttl).toBe(adapter.__internals.TTL.boxScoreLive);
|
||||
});
|
||||
|
||||
test('getNBAGamesForDate strips dashes from ISO dates', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } });
|
||||
await adapter.getNBAGamesForDate('2026-06-11');
|
||||
const [url] = mockAxiosGet.mock.calls[0];
|
||||
expect(url).toMatch(/gameDate=20260611/);
|
||||
});
|
||||
|
||||
test('getNBAGamesForDate projects to stable shape', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: { body: [
|
||||
{ gameID: '20260611_BOS_LAL', home: 'LAL', away: 'BOS', gameTime: '7:30p ET', gameStatus: 'Final', homePts: 110, awayPts: 105 },
|
||||
] },
|
||||
});
|
||||
const games = await adapter.getNBAGamesForDate('20260611');
|
||||
expect(games[0]).toMatchObject({
|
||||
gameId: '20260611_BOS_LAL', homeTeam: 'LAL', awayTeam: 'BOS', homeScore: 110, awayScore: 105, gameStatus: 'Final',
|
||||
});
|
||||
});
|
||||
|
||||
test('null IDs return null without touching axios', async () => {
|
||||
expect(await adapter.getNBABoxScore(null)).toBeNull();
|
||||
expect(await adapter.getNBAGamesForDate(null)).toBeNull();
|
||||
expect(await adapter.getNBABettingOdds(null)).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('axios error → null + stale fallback', async () => {
|
||||
mockCacheStore.set('tank01:nba:games:20260611:stale', { body: [{ gameID: 'STALE' }] });
|
||||
mockAxiosGet.mockRejectedValueOnce(new Error('upstream 503'));
|
||||
const games = await adapter.getNBAGamesForDate('20260611');
|
||||
expect(games).toHaveLength(1);
|
||||
expect(games[0].gameId).toBe('STALE');
|
||||
});
|
||||
|
||||
test('cache hit on repeat call (axios not re-invoked)', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } });
|
||||
await adapter.getNBAGamesForDate('20260611');
|
||||
await adapter.getNBAGamesForDate('20260611');
|
||||
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user