Skip to content

Commit 2094277

Browse files
Merge branch 'main' into feature/eslint-naming-conventions
2 parents ef180af + 0bb5560 commit 2094277

File tree

10 files changed

+1004
-1
lines changed

10 files changed

+1004
-1
lines changed

workers/main/src/common/utils.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ vi.mock('../configs', () => ({
55
}));
66

77
import * as configs from '../configs';
8-
import { formatDateToISOString, validateEnv } from './utils';
8+
import { formatDateToISOString, generateJitter, validateEnv } from './utils';
99

1010
type ValidationResult = {
1111
success: boolean;
@@ -79,3 +79,21 @@ describe('formatDateToISOString', () => {
7979
expect(result).toBe('2024-12-31');
8080
});
8181
});
82+
83+
describe('generateJitter', () => {
84+
it('should generate jitter between 0 and 10% of baseDelay', () => {
85+
const baseDelay = 1000;
86+
const jitter = generateJitter(baseDelay);
87+
88+
expect(jitter).toBeGreaterThanOrEqual(0);
89+
expect(jitter).toBeLessThan(0.1 * baseDelay);
90+
});
91+
92+
it('should handle different baseDelay values', () => {
93+
const baseDelay = 500;
94+
const jitter = generateJitter(baseDelay);
95+
96+
expect(jitter).toBeGreaterThanOrEqual(0);
97+
expect(jitter).toBeLessThan(0.1 * baseDelay);
98+
});
99+
});

workers/main/src/common/utils.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import crypto from 'crypto';
2+
13
import { validationResult } from '../configs';
24

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

2224
return `${year}-${month}-${day}`;
2325
}
26+
27+
/**
28+
* Generates cryptographically secure random jitter for retry delays
29+
* @param baseDelay - The base delay in milliseconds
30+
* @returns A random jitter value between 0 and 10% of the base delay
31+
*/
32+
export function generateJitter(baseDelay: number): number {
33+
const randomBytes = crypto.randomBytes(4);
34+
const randomValue = randomBytes.readUInt32BE(0) / 0x100000000; // Convert to [0,1) range
35+
36+
return randomValue * 0.1 * baseDelay;
37+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import axios from 'axios';
2+
import axiosRetry from 'axios-retry';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { z } from 'zod';
5+
6+
import { QuickBooksRepositoryError } from '../../common/errors';
7+
import { OAuth2Manager } from '../OAuth2';
8+
import { QBORepository } from './QBORepository';
9+
10+
// Mock dependencies
11+
vi.mock('axios');
12+
vi.mock('axios-retry');
13+
vi.mock('../OAuth2');
14+
vi.mock('../../configs/qbo', () => ({
15+
qboConfig: {
16+
apiUrl: 'https://sandbox-quickbooks.api.intuit.com',
17+
clientId: 'test-client-id',
18+
clientSecret: 'test-client-secret',
19+
companyId: 'test-company-id',
20+
refreshToken: 'test-refresh-token',
21+
tokenHost: 'https://oauth.platform.intuit.com',
22+
tokenPath: '/oauth2/v1/tokens/bearer',
23+
tokenExpirationWindowSeconds: 300,
24+
effectiveRevenueMonths: 4,
25+
},
26+
qboSchema: z.object({
27+
QBO_API_URL: z.string().url().min(1, 'QBO_API_URL is required'),
28+
QBO_BEARER_TOKEN: z.string().optional(),
29+
QBO_CLIENT_ID: z.string().min(1, 'QBO_CLIENT_ID is required'),
30+
QBO_CLIENT_SECRET: z.string().min(1, 'QBO_CLIENT_SECRET is required'),
31+
QBO_COMPANY_ID: z.string().min(1, 'QBO_COMPANY_ID is required'),
32+
QBO_REFRESH_TOKEN: z.string(),
33+
QBO_EFFECTIVE_REVENUE_MONTHS: z.string().optional(),
34+
}),
35+
}));
36+
37+
const mockAxios = vi.mocked(axios);
38+
const mockAxiosRetry = vi.mocked(axiosRetry);
39+
const mockOAuth2Manager = vi.mocked(OAuth2Manager);
40+
41+
describe('QBORepository Error Handling', () => {
42+
let qboRepository: QBORepository;
43+
let mockAxiosInstance: { get: ReturnType<typeof vi.fn> };
44+
let mockRetryCondition: (error: {
45+
response?: { status: number };
46+
code?: string;
47+
}) => boolean;
48+
49+
beforeEach(() => {
50+
vi.clearAllMocks();
51+
52+
mockAxiosInstance = {
53+
get: vi.fn(),
54+
};
55+
(mockAxios.create as ReturnType<typeof vi.fn>).mockReturnValue(
56+
mockAxiosInstance,
57+
);
58+
59+
// Capture retry condition function for testing
60+
(mockAxiosRetry as ReturnType<typeof vi.fn>).mockImplementation(
61+
(
62+
instance,
63+
config: {
64+
retryCondition?: (error: {
65+
response?: { status: number };
66+
code?: string;
67+
}) => boolean;
68+
},
69+
) => {
70+
if (config?.retryCondition) {
71+
mockRetryCondition = config.retryCondition;
72+
}
73+
},
74+
);
75+
76+
(mockOAuth2Manager as ReturnType<typeof vi.fn>).mockImplementation(() => ({
77+
getAccessToken: vi.fn().mockResolvedValue('test-access-token'),
78+
}));
79+
80+
qboRepository = new QBORepository();
81+
});
82+
83+
describe('retry condition logic', () => {
84+
it('should retry on 429 status code', () => {
85+
const error = {
86+
response: { status: 429 },
87+
code: undefined,
88+
};
89+
90+
expect(mockRetryCondition(error)).toBe(true);
91+
});
92+
93+
it('should retry on 500 status code', () => {
94+
const error = {
95+
response: { status: 500 },
96+
code: undefined,
97+
};
98+
99+
expect(mockRetryCondition(error)).toBe(true);
100+
});
101+
102+
it('should retry on 502 status code', () => {
103+
const error = {
104+
response: { status: 502 },
105+
code: undefined,
106+
};
107+
108+
expect(mockRetryCondition(error)).toBe(true);
109+
});
110+
111+
it('should retry on network errors', () => {
112+
const networkErrors = [
113+
'ECONNRESET',
114+
'ETIMEDOUT',
115+
'ENOTFOUND',
116+
'ECONNABORTED',
117+
];
118+
119+
networkErrors.forEach((code) => {
120+
const error = {
121+
response: undefined,
122+
code,
123+
};
124+
125+
expect(mockRetryCondition(error)).toBe(true);
126+
});
127+
});
128+
129+
it('should not retry on 400 status code', () => {
130+
const error = {
131+
response: { status: 400 },
132+
code: undefined,
133+
};
134+
135+
expect(mockRetryCondition(error)).toBe(false);
136+
});
137+
138+
it('should not retry on 404 status code', () => {
139+
const error = {
140+
response: { status: 404 },
141+
code: undefined,
142+
};
143+
144+
expect(mockRetryCondition(error)).toBe(false);
145+
});
146+
});
147+
148+
describe('OAuth2 token errors', () => {
149+
it('should handle OAuth2 token retrieval failure', async () => {
150+
(mockOAuth2Manager as ReturnType<typeof vi.fn>).mockImplementation(
151+
() => ({
152+
getAccessToken: vi.fn().mockRejectedValue(new Error('Token expired')),
153+
}),
154+
);
155+
156+
qboRepository = new QBORepository();
157+
158+
await expect(qboRepository.getEffectiveRevenue()).rejects.toThrow(
159+
QuickBooksRepositoryError,
160+
);
161+
162+
await expect(qboRepository.getEffectiveRevenue()).rejects.toThrow(
163+
'QBORepository.getEffectiveRevenue failed: QBORepository.getPaidInvoices failed: Token expired',
164+
);
165+
});
166+
});
167+
168+
describe('API error scenarios', () => {
169+
it('should handle malformed API response', async () => {
170+
mockAxiosInstance.get.mockResolvedValue({
171+
data: { QueryResponse: {} }, // Missing Invoice property
172+
});
173+
174+
const result = await qboRepository.getEffectiveRevenue();
175+
176+
expect(result).toEqual({});
177+
});
178+
179+
it('should handle null API response', async () => {
180+
mockAxiosInstance.get.mockResolvedValue({
181+
data: { QueryResponse: { Invoice: null } },
182+
});
183+
184+
const result = await qboRepository.getEffectiveRevenue();
185+
186+
expect(result).toEqual({});
187+
});
188+
});
189+
});

0 commit comments

Comments
 (0)