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), })); // Session 7h: the route now layers free-tier response gating on top of // the engine output. This suite tests the ENGINE → route contract // (full shape, kill conditions, etc.); the gating layer has its own // dedicated tests in tests/unit/tierGating.test.js. Pass-through here. jest.mock('../../src/utils/tierGating', () => ({ applyTierGating: (result) => result, })); 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, }; } // Session 7h: scan-limit middleware mounts on /api/analyze with a 24h // rolling per-IP quota. Without resetting, supertest's loopback IP // burns its 3 free-tier slots inside this file and later cases 429. const { __internals: scanLimitInternals } = require('../../src/middleware/scanLimit'); beforeEach(() => { jest.clearAllMocks(); mockRedis.get.mockResolvedValue(null); mockRedis.set.mockResolvedValue('OK'); mockAnalyzeViaEngine1.mockReset(); scanLimitInternals.resetForTests(); }); 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'); }); });