From f61dc068576598c716f391e8bb428b6d12e0ae07 Mon Sep 17 00:00:00 2001 From: ash-balla <294287477+ash-balla@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:46:14 -0400 Subject: [PATCH] test(scripts): add unit tests for retry and rate-limit utils; fix jest config under ESM - Add test/retry_utils.test.ts covering retryWithBackoff: success, retry-then-succeed, non-retryable errors, retry exhaustion, retryable detection (code/status/name/message), custom retryableErrors, and exponential backoff capping. - Add test/rate_limit_utils.test.ts covering processBatch (ordering, empty input, oversized batch, in-batch parallelism) and checkRateLimit (10-call cadence, pause below threshold, no pause above, error tolerance). - Rename jest.config.js to jest.config.cjs so the config loads correctly given package.json "type": "module" (previously threw "module is not defined in ES module scope", which prevented any jest tests from running). All 3 suites pass: 34 tests, jest exit code 0. --- scripts/{jest.config.js => jest.config.cjs} | 0 scripts/test/rate_limit_utils.test.ts | 148 +++++++++++++++++ scripts/test/retry_utils.test.ts | 167 ++++++++++++++++++++ 3 files changed, 315 insertions(+) rename scripts/{jest.config.js => jest.config.cjs} (100%) create mode 100644 scripts/test/rate_limit_utils.test.ts create mode 100644 scripts/test/retry_utils.test.ts diff --git a/scripts/jest.config.js b/scripts/jest.config.cjs similarity index 100% rename from scripts/jest.config.js rename to scripts/jest.config.cjs diff --git a/scripts/test/rate_limit_utils.test.ts b/scripts/test/rate_limit_utils.test.ts new file mode 100644 index 00000000000..3a12e92a07f --- /dev/null +++ b/scripts/test/rate_limit_utils.test.ts @@ -0,0 +1,148 @@ +/** + * Unit tests for rate limit utilities + */ + +import { checkRateLimit, processBatch } from '../rate_limit_utils'; + +describe('processBatch', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('processes every item and preserves input order across batches', async () => { + const items = [1, 2, 3, 4, 5]; + const processor = jest.fn(async (n: number) => n * 2); + + const results = await processBatch(items, 2, processor, 0); + + expect(results).toEqual([2, 4, 6, 8, 10]); + expect(processor).toHaveBeenCalledTimes(5); + }); + + it('returns an empty array and never calls the processor for empty input', async () => { + const processor = jest.fn(async (n: number) => n); + + const results = await processBatch([], 3, processor, 0); + + expect(results).toEqual([]); + expect(processor).not.toHaveBeenCalled(); + }); + + it('handles a batch size larger than the number of items (single batch)', async () => { + const items = ['a', 'b']; + const processor = jest.fn(async (s: string) => s.toUpperCase()); + + const results = await processBatch(items, 10, processor, 0); + + expect(results).toEqual(['A', 'B']); + expect(processor).toHaveBeenCalledTimes(2); + }); + + it('processes items within a batch in parallel', async () => { + const order: string[] = []; + const items = [30, 10, 20]; + // Lower numbers resolve sooner; parallel execution within the batch means + // completion order follows the delay, not the input order. + const processor = (n: number) => + new Promise((resolve) => { + setTimeout(() => { + order.push(`done-${n}`); + resolve(n); + }, n); + }); + + const results = await processBatch(items, 3, processor, 0); + + // Results stay in input order... + expect(results).toEqual([30, 10, 20]); + // ...but they completed in delay order, proving parallel execution. + expect(order).toEqual(['done-10', 'done-20', 'done-30']); + }); +}); + +describe('checkRateLimit', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // The module checks the live rate limit only on every 10th call. Ten + // consecutive calls always span exactly one multiple of ten, so the API is + // queried exactly once per helper invocation regardless of prior state. + function callTenTimes(client: any): Promise { + return Promise.all( + Array.from({ length: 10 }, () => checkRateLimit(client)) + ); + } + + function makeClient(remaining: number, resetEpochSeconds: number) { + return { + rateLimit: { + get: jest.fn().mockResolvedValue({ + data: { rate: { remaining, reset: resetEpochSeconds } }, + }), + }, + }; + } + + it('queries the rate limit once per ten calls', async () => { + const client = makeClient(5000, Math.floor(Date.now() / 1000) + 60); + + await callTenTimes(client); + + expect(client.rateLimit.get).toHaveBeenCalledTimes(1); + }); + + it('does not pause when remaining requests are above the threshold', async () => { + const client = makeClient(5000, Math.floor(Date.now() / 1000) + 60); + const setTimeoutSpy = jest + .spyOn(global, 'setTimeout') + .mockImplementation(((cb: () => void) => { + cb(); + return 0 as unknown as NodeJS.Timeout; + }) as unknown as typeof setTimeout); + + await callTenTimes(client); + + // No sleep should be scheduled when we are well under the limit. + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }); + + it('pauses until reset when remaining requests are below the threshold', async () => { + const resetEpochSeconds = Math.floor(Date.now() / 1000) + 60; + const client = makeClient(10, resetEpochSeconds); + const delays: number[] = []; + jest + .spyOn(global, 'setTimeout') + .mockImplementation(((cb: () => void, ms?: number) => { + delays.push(ms ?? 0); + cb(); + return 0 as unknown as NodeJS.Timeout; + }) as unknown as typeof setTimeout); + + await callTenTimes(client); + + expect(delays).toHaveLength(1); + // Waits until reset plus a 1s buffer; allow slack for clock drift in-test. + expect(delays[0]).toBeGreaterThan(0); + }); + + it('does not throw when the rate limit lookup fails', async () => { + const client = { + rateLimit: { + get: jest.fn().mockRejectedValue(new Error('network down')), + }, + }; + + await expect(callTenTimes(client)).resolves.toBeDefined(); + expect(client.rateLimit.get).toHaveBeenCalledTimes(1); + }); +}); diff --git a/scripts/test/retry_utils.test.ts b/scripts/test/retry_utils.test.ts new file mode 100644 index 00000000000..095d86314aa --- /dev/null +++ b/scripts/test/retry_utils.test.ts @@ -0,0 +1,167 @@ +/** + * Unit tests for retry utilities + */ + +import { retryWithBackoff, RetryOptions } from '../retry_utils'; + +// Options that make sleeps effectively instant for fast, deterministic tests. +const fastOpts: RetryOptions = { baseDelay: 0, maxDelay: 0 }; + +describe('retryWithBackoff', () => { + beforeEach(() => { + // Silence the informational logging the function emits during retries. + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('success paths', () => { + it('returns the result without retrying when fn succeeds on first attempt', async () => { + const fn = jest.fn().mockResolvedValue('ok'); + + await expect(retryWithBackoff(fn, fastOpts)).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('retries on a retryable error and resolves once fn succeeds', async () => { + const fn = jest + .fn() + .mockRejectedValueOnce(new Error('ThrottlingException: slow down')) + .mockResolvedValueOnce('done'); + + await expect(retryWithBackoff(fn, fastOpts)).resolves.toBe('done'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('preserves the resolved value type', async () => { + const value = { id: 42, name: 'issue' }; + const fn = jest.fn().mockResolvedValue(value); + + await expect(retryWithBackoff(fn, fastOpts)).resolves.toEqual(value); + }); + }); + + describe('non-retryable errors', () => { + it('throws immediately without retrying when the error is not retryable', async () => { + const fn = jest.fn().mockRejectedValue(new Error('ValidationError')); + + await expect(retryWithBackoff(fn, fastOpts)).rejects.toThrow('ValidationError'); + expect(fn).toHaveBeenCalledTimes(1); + }); + }); + + describe('retry exhaustion', () => { + it('throws the last error after exhausting the default 3 retries (4 attempts total)', async () => { + const fn = jest.fn().mockRejectedValue(new Error('ThrottlingException')); + + await expect(retryWithBackoff(fn, fastOpts)).rejects.toThrow('ThrottlingException'); + expect(fn).toHaveBeenCalledTimes(4); + }); + + it('respects a custom maxRetries value', async () => { + const fn = jest.fn().mockRejectedValue(new Error('ServiceUnavailable')); + + await expect( + retryWithBackoff(fn, { ...fastOpts, maxRetries: 2 }) + ).rejects.toThrow('ServiceUnavailable'); + expect(fn).toHaveBeenCalledTimes(3); // initial attempt + 2 retries + }); + + it('throws the most recent error instance', async () => { + const first = new Error('ThrottlingException #1'); + const last = new Error('ThrottlingException #2'); + const fn = jest + .fn() + .mockRejectedValueOnce(first) + .mockRejectedValueOnce(last); + + await expect( + retryWithBackoff(fn, { ...fastOpts, maxRetries: 1 }) + ).rejects.toBe(last); + }); + }); + + describe('retryable error detection', () => { + it('detects a retryable error via error.code', async () => { + const err: any = new Error('socket hang up'); + err.code = 'ECONNRESET'; + const fn = jest.fn().mockRejectedValueOnce(err).mockResolvedValueOnce('ok'); + + await expect(retryWithBackoff(fn, fastOpts)).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('detects a retryable error via error.status', async () => { + const err: any = new Error('boom'); + err.status = 'ETIMEDOUT'; + const fn = jest.fn().mockRejectedValueOnce(err).mockResolvedValueOnce('ok'); + + await expect(retryWithBackoff(fn, fastOpts)).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('detects a retryable error via error.name', async () => { + const err: any = new Error('upstream failure'); + err.name = 'ServiceUnavailable'; + const fn = jest.fn().mockRejectedValueOnce(err).mockResolvedValueOnce('ok'); + + await expect(retryWithBackoff(fn, fastOpts)).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('honors a custom retryableErrors list', async () => { + const fn = jest + .fn() + .mockRejectedValueOnce(new Error('CustomTransient')) + .mockResolvedValueOnce('ok'); + + await expect( + retryWithBackoff(fn, { ...fastOpts, retryableErrors: ['CustomTransient'] }) + ).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('treats an error not in a custom list as non-retryable', async () => { + const fn = jest.fn().mockRejectedValue(new Error('ThrottlingException')); + + // ThrottlingException is a default, but a custom list overrides defaults. + await expect( + retryWithBackoff(fn, { ...fastOpts, retryableErrors: ['SomethingElse'] }) + ).rejects.toThrow('ThrottlingException'); + expect(fn).toHaveBeenCalledTimes(1); + }); + }); + + describe('exponential backoff timing', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('uses exponential delays capped at maxDelay', async () => { + const delays: number[] = []; + // Capture each requested delay and invoke the callback synchronously so + // the retry loop proceeds without real waiting. + jest + .spyOn(global, 'setTimeout') + .mockImplementation(((cb: () => void, ms?: number) => { + delays.push(ms ?? 0); + cb(); + return 0 as unknown as NodeJS.Timeout; + }) as unknown as typeof setTimeout); + + const fn = jest.fn().mockRejectedValue(new Error('ThrottlingException')); + + await expect( + retryWithBackoff(fn, { maxRetries: 4, baseDelay: 100, maxDelay: 500 }) + ).rejects.toThrow('ThrottlingException'); + + // Delays after attempts 0..3 (the final attempt does not sleep): + // 100*2^0=100, 100*2^1=200, 100*2^2=400, 100*2^3=800 -> capped to 500 + expect(delays).toEqual([100, 200, 400, 500]); + expect(fn).toHaveBeenCalledTimes(5); + }); + }); +});