Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(Linear Node): Fix issue with error handling #12191

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions packages/nodes-base/nodes/Linear/GenericFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import type {
ILoadOptionsFunctions,
IHookFunctions,
IWebhookFunctions,
JsonObject,
IRequestOptions,
IHttpRequestOptions,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';

Expand All @@ -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,
},
);
}
}

Expand Down Expand Up @@ -85,7 +103,7 @@ export async function validateCredentials(
): Promise<any> {
const credentials = decryptedCredentials;

const options: IRequestOptions = {
const options: IHttpRequestOptions = {
headers: {
'Content-Type': 'application/json',
Authorization: credentials.apiKey,
Expand All @@ -97,7 +115,7 @@ export async function validateCredentials(
first: 1,
},
},
uri: 'https://api.linear.app/graphql',
url: 'https://api.linear.app/graphql',
json: true,
};

Expand Down
6 changes: 6 additions & 0 deletions packages/nodes-base/nodes/Linear/LinearTrigger.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
135 changes: 135 additions & 0 deletions packages/nodes-base/nodes/Linear/test/GenericFunctions.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading