123 lines
5.1 KiB
JavaScript
123 lines
5.1 KiB
JavaScript
// 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',
|
|
});
|
|
});
|
|
});
|