Skip to main content
Glama

n8n-MCP

by 88-888
n8n-api-client.test.tsโ€ข38.5 kB
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import axios from 'axios'; import { N8nApiClient, N8nApiClientConfig } from '../../../src/services/n8n-api-client'; import { ExecutionStatus } from '../../../src/types/n8n-api'; import { N8nApiError, N8nAuthenticationError, N8nNotFoundError, N8nValidationError, N8nRateLimitError, N8nServerError, } from '../../../src/utils/n8n-errors'; import * as n8nValidation from '../../../src/services/n8n-validation'; import { logger } from '../../../src/utils/logger'; import * as dns from 'dns/promises'; // Mock DNS module for SSRF protection vi.mock('dns/promises', () => ({ lookup: vi.fn(), })); // Mock dependencies vi.mock('axios'); vi.mock('../../../src/utils/logger'); // Mock the validation functions vi.mock('../../../src/services/n8n-validation', () => ({ cleanWorkflowForCreate: vi.fn((workflow) => workflow), cleanWorkflowForUpdate: vi.fn((workflow) => workflow), })); // We don't need to mock n8n-errors since we want the actual error transformation to work describe('N8nApiClient', () => { let client: N8nApiClient; let mockAxiosInstance: any; const defaultConfig: N8nApiClientConfig = { baseUrl: 'https://n8n.example.com', apiKey: 'test-api-key', timeout: 30000, maxRetries: 3, }; // Helper to create a proper axios error const createAxiosError = (config: any) => { const error = new Error(config.message || 'Request failed') as any; error.isAxiosError = true; error.config = {}; if (config.response) { error.response = config.response; } if (config.request) { error.request = config.request; } return error; }; beforeEach(() => { vi.clearAllMocks(); // Mock DNS lookup for SSRF protection vi.mocked(dns.lookup).mockImplementation(async (hostname: any) => { // Simulate real DNS behavior for test URLs if (hostname === 'localhost') { return { address: '127.0.0.1', family: 4 } as any; } // For hostnames that look like IPs, return as-is const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; if (ipv4Regex.test(hostname)) { return { address: hostname, family: 4 } as any; } // For real hostnames (like n8n.example.com), return a public IP return { address: '8.8.8.8', family: 4 } as any; }); // Create mock axios instance mockAxiosInstance = { defaults: { baseURL: 'https://n8n.example.com/api/v1' }, interceptors: { request: { use: vi.fn() }, response: { use: vi.fn((onFulfilled, onRejected) => { // Store the interceptor handlers for later use mockAxiosInstance._responseInterceptor = { onFulfilled, onRejected }; return 0; }) }, }, get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn(), request: vi.fn(), _responseInterceptor: null, }; // Mock axios.create to return our mock instance vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any); vi.mocked(axios.get).mockResolvedValue({ status: 200, data: { status: 'ok' } }); // Helper function to simulate axios error with interceptor mockAxiosInstance.simulateError = async (method: string, errorConfig: any) => { const axiosError = createAxiosError(errorConfig); mockAxiosInstance[method].mockImplementation(async () => { if (mockAxiosInstance._responseInterceptor?.onRejected) { // Pass error through the interceptor and ensure it's properly handled try { // The interceptor returns a rejected promise with the transformed error const transformedError = await mockAxiosInstance._responseInterceptor.onRejected(axiosError); // This shouldn't happen as onRejected should throw return Promise.reject(transformedError); } catch (error) { // This is the expected path - interceptor throws the transformed error return Promise.reject(error); } } return Promise.reject(axiosError); }); }; }); afterEach(() => { vi.clearAllMocks(); }); describe('constructor', () => { it('should create client with default configuration', () => { client = new N8nApiClient(defaultConfig); expect(axios.create).toHaveBeenCalledWith({ baseURL: 'https://n8n.example.com/api/v1', timeout: 30000, headers: { 'X-N8N-API-KEY': 'test-api-key', 'Content-Type': 'application/json', }, }); }); it('should handle baseUrl without /api/v1', () => { client = new N8nApiClient({ ...defaultConfig, baseUrl: 'https://n8n.example.com/', }); expect(axios.create).toHaveBeenCalledWith( expect.objectContaining({ baseURL: 'https://n8n.example.com/api/v1', }) ); }); it('should handle baseUrl with /api/v1', () => { client = new N8nApiClient({ ...defaultConfig, baseUrl: 'https://n8n.example.com/api/v1', }); expect(axios.create).toHaveBeenCalledWith( expect.objectContaining({ baseURL: 'https://n8n.example.com/api/v1', }) ); }); it('should use custom timeout', () => { client = new N8nApiClient({ ...defaultConfig, timeout: 60000, }); expect(axios.create).toHaveBeenCalledWith( expect.objectContaining({ timeout: 60000, }) ); }); it('should setup request and response interceptors', () => { client = new N8nApiClient(defaultConfig); expect(mockAxiosInstance.interceptors.request.use).toHaveBeenCalled(); expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalled(); }); }); describe('healthCheck', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); }); it('should check health using healthz endpoint', async () => { vi.mocked(axios.get).mockResolvedValue({ status: 200, data: { status: 'ok' }, }); const result = await client.healthCheck(); expect(axios.get).toHaveBeenCalledWith( 'https://n8n.example.com/healthz', { timeout: 5000, validateStatus: expect.any(Function), } ); expect(result).toEqual({ status: 'ok', features: {} }); }); it('should fallback to workflow list when healthz fails', async () => { vi.mocked(axios.get).mockRejectedValueOnce(new Error('healthz not found')); mockAxiosInstance.get.mockResolvedValue({ data: [] }); const result = await client.healthCheck(); expect(mockAxiosInstance.get).toHaveBeenCalledWith('/workflows', { params: { limit: 1 } }); expect(result).toEqual({ status: 'ok', features: {} }); }); it('should throw error when both health checks fail', async () => { vi.mocked(axios.get).mockRejectedValueOnce(new Error('healthz not found')); mockAxiosInstance.get.mockRejectedValue(new Error('API error')); await expect(client.healthCheck()).rejects.toThrow(); }); }); describe('createWorkflow', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); }); it('should create workflow successfully', async () => { const workflow = { name: 'Test Workflow', nodes: [], connections: {}, }; const createdWorkflow = { ...workflow, id: '123' }; mockAxiosInstance.post.mockResolvedValue({ data: createdWorkflow }); const result = await client.createWorkflow(workflow); expect(n8nValidation.cleanWorkflowForCreate).toHaveBeenCalledWith(workflow); expect(mockAxiosInstance.post).toHaveBeenCalledWith('/workflows', workflow); expect(result).toEqual(createdWorkflow); }); it('should handle creation error', async () => { const workflow = { name: 'Test', nodes: [], connections: {} }; const error = { message: 'Request failed', response: { status: 400, data: { message: 'Invalid workflow' } } }; await mockAxiosInstance.simulateError('post', error); try { await client.createWorkflow(workflow); expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nValidationError); expect((err as N8nValidationError).message).toBe('Invalid workflow'); expect((err as N8nValidationError).statusCode).toBe(400); } }); }); describe('getWorkflow', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); }); it('should get workflow successfully', async () => { const workflow = { id: '123', name: 'Test', nodes: [], connections: {} }; mockAxiosInstance.get.mockResolvedValue({ data: workflow }); const result = await client.getWorkflow('123'); expect(mockAxiosInstance.get).toHaveBeenCalledWith('/workflows/123'); expect(result).toEqual(workflow); }); it('should handle 404 error', async () => { const error = { message: 'Request failed', response: { status: 404, data: { message: 'Not found' } } }; await mockAxiosInstance.simulateError('get', error); try { await client.getWorkflow('123'); expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nNotFoundError); expect((err as N8nNotFoundError).message).toContain('not found'); expect((err as N8nNotFoundError).statusCode).toBe(404); } }); }); describe('updateWorkflow', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); }); it('should update workflow using PUT method', async () => { const workflow = { name: 'Updated', nodes: [], connections: {} }; const updatedWorkflow = { ...workflow, id: '123' }; mockAxiosInstance.put.mockResolvedValue({ data: updatedWorkflow }); const result = await client.updateWorkflow('123', workflow); expect(n8nValidation.cleanWorkflowForUpdate).toHaveBeenCalledWith(workflow); expect(mockAxiosInstance.put).toHaveBeenCalledWith('/workflows/123', workflow); expect(result).toEqual(updatedWorkflow); }); it('should fallback to PATCH when PUT is not supported', async () => { const workflow = { name: 'Updated', nodes: [], connections: {} }; const updatedWorkflow = { ...workflow, id: '123' }; mockAxiosInstance.put.mockRejectedValue({ response: { status: 405 } }); mockAxiosInstance.patch.mockResolvedValue({ data: updatedWorkflow }); const result = await client.updateWorkflow('123', workflow); expect(mockAxiosInstance.put).toHaveBeenCalled(); expect(mockAxiosInstance.patch).toHaveBeenCalledWith('/workflows/123', workflow); expect(result).toEqual(updatedWorkflow); }); it('should handle update error', async () => { const workflow = { name: 'Updated', nodes: [], connections: {} }; const error = { message: 'Request failed', response: { status: 400, data: { message: 'Invalid update' } } }; await mockAxiosInstance.simulateError('put', error); try { await client.updateWorkflow('123', workflow); expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nValidationError); expect((err as N8nValidationError).message).toBe('Invalid update'); expect((err as N8nValidationError).statusCode).toBe(400); } }); }); describe('deleteWorkflow', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); }); it('should delete workflow successfully', async () => { mockAxiosInstance.delete.mockResolvedValue({ data: {} }); await client.deleteWorkflow('123'); expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/workflows/123'); }); it('should handle deletion error', async () => { const error = { message: 'Request failed', response: { status: 404, data: { message: 'Not found' } } }; await mockAxiosInstance.simulateError('delete', error); try { await client.deleteWorkflow('123'); expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nNotFoundError); expect((err as N8nNotFoundError).message).toContain('not found'); expect((err as N8nNotFoundError).statusCode).toBe(404); } }); }); describe('listWorkflows', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); }); it('should list workflows with default params', async () => { const response = { data: [], nextCursor: null }; mockAxiosInstance.get.mockResolvedValue({ data: response }); const result = await client.listWorkflows(); expect(mockAxiosInstance.get).toHaveBeenCalledWith('/workflows', { params: {} }); expect(result).toEqual(response); }); it('should list workflows with custom params', async () => { const params = { limit: 10, active: true, tags: 'test,production' }; const response = { data: [], nextCursor: null }; mockAxiosInstance.get.mockResolvedValue({ data: response }); const result = await client.listWorkflows(params); expect(mockAxiosInstance.get).toHaveBeenCalledWith('/workflows', { params }); expect(result).toEqual(response); }); }); describe('Response Format Validation (PR #367)', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); }); describe('listWorkflows - validation', () => { it('should handle modern format with data and nextCursor', async () => { const response = { data: [{ id: '1', name: 'Test' }], nextCursor: 'abc123' }; mockAxiosInstance.get.mockResolvedValue({ data: response }); const result = await client.listWorkflows(); expect(result).toEqual(response); expect(result.data).toHaveLength(1); expect(result.nextCursor).toBe('abc123'); }); it('should wrap legacy array format and log warning', async () => { const workflows = [{ id: '1', name: 'Test' }]; mockAxiosInstance.get.mockResolvedValue({ data: workflows }); const result = await client.listWorkflows(); expect(result).toEqual({ data: workflows, nextCursor: null }); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining('n8n API returned array directly') ); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining('workflows') ); }); it('should throw error on null response', async () => { mockAxiosInstance.get.mockResolvedValue({ data: null }); await expect(client.listWorkflows()).rejects.toThrow( 'Invalid response from n8n API for workflows: response is not an object' ); }); it('should throw error on undefined response', async () => { mockAxiosInstance.get.mockResolvedValue({ data: undefined }); await expect(client.listWorkflows()).rejects.toThrow( 'Invalid response from n8n API for workflows: response is not an object' ); }); it('should throw error on string response', async () => { mockAxiosInstance.get.mockResolvedValue({ data: 'invalid' }); await expect(client.listWorkflows()).rejects.toThrow( 'Invalid response from n8n API for workflows: response is not an object' ); }); it('should throw error on number response', async () => { mockAxiosInstance.get.mockResolvedValue({ data: 42 }); await expect(client.listWorkflows()).rejects.toThrow( 'Invalid response from n8n API for workflows: response is not an object' ); }); it('should throw error on invalid structure with different keys', async () => { mockAxiosInstance.get.mockResolvedValue({ data: { items: [], total: 10 } }); await expect(client.listWorkflows()).rejects.toThrow( 'Invalid response from n8n API for workflows: expected {data: [], nextCursor?: string}, got object with keys: [items, total]' ); }); it('should throw error when data is not an array', async () => { mockAxiosInstance.get.mockResolvedValue({ data: { data: 'invalid' } }); await expect(client.listWorkflows()).rejects.toThrow( 'Invalid response from n8n API for workflows: expected {data: [], nextCursor?: string}' ); }); it('should limit exposed keys to first 5 when many keys present', async () => { const manyKeys = { items: [], total: 10, page: 1, limit: 20, hasMore: true, metadata: {} }; mockAxiosInstance.get.mockResolvedValue({ data: manyKeys }); try { await client.listWorkflows(); expect.fail('Should have thrown error'); } catch (error: any) { expect(error.message).toContain('items, total, page, limit, hasMore...'); expect(error.message).not.toContain('metadata'); } }); }); describe('listExecutions - validation', () => { it('should handle modern format with data and nextCursor', async () => { const response = { data: [{ id: '1' }], nextCursor: 'abc123' }; mockAxiosInstance.get.mockResolvedValue({ data: response }); const result = await client.listExecutions(); expect(result).toEqual(response); }); it('should wrap legacy array format and log warning', async () => { const executions = [{ id: '1' }]; mockAxiosInstance.get.mockResolvedValue({ data: executions }); const result = await client.listExecutions(); expect(result).toEqual({ data: executions, nextCursor: null }); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining('executions') ); }); it('should throw error on null response', async () => { mockAxiosInstance.get.mockResolvedValue({ data: null }); await expect(client.listExecutions()).rejects.toThrow( 'Invalid response from n8n API for executions: response is not an object' ); }); it('should throw error on invalid structure', async () => { mockAxiosInstance.get.mockResolvedValue({ data: { items: [] } }); await expect(client.listExecutions()).rejects.toThrow( 'Invalid response from n8n API for executions' ); }); it('should throw error when data is not an array', async () => { mockAxiosInstance.get.mockResolvedValue({ data: { data: 'invalid' } }); await expect(client.listExecutions()).rejects.toThrow( 'Invalid response from n8n API for executions' ); }); }); describe('listCredentials - validation', () => { it('should handle modern format with data and nextCursor', async () => { const response = { data: [{ id: '1' }], nextCursor: 'abc123' }; mockAxiosInstance.get.mockResolvedValue({ data: response }); const result = await client.listCredentials(); expect(result).toEqual(response); }); it('should wrap legacy array format and log warning', async () => { const credentials = [{ id: '1' }]; mockAxiosInstance.get.mockResolvedValue({ data: credentials }); const result = await client.listCredentials(); expect(result).toEqual({ data: credentials, nextCursor: null }); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining('credentials') ); }); it('should throw error on null response', async () => { mockAxiosInstance.get.mockResolvedValue({ data: null }); await expect(client.listCredentials()).rejects.toThrow( 'Invalid response from n8n API for credentials: response is not an object' ); }); it('should throw error on invalid structure', async () => { mockAxiosInstance.get.mockResolvedValue({ data: { items: [] } }); await expect(client.listCredentials()).rejects.toThrow( 'Invalid response from n8n API for credentials' ); }); it('should throw error when data is not an array', async () => { mockAxiosInstance.get.mockResolvedValue({ data: { data: 'invalid' } }); await expect(client.listCredentials()).rejects.toThrow( 'Invalid response from n8n API for credentials' ); }); }); describe('listTags - validation', () => { it('should handle modern format with data and nextCursor', async () => { const response = { data: [{ id: '1' }], nextCursor: 'abc123' }; mockAxiosInstance.get.mockResolvedValue({ data: response }); const result = await client.listTags(); expect(result).toEqual(response); }); it('should wrap legacy array format and log warning', async () => { const tags = [{ id: '1' }]; mockAxiosInstance.get.mockResolvedValue({ data: tags }); const result = await client.listTags(); expect(result).toEqual({ data: tags, nextCursor: null }); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining('tags') ); }); it('should throw error on null response', async () => { mockAxiosInstance.get.mockResolvedValue({ data: null }); await expect(client.listTags()).rejects.toThrow( 'Invalid response from n8n API for tags: response is not an object' ); }); it('should throw error on invalid structure', async () => { mockAxiosInstance.get.mockResolvedValue({ data: { items: [] } }); await expect(client.listTags()).rejects.toThrow( 'Invalid response from n8n API for tags' ); }); it('should throw error when data is not an array', async () => { mockAxiosInstance.get.mockResolvedValue({ data: { data: 'invalid' } }); await expect(client.listTags()).rejects.toThrow( 'Invalid response from n8n API for tags' ); }); }); }); describe('getExecution', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); }); it('should get execution without data', async () => { const execution = { id: '123', status: 'success' }; mockAxiosInstance.get.mockResolvedValue({ data: execution }); const result = await client.getExecution('123'); expect(mockAxiosInstance.get).toHaveBeenCalledWith('/executions/123', { params: { includeData: false }, }); expect(result).toEqual(execution); }); it('should get execution with data', async () => { const execution = { id: '123', status: 'success', data: {} }; mockAxiosInstance.get.mockResolvedValue({ data: execution }); const result = await client.getExecution('123', true); expect(mockAxiosInstance.get).toHaveBeenCalledWith('/executions/123', { params: { includeData: true }, }); expect(result).toEqual(execution); }); }); describe('listExecutions', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); }); it('should list executions with filters', async () => { const params = { workflowId: '123', status: ExecutionStatus.SUCCESS, limit: 50 }; const response = { data: [], nextCursor: null }; mockAxiosInstance.get.mockResolvedValue({ data: response }); const result = await client.listExecutions(params); expect(mockAxiosInstance.get).toHaveBeenCalledWith('/executions', { params }); expect(result).toEqual(response); }); }); describe('deleteExecution', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); }); it('should delete execution successfully', async () => { mockAxiosInstance.delete.mockResolvedValue({ data: {} }); await client.deleteExecution('123'); expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/executions/123'); }); }); describe('triggerWebhook', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); }); it('should trigger webhook with GET method', async () => { const webhookRequest = { webhookUrl: 'https://n8n.example.com/webhook/abc-123', httpMethod: 'GET' as const, data: { key: 'value' }, waitForResponse: true, }; const response = { status: 200, statusText: 'OK', data: { result: 'success' }, headers: {}, }; vi.mocked(axios.create).mockReturnValue({ request: vi.fn().mockResolvedValue(response), } as any); const result = await client.triggerWebhook(webhookRequest); expect(axios.create).toHaveBeenCalledWith({ baseURL: 'https://n8n.example.com/', validateStatus: expect.any(Function), }); expect(result).toEqual(response); }); it('should trigger webhook with POST method', async () => { const webhookRequest = { webhookUrl: 'https://n8n.example.com/webhook/abc-123', httpMethod: 'POST' as const, data: { key: 'value' }, headers: { 'Custom-Header': 'test' }, waitForResponse: false, }; const response = { status: 201, statusText: 'Created', data: { id: '456' }, headers: {}, }; const mockWebhookClient = { request: vi.fn().mockResolvedValue(response), }; vi.mocked(axios.create).mockReturnValue(mockWebhookClient as any); const result = await client.triggerWebhook(webhookRequest); expect(mockWebhookClient.request).toHaveBeenCalledWith({ method: 'POST', url: '/webhook/abc-123', headers: { 'Custom-Header': 'test', 'X-N8N-API-KEY': undefined, }, data: { key: 'value' }, params: undefined, timeout: 30000, }); expect(result).toEqual(response); }); it('should handle webhook trigger error', async () => { const webhookRequest = { webhookUrl: 'https://n8n.example.com/webhook/abc-123', httpMethod: 'POST' as const, data: {}, }; vi.mocked(axios.create).mockReturnValue({ request: vi.fn().mockRejectedValue(new Error('Webhook failed')), } as any); await expect(client.triggerWebhook(webhookRequest)).rejects.toThrow(); }); }); describe('error handling', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); }); it('should handle authentication error (401)', async () => { const error = { message: 'Request failed', response: { status: 401, data: { message: 'Invalid API key' } } }; await mockAxiosInstance.simulateError('get', error); try { await client.getWorkflow('123'); expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nAuthenticationError); expect((err as N8nAuthenticationError).message).toBe('Invalid API key'); expect((err as N8nAuthenticationError).statusCode).toBe(401); } }); it('should handle rate limit error (429)', async () => { const error = { message: 'Request failed', response: { status: 429, data: { message: 'Rate limit exceeded' }, headers: { 'retry-after': '60' } } }; await mockAxiosInstance.simulateError('get', error); try { await client.getWorkflow('123'); expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nRateLimitError); expect((err as N8nRateLimitError).message).toContain('Rate limit exceeded'); expect((err as N8nRateLimitError).statusCode).toBe(429); expect(((err as N8nRateLimitError).details as any)?.retryAfter).toBe(60); } }); it('should handle server error (500)', async () => { const error = { message: 'Request failed', response: { status: 500, data: { message: 'Internal server error' } } }; await mockAxiosInstance.simulateError('get', error); try { await client.getWorkflow('123'); expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nServerError); expect((err as N8nServerError).message).toBe('Internal server error'); expect((err as N8nServerError).statusCode).toBe(500); } }); it('should handle network error', async () => { const error = { message: 'Network error', request: {} }; await mockAxiosInstance.simulateError('get', error); await expect(client.getWorkflow('123')).rejects.toThrow(N8nApiError); }); }); describe('credential management', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); }); it('should list credentials', async () => { const response = { data: [], nextCursor: null }; mockAxiosInstance.get.mockResolvedValue({ data: response }); const result = await client.listCredentials({ limit: 10 }); expect(mockAxiosInstance.get).toHaveBeenCalledWith('/credentials', { params: { limit: 10 } }); expect(result).toEqual(response); }); it('should get credential', async () => { const credential = { id: '123', name: 'Test Credential' }; mockAxiosInstance.get.mockResolvedValue({ data: credential }); const result = await client.getCredential('123'); expect(mockAxiosInstance.get).toHaveBeenCalledWith('/credentials/123'); expect(result).toEqual(credential); }); it('should create credential', async () => { const credential = { name: 'New Credential', type: 'httpHeader' }; const created = { ...credential, id: '123' }; mockAxiosInstance.post.mockResolvedValue({ data: created }); const result = await client.createCredential(credential); expect(mockAxiosInstance.post).toHaveBeenCalledWith('/credentials', credential); expect(result).toEqual(created); }); it('should update credential', async () => { const updates = { name: 'Updated Credential' }; const updated = { id: '123', ...updates }; mockAxiosInstance.patch.mockResolvedValue({ data: updated }); const result = await client.updateCredential('123', updates); expect(mockAxiosInstance.patch).toHaveBeenCalledWith('/credentials/123', updates); expect(result).toEqual(updated); }); it('should delete credential', async () => { mockAxiosInstance.delete.mockResolvedValue({ data: {} }); await client.deleteCredential('123'); expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/credentials/123'); }); }); describe('tag management', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); }); it('should list tags', async () => { const response = { data: [], nextCursor: null }; mockAxiosInstance.get.mockResolvedValue({ data: response }); const result = await client.listTags(); expect(mockAxiosInstance.get).toHaveBeenCalledWith('/tags', { params: {} }); expect(result).toEqual(response); }); it('should create tag', async () => { const tag = { name: 'New Tag' }; const created = { ...tag, id: '123' }; mockAxiosInstance.post.mockResolvedValue({ data: created }); const result = await client.createTag(tag); expect(mockAxiosInstance.post).toHaveBeenCalledWith('/tags', tag); expect(result).toEqual(created); }); it('should update tag', async () => { const updates = { name: 'Updated Tag' }; const updated = { id: '123', ...updates }; mockAxiosInstance.patch.mockResolvedValue({ data: updated }); const result = await client.updateTag('123', updates); expect(mockAxiosInstance.patch).toHaveBeenCalledWith('/tags/123', updates); expect(result).toEqual(updated); }); it('should delete tag', async () => { mockAxiosInstance.delete.mockResolvedValue({ data: {} }); await client.deleteTag('123'); expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/tags/123'); }); }); describe('source control management', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); }); it('should get source control status', async () => { const status = { connected: true, branch: 'main' }; mockAxiosInstance.get.mockResolvedValue({ data: status }); const result = await client.getSourceControlStatus(); expect(mockAxiosInstance.get).toHaveBeenCalledWith('/source-control/status'); expect(result).toEqual(status); }); it('should pull source control changes', async () => { const pullResult = { pulled: 5, conflicts: 0 }; mockAxiosInstance.post.mockResolvedValue({ data: pullResult }); const result = await client.pullSourceControl(true); expect(mockAxiosInstance.post).toHaveBeenCalledWith('/source-control/pull', { force: true }); expect(result).toEqual(pullResult); }); it('should push source control changes', async () => { const pushResult = { pushed: 3 }; mockAxiosInstance.post.mockResolvedValue({ data: pushResult }); const result = await client.pushSourceControl('Update workflows', ['workflow1.json']); expect(mockAxiosInstance.post).toHaveBeenCalledWith('/source-control/push', { message: 'Update workflows', fileNames: ['workflow1.json'], }); expect(result).toEqual(pushResult); }); }); describe('variable management', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); }); it('should get variables', async () => { const variables = [{ id: '1', key: 'VAR1', value: 'value1' }]; mockAxiosInstance.get.mockResolvedValue({ data: { data: variables } }); const result = await client.getVariables(); expect(mockAxiosInstance.get).toHaveBeenCalledWith('/variables'); expect(result).toEqual(variables); }); it('should return empty array when variables API not available', async () => { mockAxiosInstance.get.mockRejectedValue(new Error('Not found')); const result = await client.getVariables(); expect(result).toEqual([]); expect(logger.warn).toHaveBeenCalledWith( 'Variables API not available, returning empty array' ); }); it('should create variable', async () => { const variable = { key: 'NEW_VAR', value: 'new value' }; const created = { ...variable, id: '123' }; mockAxiosInstance.post.mockResolvedValue({ data: created }); const result = await client.createVariable(variable); expect(mockAxiosInstance.post).toHaveBeenCalledWith('/variables', variable); expect(result).toEqual(created); }); it('should update variable', async () => { const updates = { value: 'updated value' }; const updated = { id: '123', key: 'VAR1', ...updates }; mockAxiosInstance.patch.mockResolvedValue({ data: updated }); const result = await client.updateVariable('123', updates); expect(mockAxiosInstance.patch).toHaveBeenCalledWith('/variables/123', updates); expect(result).toEqual(updated); }); it('should delete variable', async () => { mockAxiosInstance.delete.mockResolvedValue({ data: {} }); await client.deleteVariable('123'); expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/variables/123'); }); }); describe('interceptors', () => { let requestInterceptor: any; let responseInterceptor: any; let responseErrorInterceptor: any; beforeEach(() => { // Capture the interceptor functions vi.mocked(mockAxiosInstance.interceptors.request.use).mockImplementation((onFulfilled: any) => { requestInterceptor = onFulfilled; return 0; }); vi.mocked(mockAxiosInstance.interceptors.response.use).mockImplementation((onFulfilled: any, onRejected: any) => { responseInterceptor = onFulfilled; responseErrorInterceptor = onRejected; return 0; }); client = new N8nApiClient(defaultConfig); }); it('should log requests', () => { const config = { method: 'get', url: '/workflows', params: { limit: 10 }, data: undefined, }; const result = requestInterceptor(config); expect(logger.debug).toHaveBeenCalledWith( 'n8n API Request: GET /workflows', { params: { limit: 10 }, data: undefined } ); expect(result).toBe(config); }); it('should log successful responses', () => { const response = { status: 200, config: { url: '/workflows' }, data: [], }; const result = responseInterceptor(response); expect(logger.debug).toHaveBeenCalledWith( 'n8n API Response: 200 /workflows' ); expect(result).toBe(response); }); it('should handle response errors', async () => { const error = new Error('Request failed'); Object.assign(error, { response: { status: 400, data: { message: 'Bad request' }, }, }); const result = await responseErrorInterceptor(error).catch((e: any) => e); expect(result).toBeInstanceOf(N8nValidationError); expect(result.message).toBe('Bad request'); }); }); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/88-888/n8n-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server