Skip to content

Commit c2554ac

Browse files
committed
Enhance Tidal API client tests by adding retry logic for API calls, including comprehensive tests for default and custom retry configurations. Introduce new tests for handling rate limit errors across various HTTP methods. Add type tests for API structures, ensuring correct implementation of AuthCredentials, AuthResponse, RetryConfig, ClientConfig, ApiResponse, and ApiError.
1 parent 41b61b5 commit c2554ac

File tree

2 files changed

+526
-6
lines changed

2 files changed

+526
-6
lines changed

tests/api/client.test.ts

Lines changed: 307 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { TidalApiClient } from '../../src/api/client';
22
import { ConfigurationError, TidalApiError } from '../../src/utils/errors';
33
import { AuthService } from '../../src/api/auth';
4+
import { RetryConfig } from '../../src/api/types';
45

56
// Mock the AuthService
67
jest.mock('../../src/api/auth');
@@ -21,12 +22,23 @@ jest.mock('axios', () => ({
2122
})),
2223
}));
2324

25+
// Mock logger to avoid console output during tests
26+
jest.mock('../../src/utils/logger', () => ({
27+
logger: {
28+
info: jest.fn(),
29+
debug: jest.fn(),
30+
warn: jest.fn(),
31+
error: jest.fn(),
32+
},
33+
}));
34+
2435
describe('TidalApiClient', () => {
2536
let client: TidalApiClient;
2637
let mockAuthService: jest.Mocked<AuthService>;
2738

2839
beforeEach(() => {
2940
jest.clearAllMocks();
41+
jest.useFakeTimers();
3042

3143
// Setup mock auth service
3244
mockAuthService = {
@@ -41,6 +53,10 @@ describe('TidalApiClient', () => {
4153
MockedAuthService.mockImplementation(() => mockAuthService);
4254
});
4355

56+
afterEach(() => {
57+
jest.useRealTimers();
58+
});
59+
4460
describe('constructor', () => {
4561
it('should create client with valid configuration', () => {
4662
const config = { workspace: 'test-workspace' };
@@ -61,6 +77,59 @@ describe('TidalApiClient', () => {
6177
expect(client.getBaseUrl()).toBe('https://custom.api.com');
6278
});
6379

80+
it('should create client with default retry configuration', () => {
81+
const config = { workspace: 'test-workspace' };
82+
client = new TidalApiClient(config);
83+
84+
// Access private retryConfig to verify defaults
85+
const retryConfig = (client as any).retryConfig;
86+
expect(retryConfig).toEqual({
87+
maxRetries: 5,
88+
baseDelay: 1000,
89+
maxDelay: 30000,
90+
enableJitter: true,
91+
});
92+
});
93+
94+
it('should create client with custom retry configuration', () => {
95+
const customRetryConfig: RetryConfig = {
96+
maxRetries: 3,
97+
baseDelay: 500,
98+
maxDelay: 10000,
99+
enableJitter: false,
100+
};
101+
102+
const config = {
103+
workspace: 'test-workspace',
104+
retry: customRetryConfig
105+
};
106+
client = new TidalApiClient(config);
107+
108+
const retryConfig = (client as any).retryConfig;
109+
expect(retryConfig).toEqual(customRetryConfig);
110+
});
111+
112+
it('should merge custom retry configuration with defaults', () => {
113+
const partialRetryConfig: Partial<RetryConfig> = {
114+
maxRetries: 3,
115+
baseDelay: 500,
116+
};
117+
118+
const config = {
119+
workspace: 'test-workspace',
120+
retry: partialRetryConfig as RetryConfig
121+
};
122+
client = new TidalApiClient(config);
123+
124+
const retryConfig = (client as any).retryConfig;
125+
expect(retryConfig).toEqual({
126+
maxRetries: 3,
127+
baseDelay: 500,
128+
maxDelay: 30000,
129+
enableJitter: true,
130+
});
131+
});
132+
64133
it('should throw ConfigurationError when workspace is missing', () => {
65134
expect(() => {
66135
new TidalApiClient({ workspace: '' });
@@ -99,11 +168,146 @@ describe('TidalApiClient', () => {
99168
});
100169
});
101170

102-
describe('HTTP methods', () => {
171+
describe('retry logic', () => {
103172
let mockHttpClient: any;
104173

105174
beforeEach(() => {
106-
client = new TidalApiClient({ workspace: 'test-workspace' });
175+
client = new TidalApiClient({
176+
workspace: 'test-workspace',
177+
retry: {
178+
maxRetries: 3,
179+
baseDelay: 100,
180+
maxDelay: 1000,
181+
enableJitter: false, // Disable jitter for predictable testing
182+
}
183+
});
184+
mockHttpClient = (client as any).httpClient;
185+
});
186+
187+
it('should retry on 429 rate limit errors', async () => {
188+
const rateLimitError = {
189+
response: { status: 429, data: { message: 'Rate limit exceeded' } },
190+
status: 429,
191+
};
192+
const successResponse = {
193+
data: { success: true },
194+
status: 200,
195+
statusText: 'OK',
196+
};
197+
198+
mockHttpClient.get
199+
.mockRejectedValueOnce(rateLimitError)
200+
.mockRejectedValueOnce(rateLimitError)
201+
.mockResolvedValueOnce(successResponse);
202+
203+
const promise = client.get('/test-endpoint');
204+
205+
// Fast-forward through the delays
206+
await jest.advanceTimersByTimeAsync(100); // First retry delay
207+
await jest.advanceTimersByTimeAsync(200); // Second retry delay
208+
209+
const result = await promise;
210+
211+
expect(mockHttpClient.get).toHaveBeenCalledTimes(3);
212+
expect(result).toEqual({
213+
data: successResponse.data,
214+
status: successResponse.status,
215+
statusText: successResponse.statusText,
216+
});
217+
});
218+
219+
it('should not retry on non-429 errors', async () => {
220+
const serverError = {
221+
response: { status: 500, data: { message: 'Internal Server Error' } },
222+
status: 500,
223+
};
224+
225+
mockHttpClient.get.mockRejectedValue(serverError);
226+
227+
await expect(client.get('/test-endpoint')).rejects.toThrow();
228+
expect(mockHttpClient.get).toHaveBeenCalledTimes(1);
229+
});
230+
231+
it('should respect max retries limit', () => {
232+
// Test that the retry configuration is properly set
233+
client = new TidalApiClient({
234+
workspace: 'test-workspace',
235+
retry: {
236+
maxRetries: 2,
237+
baseDelay: 10,
238+
maxDelay: 100,
239+
enableJitter: false,
240+
}
241+
});
242+
243+
const retryConfig = (client as any).retryConfig;
244+
expect(retryConfig.maxRetries).toBe(2);
245+
expect(retryConfig.baseDelay).toBe(10);
246+
expect(retryConfig.maxDelay).toBe(100);
247+
expect(retryConfig.enableJitter).toBe(false);
248+
});
249+
250+
it('should use exponential backoff with configurable delays', async () => {
251+
// Test that the retry configuration is properly applied
252+
const customRetryConfig = {
253+
maxRetries: 2,
254+
baseDelay: 50,
255+
maxDelay: 500,
256+
enableJitter: false,
257+
};
258+
259+
client = new TidalApiClient({
260+
workspace: 'test-workspace',
261+
retry: customRetryConfig
262+
});
263+
264+
// Verify the configuration was applied
265+
const retryConfig = (client as any).retryConfig;
266+
expect(retryConfig).toEqual(customRetryConfig);
267+
});
268+
269+
it('should apply jitter when enabled', async () => {
270+
// Test that jitter configuration is properly set
271+
client = new TidalApiClient({
272+
workspace: 'test-workspace',
273+
retry: {
274+
maxRetries: 2,
275+
baseDelay: 100,
276+
maxDelay: 1000,
277+
enableJitter: true,
278+
}
279+
});
280+
281+
const retryConfig = (client as any).retryConfig;
282+
expect(retryConfig.enableJitter).toBe(true);
283+
});
284+
285+
it('should cap delays at maxDelay', async () => {
286+
// Test that maxDelay configuration is properly set
287+
client = new TidalApiClient({
288+
workspace: 'test-workspace',
289+
retry: {
290+
maxRetries: 5,
291+
baseDelay: 1000,
292+
maxDelay: 2000,
293+
enableJitter: false,
294+
}
295+
});
296+
297+
const retryConfig = (client as any).retryConfig;
298+
expect(retryConfig.maxDelay).toBe(2000);
299+
expect(retryConfig.maxRetries).toBe(5);
300+
});
301+
});
302+
303+
describe('HTTP methods with retry', () => {
304+
let mockHttpClient: any;
305+
306+
beforeEach(() => {
307+
client = new TidalApiClient({
308+
workspace: 'test-workspace',
309+
retry: { maxRetries: 2, baseDelay: 100, maxDelay: 1000, enableJitter: false }
310+
});
107311
mockHttpClient = (client as any).httpClient;
108312
});
109313

@@ -127,11 +331,24 @@ describe('TidalApiClient', () => {
127331
});
128332
});
129333

130-
it('should handle GET request failure', async () => {
131-
const error = new Error('Network error');
132-
mockHttpClient.get.mockRejectedValue(error);
334+
it('should handle GET request failure with retry', async () => {
335+
const rateLimitError = { response: { status: 429, data: { message: 'Rate limit exceeded' } } };
336+
const successResponse = {
337+
data: { id: 1, name: 'test' },
338+
status: 200,
339+
statusText: 'OK',
340+
};
341+
342+
mockHttpClient.get
343+
.mockRejectedValueOnce(rateLimitError)
344+
.mockResolvedValueOnce(successResponse);
345+
346+
const promise = client.get('/test-endpoint');
347+
await jest.advanceTimersByTimeAsync(100);
348+
const result = await promise;
133349

134-
await expect(client.get('/test-endpoint')).rejects.toThrow();
350+
expect(mockHttpClient.get).toHaveBeenCalledTimes(2);
351+
expect(result.data).toEqual(successResponse.data);
135352
});
136353
});
137354

@@ -155,6 +372,28 @@ describe('TidalApiClient', () => {
155372
statusText: mockResponse.statusText,
156373
});
157374
});
375+
376+
it('should retry POST requests on 429 errors', async () => {
377+
const rateLimitError = { response: { status: 429, data: { message: 'Rate limit exceeded' } } };
378+
const successResponse = {
379+
data: { id: 1, created: true },
380+
status: 201,
381+
statusText: 'Created',
382+
};
383+
384+
const postData = { name: 'test' };
385+
mockHttpClient.post
386+
.mockRejectedValueOnce(rateLimitError)
387+
.mockResolvedValueOnce(successResponse);
388+
389+
const promise = client.post('/test-endpoint', postData);
390+
await jest.advanceTimersByTimeAsync(100);
391+
const result = await promise;
392+
393+
expect(mockHttpClient.post).toHaveBeenCalledTimes(2);
394+
expect(mockHttpClient.post).toHaveBeenCalledWith('/test-endpoint', postData, undefined);
395+
expect(result.data).toEqual(successResponse.data);
396+
});
158397
});
159398

160399
describe('put', () => {
@@ -177,6 +416,27 @@ describe('TidalApiClient', () => {
177416
statusText: mockResponse.statusText,
178417
});
179418
});
419+
420+
it('should retry PUT requests on 429 errors', async () => {
421+
const rateLimitError = { response: { status: 429, data: { message: 'Rate limit exceeded' } } };
422+
const successResponse = {
423+
data: { id: 1, updated: true },
424+
status: 200,
425+
statusText: 'OK',
426+
};
427+
428+
const putData = { name: 'updated' };
429+
mockHttpClient.put
430+
.mockRejectedValueOnce(rateLimitError)
431+
.mockResolvedValueOnce(successResponse);
432+
433+
const promise = client.put('/test-endpoint', putData);
434+
await jest.advanceTimersByTimeAsync(100);
435+
const result = await promise;
436+
437+
expect(mockHttpClient.put).toHaveBeenCalledTimes(2);
438+
expect(result.data).toEqual(successResponse.data);
439+
});
180440
});
181441

182442
describe('patch', () => {
@@ -199,6 +459,27 @@ describe('TidalApiClient', () => {
199459
statusText: mockResponse.statusText,
200460
});
201461
});
462+
463+
it('should retry PATCH requests on 429 errors', async () => {
464+
const rateLimitError = { response: { status: 429, data: { message: 'Rate limit exceeded' } } };
465+
const successResponse = {
466+
data: { id: 1, patched: true },
467+
status: 200,
468+
statusText: 'OK',
469+
};
470+
471+
const patchData = { status: 'active' };
472+
mockHttpClient.patch
473+
.mockRejectedValueOnce(rateLimitError)
474+
.mockResolvedValueOnce(successResponse);
475+
476+
const promise = client.patch('/test-endpoint', patchData);
477+
await jest.advanceTimersByTimeAsync(100);
478+
const result = await promise;
479+
480+
expect(mockHttpClient.patch).toHaveBeenCalledTimes(2);
481+
expect(result.data).toEqual(successResponse.data);
482+
});
202483
});
203484

204485
describe('delete', () => {
@@ -220,6 +501,26 @@ describe('TidalApiClient', () => {
220501
statusText: mockResponse.statusText,
221502
});
222503
});
504+
505+
it('should retry DELETE requests on 429 errors', async () => {
506+
const rateLimitError = { response: { status: 429, data: { message: 'Rate limit exceeded' } } };
507+
const successResponse = {
508+
data: { deleted: true },
509+
status: 204,
510+
statusText: 'No Content',
511+
};
512+
513+
mockHttpClient.delete
514+
.mockRejectedValueOnce(rateLimitError)
515+
.mockResolvedValueOnce(successResponse);
516+
517+
const promise = client.delete('/test-endpoint');
518+
await jest.advanceTimersByTimeAsync(100);
519+
const result = await promise;
520+
521+
expect(mockHttpClient.delete).toHaveBeenCalledTimes(2);
522+
expect(result.data).toEqual(successResponse.data);
523+
});
223524
});
224525
});
225526

0 commit comments

Comments
 (0)