/ tests / utils / rate-limit-scheduler.test.js
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  });