Session 21: All adapters through gateway, ntfy alerts, provider registry correction (1486 tests)
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
// Integration: gateway wiring across adapters (Session 21).
|
||||
//
|
||||
// Verifies that the per-adapter `fetchWithCache` gateway wrap
|
||||
// actually drives the quotaTracker counter forward — i.e. each
|
||||
// successful adapter HTTP call increments the right provider's
|
||||
// quota, and the gateway short-circuits when the counter passes
|
||||
// the BLOCK threshold.
|
||||
//
|
||||
// We test through the Tank01 NBA adapter because it has the
|
||||
// simplest fetchWithCache (no token-bucket / circuit-breaker), so
|
||||
// the gateway behavior dominates. The same pattern applies to the
|
||||
// other adapters by symmetry; their per-adapter unit suites cover
|
||||
// their internal logic.
|
||||
|
||||
// In-memory store backing the redis mock. Shared across the
|
||||
// tracker + adapter so the counter the tracker increments is the
|
||||
// one the gateway reads back on the next call. `mock`-prefixed so
|
||||
// jest's `jest.mock` hoisting accepts the reference inside the
|
||||
// factory.
|
||||
const mockStore = new Map();
|
||||
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: jest.fn(async (key) => (mockStore.has(key) ? mockStore.get(key) : null)),
|
||||
cacheSet: jest.fn(async (key, value) => { mockStore.set(key, value); return true; }),
|
||||
cacheDel: jest.fn(async (key) => { mockStore.delete(key); return true; }),
|
||||
isDegraded: jest.fn(() => false),
|
||||
getRedisClient: () => ({}),
|
||||
}));
|
||||
|
||||
const mockAxiosGet = jest.fn();
|
||||
jest.mock('axios', () => ({
|
||||
get: (...args) => mockAxiosGet(...args),
|
||||
post: jest.fn(async () => ({ status: 200 })),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mockStore.clear();
|
||||
mockAxiosGet.mockReset();
|
||||
delete process.env.NTFY_URL;
|
||||
process.env.RAPID_API_KEY = 'test-rapid';
|
||||
});
|
||||
|
||||
describe('Tank01 → gateway → quotaTracker (Session 21)', () => {
|
||||
test('a successful adapter call increments the tank01 counter', async () => {
|
||||
const adapter = require('../../src/services/adapters/tank01NbaAdapter');
|
||||
const tracker = require('../../src/services/quotaTracker');
|
||||
|
||||
// The Tank01 NBA box-score endpoint returns `{body: {playerStats:
|
||||
// {...}}}`. We don't assert the projection here — that's the
|
||||
// adapter's unit test — only the side effect on the tracker.
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { body: { playerStats: {} } } });
|
||||
|
||||
const before = await tracker.getQuotaStatus('tank01');
|
||||
expect(before.used).toBe(0);
|
||||
|
||||
await adapter.getNBABoxScore('g1');
|
||||
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
||||
|
||||
const after = await tracker.getQuotaStatus('tank01');
|
||||
expect(after.used).toBe(1);
|
||||
expect(after.allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('counter does NOT advance when the cache hits before HTTP', async () => {
|
||||
const adapter = require('../../src/services/adapters/tank01NbaAdapter');
|
||||
const tracker = require('../../src/services/quotaTracker');
|
||||
|
||||
// First call → HTTP, counter to 1.
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { body: { playerStats: {} } } });
|
||||
await adapter.getNBABoxScore('g1');
|
||||
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
||||
let status = await tracker.getQuotaStatus('tank01');
|
||||
expect(status.used).toBe(1);
|
||||
|
||||
// Second call → cache hit, no HTTP, counter stays at 1.
|
||||
await adapter.getNBABoxScore('g1');
|
||||
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
||||
status = await tracker.getQuotaStatus('tank01');
|
||||
expect(status.used).toBe(1);
|
||||
});
|
||||
|
||||
test('gateway blocks the call at 95% — adapter returns null (no stale)', async () => {
|
||||
const adapter = require('../../src/services/adapters/tank01NbaAdapter');
|
||||
const tracker = require('../../src/services/quotaTracker');
|
||||
|
||||
// Seed the counter at the BLOCK threshold via the truth-source
|
||||
// syncFromHeaders so we don't have to fire 950 calls.
|
||||
await tracker.syncFromHeaders('tank01', {
|
||||
'x-quota-used': '950',
|
||||
'x-quota-limit': '1000',
|
||||
});
|
||||
const status = await tracker.getQuotaStatus('tank01');
|
||||
expect(status.allowed).toBe(false);
|
||||
|
||||
// The adapter wraps the axios call in gateway.fetch — the
|
||||
// gateway short-circuits before invoking axios.
|
||||
const result = await adapter.getNBABoxScore('blocked-game');
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
// No stale cache → adapter degrades to null.
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('quota-exhausted call rolls back the optimistic increment', async () => {
|
||||
const adapter = require('../../src/services/adapters/tank01NbaAdapter');
|
||||
const tracker = require('../../src/services/quotaTracker');
|
||||
|
||||
// Seed at 50 so we can observe rollback unambiguously.
|
||||
await tracker.syncFromHeaders('tank01', {
|
||||
'x-quota-used': '50',
|
||||
'x-quota-limit': '1000',
|
||||
});
|
||||
|
||||
// Network throws after the counter increments → gateway rolls
|
||||
// it back so a failed call doesn't consume quota.
|
||||
mockAxiosGet.mockRejectedValueOnce(new Error('upstream 502'));
|
||||
const result = await adapter.getNBABoxScore('exploding-game');
|
||||
expect(result).toBeNull(); // adapter's catch returns null
|
||||
const after = await tracker.getQuotaStatus('tank01');
|
||||
expect(after.used).toBe(50); // rollback remockStored
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user