// 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 }); });