122 lines
4.9 KiB
JavaScript
122 lines
4.9 KiB
JavaScript
// 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
|
|
});
|
|
});
|