/** * Unit Tests for GHL API Client * Tests API client configuration, connection, and error handling * Updated 2026-02-09 to match actual implementation */ import { describe, it, expect, beforeEach, jest, afterEach } from '@jest/globals'; import { GHLApiClient } from '../../src/clients/ghl-api-client.js'; // Mock axios jest.mock('axios'); import axios from 'axios'; const mockAxios = axios as jest.Mocked; describe('GHLApiClient', () => { let ghlClient: GHLApiClient; let mockAxiosInstance: any; let mockCreate: jest.Mock; beforeEach(() => { // Reset environment variables process.env.GHL_API_KEY = 'test_api_key_123'; process.env.GHL_BASE_URL = 'https://test.leadconnectorhq.com'; process.env.GHL_LOCATION_ID = 'test_location_123'; mockAxiosInstance = { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn(), patch: jest.fn(), defaults: { headers: {} as any }, interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } } }; mockCreate = jest.fn().mockReturnValue(mockAxiosInstance); (mockAxios as any).create = mockCreate; ghlClient = new GHLApiClient({ accessToken: 'test_api_key_123', baseUrl: 'https://test.leadconnectorhq.com', locationId: 'test_location_123', version: '2021-07-28' }); }); afterEach(() => { jest.clearAllMocks(); }); describe('constructor', () => { it('should initialize with correct axios config including Accept header and timeout', () => { expect(mockCreate).toHaveBeenCalledWith({ baseURL: 'https://test.leadconnectorhq.com', headers: { 'Authorization': 'Bearer test_api_key_123', 'Content-Type': 'application/json', 'Accept': 'application/json', 'Version': '2021-07-28' }, timeout: 30000 }); }); it('should set up request interceptor', () => { expect(mockAxiosInstance.interceptors.request.use).toHaveBeenCalled(); }); it('should set up response interceptor', () => { expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalled(); }); it('should use custom configuration when provided', () => { const customConfig = { accessToken: 'custom_token', baseUrl: 'https://custom.ghl.com', locationId: 'custom_location', version: '2022-01-01' }; new GHLApiClient(customConfig); expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ baseURL: 'https://custom.ghl.com', headers: expect.objectContaining({ 'Authorization': 'Bearer custom_token', 'Version': '2022-01-01' }) }) ); }); }); describe('getConfig', () => { it('should return current configuration', () => { const config = ghlClient.getConfig(); expect(config).toEqual({ accessToken: 'test_api_key_123', baseUrl: 'https://test.leadconnectorhq.com', locationId: 'test_location_123', version: '2021-07-28' }); }); }); describe('updateAccessToken', () => { it('should update access token in config and axios defaults', () => { ghlClient.updateAccessToken('new_token_456'); const config = ghlClient.getConfig(); expect(config.accessToken).toBe('new_token_456'); // Implementation updates defaults.headers directly, not recreating instance expect(mockAxiosInstance.defaults.headers['Authorization']).toBe('Bearer new_token_456'); }); }); describe('testConnection', () => { it('should test connection by calling locations endpoint', async () => { mockAxiosInstance.get.mockResolvedValueOnce({ data: { location: { id: 'test_location_123' } }, status: 200 }); const result = await ghlClient.testConnection(); expect(result.success).toBe(true); expect(result.data).toEqual({ status: 'connected', locationId: 'test_location_123' }); expect(mockAxiosInstance.get).toHaveBeenCalledWith('/locations/test_location_123'); }); it('should throw descriptive error on connection failure', async () => { mockAxiosInstance.get.mockRejectedValueOnce(new Error('Network error')); await expect(ghlClient.testConnection()).rejects.toThrow('GHL API connection test failed'); }); }); describe('Contact API methods', () => { describe('createContact', () => { it('should create contact with locationId injected', async () => { const contactData = { firstName: 'John', lastName: 'Doe', email: 'john@example.com' }; mockAxiosInstance.post.mockResolvedValueOnce({ data: { contact: { id: 'contact_123', ...contactData } } }); const result = await ghlClient.createContact(contactData); expect(result.success).toBe(true); expect(result.data.id).toBe('contact_123'); // Implementation adds locationId to payload expect(mockAxiosInstance.post).toHaveBeenCalledWith('/contacts/', { ...contactData, locationId: 'test_location_123' }); }); it('should propagate API errors', async () => { // The response interceptor transforms axios errors, so they come through as Error objects mockAxiosInstance.post.mockRejectedValueOnce( new Error('GHL API Error (400): Invalid email') ); await expect( ghlClient.createContact({ email: 'invalid' } as any) ).rejects.toThrow('GHL API Error (400): Invalid email'); }); }); describe('getContact', () => { it('should get contact successfully', async () => { mockAxiosInstance.get.mockResolvedValueOnce({ data: { contact: { id: 'contact_123', name: 'John Doe' } } }); const result = await ghlClient.getContact('contact_123'); expect(result.success).toBe(true); expect(result.data.id).toBe('contact_123'); expect(mockAxiosInstance.get).toHaveBeenCalledWith('/contacts/contact_123'); }); }); describe('searchContacts', () => { it('should search contacts via POST to /contacts/search', async () => { mockAxiosInstance.post.mockResolvedValueOnce({ data: { contacts: [{ id: 'contact_123' }], total: 1 } }); const result = await ghlClient.searchContacts({ query: 'John' }); expect(result.success).toBe(true); // searchContacts uses POST, not GET expect(mockAxiosInstance.post).toHaveBeenCalledWith('/contacts/search', expect.objectContaining({ locationId: 'test_location_123', pageLimit: 25, query: 'John' }) ); }); }); }); describe('Conversation API methods', () => { describe('sendSMS', () => { it('should send SMS via sendMessage with conversation headers', async () => { mockAxiosInstance.post.mockResolvedValueOnce({ data: { messageId: 'msg_123', conversationId: 'conv_123' } }); const result = await ghlClient.sendSMS('contact_123', 'Hello World'); expect(result.success).toBe(true); expect(result.data.messageId).toBe('msg_123'); expect(mockAxiosInstance.post).toHaveBeenCalledWith( '/conversations/messages', { type: 'SMS', contactId: 'contact_123', message: 'Hello World', fromNumber: undefined }, { headers: expect.objectContaining({ 'Authorization': 'Bearer test_api_key_123', 'Version': '2021-04-15' })} ); }); it('should send SMS with custom from number', async () => { mockAxiosInstance.post.mockResolvedValueOnce({ data: { messageId: 'msg_123' } }); await ghlClient.sendSMS('contact_123', 'Hello', '+1-555-000-0000'); expect(mockAxiosInstance.post).toHaveBeenCalledWith( '/conversations/messages', { type: 'SMS', contactId: 'contact_123', message: 'Hello', fromNumber: '+1-555-000-0000' }, { headers: expect.any(Object) } ); }); }); describe('sendEmail', () => { it('should send email via sendMessage with conversation headers', async () => { mockAxiosInstance.post.mockResolvedValueOnce({ data: { emailMessageId: 'email_123' } }); const result = await ghlClient.sendEmail('contact_123', 'Test Subject', 'Test body'); expect(result.success).toBe(true); expect(result.data.emailMessageId).toBe('email_123'); expect(mockAxiosInstance.post).toHaveBeenCalledWith( '/conversations/messages', expect.objectContaining({ type: 'Email', contactId: 'contact_123', subject: 'Test Subject', message: 'Test body' }), { headers: expect.objectContaining({ 'Version': '2021-04-15' })} ); }); it('should send email with HTML and options', async () => { mockAxiosInstance.post.mockResolvedValueOnce({ data: { emailMessageId: 'email_123' } }); const options = { emailCc: ['cc@example.com'] }; await ghlClient.sendEmail('contact_123', 'Subject', 'Text', '

HTML

', options); expect(mockAxiosInstance.post).toHaveBeenCalledWith( '/conversations/messages', expect.objectContaining({ type: 'Email', contactId: 'contact_123', subject: 'Subject', message: 'Text', html: '

HTML

', emailCc: ['cc@example.com'] }), { headers: expect.any(Object) } ); }); }); }); describe('Blog API methods', () => { describe('createBlogPost', () => { it('should create blog post with locationId injected', async () => { mockAxiosInstance.post.mockResolvedValueOnce({ data: { data: { _id: 'post_123', title: 'Test Post' } } }); const postData = { title: 'Test Post', blogId: 'blog_123', rawHTML: '

Content

' }; const result = await ghlClient.createBlogPost(postData); expect(result.success).toBe(true); // Implementation posts to /blogs/posts (not /blogs/{blogId}/posts) expect(mockAxiosInstance.post).toHaveBeenCalledWith('/blogs/posts', expect.objectContaining({ title: 'Test Post', blogId: 'blog_123', rawHTML: '

Content

', locationId: 'test_location_123' }) ); }); }); describe('getBlogSites', () => { it('should get blog sites from /blogs/site/all', async () => { mockAxiosInstance.get.mockResolvedValueOnce({ data: { data: [{ _id: 'blog_123', name: 'Test Blog' }] } }); const result = await ghlClient.getBlogSites({ locationId: 'loc_123' }); expect(result.success).toBe(true); // Implementation uses /blogs/site/all expect(mockAxiosInstance.get).toHaveBeenCalledWith('/blogs/site/all', { params: expect.objectContaining({ locationId: 'loc_123' }) }); }); }); }); describe('Error handling', () => { it('should format axios error with response', async () => { // The response interceptor transforms errors before they reach the method // So mock a pre-transformed error (Error object) mockAxiosInstance.get.mockRejectedValueOnce( new Error('GHL API Error (404): Contact not found') ); await expect( ghlClient.getContact('not_found') ).rejects.toThrow('GHL API Error (404): Contact not found'); }); it('should handle errors without response data', async () => { mockAxiosInstance.get.mockRejectedValueOnce( new Error('GHL API Error (500): Internal Server Error') ); await expect( ghlClient.getContact('contact_123') ).rejects.toThrow('GHL API Error (500): Internal Server Error'); }); it('should handle network errors', async () => { mockAxiosInstance.get.mockRejectedValueOnce( new Error('GHL API Error: Network Error') ); await expect( ghlClient.getContact('contact_123') ).rejects.toThrow('GHL API Error: Network Error'); }); }); describe('Request/Response handling', () => { it('should properly format successful responses', async () => { mockAxiosInstance.get.mockResolvedValueOnce({ data: { contact: { id: 'contact_123' } }, status: 200 }); const result = await ghlClient.getContact('contact_123'); expect(result).toEqual({ success: true, data: { id: 'contact_123' } }); }); it('should extract nested data correctly from blog posts', async () => { mockAxiosInstance.post.mockResolvedValueOnce({ data: { data: { blogPost: { _id: 'post_123', title: 'Test' } } } }); const result = await ghlClient.createBlogPost({ title: 'Test', blogId: 'blog_123' } as any); // wrapResponse wraps the full response.data expect(result.success).toBe(true); expect(result.data).toHaveProperty('data'); }); }); });