feat: Feature 2.2 — Line Movement + Cascade Detection
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>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
const mockSupabaseFrom = jest.fn();
|
||||
jest.mock('../../src/utils/supabase', () => ({
|
||||
getSupabaseServiceClient: () => ({ from: mockSupabaseFrom }),
|
||||
}));
|
||||
|
||||
const { getAlertsForUser, markAlertRead } = require('../../src/services/alertService');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('alertService', () => {
|
||||
test('getAlertsForUser returns unread alerts', async () => {
|
||||
const alerts = [
|
||||
{ id: 'a1', alert_type: 'player_scratched', detail: 'Player X scratched', is_read: false },
|
||||
];
|
||||
mockSupabaseFrom.mockImplementation(() => ({
|
||||
select: (sel, opts) => {
|
||||
if (opts?.head) {
|
||||
return {
|
||||
eq: () => ({ eq: () => Promise.resolve({ count: 1 }) }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
eq: (col, val) => ({
|
||||
eq: () => ({
|
||||
order: () => Promise.resolve({ data: alerts, error: null }),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await getAlertsForUser('user-1');
|
||||
expect(result.alerts).toHaveLength(1);
|
||||
expect(result.unread_count).toBe(1);
|
||||
});
|
||||
|
||||
test('markAlertRead returns updated alert', async () => {
|
||||
mockSupabaseFrom.mockReturnValue({
|
||||
update: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
select: () => ({
|
||||
single: () => Promise.resolve({ data: { id: 'a1', is_read: true }, error: null }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await markAlertRead('a1', 'user-1');
|
||||
expect(result.id).toBe('a1');
|
||||
expect(result.is_read).toBe(true);
|
||||
});
|
||||
|
||||
test('markAlertRead returns null for nonexistent alert', async () => {
|
||||
mockSupabaseFrom.mockReturnValue({
|
||||
update: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({
|
||||
select: () => ({
|
||||
single: () => Promise.resolve({ data: null, error: { message: 'not found' } }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await markAlertRead('nonexistent', 'user-1');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user