/** * Unit tests for graph-api retry/backoff behavior. * Spawns a local HTTPS server that returns 429/503 for a configurable * number of requests, then asserts the helper retries and succeeds/fails. * * Run with: node tests/backoff.test.js */ const assert = require('assert'); const https = require('https'); const fs = require('fs'); const path = require('path'); // Override config before requiring graph-api so retry values are tiny. process.env.OUTLOOK_MAX_RETRIES = '3'; process.env.OUTLOOK_BASE_RETRY_DELAY_MS = '10'; process.env.OUTLOOK_MAX_RETRY_DELAY_MS = '50'; process.env.GRAPH_API_ENDPOINT = ''; // Will be set per-test against the local server const config = require('../config'); const { callGraphAPI, getRetryDelay } = require('../utils/graph-api'); // Test getRetryDelay assert.strictEqual(getRetryDelay(0, 0.04), 40, 'Retry-After header should be honored (in ms)'); assert.strictEqual(getRetryDelay(0, 100), config.MAX_RETRY_DELAY_MS, 'Retry-After should clamp to MAX_RETRY_DELAY_MS'); const noHeaderDelay = getRetryDelay(1); assert(noHeaderDelay >= config.BASE_RETRY_DELAY_MS * 2, 'Exponential backoff base should be at least 2*base on attempt 1'); assert(noHeaderDelay <= config.MAX_RETRY_DELAY_MS, 'Exponential backoff should clamp to MAX_RETRY_DELAY_MS'); console.log('✅ getRetryDelay assertions passed'); let server = null; let nextRequest = null; // function(req, res) set by each test function startMockServer() { return new Promise((resolve) => { const options = { key: fs.readFileSync(path.join(__dirname, 'test-key.pem')), cert: fs.readFileSync(path.join(__dirname, 'test-cert.pem')) }; server = https.createServer(options, (req, res) => { if (nextRequest) nextRequest(req, res); }); server.listen(0, '127.0.0.1', () => { const port = server.address().port; resolve(port); }); }); } function stopMockServer() { return new Promise((resolve) => server.close(resolve)); } function makeSelfSignedAgent() { return new https.Agent({ rejectUnauthorized: false }); } async function runTests() { const port = await startMockServer(); const baseUrl = `https://127.0.0.1:${port}`; // Monkey-patch https.request so we can force our test URL and ignore cert errors. const originalRequest = https.request; https.request = (url, options, callback) => { let actualUrl = url; if (typeof url === 'string' && url.startsWith(config.GRAPH_API_ENDPOINT)) { actualUrl = url.replace(config.GRAPH_API_ENDPOINT, baseUrl + '/'); } const actualOptions = { ...options, agent: makeSelfSignedAgent() }; return originalRequest(actualUrl, actualOptions, callback); }; try { // Test 1: 429 with Retry-After eventually succeeds. let requestCount = 0; nextRequest = (req, res) => { requestCount++; if (requestCount < 3) { res.writeHead(429, { 'Retry-After': '1', 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Throttled' } })); } else { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ value: [{ id: 'msg-1', subject: 'Hello' }] })); } }; const result1 = await callGraphAPI('real-token', 'GET', 'me/messages', null, { $top: 1 }); assert.deepStrictEqual(result1, { value: [{ id: 'msg-1', subject: 'Hello' }] }); assert.strictEqual(requestCount, 3, 'Should retry twice after 429 before success'); console.log('✅ 429 Retry-After retry test passed'); // Test 2: 503 retries then fails after MAX_RETRIES+1 attempts. requestCount = 0; nextRequest = (req, res) => { requestCount++; res.writeHead(503, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Service Unavailable' } })); }; let threw = false; try { await callGraphAPI('real-token', 'GET', 'me/messages', null, { $top: 1 }); } catch (err) { threw = true; assert(err.message.includes('503'), `Expected 503 error, got: ${err.message}`); } assert(threw, 'Should throw after exhausting 503 retries'); assert.strictEqual(requestCount, config.MAX_RETRIES + 1, 'Should attempt MAX_RETRIES+1 times'); console.log('✅ 503 exhausted retry test passed'); // Test 3: 401 fails immediately with no retries. requestCount = 0; nextRequest = (req, res) => { requestCount++; res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Unauthorized' } })); }; threw = false; try { await callGraphAPI('real-token', 'GET', 'me/messages', null, { $top: 1 }); } catch (err) { threw = true; assert.strictEqual(err.message, 'UNAUTHORIZED'); } assert(threw, 'Should throw on 401'); assert.strictEqual(requestCount, 1, '401 should not retry'); console.log('✅ 401 no-retry test passed'); // Test 4: 400 fails immediately with no retries. requestCount = 0; nextRequest = (req, res) => { requestCount++; res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Bad Request' } })); }; threw = false; try { await callGraphAPI('real-token', 'GET', 'me/messages', null, { $top: 1 }); } catch (err) { threw = true; assert(err.message.includes('400'), `Expected 400 error, got: ${err.message}`); } assert(threw, 'Should throw on 400'); assert.strictEqual(requestCount, 1, '400 should not retry'); console.log('✅ 400 no-retry test passed'); } finally { https.request = originalRequest; await stopMockServer(); } console.log('\nAll backoff tests passed.'); } // Generate a throwaway self-signed cert if the test files don't exist. function ensureTestCert() { const keyPath = path.join(__dirname, 'test-key.pem'); const certPath = path.join(__dirname, 'test-cert.pem'); if (fs.existsSync(keyPath) && fs.existsSync(certPath)) return; const { execSync } = require('child_process'); execSync( `openssl req -x509 -newkey rsa:2048 -keyout "${keyPath}" -out "${certPath}" -days 1 -nodes -subj "/CN=localhost"`, { stdio: 'ignore' } ); } ensureTestCert(); runTests().catch((err) => { console.error('Test failure:', err); process.exit(1); });