UNPKG

@memberjunction/actions-bizapps-lms

Version:

LMS system integration actions for MemberJunction

297 lines (231 loc) 11.2 kB
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // Mock dependencies vi.mock('@memberjunction/actions', () => ({ BaseAction: class BaseAction { protected async InternalRunAction(): Promise<unknown> { return {}; } }, })); vi.mock('@memberjunction/global', () => ({ RegisterClass: () => (target: unknown) => target, })); vi.mock('@memberjunction/core', () => ({ UserInfo: class UserInfo {}, Metadata: vi.fn(), RunView: vi.fn().mockImplementation(() => ({ RunView: vi.fn().mockResolvedValue({ Success: true, Results: [] }), })), })); vi.mock('@memberjunction/core-entities', () => ({ MJCompanyIntegrationEntity: class MJCompanyIntegrationEntity { CompanyID: string = ''; APIKey: string | null = null; AccessToken: string | null = null; ExternalSystemID: string | null = null; CustomAttribute1: string | null = null; }, })); vi.mock('@memberjunction/actions-base', () => ({ ActionParam: class ActionParam { Name: string = ''; Value: unknown = null; Type: string = 'Input'; }, })); import { LearnWorldsBaseAction } from '../providers/learnworlds/learnworlds-base.action'; /** * Concrete subclass to expose protected/private methods for testing. */ class TestableLearnWorldsAction extends LearnWorldsBaseAction { protected async InternalRunAction(): Promise<{ Success: boolean; Message: string; ResultCode: string }> { return { Success: true, Message: '', ResultCode: 'SUCCESS' }; } // Expose rate limiter reset for testing public static resetRateLimiter(): void { LearnWorldsBaseAction.ResetRateLimiter(); } // Expose protected methods for testing public testCalculateRetryDelay(attempt: number, retryAfterHeader: string | null): number { return this['calculateRetryDelay'](attempt, retryAfterHeader); } public async testProcessInBatches<TItem, TResult>( items: TItem[], processFn: (item: TItem) => Promise<TResult>, batchSize?: number, ): Promise<TResult[]> { return this.processInBatches(items, processFn, batchSize); } public async testSendRequestWithRetry(url: string, init: RequestInit): Promise<Response> { return this['sendRequestWithRetry'](url, init); } public testWaitForRetryDelay(ms: number): Promise<void> { return this['waitForRetryDelay'](ms); } } function createMockResponse(status: number, body: object, headers?: Record<string, string>): Response { const headerMap = new Headers(headers); return { ok: status >= 200 && status < 300, status, statusText: status === 429 ? 'Too Many Requests' : 'OK', headers: headerMap, json: () => Promise.resolve(body), text: () => Promise.resolve(JSON.stringify(body)), } as unknown as Response; } describe('Rate Limit Retry Logic', () => { let action: TestableLearnWorldsAction; let originalFetch: typeof globalThis.fetch; beforeEach(() => { action = new TestableLearnWorldsAction(); originalFetch = globalThis.fetch; vi.useFakeTimers({ shouldAdvanceTime: true }); }); afterEach(() => { globalThis.fetch = originalFetch; vi.useRealTimers(); vi.restoreAllMocks(); TestableLearnWorldsAction.resetRateLimiter(); }); describe('sendRequestWithRetry', () => { it('should return response on first success without retrying', async () => { const mockResponse = createMockResponse(200, { data: 'ok' }); globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); const result = await action.testSendRequestWithRetry('https://api.test.com/v2/users', { method: 'GET' }); expect(result.status).toBe(200); expect(globalThis.fetch).toHaveBeenCalledTimes(1); }); it('should retry on 429 and succeed on subsequent attempt', async () => { const rateLimitResponse = createMockResponse(429, { error: 'rate limited' }); const successResponse = createMockResponse(200, { data: 'ok' }); globalThis.fetch = vi.fn() .mockResolvedValueOnce(rateLimitResponse) .mockResolvedValueOnce(successResponse); // Mock waitForRetryDelay to not actually wait vi.spyOn(action as unknown as { waitForRetryDelay: (ms: number) => Promise<void> }, 'waitForRetryDelay' as never) .mockResolvedValue(undefined as never); const result = await action.testSendRequestWithRetry('https://api.test.com/v2/users', { method: 'GET' }); expect(result.status).toBe(200); expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); it('should return 429 response after exhausting MAX_RETRIES', async () => { const rateLimitResponse = createMockResponse(429, { error: 'rate limited' }); globalThis.fetch = vi.fn().mockResolvedValue(rateLimitResponse); vi.spyOn(action as unknown as { waitForRetryDelay: (ms: number) => Promise<void> }, 'waitForRetryDelay' as never) .mockResolvedValue(undefined as never); const result = await action.testSendRequestWithRetry('https://api.test.com/v2/users', { method: 'GET' }); expect(result.status).toBe(429); // MAX_RETRIES = 5, so: 1 initial + 5 retries = 6 total fetch calls expect(globalThis.fetch).toHaveBeenCalledTimes(6); }); it('should pass through non-429 error responses without retrying', async () => { const errorResponse = createMockResponse(500, { error: 'server error' }); globalThis.fetch = vi.fn().mockResolvedValue(errorResponse); const result = await action.testSendRequestWithRetry('https://api.test.com/v2/users', { method: 'GET' }); expect(result.status).toBe(500); expect(globalThis.fetch).toHaveBeenCalledTimes(1); }); it('should respect Retry-After header on 429 responses', async () => { const rateLimitResponse = createMockResponse(429, { error: 'rate limited' }, { 'Retry-After': '3' }); const successResponse = createMockResponse(200, { data: 'ok' }); globalThis.fetch = vi.fn() .mockResolvedValueOnce(rateLimitResponse) .mockResolvedValueOnce(successResponse); const waitSpy = vi.spyOn(action as unknown as { waitForRetryDelay: (ms: number) => Promise<void> }, 'waitForRetryDelay' as never) .mockResolvedValue(undefined as never); await action.testSendRequestWithRetry('https://api.test.com/v2/users', { method: 'GET' }); // Retry-After: 3 => 3000ms delay expect(waitSpy).toHaveBeenCalledWith(3000); }); }); describe('calculateRetryDelay', () => { it('should use Retry-After header value when present', () => { const delay = action.testCalculateRetryDelay(0, '5'); expect(delay).toBe(5000); }); it('should cap Retry-After at MAX_DELAY_MS', () => { const delay = action.testCalculateRetryDelay(0, '60'); expect(delay).toBe(30_000); }); it('should ignore invalid Retry-After values and use exponential backoff', () => { const delay = action.testCalculateRetryDelay(0, 'invalid'); // Attempt 0: BASE_DELAY_MS * 2^0 + jitter = 1000 + [0..1000) expect(delay).toBeGreaterThanOrEqual(1000); expect(delay).toBeLessThan(2000); }); it('should use exponential backoff when no Retry-After header', () => { const delay0 = action.testCalculateRetryDelay(0, null); const delay2 = action.testCalculateRetryDelay(2, null); // Attempt 0: 1000 * 2^0 + jitter => [1000, 2000) expect(delay0).toBeGreaterThanOrEqual(1000); expect(delay0).toBeLessThan(2000); // Attempt 2: 1000 * 2^2 + jitter => [4000, 5000) expect(delay2).toBeGreaterThanOrEqual(4000); expect(delay2).toBeLessThan(5000); }); it('should cap exponential backoff at MAX_DELAY_MS', () => { const delay = action.testCalculateRetryDelay(10, null); expect(delay).toBe(30_000); }); it('should ignore zero or negative Retry-After values', () => { const delayZero = action.testCalculateRetryDelay(0, '0'); const delayNeg = action.testCalculateRetryDelay(0, '-5'); // Both should fall back to exponential backoff expect(delayZero).toBeGreaterThanOrEqual(1000); expect(delayNeg).toBeGreaterThanOrEqual(1000); }); }); describe('processInBatches with inter-batch delay', () => { it('should add delay between batches but not before the first', async () => { const waitSpy = vi.spyOn(action as unknown as { waitForRetryDelay: (ms: number) => Promise<void> }, 'waitForRetryDelay' as never) .mockResolvedValue(undefined as never); const items = [1, 2, 3, 4, 5, 6]; const processFn = vi.fn().mockImplementation((n: number) => Promise.resolve(n * 2)); const results = await action.testProcessInBatches(items, processFn, 2); expect(results).toEqual([2, 4, 6, 8, 10, 12]); // 3 batches of 2: delay before batch 2 and batch 3 (not before batch 1) expect(waitSpy).toHaveBeenCalledTimes(2); }); it('should not add delay when there is only one batch', async () => { const waitSpy = vi.spyOn(action as unknown as { waitForRetryDelay: (ms: number) => Promise<void> }, 'waitForRetryDelay' as never) .mockResolvedValue(undefined as never); const items = [1, 2]; const processFn = vi.fn().mockImplementation((n: number) => Promise.resolve(n * 2)); const results = await action.testProcessInBatches(items, processFn, 5); expect(results).toEqual([2, 4]); expect(waitSpy).not.toHaveBeenCalled(); }); }); describe('proactive rate limiter', () => { it('should allow requests when under capacity', async () => { const mockResponse = createMockResponse(200, { data: 'ok' }); globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); // Make a few requests — well under the 25/10s limit for (let i = 0; i < 3; i++) { await action.testSendRequestWithRetry('https://api.test.com/v2/users', { method: 'GET' }); } // All should have gone through without delays expect(globalThis.fetch).toHaveBeenCalledTimes(3); }); it('should share rate limiter state across instances', async () => { const mockResponse = createMockResponse(200, { data: 'ok' }); globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); const action1 = new TestableLearnWorldsAction(); const action2 = new TestableLearnWorldsAction(); // Both instances share the same static rate limiter await action1.testSendRequestWithRetry('https://api.test.com/v2/users', { method: 'GET' }); await action2.testSendRequestWithRetry('https://api.test.com/v2/courses', { method: 'GET' }); // Both should succeed (2 requests, well under limit) expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); it('should reset cleanly between tests', async () => { const mockResponse = createMockResponse(200, { data: 'ok' }); globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); // After resetRateLimiter() in afterEach, the window should be empty TestableLearnWorldsAction.resetRateLimiter(); await action.testSendRequestWithRetry('https://api.test.com/v2/users', { method: 'GET' }); expect(globalThis.fetch).toHaveBeenCalledTimes(1); }); }); });