/**
* Tests for AttioClient
*
* Tests HTTP client with retry logic, error handling, and all HTTP methods.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
AttioClient,
AttioError,
createAttioClient,
} from '../../../src/attio-client.js';
// Mock fetch globally
global.fetch = vi.fn();
describe('AttioClient', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Constructor and configuration', () => {
it('should create client with API key', () => {
const client = new AttioClient({ apiKey: 'test-key' });
expect(client).toBeInstanceOf(AttioClient);
});
it('should use default baseUrl', async () => {
const client = new AttioClient({ apiKey: 'test-key' });
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ data: 'test' }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
await client.get('/test');
expect(fetch).toHaveBeenCalledWith(
'https://api.attio.com/v2/test',
expect.any(Object)
);
});
it('should use custom baseUrl', async () => {
const client = new AttioClient({
apiKey: 'test-key',
baseUrl: 'https://custom.api.com/v1',
});
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ data: 'test' }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
await client.get('/test');
expect(fetch).toHaveBeenCalledWith(
'https://custom.api.com/v1/test',
expect.any(Object)
);
});
it('should set correct headers', async () => {
const client = new AttioClient({ apiKey: 'secret-key' });
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({}), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
await client.get('/test');
const callArgs = vi.mocked(fetch).mock.calls[0];
const headers = callArgs[1]?.headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer secret-key');
expect(headers['Content-Type']).toBe('application/json');
expect(headers.Accept).toBe('application/json');
});
});
describe('createAttioClient helper', () => {
it('should create client with apiKey', () => {
const client = createAttioClient('test-key');
expect(client).toBeInstanceOf(AttioClient);
});
});
describe('HTTP methods', () => {
it('should make GET request', async () => {
const client = new AttioClient({ apiKey: 'test-key' });
const mockData = { result: 'success' };
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify(mockData), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
const result = await client.get('/test');
expect(result).toEqual(mockData);
expect(fetch).toHaveBeenCalledWith(
'https://api.attio.com/v2/test',
expect.objectContaining({ method: 'GET' })
);
});
it('should make POST request with body', async () => {
const client = new AttioClient({ apiKey: 'test-key' });
const requestBody = { name: 'Test' };
const mockData = { id: '123' };
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify(mockData), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
const result = await client.post('/test', requestBody);
expect(result).toEqual(mockData);
const callArgs = vi.mocked(fetch).mock.calls[0];
expect(callArgs[1]?.method).toBe('POST');
expect(callArgs[1]?.body).toBe(JSON.stringify(requestBody));
});
it('should make PUT request', async () => {
const client = new AttioClient({ apiKey: 'test-key' });
const requestBody = { name: 'Updated' };
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({}), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
await client.put('/test/123', requestBody);
const callArgs = vi.mocked(fetch).mock.calls[0];
expect(callArgs[1]?.method).toBe('PUT');
});
it('should make PATCH request', async () => {
const client = new AttioClient({ apiKey: 'test-key' });
const requestBody = { name: 'Patched' };
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({}), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
await client.patch('/test/123', requestBody);
const callArgs = vi.mocked(fetch).mock.calls[0];
expect(callArgs[1]?.method).toBe('PATCH');
});
});
describe('Query parameters', () => {
it('should append query parameters to URL', async () => {
const client = new AttioClient({ apiKey: 'test-key' });
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({}), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
await client.get('/test', { limit: 50, offset: 0, active: true });
const callArgs = vi.mocked(fetch).mock.calls[0];
const url = new URL(callArgs[0] as string);
expect(url.searchParams.get('limit')).toBe('50');
expect(url.searchParams.get('offset')).toBe('0');
expect(url.searchParams.get('active')).toBe('true');
});
});
describe('Error handling', () => {
it('should throw AttioError for 4xx errors', async () => {
const client = new AttioClient({ apiKey: 'test-key' });
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Not found' }), {
status: 404,
statusText: 'Not Found',
headers: { 'content-type': 'application/json' },
})
);
await expect(client.get('/test')).rejects.toThrow(AttioError);
});
it('should throw AttioError with status code', async () => {
const client = new AttioClient({ apiKey: 'test-key' });
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Unauthorized' }), {
status: 401,
statusText: 'Unauthorized',
})
);
try {
await client.get('/test');
} catch (error) {
expect(error).toBeInstanceOf(AttioError);
expect((error as AttioError).statusCode).toBe(401);
}
});
it('should parse error message from JSON response', async () => {
const client = new AttioClient({ apiKey: 'test-key' });
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Custom error message' }), {
status: 400,
statusText: 'Bad Request',
})
);
await expect(client.get('/test')).rejects.toThrow('Custom error message');
});
it('should handle non-JSON error responses', async () => {
const client = new AttioClient({ apiKey: 'test-key' });
vi.mocked(fetch).mockResolvedValueOnce(
new Response('Plain text error', {
status: 500,
statusText: 'Internal Server Error',
})
);
// Should retry 3 times for 5xx errors
vi.mocked(fetch).mockResolvedValueOnce(
new Response('Plain text error', { status: 500 })
);
vi.mocked(fetch).mockResolvedValueOnce(
new Response('Plain text error', { status: 500 })
);
await expect(client.get('/test')).rejects.toThrow(AttioError);
expect(fetch).toHaveBeenCalledTimes(3);
});
});
describe('Retry logic', () => {
it('should retry on 5xx errors', async () => {
const client = new AttioClient({
apiKey: 'test-key',
retryAttempts: 3,
});
// First two attempts fail with 500
vi.mocked(fetch)
.mockResolvedValueOnce(
new Response('Server error', { status: 500 })
)
.mockResolvedValueOnce(
new Response('Server error', { status: 500 })
)
// Third attempt succeeds
.mockResolvedValueOnce(
new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
const result = await client.get('/test');
expect(result).toEqual({ success: true });
expect(fetch).toHaveBeenCalledTimes(3);
});
it('should not retry on 4xx errors', async () => {
const client = new AttioClient({ apiKey: 'test-key' });
vi.mocked(fetch).mockResolvedValueOnce(
new Response('Not found', { status: 404 })
);
await expect(client.get('/test')).rejects.toThrow(AttioError);
expect(fetch).toHaveBeenCalledTimes(1); // Only called once, no retry
});
it('should fail after exhausting retry attempts', async () => {
const client = new AttioClient({
apiKey: 'test-key',
retryAttempts: 3,
});
// All attempts fail with 500
vi.mocked(fetch).mockResolvedValue(
new Response('Server error', { status: 500 })
);
await expect(client.get('/test')).rejects.toThrow('failed after 3 attempts');
expect(fetch).toHaveBeenCalledTimes(3);
});
it('should retry on network errors', async () => {
const client = new AttioClient({
apiKey: 'test-key',
retryAttempts: 2,
});
// First attempt fails with network error
vi.mocked(fetch)
.mockRejectedValueOnce(new Error('Network error'))
// Second attempt succeeds
.mockResolvedValueOnce(
new Response(JSON.stringify({ data: 'success' }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
const result = await client.get('/test');
expect(result).toEqual({ data: 'success' });
expect(fetch).toHaveBeenCalledTimes(2);
});
});
describe('Response parsing', () => {
it('should parse JSON responses', async () => {
const client = new AttioClient({ apiKey: 'test-key' });
const mockData = { id: '123', name: 'Test' };
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify(mockData), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
const result = await client.get('/test');
expect(result).toEqual(mockData);
});
it('should handle non-JSON responses', async () => {
const client = new AttioClient({ apiKey: 'test-key' });
vi.mocked(fetch).mockResolvedValueOnce(
new Response('OK', {
status: 200,
headers: { 'content-type': 'text/plain' },
})
);
const result = await client.get('/test');
expect(result).toEqual({});
});
});
describe('AttioError class', () => {
it('should create error with message', () => {
const error = new AttioError('Test error');
expect(error).toBeInstanceOf(Error);
expect(error.name).toBe('AttioError');
expect(error.message).toBe('Test error');
expect(error.statusCode).toBeUndefined();
});
it('should create error with status code', () => {
const error = new AttioError('Test error', 404);
expect(error.statusCode).toBe(404);
});
it('should create error with response data', () => {
const responseData = { details: 'Error details' };
const error = new AttioError('Test error', 400, responseData);
expect(error.response).toEqual(responseData);
});
});
});