rate-limit-scheduler.test.js
1 /** 2 * Tests for src/utils/rate-limit-scheduler.js 3 * 4 * The module manages dynamic pipeline stage skipping based on API rate limits. 5 * State is held in a module-level singleton (_state) and persisted to 6 * logs/rate-limits.json. Tests clean up after themselves via clearRateLimit(). 7 */ 8 9 import { test, describe, beforeEach, afterEach } from 'node:test'; 10 import assert from 'node:assert'; 11 import { existsSync, unlinkSync } from 'node:fs'; 12 import { join } from 'node:path'; 13 14 // Use a temp logs dir so we don't pollute production logs 15 process.env.LOGS_DIR = '/tmp/test-rate-limit-scheduler'; 16 17 const { 18 calculateResetTime, 19 setRateLimit, 20 clearRateLimit, 21 getSkipStages, 22 isRateLimited, 23 getRateLimitStatus, 24 API_STAGE_MAP, 25 EVENTS_FILE, 26 } = await import('../../src/utils/rate-limit-scheduler.js'); 27 28 // Helper: clear all test APIs from state 29 const TEST_APIS = ['zenrows', 'openrouter', 'twilio', 'resend', 'anthropic', 'testapi', 'myapi']; 30 31 function cleanupState() { 32 for (const api of TEST_APIS) { 33 clearRateLimit(api); 34 } 35 } 36 37 describe('rate-limit-scheduler', () => { 38 beforeEach(cleanupState); 39 afterEach(cleanupState); 40 41 describe('API_STAGE_MAP', () => { 42 test('exports the expected API-to-stage mappings', () => { 43 assert.deepStrictEqual(API_STAGE_MAP.zenrows, ['serps']); 44 assert.deepStrictEqual(API_STAGE_MAP.openrouter, ['scoring', 'rescoring']); // proposals moved to anthropic 45 assert.deepStrictEqual(API_STAGE_MAP.twilio, ['outreach']); 46 assert.deepStrictEqual(API_STAGE_MAP.resend, ['outreach']); 47 assert.deepStrictEqual(API_STAGE_MAP.anthropic, ['proposals']); 48 }); 49 }); 50 51 describe('EVENTS_FILE', () => { 52 test('is a string path ending in .jsonl', () => { 53 assert.ok(typeof EVENTS_FILE === 'string'); 54 assert.ok(EVENTS_FILE.endsWith('.jsonl')); 55 }); 56 }); 57 58 describe('calculateResetTime', () => { 59 test('returns now + retryAfterSeconds * 1000 when retryAfterSeconds > 0', () => { 60 const before = Date.now(); 61 const reset = calculateResetTime('daily', 300); // 5 minutes 62 const after = Date.now(); 63 assert.ok(reset >= before + 300_000); 64 assert.ok(reset <= after + 300_000); 65 }); 66 67 test('daily: returns a timestamp at midnight UTC tonight or later', () => { 68 const reset = calculateResetTime('daily'); 69 const now = new Date(); 70 // Should be midnight UTC on the next day 71 const expectedMidnight = Date.UTC( 72 now.getUTCFullYear(), 73 now.getUTCMonth(), 74 now.getUTCDate() + 1, 75 0, 76 0, 77 0, 78 0 79 ); 80 assert.strictEqual(reset, expectedMidnight); 81 }); 82 83 test('hourly: returns a timestamp at the top of the next UTC hour', () => { 84 const reset = calculateResetTime('hourly'); 85 const now = new Date(); 86 const expectedHour = Date.UTC( 87 now.getUTCFullYear(), 88 now.getUTCMonth(), 89 now.getUTCDate(), 90 now.getUTCHours() + 1, 91 0, 92 0, 93 0 94 ); 95 assert.strictEqual(reset, expectedHour); 96 }); 97 98 test('minute: returns a timestamp at the top of the next minute', () => { 99 const before = Date.now(); 100 const reset = calculateResetTime('minute'); 101 // Should be roughly 60s from now (within a few ms of variance) 102 assert.ok(reset > before); 103 assert.ok(reset <= before + 61_000); 104 }); 105 106 test('unknown limitType: returns now + 5 minutes fallback', () => { 107 const before = Date.now(); 108 const reset = calculateResetTime('custom'); 109 const after = Date.now(); 110 assert.ok(reset >= before + 5 * 60_000); 111 assert.ok(reset <= after + 5 * 60_000 + 100); 112 }); 113 114 test('retryAfterSeconds=0 falls through to limitType logic', () => { 115 const reset = calculateResetTime('daily', 0); 116 // Should use daily logic (not now + 0) 117 const now = new Date(); 118 const expectedMidnight = Date.UTC( 119 now.getUTCFullYear(), 120 now.getUTCMonth(), 121 now.getUTCDate() + 1, 122 0, 123 0, 124 0, 125 0 126 ); 127 assert.strictEqual(reset, expectedMidnight); 128 }); 129 }); 130 131 describe('setRateLimit', () => { 132 test('registers a rate limit and returns stages + resetAt + reason', () => { 133 const result = setRateLimit('zenrows', { limitType: 'hourly' }); 134 assert.deepStrictEqual(result.stages, ['serps']); 135 assert.ok(typeof result.resetAt === 'number'); 136 assert.ok(result.resetAt > Date.now()); 137 assert.ok(result.reason.includes('zenrows')); 138 }); 139 140 test('uses API_STAGE_MAP stages by default', () => { 141 const result = setRateLimit('openrouter', { limitType: 'daily' }); 142 assert.deepStrictEqual(result.stages, ['scoring', 'rescoring']); // proposals moved to anthropic 143 }); 144 145 test('accepts custom stages override', () => { 146 const result = setRateLimit('testapi', { stages: ['custom-stage'], limitType: 'minute' }); 147 assert.deepStrictEqual(result.stages, ['custom-stage']); 148 }); 149 150 test('accepts explicit resetAt timestamp', () => { 151 const future = Date.now() + 3600_000; 152 const result = setRateLimit('testapi', { resetAt: future, limitType: 'daily' }); 153 assert.strictEqual(result.resetAt, future); 154 }); 155 156 test('accepts retryAfterSeconds from Retry-After header', () => { 157 const before = Date.now(); 158 const result = setRateLimit('testapi', { retryAfterSeconds: 120 }); 159 const after = Date.now(); 160 assert.ok(result.resetAt >= before + 120_000); 161 assert.ok(result.resetAt <= after + 120_000); 162 }); 163 164 test('accepts custom reason string', () => { 165 const result = setRateLimit('testapi', { 166 stages: ['serps'], 167 limitType: 'hourly', 168 reason: 'Custom rate limit reason', 169 }); 170 assert.strictEqual(result.reason, 'Custom rate limit reason'); 171 }); 172 }); 173 174 describe('clearRateLimit', () => { 175 test('removes an active rate limit', () => { 176 setRateLimit('testapi', { stages: ['serps'], resetAt: Date.now() + 3600_000 }); 177 assert.ok(isRateLimited('testapi')); 178 clearRateLimit('testapi'); 179 assert.ok(!isRateLimited('testapi')); 180 }); 181 182 test('no-ops when the API is not rate-limited', () => { 183 // Should not throw 184 assert.doesNotThrow(() => clearRateLimit('testapi')); 185 }); 186 }); 187 188 describe('getSkipStages', () => { 189 test('returns an empty Set when no rate limits are active', () => { 190 const skipped = getSkipStages(); 191 assert.ok(skipped instanceof Set); 192 assert.strictEqual(skipped.size, 0); 193 }); 194 195 test('returns stages for active rate limits', () => { 196 setRateLimit('zenrows', { resetAt: Date.now() + 3600_000 }); 197 const skipped = getSkipStages(); 198 assert.ok(skipped.has('serps')); 199 }); 200 201 test('includes stages from multiple concurrent rate limits', () => { 202 setRateLimit('zenrows', { resetAt: Date.now() + 3600_000 }); 203 setRateLimit('openrouter', { resetAt: Date.now() + 1800_000 }); 204 const skipped = getSkipStages(); 205 assert.ok(skipped.has('serps')); 206 assert.ok(skipped.has('scoring')); 207 assert.ok(skipped.has('rescoring')); 208 // proposals are NOT skipped when openrouter is limited (proposals use anthropic now) 209 }); 210 211 test('auto-expires rate limits whose resetAt has passed', () => { 212 // Set a rate limit that expires immediately (in the past) 213 setRateLimit('testapi', { stages: ['serps'], resetAt: Date.now() - 1 }); 214 const skipped = getSkipStages(); 215 assert.ok(!skipped.has('serps'), 'Expired rate limit should be removed'); 216 }); 217 218 test('stages are lowercase', () => { 219 setRateLimit('testapi', { stages: ['SERPS', 'SCORING'], resetAt: Date.now() + 3600_000 }); 220 const skipped = getSkipStages(); 221 assert.ok(skipped.has('serps')); 222 assert.ok(skipped.has('scoring')); 223 }); 224 }); 225 226 describe('isRateLimited', () => { 227 test('returns false when API is not rate-limited', () => { 228 assert.strictEqual(isRateLimited('testapi'), false); 229 }); 230 231 test('returns true when API has an active rate limit', () => { 232 setRateLimit('testapi', { stages: ['serps'], resetAt: Date.now() + 3600_000 }); 233 assert.strictEqual(isRateLimited('testapi'), true); 234 }); 235 236 test('returns false after clearRateLimit', () => { 237 setRateLimit('testapi', { stages: ['serps'], resetAt: Date.now() + 3600_000 }); 238 clearRateLimit('testapi'); 239 assert.strictEqual(isRateLimited('testapi'), false); 240 }); 241 }); 242 243 describe('getRateLimitStatus', () => { 244 test('returns an empty array when no rate limits are active', () => { 245 const status = getRateLimitStatus(); 246 assert.deepStrictEqual(status, []); 247 }); 248 249 test('returns status objects for active rate limits', () => { 250 const future = Date.now() + 3600_000; 251 setRateLimit('zenrows', { limitType: 'hourly', reason: 'Test zenrows limit' }); 252 const status = getRateLimitStatus(); 253 const zenStatus = status.find(s => s.api === 'zenrows'); 254 assert.ok(zenStatus, 'zenrows should appear in status'); 255 assert.deepStrictEqual(zenStatus.stages, ['serps']); 256 assert.ok(typeof zenStatus.resetAt === 'string'); // ISO string 257 assert.ok(zenStatus.waitMinutes >= 0); 258 }); 259 260 test('does not include expired rate limits', () => { 261 // Set an expired rate limit — getSkipStages would clear it, but getRateLimitStatus 262 // also filters by resetAt > now 263 setRateLimit('testapi', { stages: ['serps'], resetAt: Date.now() - 1 }); 264 const status = getRateLimitStatus(); 265 const found = status.find(s => s.api === 'testapi'); 266 assert.ok(!found, 'Expired rate limit should not appear in status'); 267 }); 268 269 test('returns multiple active limits', () => { 270 setRateLimit('zenrows', { limitType: 'daily' }); 271 setRateLimit('openrouter', { limitType: 'hourly' }); 272 const status = getRateLimitStatus(); 273 assert.ok(status.length >= 2); 274 const apis = status.map(s => s.api); 275 assert.ok(apis.includes('zenrows')); 276 assert.ok(apis.includes('openrouter')); 277 }); 278 }); 279 });