251 lines
8.4 KiB
JavaScript
251 lines
8.4 KiB
JavaScript
const request = require('supertest');
|
|
|
|
// Mock Redis — covers both the legacy `getRedisClient()` surface and
|
|
// the cacheGet/cacheSet helpers added in Session 6c (used by /api/analyze
|
|
// cache from Session 7d).
|
|
const mockRedis = {
|
|
get: jest.fn(),
|
|
set: jest.fn(),
|
|
hset: jest.fn(),
|
|
hgetall: jest.fn(),
|
|
expire: jest.fn(),
|
|
};
|
|
jest.mock('../../src/utils/redis', () => ({
|
|
getRedisClient: () => mockRedis,
|
|
cacheGet: async () => null,
|
|
cacheSet: async () => true,
|
|
cacheDel: async () => true,
|
|
isDegraded: () => false,
|
|
}));
|
|
|
|
// ARCH-1 (Session 7g): the route now grades through engine1 via the
|
|
// analyzeViaEngine1 helper. We mock the helper directly because it owns
|
|
// the upstream chain — there's no value in driving the chain through
|
|
// the test now that the only legacy fallback path is gone.
|
|
//
|
|
// Every mocked return is a fully-shaped legacy response so the route
|
|
// (which still does cache wrap + _cache: 'MISS|HIT' tagging) is the
|
|
// only thing under test.
|
|
const mockAnalyzeViaEngine1 = jest.fn();
|
|
jest.mock('../../src/services/intelligence/analyzeViaEngine1', () => ({
|
|
analyzeViaEngine1: (...args) => mockAnalyzeViaEngine1(...args),
|
|
}));
|
|
|
|
const app = require('../../src/app');
|
|
|
|
function fullShapedResponse(overrides = {}) {
|
|
return {
|
|
player: 'Nikola Jokic',
|
|
stat_type: 'points',
|
|
line: 26.5,
|
|
direction: 'over',
|
|
book: 'draftkings',
|
|
grade: 'A',
|
|
confidence: 78,
|
|
edge_pct: 6.2,
|
|
kill_conditions_triggered: [],
|
|
reasoning: {
|
|
summary: 'Concrete sentence about Jokic averaging 28.4 last 5.',
|
|
steps: {
|
|
season_avg: { value: 26.3, vs_line: -0.2, signal: null },
|
|
recent_form: { value: 28.1, vs_line: 1.6, signal: null },
|
|
situational: {
|
|
home_away: { value: null, context: 'home', signal: null },
|
|
rest_days: { value: 2, context: 'rested', signal: null },
|
|
vs_opponent: { value: null, games: null, signal: null },
|
|
},
|
|
line_comparison: { best_line: null, worst_line: null, edge_from_best: 0, signal: null },
|
|
kill_conditions: [],
|
|
final_grade: 'A',
|
|
narrative: [{ step: 1, detail: 'placeholder' }],
|
|
},
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockRedis.get.mockResolvedValue(null);
|
|
mockRedis.set.mockResolvedValue('OK');
|
|
mockAnalyzeViaEngine1.mockReset();
|
|
});
|
|
|
|
describe('POST /api/analyze/prop', () => {
|
|
it('returns complete analysis with all fields', async () => {
|
|
mockAnalyzeViaEngine1.mockResolvedValueOnce(fullShapedResponse({ confidence: 78 }));
|
|
|
|
const res = await request(app)
|
|
.post('/api/analyze/prop')
|
|
.send({
|
|
player: 'Nikola Jokic',
|
|
stat_type: 'points',
|
|
line: 26.5,
|
|
direction: 'over',
|
|
book: 'draftkings',
|
|
})
|
|
.expect(200);
|
|
|
|
// SHAPE assertions (must survive engine swap — these are the API contract):
|
|
expect(res.body.player).toBe('Nikola Jokic');
|
|
expect(res.body.stat_type).toBe('points');
|
|
expect(res.body.grade).toMatch(/^[ABCDF]$/);
|
|
expect(typeof res.body.edge_pct).toBe('number');
|
|
expect(typeof res.body.confidence).toBe('number');
|
|
expect(res.body.confidence).toBeGreaterThanOrEqual(0);
|
|
expect(res.body.confidence).toBeLessThanOrEqual(100);
|
|
expect(Array.isArray(res.body.kill_conditions_triggered)).toBe(true);
|
|
expect(res.body.reasoning).toBeDefined();
|
|
expect(res.body.reasoning.summary).toBeDefined();
|
|
expect(res.body.reasoning.steps).toBeDefined();
|
|
expect(res.body.reasoning.steps.season_avg).toBeDefined();
|
|
expect(res.body.reasoning.steps.recent_form).toBeDefined();
|
|
expect(res.body.reasoning.steps.situational).toBeDefined();
|
|
expect(res.body.reasoning.steps.line_comparison).toBeDefined();
|
|
expect(res.body.reasoning.steps.kill_conditions).toBeDefined();
|
|
expect(res.body.reasoning.steps.final_grade).toBeDefined();
|
|
// PERF-1 cache tag — every fresh call is MISS.
|
|
expect(res.body._cache).toBe('MISS');
|
|
});
|
|
|
|
it('returns grade A/B for player averaging above line with good recent form', async () => {
|
|
mockAnalyzeViaEngine1.mockResolvedValueOnce(fullShapedResponse({
|
|
grade: 'A',
|
|
confidence: 82,
|
|
edge_pct: 8.4,
|
|
}));
|
|
|
|
const res = await request(app)
|
|
.post('/api/analyze/prop')
|
|
.send({
|
|
player: 'Nikola Jokic',
|
|
stat_type: 'points',
|
|
line: 24.5,
|
|
direction: 'over',
|
|
book: 'draftkings',
|
|
})
|
|
.expect(200);
|
|
|
|
expect(['A', 'B']).toContain(res.body.grade);
|
|
expect(res.body.edge_pct).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('returns a low grade for player averaging below line', async () => {
|
|
// Engine 1 collapses A+/A/A- → A and (D|F) → D|F. For a cold prop the
|
|
// adapter produces D or F; both are valid for this assertion.
|
|
mockAnalyzeViaEngine1.mockResolvedValueOnce(fullShapedResponse({
|
|
grade: 'D',
|
|
confidence: 25,
|
|
edge_pct: -12.1,
|
|
}));
|
|
|
|
const res = await request(app)
|
|
.post('/api/analyze/prop')
|
|
.send({
|
|
player: 'Nikola Jokic',
|
|
stat_type: 'points',
|
|
line: 35.5,
|
|
direction: 'over',
|
|
book: 'draftkings',
|
|
})
|
|
.expect(200);
|
|
|
|
expect(['D', 'F']).toContain(res.body.grade);
|
|
});
|
|
|
|
it('surfaces kill conditions through the adapter', async () => {
|
|
// The 7e adapter translates engine1 factors into kill_conditions
|
|
// entries. The legacy "blowout_risk" code is gone; the new code
|
|
// set comes from FACTOR_TO_KILL_CONDITION in gradeAdapter.js.
|
|
mockAnalyzeViaEngine1.mockResolvedValueOnce(fullShapedResponse({
|
|
grade: 'C',
|
|
confidence: 35,
|
|
kill_conditions_triggered: [
|
|
{ code: 'TRAP', reason: 'Multiple trap signals firing.' },
|
|
],
|
|
}));
|
|
|
|
const res = await request(app)
|
|
.post('/api/analyze/prop')
|
|
.send({
|
|
player: 'Nikola Jokic',
|
|
stat_type: 'points',
|
|
line: 24.5,
|
|
direction: 'over',
|
|
book: 'draftkings',
|
|
})
|
|
.expect(200);
|
|
|
|
expect(Array.isArray(res.body.kill_conditions_triggered)).toBe(true);
|
|
expect(res.body.kill_conditions_triggered.length).toBeGreaterThan(0);
|
|
const first = res.body.kill_conditions_triggered[0];
|
|
expect(typeof first.code).toBe('string');
|
|
expect(typeof first.reason).toBe('string');
|
|
expect(['C', 'D', 'F']).toContain(res.body.grade);
|
|
});
|
|
|
|
it('returns 400 for missing player field', async () => {
|
|
const res = await request(app)
|
|
.post('/api/analyze/prop')
|
|
.send({ stat_type: 'points', line: 26.5, direction: 'over' })
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toContain('player is required');
|
|
// Validation runs before the engine — the helper must not be called.
|
|
expect(mockAnalyzeViaEngine1).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns 400 for invalid stat_type', async () => {
|
|
const res = await request(app)
|
|
.post('/api/analyze/prop')
|
|
.send({ player: 'Jokic', stat_type: 'invalid', line: 26.5, direction: 'over' })
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toContain('Invalid stat_type');
|
|
});
|
|
|
|
it('returns 400 for missing direction', async () => {
|
|
const res = await request(app)
|
|
.post('/api/analyze/prop')
|
|
.send({ player: 'Jokic', stat_type: 'points', line: 26.5 })
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toContain('direction is required');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/analyze/batch', () => {
|
|
it('processes multiple props and returns array', async () => {
|
|
mockAnalyzeViaEngine1
|
|
.mockResolvedValueOnce(fullShapedResponse({ stat_type: 'points', line: 26.5 }))
|
|
.mockResolvedValueOnce(fullShapedResponse({ stat_type: 'rebounds', line: 12.5, grade: 'B' }));
|
|
|
|
const res = await request(app)
|
|
.post('/api/analyze/batch')
|
|
.send({
|
|
props: [
|
|
{ player: 'Nikola Jokic', stat_type: 'points', line: 26.5, direction: 'over', book: 'draftkings' },
|
|
{ player: 'Nikola Jokic', stat_type: 'rebounds', line: 12.5, direction: 'over', book: 'fanduel' },
|
|
],
|
|
})
|
|
.expect(200);
|
|
|
|
expect(Array.isArray(res.body.results)).toBe(true);
|
|
expect(res.body.results.length).toBe(2);
|
|
for (const r of res.body.results) {
|
|
expect(r.grade).toMatch(/^[ABCDF]$/);
|
|
expect(typeof r.confidence).toBe('number');
|
|
expect(r.reasoning.summary).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('returns 400 for empty props array', async () => {
|
|
const res = await request(app)
|
|
.post('/api/analyze/batch')
|
|
.send({ props: [] })
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toContain('props array is required');
|
|
});
|
|
});
|