Session 21: All adapters through gateway, ntfy alerts, provider registry correction (1486 tests)

This commit is contained in:
Kev
2026-06-12 02:06:22 -04:00
parent 9b10bb4138
commit ea848e327e
14 changed files with 614 additions and 46 deletions
+5
View File
@@ -9,6 +9,11 @@ jest.mock('../../src/utils/redis', () => ({
cacheGet: async (k) => mockCache.current.get(k) ?? null,
cacheSet: async (k, v) => { mockCache.current.set(k, v); return true; },
cacheDel: async (k) => { mockCache.current.delete(k); return true; },
// Session 21 — gateway pulls isDegraded to decide whether to track
// the call. Returning true here puts the gateway in degraded-mode
// fail-open so the adapter's existing cache-and-axios assertions
// stay accurate (no extra Redis writes for the quota counter).
isDegraded: () => true,
}));
const adapter = require('../../src/services/adapters/parlayApiAdapter');
+120
View File
@@ -7,6 +7,12 @@
// `allowed` from true to false. Redis is mocked so the tests don't
// require a live server.
// Session 21 — mock axios so the ntfy POST inside recordCall
// doesn't try to hit the network. The mock is scoped per-test via
// resetAllMocks below.
jest.mock('axios', () => ({ post: jest.fn(async () => ({ status: 200 })) }));
const axios = require('axios');
jest.mock('../../src/utils/redis', () => {
const store = new Map();
return {
@@ -39,12 +45,20 @@ beforeEach(() => {
redis.cacheSet.mockClear();
redis.cacheDel.mockClear();
redis.isDegraded.mockReturnValue(false);
axios.post.mockClear();
delete process.env.NTFY_URL;
process.env.ODDS_API_KEY = 'test-odds-key';
process.env.RAPID_API_KEY = 'test-tank01-key';
process.env.API_FOOTBALL_KEY = 'test-apifoot-key';
process.env.FOOTBALL_DATA_API_KEY = 'test-fd-key';
});
// Helper for the ntfy block — awaits a microtask flush so the
// fire-and-forget `sendQuotaAlert(...).catch(...)` inside
// recordCall has resolved before assertions run. Without this the
// axios.post mock can be observed pre-call from inside the test.
const flushAsync = () => new Promise((r) => setImmediate(r));
describe('quotaTracker.getPeriodKey', () => {
test('monthly produces YYYY-MM', () => {
const key = tracker.getPeriodKey('odds-api', new Date(Date.UTC(2026, 5, 12)));
@@ -207,6 +221,112 @@ describe('quotaTracker degraded-mode fail-open', () => {
});
});
describe('quotaTracker ntfy alerts (Session 21)', () => {
test('does NOT post to ntfy when NTFY_URL is unset', async () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '399',
'x-requests-remaining': '101',
});
await tracker.recordCall('odds-api'); // 400/500 → 80%
await flushAsync();
expect(axios.post).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
test('posts WARN to ntfy at 80% with priority 4', async () => {
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '399',
'x-requests-remaining': '101',
});
await tracker.recordCall('odds-api'); // 400/500 → 80%
await flushAsync();
expect(axios.post).toHaveBeenCalledTimes(1);
const [url, body, opts] = axios.post.mock.calls[0];
expect(url).toBe('https://alerts.example.com/vyndr');
expect(body).toMatch(/Odds API/);
expect(body).toMatch(/80%/);
expect(opts.headers.Priority).toBe('4');
expect(opts.headers.Title).toMatch(/Warning/);
warnSpy.mockRestore();
});
test('posts BLOCK to ntfy at 95% with priority 5', async () => {
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '474',
'x-requests-remaining': '26',
});
await tracker.recordCall('odds-api'); // 475/500 → 95%
await flushAsync();
expect(axios.post).toHaveBeenCalledTimes(1);
const [, body, opts] = axios.post.mock.calls[0];
expect(body).toMatch(/BLOCKED|95%/);
expect(opts.headers.Priority).toBe('5');
expect(opts.headers.Title).toMatch(/BLOCKED/);
warnSpy.mockRestore();
});
test('dedupes — second recordCall in the same period does not re-post', async () => {
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '399',
'x-requests-remaining': '101',
});
await tracker.recordCall('odds-api');
await flushAsync();
await tracker.recordCall('odds-api');
await flushAsync();
await tracker.recordCall('odds-api');
await flushAsync();
expect(axios.post).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
test('WARN→BLOCK transition fires BOTH alerts (separate dedupe keys)', async () => {
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
// Seed at 79%; first recordCall → 80% (WARN).
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '395',
'x-requests-remaining': '105',
});
await tracker.recordCall('odds-api'); // 396 → 79.2% — under warn
await flushAsync();
expect(axios.post).toHaveBeenCalledTimes(0);
// Jump to 95% — should fire BLOCK even though WARN never fired.
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '474',
'x-requests-remaining': '26',
});
await tracker.recordCall('odds-api'); // 475 → 95% — BLOCK
await flushAsync();
expect(axios.post).toHaveBeenCalledTimes(1);
expect(axios.post.mock.calls[0][2].headers.Priority).toBe('5');
warnSpy.mockRestore();
});
test('axios.post failure does NOT break recordCall', async () => {
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
axios.post.mockRejectedValueOnce(new Error('ntfy down'));
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '399',
'x-requests-remaining': '101',
});
const result = await tracker.recordCall('odds-api');
await flushAsync();
// recordCall returns the normal status even when ntfy throws.
expect(result.allowed).toBe(true);
expect(result.used).toBe(400);
warnSpy.mockRestore();
});
});
describe('quotaTracker.getAllQuotaStatuses', () => {
test('returns one entry per configured provider', async () => {
const statuses = await tracker.getAllQuotaStatuses();