Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions workers/main/src/common/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ vi.mock('../configs', () => ({
}));

import * as configs from '../configs';
import { formatDateToISOString, validateEnv } from './utils';
import { formatDateToISOString, generateJitter, validateEnv } from './utils';

type ValidationResult = {
success: boolean;
Expand All @@ -20,7 +20,7 @@ describe('validateEnv', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('exit');
}) as unknown as ReturnType<typeof vi.spyOn>;
Expand Down Expand Up @@ -51,7 +51,7 @@ describe('validateEnv', () => {
expect(() => validateEnv()).toThrow('exit');
expect(errorSpy).toHaveBeenCalledWith(
'Missing or invalid environment variable: FOO (is required)\n' +
'Missing or invalid environment variable: (unknown variable) (unknown)',
'Missing or invalid environment variable: (unknown variable) (unknown)',
);
expect(exitSpy).toHaveBeenCalledWith(1);
});
Expand Down Expand Up @@ -79,3 +79,39 @@ describe('formatDateToISOString', () => {
expect(result).toBe('2024-12-31');
});
});

describe('generateJitter', () => {
it('generates jitter within expected range', () => {
const baseDelay = 1000;
const jitter = generateJitter(baseDelay);

// Jitter should be between 0 and 10% of baseDelay
expect(jitter).toBeGreaterThanOrEqual(0);
expect(jitter).toBeLessThanOrEqual(0.1 * baseDelay);
expect(jitter).toBeLessThan(100); // 10% of 1000ms
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

it('generates different jitter values on multiple calls', () => {
const baseDelay = 2000;
const jitter1 = generateJitter(baseDelay);
const jitter2 = generateJitter(baseDelay);

// Values should be different (cryptographically random)
expect(jitter1).not.toBe(jitter2);
});
Comment thread
anatolyshipitz marked this conversation as resolved.
Outdated

it('scales jitter proportionally with base delay', () => {
const smallDelay = 500;
const largeDelay = 2000;

const smallJitter = generateJitter(smallDelay);
const largeJitter = generateJitter(largeDelay);

// Large delay should produce larger jitter
expect(largeJitter).toBeGreaterThan(smallJitter);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
// Both should be within 10% of their respective base delays
expect(smallJitter).toBeLessThanOrEqual(0.1 * smallDelay);
expect(largeJitter).toBeLessThanOrEqual(0.1 * largeDelay);
});
});
14 changes: 14 additions & 0 deletions workers/main/src/common/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import crypto from 'crypto';

import { validationResult } from '../configs';

export function validateEnv() {
Expand All @@ -21,3 +23,15 @@ export function formatDateToISOString(date: Date): string {

return `${year}-${month}-${day}`;
}

/**
* Generates cryptographically secure random jitter for retry delays
* @param baseDelay - The base delay in milliseconds
* @returns A random jitter value between 0 and 10% of the base delay
*/
export function generateJitter(baseDelay: number): number {
const randomBytes = crypto.randomBytes(4);
const randomValue = randomBytes.readUInt32BE(0) / 0xffffffff; // Convert to 0-1 range

return randomValue * 0.1 * baseDelay;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
189 changes: 189 additions & 0 deletions workers/main/src/services/QBO/QBORepository.errorHandling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import axios from 'axios';
import axiosRetry from 'axios-retry';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';

import { QuickBooksRepositoryError } from '../../common/errors';
import { OAuth2Manager } from '../OAuth2';
import { QBORepository } from './QBORepository';

// Mock dependencies
vi.mock('axios');
vi.mock('axios-retry');
vi.mock('../OAuth2');
vi.mock('../../configs/qbo', () => ({
qboConfig: {
apiUrl: 'https://sandbox-quickbooks.api.intuit.com',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
companyId: 'test-company-id',
refreshToken: 'test-refresh-token',
tokenHost: 'https://oauth.platform.intuit.com',
tokenPath: '/oauth2/v1/tokens/bearer',
tokenExpirationWindowSeconds: 300,
effectiveRevenueMonths: 4,
},
qboSchema: z.object({
QBO_API_URL: z.string().url().min(1, 'QBO_API_URL is required'),
QBO_BEARER_TOKEN: z.string().optional(),
QBO_CLIENT_ID: z.string().min(1, 'QBO_CLIENT_ID is required'),
QBO_CLIENT_SECRET: z.string().min(1, 'QBO_CLIENT_SECRET is required'),
QBO_COMPANY_ID: z.string().min(1, 'QBO_COMPANY_ID is required'),
QBO_REFRESH_TOKEN: z.string(),
QBO_EFFECTIVE_REVENUE_MONTHS: z.string().optional(),
}),
}));

const mockAxios = vi.mocked(axios);
const mockAxiosRetry = vi.mocked(axiosRetry);
const mockOAuth2Manager = vi.mocked(OAuth2Manager);

describe('QBORepository Error Handling', () => {
let qboRepository: QBORepository;
let mockAxiosInstance: { get: ReturnType<typeof vi.fn> };
let mockRetryCondition: (error: {
response?: { status: number };
code?: string;
}) => boolean;

beforeEach(() => {
vi.clearAllMocks();

mockAxiosInstance = {
get: vi.fn(),
};
(mockAxios.create as ReturnType<typeof vi.fn>).mockReturnValue(
mockAxiosInstance,
);

// Capture retry condition function for testing
(mockAxiosRetry as ReturnType<typeof vi.fn>).mockImplementation(
(
instance,
config: {
retryCondition?: (error: {
response?: { status: number };
code?: string;
}) => boolean;
},
) => {
if (config?.retryCondition) {
mockRetryCondition = config.retryCondition;
}
},
);

(mockOAuth2Manager as ReturnType<typeof vi.fn>).mockImplementation(() => ({
getAccessToken: vi.fn().mockResolvedValue('test-access-token'),
}));

qboRepository = new QBORepository();
});

describe('retry condition logic', () => {
it('should retry on 429 status code', () => {
const error = {
response: { status: 429 },
code: undefined,
};

expect(mockRetryCondition(error)).toBe(true);
});

it('should retry on 500 status code', () => {
const error = {
response: { status: 500 },
code: undefined,
};

expect(mockRetryCondition(error)).toBe(true);
});

it('should retry on 502 status code', () => {
const error = {
response: { status: 502 },
code: undefined,
};

expect(mockRetryCondition(error)).toBe(true);
});

it('should retry on network errors', () => {
const networkErrors = [
'ECONNRESET',
'ETIMEDOUT',
'ENOTFOUND',
'ECONNABORTED',
];

networkErrors.forEach((code) => {
const error = {
response: undefined,
code,
};

expect(mockRetryCondition(error)).toBe(true);
});
});

it('should not retry on 400 status code', () => {
const error = {
response: { status: 400 },
code: undefined,
};

expect(mockRetryCondition(error)).toBe(false);
});

it('should not retry on 404 status code', () => {
const error = {
response: { status: 404 },
code: undefined,
};

expect(mockRetryCondition(error)).toBe(false);
});
});

describe('OAuth2 token errors', () => {
it('should handle OAuth2 token retrieval failure', async () => {
(mockOAuth2Manager as ReturnType<typeof vi.fn>).mockImplementation(
() => ({
getAccessToken: vi.fn().mockRejectedValue(new Error('Token expired')),
}),
);

qboRepository = new QBORepository();

await expect(qboRepository.getEffectiveRevenue()).rejects.toThrow(
QuickBooksRepositoryError,
);

await expect(qboRepository.getEffectiveRevenue()).rejects.toThrow(
'QBORepository.getEffectiveRevenue failed: QBORepository.getPaidInvoices failed: Token expired',
);
});
});

describe('API error scenarios', () => {
it('should handle malformed API response', async () => {
mockAxiosInstance.get.mockResolvedValue({
data: { QueryResponse: {} }, // Missing Invoice property
});

const result = await qboRepository.getEffectiveRevenue();

expect(result).toEqual({});
});

it('should handle null API response', async () => {
mockAxiosInstance.get.mockResolvedValue({
data: { QueryResponse: { Invoice: null } },
});

const result = await qboRepository.getEffectiveRevenue();

expect(result).toEqual({});
});
});
});
Loading
Loading