import { NodeTestHarness } from '@nodes-testing/node-test-harness'; import { NodeApiError, NodeOperationError } from 'n8n-workflow'; import nock from 'nock'; import * as utilities from '../../../../utils/utilities'; import { Github } from '../../Github.node'; describe('Test Github Node', () => { describe('Workflow Dispatch', () => { const now = 1683028800000; const owner = 'testOwner'; const repository = 'testRepository'; const workflowId = 147025216; const usersResponse = { total_count: 12, items: [ { login: 'testOwner', id: 1, }, ], }; const repositoriesResponse = { total_count: 40, items: [ { id: 3081286, name: 'testRepository', }, ], }; const workflowsResponse = { total_count: 2, workflows: [ { id: workflowId, node_id: 'MDg6V29ya2Zsb3cxNjEzMzU=', name: 'CI', path: '.github/workflows/blank.yaml', state: 'active', created_at: '2020-01-08T23:48:37.000-08:00', updated_at: '2020-01-08T23:50:21.000-08:00', url: 'https://api.github.com/repos/octo-org/octo-repo/actions/workflows/161335', html_url: 'https://github.com/octo-org/octo-repo/blob/master/.github/workflows/161335', badge_url: 'https://github.com/octo-org/octo-repo/workflows/CI/badge.svg', }, { id: 269289, node_id: 'MDE4OldvcmtmbG93IFNlY29uZGFyeTI2OTI4OQ==', name: 'Linter', path: '.github/workflows/linter.yaml', state: 'active', created_at: '2020-01-08T23:48:37.000-08:00', updated_at: '2020-01-08T23:50:21.000-08:00', url: 'https://api.github.com/repos/octo-org/octo-repo/actions/workflows/269289', html_url: 'https://github.com/octo-org/octo-repo/blob/master/.github/workflows/269289', badge_url: 'https://github.com/octo-org/octo-repo/workflows/Linter/badge.svg', }, ], }; beforeAll(async () => { jest.useFakeTimers({ doNotFake: ['nextTick'], now }); }); describe('removeTrailingSlash Function', () => { let githubNode: Github; let mockExecutionContext: any; beforeEach(() => { githubNode = new Github(); mockExecutionContext = { getNode: jest.fn().mockReturnValue({ name: 'Github' }), getNodeParameter: jest.fn(), getInputData: jest.fn().mockReturnValue([{ json: {} }]), continueOnFail: jest.fn().mockReturnValue(false), getCredentials: jest.fn().mockResolvedValue({ server: 'https://api.github.com', user: 'test', accessToken: 'test', }), helpers: { returnJsonArray: jest.fn().mockReturnValue([{ json: {} }]), requestWithAuthentication: jest.fn().mockResolvedValue({}), constructExecutionMetaData: jest.fn().mockReturnValue([{ json: {} }]), }, }; jest.spyOn(utilities, 'removeTrailingSlash'); jest.mock('../../../../utils/utilities', () => ({ ...jest.requireActual('../../../../utils/utilities'), getFileSha: jest.fn().mockResolvedValue('mockedSHA'), })); }); it('should call remove trailing slash', async () => { mockExecutionContext.getNodeParameter.mockImplementation((parameterName: string) => { if (parameterName === 'operation') { return 'list'; } if (parameterName === 'resource') { return 'file'; } if (parameterName === 'filePath') { return 'path/to/file/'; } if (parameterName === 'owner') { return 'me'; } if (parameterName === 'repository') { return 'repo'; } return ''; }); await githubNode.execute.call(mockExecutionContext); expect(utilities.removeTrailingSlash).toHaveBeenCalledWith('path/to/file/'); expect(mockExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith( 'githubOAuth2Api', { body: {}, headers: { 'User-Agent': 'n8n' }, json: true, method: 'GET', qs: {}, uri: 'https://api.github.com/repos/me/repo/contents/path%2Fto%2Ffile', }, ); }); }); beforeEach(async () => { const baseUrl = 'https://api.github.com'; nock(baseUrl) .persist() .defaultReplyHeaders({ 'Content-Type': 'application/json' }) .get('/search/users') .query(true) .reply(200, usersResponse) .get('/search/repositories') .query(true) .reply(200, repositoriesResponse) .get(`/repos/${owner}/${repository}/actions/workflows`) .reply(200, workflowsResponse) .post(`/repos/${owner}/${repository}/actions/workflows/${workflowId}/dispatches`, { ref: 'main', inputs: {}, }) .reply(200, {}); }); new NodeTestHarness().setupTests({ workflowFiles: ['GithubTestWorkflow.json'], }); }); describe('Error Handling', () => { let githubNode: Github; let mockExecutionContext: any; beforeEach(() => { githubNode = new Github(); mockExecutionContext = { getNode: jest.fn().mockReturnValue({ name: 'Github' }), getNodeParameter: jest.fn(), getInputData: jest.fn().mockReturnValue([{ json: {} }]), continueOnFail: jest.fn().mockReturnValue(false), putExecutionToWait: jest.fn(), getCredentials: jest.fn().mockResolvedValue({ server: 'https://api.github.com', user: 'test', accessToken: 'test', }), helpers: { returnJsonArray: jest.fn().mockReturnValue([{ json: {} }]), httpRequest: jest.fn(), httpRequestWithAuthentication: jest.fn(), requestWithAuthentication: jest .fn() .mockImplementation(async (_credentialType, options) => { if (options.uri.includes('dispatches') && options.method === 'POST') { const error: any = new Error('Not Found'); error.statusCode = 404; error.message = 'Not Found'; throw error; } return {}; }), request: jest.fn(), constructExecutionMetaData: jest.fn().mockReturnValue([{ json: {} }]), assertBinaryData: jest.fn(), prepareBinaryData: jest.fn(), }, getWorkflowDataProxy: jest.fn().mockReturnValue({ $execution: { resumeUrl: 'https://example.com/webhook', }, }), }; }); it('should throw NodeOperationError for invalid JSON inputs', async () => { mockExecutionContext.getNodeParameter.mockImplementation((parameterName: string) => { if (parameterName === 'inputs') { return 'invalid json'; } if (parameterName === 'resource') { return 'workflow'; } if (parameterName === 'operation') { return 'dispatchAndWait'; } if (parameterName === 'authentication') { return 'accessToken'; } return ''; }); await expect(async () => { await githubNode.execute.call(mockExecutionContext); }).rejects.toThrow(NodeOperationError); }); it('should throw NodeOperationError for 404 errors when dispatching a workflow', async () => { const owner = 'testOwner'; const repository = 'testRepository'; const workflowId = 147025216; mockExecutionContext.helpers.requestWithAuthentication.mockRejectedValueOnce({ statusCode: 404, message: 'Not Found', }); mockExecutionContext.getNodeParameter.mockImplementation((parameterName: string) => { if (parameterName === 'owner') { return owner; } if (parameterName === 'repository') { return repository; } if (parameterName === 'workflowId') { return workflowId; } if (parameterName === 'inputs') { return '{}'; } if (parameterName === 'ref') { return 'main'; } if (parameterName === 'resource') { return 'workflow'; } if (parameterName === 'operation') { return 'dispatchAndWait'; } if (parameterName === 'authentication') { return 'accessToken'; } return ''; }); await expect(async () => { await githubNode.execute.call(mockExecutionContext); }).rejects.toThrow(/The workflow to dispatch could not be found/); }); it('should throw NodeApiError for general API errors', async () => { const owner = 'testOwner'; const repository = 'testRepository'; const workflowId = 147025216; mockExecutionContext.helpers.requestWithAuthentication.mockRejectedValueOnce({ statusCode: 500, message: 'Internal Server Error', }); mockExecutionContext.getNodeParameter.mockImplementation((parameterName: string) => { if (parameterName === 'owner') { return owner; } if (parameterName === 'repository') { return repository; } if (parameterName === 'workflowId') { return workflowId; } if (parameterName === 'inputs') { return '{}'; } if (parameterName === 'ref') { return 'main'; } if (parameterName === 'resource') { return 'workflow'; } if (parameterName === 'operation') { return 'dispatch'; } if (parameterName === 'authentication') { return 'accessToken'; } return ''; }); await expect(async () => { await githubNode.execute.call(mockExecutionContext); }).rejects.toThrow(); }); it('should throw NodeApiError for general API errors in dispatchAndWait operation', async () => { const owner = 'testOwner'; const repository = 'testRepository'; const workflowId = 147025216; mockExecutionContext.getWorkflowDataProxy = jest.fn().mockReturnValue({ $execution: { resumeUrl: 'https://example.com/webhook', }, }); mockExecutionContext.helpers.requestWithAuthentication.mockRejectedValueOnce({ statusCode: 500, message: 'Internal Server Error', }); mockExecutionContext.getNodeParameter.mockImplementation((parameterName: string) => { if (parameterName === 'owner') { return owner; } if (parameterName === 'repository') { return repository; } if (parameterName === 'workflowId') { return workflowId; } if (parameterName === 'inputs') { return '{}'; } if (parameterName === 'ref') { return 'main'; } if (parameterName === 'resource') { return 'workflow'; } if (parameterName === 'operation') { return 'dispatchAndWait'; } if (parameterName === 'authentication') { return 'accessToken'; } return ''; }); await expect(async () => { await githubNode.execute.call(mockExecutionContext); }).rejects.toThrow(NodeApiError); expect(mockExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ method: 'POST', uri: expect.stringContaining( `/repos/${owner}/${repository}/actions/workflows/${workflowId}/dispatches`, ), body: expect.objectContaining({ ref: 'main', inputs: expect.objectContaining({ resumeUrl: 'https://example.com/webhook', }), }), }), ); }); }); describe('Workflow Operations', () => { let githubNode: Github; let mockExecutionContext: any; beforeEach(() => { githubNode = new Github(); mockExecutionContext = { getNode: jest.fn().mockReturnValue({ name: 'Github' }), getNodeParameter: jest.fn(), getInputData: jest.fn().mockReturnValue([{ json: {} }]), continueOnFail: jest.fn().mockReturnValue(false), getCredentials: jest.fn().mockResolvedValue({ server: 'https://api.github.com', user: 'test', accessToken: 'test', }), helpers: { returnJsonArray: jest.fn().mockReturnValue([{ json: {} }]), requestWithAuthentication: jest.fn().mockResolvedValue({}), constructExecutionMetaData: jest.fn().mockReturnValue([{ json: {} }]), }, }; }); it('should use extractValue for workflowId in disable operation', async () => { const owner = 'testOwner'; const repository = 'testRepository'; const workflowId = 147025216; mockExecutionContext.getNodeParameter.mockImplementation( (parameterName: string, _itemIndex: number, defaultValue: string, options?: any) => { if (parameterName === 'owner') { return owner; } if (parameterName === 'repository') { return repository; } if (parameterName === 'workflowId') { expect(options).toBeDefined(); expect(options.extractValue).toBe(true); return workflowId; } if (parameterName === 'resource') { return 'workflow'; } if (parameterName === 'operation') { return 'disable'; } if (parameterName === 'authentication') { return 'accessToken'; } return defaultValue; }, ); await githubNode.execute.call(mockExecutionContext); expect(mockExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ method: 'PUT', uri: `https://api.github.com/repos/${owner}/${repository}/actions/workflows/${workflowId}/disable`, }), ); }); it('should use extractValue for workflowId in enable operation', async () => { const owner = 'testOwner'; const repository = 'testRepository'; const workflowId = 147025216; mockExecutionContext.getNodeParameter.mockImplementation( (parameterName: string, _itemIndex: number, defaultValue: string, options?: any) => { if (parameterName === 'owner') { return owner; } if (parameterName === 'repository') { return repository; } if (parameterName === 'workflowId') { expect(options).toBeDefined(); expect(options.extractValue).toBe(true); return workflowId; } if (parameterName === 'resource') { return 'workflow'; } if (parameterName === 'operation') { return 'enable'; } if (parameterName === 'authentication') { return 'accessToken'; } return defaultValue; }, ); await githubNode.execute.call(mockExecutionContext); expect(mockExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ method: 'PUT', uri: `https://api.github.com/repos/${owner}/${repository}/actions/workflows/${workflowId}/enable`, }), ); }); it('should use extractValue for workflowId in get operation', async () => { const owner = 'testOwner'; const repository = 'testRepository'; const workflowId = 147025216; mockExecutionContext.getNodeParameter.mockImplementation( (parameterName: string, _itemIndex: number, defaultValue: string, options?: any) => { if (parameterName === 'owner') { return owner; } if (parameterName === 'repository') { return repository; } if (parameterName === 'workflowId') { expect(options).toBeDefined(); expect(options.extractValue).toBe(true); return workflowId; } if (parameterName === 'resource') { return 'workflow'; } if (parameterName === 'operation') { return 'get'; } if (parameterName === 'authentication') { return 'accessToken'; } return defaultValue; }, ); await githubNode.execute.call(mockExecutionContext); expect(mockExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ method: 'GET', uri: `https://api.github.com/repos/${owner}/${repository}/actions/workflows/${workflowId}`, }), ); }); it('should use extractValue for workflowId in getUsage operation', async () => { const owner = 'testOwner'; const repository = 'testRepository'; const workflowId = 147025216; mockExecutionContext.getNodeParameter.mockImplementation( (parameterName: string, _itemIndex: number, defaultValue: string, options?: any) => { if (parameterName === 'owner') { return owner; } if (parameterName === 'repository') { return repository; } if (parameterName === 'workflowId') { expect(options).toBeDefined(); expect(options.extractValue).toBe(true); return workflowId; } if (parameterName === 'resource') { return 'workflow'; } if (parameterName === 'operation') { return 'getUsage'; } if (parameterName === 'authentication') { return 'accessToken'; } return defaultValue; }, ); await githubNode.execute.call(mockExecutionContext); expect(mockExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ method: 'GET', uri: `https://api.github.com/repos/${owner}/${repository}/actions/workflows/${workflowId}/timing`, }), ); }); }); describe('Parameter Extraction', () => { it('should use extractValue for workflowId parameter', () => { const githubNode = new Github(); const description = githubNode.description; const workflowIdParam = description.properties.find((prop) => prop.name === 'workflowId'); expect(workflowIdParam).toBeDefined(); expect(workflowIdParam?.type).toBe('resourceLocator'); const workflowOperations = description.properties.find( (prop) => prop.name === 'operation' && prop.displayOptions?.show?.resource?.includes('workflow'), ); expect(workflowOperations).toBeDefined(); expect(workflowOperations?.options).toEqual( expect.arrayContaining([ expect.objectContaining({ value: 'disable' }), expect.objectContaining({ value: 'dispatch' }), expect.objectContaining({ value: 'enable' }), expect.objectContaining({ value: 'get' }), expect.objectContaining({ value: 'getUsage' }), ]), ); }); }); });