// Import the BackOff instance from your code. // Assuming the file is named `backoff.ts` and the default export is the BackOff instance. import backOff from '../back-off'; describe('backOff', () => { // Use Jest's fake timers to control `setTimeout`. This is crucial for testing delays. jest.useFakeTimers(); // Mock console.log to prevent test logs from cluttering the console output. let consoleLogMock: jest.SpyInstance; let consoleErrorMock: jest.SpyInstance; beforeEach(() => { // Before each test, reset the BackOff state. backOff.resetAll(); // Create new mocks for each test to ensure a clean slate. consoleLogMock = jest.spyOn(console, 'log').mockImplementation(() => {}); consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { // Restore the original console functions after each test. consoleLogMock.mockRestore(); consoleErrorMock.mockRestore(); }); // --- Test Suite for `execute` method --- it('should execute the function immediately the first time without a delay', async() => { const delayedFn = jest.fn(); // Call the function for the first time. await backOff.execute({ id: 'test1', description: 'Test 1', delayedFn }); // The function should have been called immediately, since the fake timer hasn't advanced. expect(delayedFn).toHaveBeenCalledTimes(0); expect(backOff.getBackOff('test1')).toBeDefined(); }); it('should back off and delay the second execution', async() => { const delayedFn = jest.fn(); const id = 'backoff-test'; // First call, which should run immediately. await backOff.execute({ id, description: 'Backoff Test', delayedFn, }); jest.advanceTimersByTime(1); // Expect the first call to be immediate. expect(delayedFn).toHaveBeenCalledTimes(1); // Call it a second time. This should initiate a backoff delay. await backOff.execute({ id, description: 'Backoff Test', delayedFn }); // The function should not have been called a second time yet. expect(delayedFn).toHaveBeenCalledTimes(1); // Advance the timer by less than the first backoff delay (250ms). jest.advanceTimersByTime(200); expect(delayedFn).toHaveBeenCalledTimes(1); // Now, advance the timer by the required delay to trigger the second call. jest.advanceTimersByTime(50); // The function should have been called a second time. await Promise.resolve(); expect(delayedFn).toHaveBeenCalledTimes(2); // Verify the backoff entry was created correctly. const backOffEntry = backOff.getBackOff(id); expect(backOffEntry.try).toBe(2); }); it('should implement exponential backoff on subsequent calls', async() => { const delayedFn = jest.fn(); const id = 'exp-backoff'; // First call (immediate) await backOff.execute({ id, description: 'Exponential Backoff Test', delayedFn }); jest.advanceTimersByTime(1); expect(delayedFn).toHaveBeenCalledTimes(1); // Second call (should have a delay of 1^2 * 250 = 250ms) await backOff.execute({ id, description: 'Exponential Backoff Test', delayedFn }); jest.advanceTimersByTime(250); await Promise.resolve(); expect(delayedFn).toHaveBeenCalledTimes(2); // Third call (should have a delay of 2^2 * 250 = 1000ms) await backOff.execute({ id, description: 'Exponential Backoff Test', delayedFn }); jest.advanceTimersByTime(1000); await Promise.resolve(); expect(delayedFn).toHaveBeenCalledTimes(3); // Fourth call (should have a delay of 3^2 * 250 = 2250ms) await backOff.execute({ id, description: 'Exponential Backoff Test', delayedFn }); jest.advanceTimersByTime(2250); await Promise.resolve(); expect(delayedFn).toHaveBeenCalledTimes(4); }); it('should skip execution if a previous backoff process is already running', async() => { const delayedFn = jest.fn(); const id = 'skip-test'; await backOff.execute({ id, description: 'Skip Test', delayedFn }); expect(delayedFn).toHaveBeenCalledTimes(0); // Second call, will be ignored await backOff.execute({ id, description: 'Skip Test', delayedFn }); expect(delayedFn).toHaveBeenCalledTimes(0); // We should only have 1 call so far. jest.advanceTimersByTime(1); await Promise.resolve(); expect(delayedFn).toHaveBeenCalledTimes(1); // A third call while the first is still pending. // This call should be ignored and the delayedFn should not be executed. await backOff.execute({ id, description: 'Skip Test', delayedFn }); expect(delayedFn).toHaveBeenCalledTimes(1); jest.advanceTimersByTime(300); await Promise.resolve(); expect(delayedFn).toHaveBeenCalledTimes(2); // Advance timers to complete the pending second call. jest.advanceTimersByTime(1000); await Promise.resolve(); // Now there should be 2 calls, not 3. expect(delayedFn).toHaveBeenCalledTimes(2); }); it('should not execute if the number of retries is exceeded', async() => { const delayedFn = jest.fn(); const id = 'retries-test'; // Set retries to 2. const retries = 2; // Call 1 (immediate) await backOff.execute({ id, description: 'Retries Test', retries, delayedFn }); jest.advanceTimersByTime(1); await Promise.resolve(); expect(delayedFn).toHaveBeenCalledTimes(1); // Call 2 (after 250ms delay) await backOff.execute({ id, description: 'Retries Test', retries, delayedFn }); jest.advanceTimersByTime(250); await Promise.resolve(); expect(delayedFn).toHaveBeenCalledTimes(2); // Call 3 (should be ignored because it exceeds the `retries` limit of 2) await backOff.execute({ id, description: 'Retries Test', retries, delayedFn }); jest.advanceTimersByTime(250); await Promise.resolve(); expect(delayedFn).toHaveBeenCalledTimes(2); }); it('should skip execution if `canFn` returns false', async() => { const delayedFn = jest.fn(); const canFn = jest.fn().mockResolvedValue(false); await backOff.execute({ id: 'canfn-test', description: 'canFn Test', canFn, delayedFn }); jest.advanceTimersByTime(250); await Promise.resolve(); expect(delayedFn).not.toHaveBeenCalled(); }); it('should not clear backoff entry if the delayedFn throws an error', async() => { const id = 'error-test'; const delayedFn = jest.fn().mockRejectedValue(new Error('Test Error')); // Call the function for the first time. await backOff.execute({ id, description: 'Error Test', delayedFn }); // Wait for the immediate call to finish. jest.advanceTimersByTime(1); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); // The entry should be removed after success/failure. expect(backOff.getBackOff(id).timeoutId).toBeUndefined(); // Call again to trigger a backoff delay and an error. await backOff.execute({ id, description: 'Error Test', delayedFn }); // Advance timers to trigger the delayed function. jest.advanceTimersByTime(250); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); // The timeoutId should be cleared, but the `try` count should be preserved on the next call. expect(backOff.getBackOff(id).timeoutId).toBeUndefined(); // Check if the next call will still back off. const newDelayedFn = jest.fn(); await backOff.execute({ id, description: 'Error Test', delayedFn: newDelayedFn }); expect(newDelayedFn).not.toHaveBeenCalled(); // The next call should still be delayed }); it('should save metadata', async() => { const delayedFn = jest.fn(); const id = 'exp-backoff'; const metadata = { a: true }; // First call (immediate) await backOff.execute({ id, description: 'Exponential Backoff Test', delayedFn, metadata }); expect(backOff.getBackOff(id)).toBeDefined(); expect(backOff.getBackOff(id).metadata).toStrictEqual(metadata); }); // --- Test Suite for Reset methods --- it('should reset a specific backoff process', async() => { const delayedFn = jest.fn(); const id = 'reset-test'; // Start a backoff process. await backOff.execute({ id, description: 'Reset Test', delayedFn }); expect(backOff.getBackOff(id)).toBeDefined(); // Reset the process. backOff.reset(id); // The entry should be deleted from the map. expect(backOff.getBackOff(id)).toBeUndefined(); // Now, a new execution should not be delayed. await backOff.execute({ id, description: 'Reset Test', delayedFn }); jest.advanceTimersByTime(250); await Promise.resolve(); expect(delayedFn).toHaveBeenCalledTimes(1); }); it('should reset all backoff processes', async() => { const delayedFn1 = jest.fn(); const delayedFn2 = jest.fn(); await backOff.execute({ id: 'all-1', description: 'All 1', delayedFn: delayedFn1 }); await backOff.execute({ id: 'all-1', description: 'All 1', delayedFn: delayedFn1 }); await backOff.execute({ id: 'all-2', description: 'All 2', delayedFn: delayedFn2 }); await backOff.execute({ id: 'all-2', description: 'All 2', delayedFn: delayedFn2 }); expect(backOff.getBackOff('all-1')).toBeDefined(); expect(backOff.getBackOff('all-2')).toBeDefined(); // Reset all processes. backOff.resetAll(); expect(backOff.getBackOff('all-1')).toBeUndefined(); expect(backOff.getBackOff('all-2')).toBeUndefined(); }); it('should reset only processes with a specific prefix', async() => { const delayedFn = jest.fn(); await backOff.execute({ id: 'prefix-test-1', description: 'Prefix Test 1', delayedFn }); await backOff.execute({ id: 'prefix-test-1', description: 'Prefix Test 1', delayedFn }); await backOff.execute({ id: 'prefix-test-2', description: 'Prefix Test 2', delayedFn }); await backOff.execute({ id: 'prefix-test-2', description: 'Prefix Test 2', delayedFn }); await backOff.execute({ id: 'other-test-1', description: 'Other Test 1', delayedFn }); await backOff.execute({ id: 'other-test-1', description: 'Other Test 1', delayedFn }); expect(backOff.getBackOff('prefix-test-1')).toBeDefined(); expect(backOff.getBackOff('prefix-test-2')).toBeDefined(); expect(backOff.getBackOff('other-test-1')).toBeDefined(); // Reset only the "prefix-test" processes. backOff.resetPrefix('prefix-test'); expect(backOff.getBackOff('prefix-test-1')).toBeUndefined(); expect(backOff.getBackOff('prefix-test-2')).toBeUndefined(); // The other process should still exist. expect(backOff.getBackOff('other-test-1')).toBeDefined(); }); });