mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
refactor(Peekalink Node): Stricter typing for Peekalink api call + Tests (no-changelog) (#8125)
This PR is an example for how we can 1. improve typing and remove boilerplate code in may of our nodes 2. use nock to write effective unit tests for nodes that make external calls ## Review / Merge checklist - [x] PR title and summary are descriptive - [x] Add tests
This commit is contained in:
parent
21788d9153
commit
1d2666b37c
|
@ -151,5 +151,11 @@ module.exports = {
|
||||||
'n8n-nodes-base/node-param-type-options-password-missing': 'error',
|
'n8n-nodes-base/node-param-type-options-password-missing': 'error',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.test.ts', '**/test/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'import/no-extraneous-dependencies': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,41 +0,0 @@
|
||||||
import type { OptionsWithUri } from 'request';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
JsonObject,
|
|
||||||
IExecuteFunctions,
|
|
||||||
IHookFunctions,
|
|
||||||
ILoadOptionsFunctions,
|
|
||||||
IDataObject,
|
|
||||||
} from 'n8n-workflow';
|
|
||||||
import { NodeApiError } from 'n8n-workflow';
|
|
||||||
|
|
||||||
export async function peekalinkApiRequest(
|
|
||||||
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
|
|
||||||
method: string,
|
|
||||||
resource: string,
|
|
||||||
|
|
||||||
body: any = {},
|
|
||||||
qs: IDataObject = {},
|
|
||||||
uri?: string,
|
|
||||||
option: IDataObject = {},
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const credentials = await this.getCredentials('peekalinkApi');
|
|
||||||
let options: OptionsWithUri = {
|
|
||||||
headers: {
|
|
||||||
'X-API-Key': credentials.apiKey,
|
|
||||||
},
|
|
||||||
method,
|
|
||||||
qs,
|
|
||||||
body,
|
|
||||||
uri: uri || `https://api.peekalink.io${resource}`,
|
|
||||||
json: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
options = Object.assign({}, options, option);
|
|
||||||
|
|
||||||
return await this.helpers.request(options);
|
|
||||||
} catch (error) {
|
|
||||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,14 +1,17 @@
|
||||||
import type {
|
import {
|
||||||
IExecuteFunctions,
|
Node,
|
||||||
IDataObject,
|
NodeApiError,
|
||||||
INodeExecutionData,
|
type IExecuteFunctions,
|
||||||
INodeType,
|
type INodeExecutionData,
|
||||||
INodeTypeDescription,
|
type INodeTypeDescription,
|
||||||
|
type JsonObject,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { peekalinkApiRequest } from './GenericFunctions';
|
export const apiUrl = 'https://api.peekalink.io';
|
||||||
|
|
||||||
export class Peekalink implements INodeType {
|
type Operation = 'preview' | 'isAvailable';
|
||||||
|
|
||||||
|
export class Peekalink extends Node {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
displayName: 'Peekalink',
|
displayName: 'Peekalink',
|
||||||
name: 'peekalink',
|
name: 'peekalink',
|
||||||
|
@ -61,44 +64,31 @@ export class Peekalink implements INodeType {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
async execute(context: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||||
const items = this.getInputData();
|
const items = context.getInputData();
|
||||||
const returnData: IDataObject[] = [];
|
const operation = context.getNodeParameter('operation', 0) as Operation;
|
||||||
const length = items.length;
|
const credentials = await context.getCredentials('peekalinkApi');
|
||||||
let responseData;
|
|
||||||
const operation = this.getNodeParameter('operation', 0);
|
|
||||||
|
|
||||||
for (let i = 0; i < length; i++) {
|
const returnData = await Promise.all(
|
||||||
try {
|
items.map(async (_, i) => {
|
||||||
if (operation === 'isAvailable') {
|
try {
|
||||||
const url = this.getNodeParameter('url', i) as string;
|
const link = context.getNodeParameter('url', i) as string;
|
||||||
const body: IDataObject = {
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
link: url,
|
return await context.helpers.request({
|
||||||
};
|
method: 'POST',
|
||||||
|
uri: operation === 'preview' ? apiUrl : `${apiUrl}/is-available/`,
|
||||||
responseData = await peekalinkApiRequest.call(this, 'POST', '/is-available/', body);
|
body: { link },
|
||||||
|
headers: { 'X-API-Key': credentials.apiKey },
|
||||||
|
json: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (context.continueOnFail()) {
|
||||||
|
return { error: error.message };
|
||||||
|
}
|
||||||
|
throw new NodeApiError(context.getNode(), error as JsonObject);
|
||||||
}
|
}
|
||||||
if (operation === 'preview') {
|
}),
|
||||||
const url = this.getNodeParameter('url', i) as string;
|
);
|
||||||
const body: IDataObject = {
|
return [context.helpers.returnJsonArray(returnData)];
|
||||||
link: url,
|
|
||||||
};
|
|
||||||
|
|
||||||
responseData = await peekalinkApiRequest.call(this, 'POST', '/', body);
|
|
||||||
}
|
|
||||||
if (Array.isArray(responseData)) {
|
|
||||||
returnData.push.apply(returnData, responseData as IDataObject[]);
|
|
||||||
} else {
|
|
||||||
returnData.push(responseData as IDataObject);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (this.continueOnFail()) {
|
|
||||||
returnData.push({ error: error.message });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [this.helpers.returnJsonArray(returnData)];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
171
packages/nodes-base/nodes/Peekalink/test/Peekalink.node.test.ts
Normal file
171
packages/nodes-base/nodes/Peekalink/test/Peekalink.node.test.ts
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
import { apiUrl } from '../Peekalink.node';
|
||||||
|
import type { WorkflowTestData } from '@test/nodes/types';
|
||||||
|
import { executeWorkflow } from '@test/nodes/ExecuteWorkflow';
|
||||||
|
import * as Helpers from '@test/nodes/Helpers';
|
||||||
|
|
||||||
|
describe('Peekalink Node', () => {
|
||||||
|
const exampleComPreview = {
|
||||||
|
url: 'https://example.com/',
|
||||||
|
domain: 'example.com',
|
||||||
|
lastUpdated: '2022-11-13T22:43:20.986744Z',
|
||||||
|
nextUpdate: '2022-11-20T22:43:20.982384Z',
|
||||||
|
contentType: 'html',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
size: 648,
|
||||||
|
redirected: false,
|
||||||
|
title: 'Example Domain',
|
||||||
|
description: 'This domain is for use in illustrative examples in documents',
|
||||||
|
name: 'EXAMPLE.COM',
|
||||||
|
trackersDetected: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tests: WorkflowTestData[] = [
|
||||||
|
{
|
||||||
|
description: 'should run isAvailable operation',
|
||||||
|
input: {
|
||||||
|
workflowData: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
parameters: {},
|
||||||
|
id: '8b7bb389-e4ef-424a-bca1-e7ead60e43eb',
|
||||||
|
name: 'When clicking "Execute Workflow"',
|
||||||
|
type: 'n8n-nodes-base.manualTrigger',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [740, 380],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
operation: 'isAvailable',
|
||||||
|
url: 'https://example.com/',
|
||||||
|
},
|
||||||
|
id: '7354367e-39a7-4fc1-8cdd-442f0b0c7b62',
|
||||||
|
name: 'Peekalink',
|
||||||
|
type: 'n8n-nodes-base.peekalink',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [960, 380],
|
||||||
|
credentials: {
|
||||||
|
peekalinkApi: 'token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'When clicking "Execute Workflow"': {
|
||||||
|
main: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
node: 'Peekalink',
|
||||||
|
type: 'main',
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
nodeExecutionOrder: ['Start'],
|
||||||
|
nodeData: {
|
||||||
|
Peekalink: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
isAvailable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nock: {
|
||||||
|
baseUrl: apiUrl,
|
||||||
|
mocks: [
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
path: '/is-available/',
|
||||||
|
statusCode: 200,
|
||||||
|
responseBody: { isAvailable: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'should run preview operation',
|
||||||
|
input: {
|
||||||
|
workflowData: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
parameters: {},
|
||||||
|
id: '8b7bb389-e4ef-424a-bca1-e7ead60e43eb',
|
||||||
|
name: 'When clicking "Execute Workflow"',
|
||||||
|
type: 'n8n-nodes-base.manualTrigger',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [740, 380],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
operation: 'preview',
|
||||||
|
url: 'https://example.com/',
|
||||||
|
},
|
||||||
|
id: '7354367e-39a7-4fc1-8cdd-442f0b0c7b62',
|
||||||
|
name: 'Peekalink',
|
||||||
|
type: 'n8n-nodes-base.peekalink',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [960, 380],
|
||||||
|
credentials: {
|
||||||
|
peekalinkApi: 'token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'When clicking "Execute Workflow"': {
|
||||||
|
main: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
node: 'Peekalink',
|
||||||
|
type: 'main',
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
nodeExecutionOrder: ['Start'],
|
||||||
|
nodeData: {
|
||||||
|
Peekalink: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
json: exampleComPreview,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nock: {
|
||||||
|
baseUrl: apiUrl,
|
||||||
|
mocks: [
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
path: '/',
|
||||||
|
statusCode: 200,
|
||||||
|
responseBody: exampleComPreview,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const nodeTypes = Helpers.setup(tests);
|
||||||
|
|
||||||
|
test.each(tests)('$description', async (testData) => {
|
||||||
|
const { result } = await executeWorkflow(testData, nodeTypes);
|
||||||
|
const resultNodeData = Helpers.getResultNodeData(result, testData);
|
||||||
|
resultNodeData.forEach(({ nodeName, resultData }) =>
|
||||||
|
expect(resultData).toEqual(testData.output.nodeData[nodeName]),
|
||||||
|
);
|
||||||
|
expect(result.finished).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,3 +1,4 @@
|
||||||
|
import nock from 'nock';
|
||||||
import { WorkflowExecute } from 'n8n-core';
|
import { WorkflowExecute } from 'n8n-core';
|
||||||
import type { INodeTypes, IRun, IRunExecutionData } from 'n8n-workflow';
|
import type { INodeTypes, IRun, IRunExecutionData } from 'n8n-workflow';
|
||||||
import { createDeferredPromise, Workflow } from 'n8n-workflow';
|
import { createDeferredPromise, Workflow } from 'n8n-workflow';
|
||||||
|
@ -5,6 +6,13 @@ import * as Helpers from './Helpers';
|
||||||
import type { WorkflowTestData } from './types';
|
import type { WorkflowTestData } from './types';
|
||||||
|
|
||||||
export async function executeWorkflow(testData: WorkflowTestData, nodeTypes: INodeTypes) {
|
export async function executeWorkflow(testData: WorkflowTestData, nodeTypes: INodeTypes) {
|
||||||
|
if (testData.nock) {
|
||||||
|
const { baseUrl, mocks } = testData.nock;
|
||||||
|
const agent = nock(baseUrl);
|
||||||
|
mocks.forEach(({ method, path, statusCode, responseBody }) =>
|
||||||
|
agent[method](path).reply(statusCode, responseBody),
|
||||||
|
);
|
||||||
|
}
|
||||||
const executionMode = testData.trigger?.mode ?? 'manual';
|
const executionMode = testData.trigger?.mode ?? 'manual';
|
||||||
const workflowInstance = new Workflow({
|
const workflowInstance = new Workflow({
|
||||||
id: 'test',
|
id: 'test',
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { readFileSync, readdirSync, mkdtempSync } from 'fs';
|
import { readFileSync, readdirSync, mkdtempSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
|
import nock from 'nock';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
import { get } from 'lodash';
|
import { get } from 'lodash';
|
||||||
import { BinaryDataService, Credentials, constructExecutionMetaData } from 'n8n-core';
|
import { BinaryDataService, Credentials, constructExecutionMetaData } from 'n8n-core';
|
||||||
|
@ -231,6 +232,16 @@ export function setup(testData: WorkflowTestData[] | WorkflowTestData) {
|
||||||
testData = [testData];
|
testData = [testData];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (testData.some((t) => !!t.nock)) {
|
||||||
|
beforeAll(() => {
|
||||||
|
nock.disableNetConnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
nock.restore();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const nodeTypes = new NodeTypes();
|
const nodeTypes = new NodeTypes();
|
||||||
|
|
||||||
const nodes = [...new Set(testData.flatMap((data) => data.input.workflowData.nodes))];
|
const nodes = [...new Set(testData.flatMap((data) => data.input.workflowData.nodes))];
|
||||||
|
|
Loading…
Reference in a new issue