2366660f5e
Line movement system: - Baseline capture on first odds fetch of the day - Movement detection >= 0.5 points with direction (up/down) - Sharp money heuristic (sharp_action/public_action/unknown) - GET /api/movements with player, stat_type, min_movement filters - Movements included in GET /api/odds/nba live responses Cascade detection system: - Scratch detection: player props disappear from 2+ books - Affected user lookup via scan_sessions + picks - Parlay re-grade without scratched legs - cascade_alerts created for affected users - GET /api/alerts (Analyst/Desk only), PATCH /api/alerts/:id/read Zero extra Odds API credits — all detection piggybacks on existing fetches. Migration 002: line_baselines, line_movements, cascade_alerts tables. 30 new tests, 188 total (161 Node.js + 27 Python), all passing. Phase 2 Core Product COMPLETE. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
230 lines
6.8 KiB
JavaScript
230 lines
6.8 KiB
JavaScript
const request = require('supertest');
|
|
|
|
// Mock Redis
|
|
const mockRedis = { get: jest.fn(), set: jest.fn(), hset: jest.fn(), hgetall: jest.fn(), expire: jest.fn() };
|
|
jest.mock('../../src/utils/redis', () => ({ getRedisClient: () => mockRedis }));
|
|
|
|
// Mock Supabase
|
|
const mockSupabaseFrom = jest.fn();
|
|
const mockSupabaseAuth = { getUser: jest.fn() };
|
|
jest.mock('../../src/utils/supabase', () => ({
|
|
getSupabaseClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }),
|
|
getSupabaseServiceClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }),
|
|
}));
|
|
|
|
jest.mock('axios');
|
|
|
|
const app = require('../../src/app');
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockRedis.get.mockResolvedValue(null);
|
|
mockRedis.set.mockResolvedValue('OK');
|
|
mockRedis.hset.mockResolvedValue(1);
|
|
mockRedis.hgetall.mockResolvedValue({});
|
|
mockRedis.expire.mockResolvedValue(1);
|
|
});
|
|
|
|
describe('GET /api/movements', () => {
|
|
test('returns movements for today', async () => {
|
|
const movements = [
|
|
{ player: 'Nikola Jokic', stat_type: 'points', book: 'draftkings', baseline_line: 26.5, current_line: 27.5, movement: 1.0, direction: 'up', sharp_indicator: 'sharp_action' },
|
|
];
|
|
mockSupabaseFrom.mockReturnValue({
|
|
select: () => ({
|
|
eq: () => ({
|
|
ilike: () => ({
|
|
eq: () => Promise.resolve({ data: movements }),
|
|
}),
|
|
}),
|
|
}),
|
|
});
|
|
|
|
// Simple mock: just return movements from Supabase
|
|
mockSupabaseFrom.mockImplementation((table) => {
|
|
if (table === 'line_movements') {
|
|
return {
|
|
select: () => ({
|
|
eq: () => Promise.resolve({ data: movements }),
|
|
}),
|
|
};
|
|
}
|
|
return { select: () => ({ eq: () => Promise.resolve({ data: [] }) }) };
|
|
});
|
|
|
|
const res = await request(app).get('/api/movements').expect(200);
|
|
expect(res.body.game_date).toBeDefined();
|
|
expect(Array.isArray(res.body.movements)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/alerts', () => {
|
|
test('returns 401 without auth', async () => {
|
|
const res = await request(app).get('/api/alerts').expect(401);
|
|
expect(res.body.error).toContain('Authentication');
|
|
});
|
|
|
|
test('returns 403 for free tier', async () => {
|
|
mockSupabaseAuth.getUser.mockResolvedValue({
|
|
data: { user: { id: 'u1', email: 'free@test.com' } },
|
|
error: null,
|
|
});
|
|
mockSupabaseFrom.mockImplementation((table) => {
|
|
if (table === 'users') {
|
|
return {
|
|
select: () => ({
|
|
eq: () => ({
|
|
single: () => Promise.resolve({
|
|
data: { id: 'u1', email: 'free@test.com', tier: 'free', scan_count: 0 },
|
|
error: null,
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
return { select: () => ({ eq: () => Promise.resolve({ data: [] }) }) };
|
|
});
|
|
|
|
const res = await request(app)
|
|
.get('/api/alerts')
|
|
.set('Authorization', 'Bearer valid-token')
|
|
.expect(403);
|
|
expect(res.body.error).toContain('Analyst and Desk');
|
|
});
|
|
|
|
test('returns alerts for paid tier user', async () => {
|
|
const alerts = [
|
|
{ id: 'a1', alert_type: 'player_scratched', player: 'X', detail: 'scratched', is_read: false, created_at: '2026-03-21T18:00:00Z' },
|
|
];
|
|
mockSupabaseAuth.getUser.mockResolvedValue({
|
|
data: { user: { id: 'u2', email: 'paid@test.com' } },
|
|
error: null,
|
|
});
|
|
mockSupabaseFrom.mockImplementation((table) => {
|
|
if (table === 'users') {
|
|
return {
|
|
select: () => ({
|
|
eq: () => ({
|
|
single: () => Promise.resolve({
|
|
data: { id: 'u2', email: 'paid@test.com', tier: 'analyst', scan_count: 10 },
|
|
error: null,
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
if (table === 'cascade_alerts') {
|
|
return {
|
|
select: (sel, opts) => {
|
|
if (opts?.head) {
|
|
return { eq: () => ({ eq: () => Promise.resolve({ count: 1 }) }) };
|
|
}
|
|
return {
|
|
eq: () => ({
|
|
eq: () => ({
|
|
order: () => Promise.resolve({ data: alerts, error: null }),
|
|
}),
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
return { select: () => ({ eq: () => Promise.resolve({ data: [] }) }) };
|
|
});
|
|
|
|
const res = await request(app)
|
|
.get('/api/alerts')
|
|
.set('Authorization', 'Bearer valid-token')
|
|
.expect(200);
|
|
|
|
expect(res.body.alerts).toHaveLength(1);
|
|
expect(res.body.alerts[0].alert_type).toBe('player_scratched');
|
|
});
|
|
});
|
|
|
|
describe('PATCH /api/alerts/:id/read', () => {
|
|
test('marks alert as read', async () => {
|
|
mockSupabaseAuth.getUser.mockResolvedValue({
|
|
data: { user: { id: 'u2', email: 'paid@test.com' } },
|
|
error: null,
|
|
});
|
|
mockSupabaseFrom.mockImplementation((table) => {
|
|
if (table === 'users') {
|
|
return {
|
|
select: () => ({
|
|
eq: () => ({
|
|
single: () => Promise.resolve({
|
|
data: { id: 'u2', email: 'paid@test.com', tier: 'desk', scan_count: 0 },
|
|
error: null,
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
if (table === 'cascade_alerts') {
|
|
return {
|
|
update: () => ({
|
|
eq: () => ({
|
|
eq: () => ({
|
|
select: () => ({
|
|
single: () => Promise.resolve({ data: { id: 'a1', is_read: true }, error: null }),
|
|
}),
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
return {};
|
|
});
|
|
|
|
const res = await request(app)
|
|
.patch('/api/alerts/a1/read')
|
|
.set('Authorization', 'Bearer valid-token')
|
|
.expect(200);
|
|
|
|
expect(res.body.is_read).toBe(true);
|
|
});
|
|
|
|
test('returns 404 for nonexistent alert', async () => {
|
|
mockSupabaseAuth.getUser.mockResolvedValue({
|
|
data: { user: { id: 'u2', email: 'paid@test.com' } },
|
|
error: null,
|
|
});
|
|
mockSupabaseFrom.mockImplementation((table) => {
|
|
if (table === 'users') {
|
|
return {
|
|
select: () => ({
|
|
eq: () => ({
|
|
single: () => Promise.resolve({
|
|
data: { id: 'u2', email: 'paid@test.com', tier: 'analyst', scan_count: 0 },
|
|
error: null,
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
if (table === 'cascade_alerts') {
|
|
return {
|
|
update: () => ({
|
|
eq: () => ({
|
|
eq: () => ({
|
|
select: () => ({
|
|
single: () => Promise.resolve({ data: null, error: { message: 'not found' } }),
|
|
}),
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
return {};
|
|
});
|
|
|
|
const res = await request(app)
|
|
.patch('/api/alerts/nonexistent/read')
|
|
.set('Authorization', 'Bearer valid-token')
|
|
.expect(404);
|
|
|
|
expect(res.body.error).toContain('not found');
|
|
});
|
|
});
|