/**
* Unit tests for TallyApiClient
* Tests the base API client functionality with mocked Axios responses
*/
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import { TallyApiClient, TallyApiClientConfig, ApiResponse } from '../services/TallyApiClient';
import {
TallySubmissionsResponseSchema,
TallyFormSchema,
TallySubmissionSchema,
TallyWorkspaceSchema,
type TallySubmissionsResponse,
type TallyForm,
type TallySubmission,
type TallyWorkspace,
} from '../models';
// Mock axios
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('TallyApiClient', () => {
let client: TallyApiClient;
let mockAxiosInstance: jest.Mocked<AxiosInstance>;
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Create a mock axios instance
mockAxiosInstance = {
request: jest.fn(),
defaults: {
headers: {
common: {},
},
},
interceptors: {
request: {
use: jest.fn(),
},
response: {
use: jest.fn(),
},
},
} as any;
// Mock axios.create to return our mock instance
mockedAxios.create.mockReturnValue(mockAxiosInstance);
});
describe('Constructor and Configuration', () => {
it('should create a client with default configuration', () => {
client = new TallyApiClient();
expect(mockedAxios.create).toHaveBeenCalledWith({
baseURL: 'https://api.tally.so',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
responseType: 'json',
});
const config = client.getConfig();
expect(config.baseURL).toBe('https://api.tally.so');
expect(config.timeout).toBe(30000);
expect(config.debug).toBe(false);
expect(config.accessToken).toBe('');
});
it('should create a client with custom configuration', () => {
const customConfig: TallyApiClientConfig = {
baseURL: 'https://custom-api.example.com',
timeout: 5000,
defaultHeaders: {
'Custom-Header': 'custom-value',
},
accessToken: 'test-token',
debug: true,
};
client = new TallyApiClient(customConfig);
expect(mockedAxios.create).toHaveBeenCalledWith({
baseURL: 'https://custom-api.example.com',
timeout: 5000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Custom-Header': 'custom-value',
},
responseType: 'json',
});
expect(mockAxiosInstance.defaults.headers.common['Authorization']).toBe('Bearer test-token');
expect(mockAxiosInstance.interceptors.request.use).toHaveBeenCalled();
expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalled();
});
it('should not set Authorization header if no access token is provided', () => {
client = new TallyApiClient();
expect(mockAxiosInstance.defaults.headers.common['Authorization']).toBeUndefined();
});
});
describe('Access Token Management', () => {
beforeEach(() => {
client = new TallyApiClient();
});
it('should set access token and update Authorization header', () => {
const token = 'new-access-token';
client.setAccessToken(token);
expect(client.getAccessToken()).toBe(token);
expect(mockAxiosInstance.defaults.headers.common['Authorization']).toBe(`Bearer ${token}`);
});
it('should remove Authorization header when setting empty token', () => {
// First set a token
client.setAccessToken('some-token');
expect(mockAxiosInstance.defaults.headers.common['Authorization']).toBe('Bearer some-token');
// Then remove it
client.setAccessToken('');
expect(mockAxiosInstance.defaults.headers.common['Authorization']).toBeUndefined();
});
it('should return current access token', () => {
expect(client.getAccessToken()).toBe('');
client.setAccessToken('test-token');
expect(client.getAccessToken()).toBe('test-token');
});
});
describe('HTTP Methods', () => {
beforeEach(() => {
client = new TallyApiClient({ accessToken: 'test-token' });
});
const mockResponseData = { id: 1, name: 'Test Data' };
const mockAxiosResponse: AxiosResponse = {
data: mockResponseData,
status: 200,
statusText: 'OK',
headers: { 'content-type': 'application/json' },
config: {
headers: {} as any,
},
};
it('should make a GET request', async () => {
mockAxiosInstance.request.mockResolvedValue(mockAxiosResponse);
const response: ApiResponse = await client.get('/test-endpoint');
expect(mockAxiosInstance.request).toHaveBeenCalledWith({
method: 'GET',
url: '/test-endpoint',
data: undefined,
});
expect(response).toEqual({
data: mockResponseData,
status: 200,
statusText: 'OK',
headers: { 'content-type': 'application/json' },
});
});
it('should make a GET request with custom config', async () => {
mockAxiosInstance.request.mockResolvedValue(mockAxiosResponse);
const customConfig = { params: { limit: 10 } };
await client.get('/test-endpoint', customConfig);
expect(mockAxiosInstance.request).toHaveBeenCalledWith({
method: 'GET',
url: '/test-endpoint',
data: undefined,
params: { limit: 10 },
});
});
it('should make a POST request with data', async () => {
mockAxiosInstance.request.mockResolvedValue(mockAxiosResponse);
const postData = { name: 'Test Form' };
await client.post('/forms', postData);
expect(mockAxiosInstance.request).toHaveBeenCalledWith({
method: 'POST',
url: '/forms',
data: postData,
});
});
it('should make a PUT request with data', async () => {
mockAxiosInstance.request.mockResolvedValue(mockAxiosResponse);
const putData = { name: 'Updated Form' };
await client.put('/forms/123', putData);
expect(mockAxiosInstance.request).toHaveBeenCalledWith({
method: 'PUT',
url: '/forms/123',
data: putData,
});
});
it('should make a DELETE request', async () => {
mockAxiosInstance.request.mockResolvedValue(mockAxiosResponse);
await client.delete('/forms/123');
expect(mockAxiosInstance.request).toHaveBeenCalledWith({
method: 'DELETE',
url: '/forms/123',
data: undefined,
});
});
it('should make a PATCH request with data', async () => {
mockAxiosInstance.request.mockResolvedValue(mockAxiosResponse);
const patchData = { enabled: false };
await client.patch('/forms/123', patchData);
expect(mockAxiosInstance.request).toHaveBeenCalledWith({
method: 'PATCH',
url: '/forms/123',
data: patchData,
});
});
});
describe('Error Handling', () => {
beforeEach(() => {
client = new TallyApiClient({ accessToken: 'test-token' });
});
it('should propagate axios errors', async () => {
const axiosError = new Error('Network Error');
mockAxiosInstance.request.mockRejectedValue(axiosError);
await expect(client.get('/test-error')).rejects.toThrow('Network Error');
});
it('should propagate HTTP error responses', async () => {
const errorResponse: AxiosResponse = {
data: { message: 'Not Found' },
status: 404,
statusText: 'Not Found',
headers: {},
config: {
headers: {} as any,
},
};
mockAxiosInstance.request.mockRejectedValue({ response: errorResponse, isAxiosError: true });
try {
await client.get('/not-found');
} catch (error: any) {
expect(error.response).toEqual(errorResponse);
}
});
});
describe('Utility Methods', () => {
beforeEach(() => {
client = new TallyApiClient();
});
it('should return the axios instance', () => {
const axiosInstance = client.getAxiosInstance();
expect(axiosInstance).toBe(mockAxiosInstance);
});
it('should return readonly configuration', () => {
const config = client.getConfig();
expect(config).toEqual({
baseURL: 'https://api.tally.so',
timeout: 30000,
defaultHeaders: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
accessToken: '',
debug: false,
retryConfig: {
maxAttempts: 3,
baseDelayMs: 1000,
maxDelayMs: 30000,
exponentialBase: 2,
jitterFactor: 0.1,
enableCircuitBreaker: true,
circuitBreakerThreshold: 5,
circuitBreakerTimeout: 60000,
},
});
// Verify it's readonly by attempting to modify (should not affect original)
try {
(config as any).baseURL = 'https://malicious.example.com';
} catch (error) {
// Expected in strict mode
}
const freshConfig = client.getConfig();
expect(freshConfig.baseURL).toBe('https://api.tally.so');
});
});
describe('Interceptors', () => {
it('should always setup interceptors for authentication and error handling', () => {
client = new TallyApiClient({ debug: false });
expect(mockAxiosInstance.interceptors.request.use).toHaveBeenCalled();
expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalled();
});
it('should setup interceptors with debug logging when debug is enabled', () => {
client = new TallyApiClient({ debug: true });
expect(mockAxiosInstance.interceptors.request.use).toHaveBeenCalled();
expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalled();
});
});
describe('Type Safety', () => {
beforeEach(() => {
client = new TallyApiClient({ accessToken: 'test-token' });
});
it('should maintain type safety with generic responses', async () => {
interface TestResponse {
id: number;
name: string;
}
const mockResponse: AxiosResponse<TestResponse> = {
data: { id: 1, name: 'Test' },
status: 200,
statusText: 'OK',
headers: {},
config: {
headers: {} as any,
},
};
mockAxiosInstance.request.mockResolvedValue(mockResponse);
const response: ApiResponse<TestResponse> = await client.get<TestResponse>('/test');
expect(response.data.id).toBe(1);
expect(response.data.name).toBe('Test');
});
});
describe('Rate Limiting and Retry Logic', () => {
beforeEach(() => {
client = new TallyApiClient({
accessToken: 'test-token',
debug: false, // Disable debug to reduce noise
retryConfig: {
maxAttempts: 3,
baseDelayMs: 10, // Very short delays for testing
maxDelayMs: 100,
exponentialBase: 2,
jitterFactor: 0,
enableCircuitBreaker: true,
circuitBreakerThreshold: 2,
circuitBreakerTimeout: 1000,
}
});
});
it('should have retry configuration set correctly', () => {
const config = client.getConfig();
expect(config.retryConfig).toEqual({
maxAttempts: 3,
baseDelayMs: 10,
maxDelayMs: 100,
exponentialBase: 2,
jitterFactor: 0,
enableCircuitBreaker: true,
circuitBreakerThreshold: 2,
circuitBreakerTimeout: 1000,
});
});
it('should setup interceptors for retry logic', () => {
// Verify that interceptors are set up
expect(mockAxiosInstance.interceptors.request.use).toHaveBeenCalled();
expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalled();
});
it('should handle custom retry configuration', () => {
const customClient = new TallyApiClient({
retryConfig: {
maxAttempts: 5,
baseDelayMs: 500,
maxDelayMs: 10000,
exponentialBase: 3,
jitterFactor: 0.2,
enableCircuitBreaker: false,
circuitBreakerThreshold: 10,
circuitBreakerTimeout: 30000,
}
});
const config = customClient.getConfig();
expect(config.retryConfig).toEqual({
maxAttempts: 5,
baseDelayMs: 500,
maxDelayMs: 10000,
exponentialBase: 3,
jitterFactor: 0.2,
enableCircuitBreaker: false,
circuitBreakerThreshold: 10,
circuitBreakerTimeout: 30000,
});
});
});
describe('Type-Safe API Methods with Zod Validation', () => {
let client: TallyApiClient;
beforeEach(() => {
client = new TallyApiClient({ accessToken: 'test-token' });
});
describe('getSubmissions', () => {
it('should validate and return submissions response', async () => {
const validSubmissionsData: TallySubmissionsResponse = {
hasMore: false,
limit: 10,
page: 1,
submissions: [
{
id: 'sub_1',
formId: 'form_123',
respondentId: 'resp_1',
isCompleted: true,
submittedAt: new Date().toISOString(),
responses: [
{
questionId: 'q1',
value: 'Test User'
}
]
}
],
questions: [],
totalNumberOfSubmissionsPerFilter: {
all: 1,
completed: 1,
partial: 0,
},
};
const mockResponse: AxiosResponse<TallySubmissionsResponse> = {
data: validSubmissionsData,
status: 200,
statusText: 'OK',
headers: {},
config: {
headers: {} as any,
},
};
mockAxiosInstance.request.mockResolvedValue(mockResponse);
const result = await client.getSubmissions('form123', { page: 1, limit: 10 });
expect(result).toEqual(validSubmissionsData);
expect(mockAxiosInstance.request).toHaveBeenCalledWith({
method: 'GET',
url: '/forms/form123/submissions?page=1&limit=10',
data: undefined,
});
});
it('should throw validation error for invalid submissions response', async () => {
const invalidData = { hasMore: false, submissions: [{ id: 'sub_1', responses: 'invalid' }] };
const mockResponse: AxiosResponse<any> = {
data: invalidData,
status: 200,
statusText: 'OK',
headers: {},
config: {
headers: {} as any,
},
};
mockAxiosInstance.request.mockResolvedValue(mockResponse);
await expect(
client.getSubmissions('form123', { page: 1, limit: 10 }),
).rejects.toThrow();
});
});
describe('getForm', () => {
it('should validate and return form data', async () => {
const validFormData: TallyForm = {
id: 'form123',
title: 'Contact Us',
description: 'Get in touch with our team',
status: 'published',
isPublished: true,
url: 'https://tally.so/r/form_123456789',
embedUrl: 'https://tally.so/embed/form_123456789',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
submissionsCount: 42,
};
const mockResponse: AxiosResponse<TallyForm> = {
data: validFormData,
status: 200,
statusText: 'OK',
headers: {},
config: {
headers: {} as any,
},
};
mockAxiosInstance.request.mockResolvedValue(mockResponse);
const result = await client.getForm('form123');
expect(result).toEqual(validFormData);
expect(mockAxiosInstance.request).toHaveBeenCalledWith({
method: 'GET',
url: '/forms/form123',
data: undefined,
});
});
it('should throw validation error for invalid form response', async () => {
const invalidData = { id: 'form123', title: 'Test Form', submissionsCount: 'not-a-number' };
const mockResponse: AxiosResponse<any> = {
data: invalidData,
status: 200,
statusText: 'OK',
headers: {},
config: {
headers: {} as any,
},
};
mockAxiosInstance.request.mockResolvedValue(mockResponse);
await expect(client.getForm('form123')).rejects.toThrow();
});
});
describe('validateResponse', () => {
it('should return success result for valid data', () => {
const validSubmission: TallySubmission = {
id: 'sub1',
formId: 'form123',
respondentId: 'resp1',
isCompleted: true,
submittedAt: '2024-01-01T11:00:00Z',
responses: [],
};
const result = client.validateResponse(TallySubmissionSchema, validSubmission);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(validSubmission);
}
});
it('should return error result for invalid data', () => {
const invalidData = {
id: 123, // Should be string
formId: null, // Should be string
};
const result = client.validateResponse(TallySubmissionSchema, invalidData);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBeDefined();
expect(result.error.issues.length).toBeGreaterThan(0); // Should have validation errors
}
});
});
describe('requestWithValidation', () => {
it('should make request and validate response with schema', async () => {
const validWorkspace: TallyWorkspace = {
id: 'ws1',
name: 'Test Workspace',
slug: 'test-workspace',
createdAt: '2024-01-01T10:00:00Z',
updatedAt: '2024-01-01T10:00:00Z',
members: [],
};
const mockResponse: AxiosResponse = {
data: validWorkspace,
status: 200,
statusText: 'OK',
headers: {},
config: { headers: {} as any },
};
mockAxiosInstance.request.mockResolvedValue(mockResponse);
const result = await client.requestWithValidation(
'GET',
'/workspaces/ws1',
TallyWorkspaceSchema
);
expect(result).toEqual(validWorkspace);
expect(mockAxiosInstance.request).toHaveBeenCalledWith({
method: 'GET',
url: '/workspaces/ws1',
data: undefined,
});
});
it('should throw validation error for invalid response data', async () => {
const invalidData = {
id: 123, // Should be string
name: null, // Should be string
};
const mockResponse: AxiosResponse = {
data: invalidData,
status: 200,
statusText: 'OK',
headers: {},
config: { headers: {} as any },
};
mockAxiosInstance.request.mockResolvedValue(mockResponse);
await expect(
client.requestWithValidation('GET', '/workspaces/ws1', TallyWorkspaceSchema)
).rejects.toThrow();
});
});
});
});