Files
vyndr/tests/integration/analyze.test.js
T

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');
});
});