diff --git a/packages/nodes-base/nodes/Linear/GenericFunctions.ts b/packages/nodes-base/nodes/Linear/GenericFunctions.ts index 4d008f2ac5d87..e1e7790bfcf8e 100644 --- a/packages/nodes-base/nodes/Linear/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Linear/GenericFunctions.ts @@ -6,8 +6,7 @@ import type { ILoadOptionsFunctions, IHookFunctions, IWebhookFunctions, - JsonObject, - IRequestOptions, + IHttpRequestOptions, } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; @@ -24,24 +23,43 @@ export async function linearApiRequest( const endpoint = 'https://api.linear.app/graphql'; const authenticationMethod = this.getNodeParameter('authentication', 0, 'apiToken') as string; - let options: IRequestOptions = { + let options: IHttpRequestOptions = { headers: { 'Content-Type': 'application/json', }, method: 'POST', body, - uri: endpoint, + url: endpoint, json: true, }; options = Object.assign({}, options, option); try { - return await this.helpers.requestWithAuthentication.call( + const response = await this.helpers.httpRequestWithAuthentication.call( this, authenticationMethod === 'apiToken' ? 'linearApi' : 'linearOAuth2Api', options, ); + + if (response.errors) { + throw new NodeApiError(this.getNode(), response.errors, { + message: response.errors[0].message, + }); + } + + return response; } catch (error) { - throw new NodeApiError(this.getNode(), error as JsonObject); + throw new NodeApiError( + this.getNode(), + {}, + { + message: error.errorResponse + ? error.errorResponse[0].message + : error.context.data.errors[0].message, + description: error.errorResponse + ? error.errorResponse[0].extensions.userPresentableMessage + : error.context.data.errors[0].extensions.userPresentableMessage, + }, + ); } } @@ -85,7 +103,7 @@ export async function validateCredentials( ): Promise { const credentials = decryptedCredentials; - const options: IRequestOptions = { + const options: IHttpRequestOptions = { headers: { 'Content-Type': 'application/json', Authorization: credentials.apiKey, @@ -97,7 +115,7 @@ export async function validateCredentials( first: 1, }, }, - uri: 'https://api.linear.app/graphql', + url: 'https://api.linear.app/graphql', json: true, }; diff --git a/packages/nodes-base/nodes/Linear/LinearTrigger.node.ts b/packages/nodes-base/nodes/Linear/LinearTrigger.node.ts index ea95e4879e405..dcbbed2ce2947 100644 --- a/packages/nodes-base/nodes/Linear/LinearTrigger.node.ts +++ b/packages/nodes-base/nodes/Linear/LinearTrigger.node.ts @@ -71,6 +71,12 @@ export class LinearTrigger implements INodeType { ], default: 'apiToken', }, + { + displayName: 'Make sure your credential has the "Admin" scope to create webhooks.', + name: 'notice', + type: 'notice', + default: '', + }, { displayName: 'Team Name or ID', name: 'teamId', diff --git a/packages/nodes-base/nodes/Linear/test/GenericFunctions.test.ts b/packages/nodes-base/nodes/Linear/test/GenericFunctions.test.ts new file mode 100644 index 0000000000000..b49b59133a3d9 --- /dev/null +++ b/packages/nodes-base/nodes/Linear/test/GenericFunctions.test.ts @@ -0,0 +1,135 @@ +import type { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IWebhookFunctions, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +import { capitalizeFirstLetter, linearApiRequest, sort } from '../GenericFunctions'; + +describe('Linear -> GenericFunctions', () => { + const mockHttpRequestWithAuthentication = jest.fn(); + + describe('linearApiRequest', () => { + let mockExecuteFunctions: + | IExecuteFunctions + | IWebhookFunctions + | IHookFunctions + | ILoadOptionsFunctions; + + const setupMockFunctions = (authentication: string) => { + mockExecuteFunctions = { + getNodeParameter: jest.fn().mockReturnValue(authentication), + helpers: { + httpRequestWithAuthentication: mockHttpRequestWithAuthentication, + }, + getNode: jest.fn().mockReturnValue({}), + } as unknown as + | IExecuteFunctions + | IWebhookFunctions + | IHookFunctions + | ILoadOptionsFunctions; + jest.clearAllMocks(); + }; + + beforeEach(() => { + setupMockFunctions('apiToken'); + }); + + it('should make a successful API request', async () => { + const response = { data: { success: true } }; + + mockHttpRequestWithAuthentication.mockResolvedValue(response); + + const result = await linearApiRequest.call(mockExecuteFunctions, { + query: '{ viewer { id } }', + }); + + expect(result).toEqual(response); + expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'linearApi', + expect.objectContaining({ + method: 'POST', + url: 'https://api.linear.app/graphql', + json: true, + body: { query: '{ viewer { id } }' }, + }), + ); + }); + + it('should handle API request errors', async () => { + const errorResponse = { + errors: [ + { + message: 'Access denied', + extensions: { + userPresentableMessage: 'You need to have the "Admin" scope to create webhooks.', + }, + }, + ], + }; + + mockHttpRequestWithAuthentication.mockResolvedValue(errorResponse); + + await expect( + linearApiRequest.call(mockExecuteFunctions, { query: '{ viewer { id } }' }), + ).rejects.toThrow(NodeApiError); + + expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'linearApi', + expect.objectContaining({ + method: 'POST', + url: 'https://api.linear.app/graphql', + json: true, + body: { query: '{ viewer { id } }' }, + }), + ); + }); + }); + + describe('capitalizeFirstLetter', () => { + it('should capitalize the first letter of a string', () => { + expect(capitalizeFirstLetter('hello')).toBe('Hello'); + expect(capitalizeFirstLetter('world')).toBe('World'); + expect(capitalizeFirstLetter('capitalize')).toBe('Capitalize'); + }); + + it('should return an empty string if input is empty', () => { + expect(capitalizeFirstLetter('')).toBe(''); + }); + + it('should handle single character strings', () => { + expect(capitalizeFirstLetter('a')).toBe('A'); + expect(capitalizeFirstLetter('b')).toBe('B'); + }); + + it('should not change the case of the rest of the string', () => { + expect(capitalizeFirstLetter('hELLO')).toBe('HELLO'); + expect(capitalizeFirstLetter('wORLD')).toBe('WORLD'); + }); + }); + + describe('sort', () => { + it('should sort objects by name in ascending order', () => { + const array = [{ name: 'banana' }, { name: 'apple' }, { name: 'cherry' }]; + + const sortedArray = array.sort(sort); + + expect(sortedArray).toEqual([{ name: 'apple' }, { name: 'banana' }, { name: 'cherry' }]); + }); + + it('should handle case insensitivity', () => { + const array = [{ name: 'Banana' }, { name: 'apple' }, { name: 'cherry' }]; + + const sortedArray = array.sort(sort); + + expect(sortedArray).toEqual([{ name: 'apple' }, { name: 'Banana' }, { name: 'cherry' }]); + }); + + it('should return 0 for objects with the same name', () => { + const result = sort({ name: 'apple' }, { name: 'apple' }); + expect(result).toBe(0); + }); + }); +});