Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
20 changes: 19 additions & 1 deletion 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 Down Expand Up @@ -79,3 +79,21 @@ describe('formatDateToISOString', () => {
expect(result).toBe('2024-12-31');
});
});

describe('generateJitter', () => {
it('should generate jitter between 0 and 10% of baseDelay', () => {
const baseDelay = 1000;
const jitter = generateJitter(baseDelay);

expect(jitter).toBeGreaterThanOrEqual(0);
expect(jitter).toBeLessThan(0.1 * baseDelay);
});

it('should handle different baseDelay values', () => {
const baseDelay = 500;
const jitter = generateJitter(baseDelay);

expect(jitter).toBeGreaterThanOrEqual(0);
expect(jitter).toBeLessThan(0.1 * baseDelay);
});
});
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) / 0x100000000; // Convert to [0,1) range

return randomValue * 0.1 * baseDelay;
}
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