dashboard/shell/utils/__tests__/back-off.test.ts

355 lines
11 KiB
TypeScript

// 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();
});
});