refactor: Move community package logic to service (no-changelog) (#6973)

This commit is contained in:
Iván Ovejero
2023-09-01 15:13:19 +02:00
committed by GitHub
parent 2432dcc661
commit 51093f649d
15 changed files with 923 additions and 951 deletions

View File

@@ -1,344 +0,0 @@
import { exec } from 'child_process';
import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises';
import axios from 'axios';
import {
checkNpmPackageStatus,
matchPackagesWithUpdates,
executeCommand,
parseNpmPackageName,
matchMissingPackages,
hasPackageLoaded,
removePackageFromMissingList,
} from '@/CommunityNodes/helpers';
import {
NODE_PACKAGE_PREFIX,
NPM_COMMAND_TOKENS,
NPM_PACKAGE_STATUS_GOOD,
RESPONSE_ERROR_MESSAGES,
} from '@/constants';
import { InstalledPackages } from '@db/entities/InstalledPackages';
import { InstalledNodes } from '@db/entities/InstalledNodes';
import { randomName } from '../integration/shared/random';
import config from '@/config';
import { installedPackagePayload, installedNodePayload } from '../integration/shared/utils/';
import type { CommunityPackages } from '@/Interfaces';
jest.mock('fs/promises');
jest.mock('child_process');
jest.mock('axios');
describe('parsePackageName', () => {
test('Should fail with empty package name', () => {
expect(() => parseNpmPackageName('')).toThrowError();
});
test('Should fail with invalid package prefix name', () => {
expect(() => parseNpmPackageName('INVALID_PREFIX@123')).toThrowError();
});
test('Should parse valid package name', () => {
const validPackageName = NODE_PACKAGE_PREFIX + 'cool-package-name';
const parsed = parseNpmPackageName(validPackageName);
expect(parsed.rawString).toBe(validPackageName);
expect(parsed.packageName).toBe(validPackageName);
expect(parsed.scope).toBeUndefined();
expect(parsed.version).toBeUndefined();
});
test('Should parse valid package name and version', () => {
const validPackageName = NODE_PACKAGE_PREFIX + 'cool-package-name';
const validPackageVersion = '0.1.1';
const fullPackageName = `${validPackageName}@${validPackageVersion}`;
const parsed = parseNpmPackageName(fullPackageName);
expect(parsed.rawString).toBe(fullPackageName);
expect(parsed.packageName).toBe(validPackageName);
expect(parsed.scope).toBeUndefined();
expect(parsed.version).toBe(validPackageVersion);
});
test('Should parse valid package name, scope and version', () => {
const validPackageScope = '@n8n';
const validPackageName = NODE_PACKAGE_PREFIX + 'cool-package-name';
const validPackageVersion = '0.1.1';
const fullPackageName = `${validPackageScope}/${validPackageName}@${validPackageVersion}`;
const parsed = parseNpmPackageName(fullPackageName);
expect(parsed.rawString).toBe(fullPackageName);
expect(parsed.packageName).toBe(`${validPackageScope}/${validPackageName}`);
expect(parsed.scope).toBe(validPackageScope);
expect(parsed.version).toBe(validPackageVersion);
});
});
describe('executeCommand', () => {
beforeEach(() => {
// @ts-ignore
fsAccess.mockReset();
// @ts-ignore
fsMkdir.mockReset();
// @ts-ignore
exec.mockReset();
});
test('Should call command with valid options', async () => {
// @ts-ignore
exec.mockImplementation((...args) => {
expect(args[1].cwd).toBeDefined();
expect(args[1].env).toBeDefined();
// PATH or NODE_PATH may be undefined depending on environment so we don't check for these keys.
const callbackFunction = args[args.length - 1];
callbackFunction(null, { stdout: 'Done' });
});
await executeCommand('ls');
expect(fsAccess).toHaveBeenCalled();
expect(exec).toHaveBeenCalled();
expect(fsMkdir).toBeCalledTimes(0);
});
test('Should make sure folder exists', async () => {
// @ts-ignore
exec.mockImplementation((...args) => {
const callbackFunction = args[args.length - 1];
callbackFunction(null, { stdout: 'Done' });
});
await executeCommand('ls');
expect(fsAccess).toHaveBeenCalled();
expect(exec).toHaveBeenCalled();
expect(fsMkdir).toBeCalledTimes(0);
});
test('Should try to create folder if it does not exist', async () => {
// @ts-ignore
exec.mockImplementation((...args) => {
const callbackFunction = args[args.length - 1];
callbackFunction(null, { stdout: 'Done' });
});
// @ts-ignore
fsAccess.mockImplementation(() => {
throw new Error('Folder does not exist.');
});
await executeCommand('ls');
expect(fsAccess).toHaveBeenCalled();
expect(exec).toHaveBeenCalled();
expect(fsMkdir).toHaveBeenCalled();
});
test('Should throw especial error when package is not found', async () => {
// @ts-ignore
exec.mockImplementation((...args) => {
const callbackFunction = args[args.length - 1];
callbackFunction(
new Error(
'Something went wrong - ' +
NPM_COMMAND_TOKENS.NPM_PACKAGE_NOT_FOUND_ERROR +
'. Aborting.',
),
);
});
await expect(async () => executeCommand('ls')).rejects.toThrow(
RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND,
);
expect(fsAccess).toHaveBeenCalled();
expect(exec).toHaveBeenCalled();
expect(fsMkdir).toHaveBeenCalledTimes(0);
});
});
describe('crossInformationPackage', () => {
test('Should return same list if availableUpdates is undefined', () => {
const fakePackages = generateListOfFakeInstalledPackages();
const crossedData = matchPackagesWithUpdates(fakePackages);
expect(crossedData).toEqual(fakePackages);
});
test('Should correctly match update versions for packages', () => {
const fakePackages = generateListOfFakeInstalledPackages();
const updates: CommunityPackages.AvailableUpdates = {
[fakePackages[0].packageName]: {
current: fakePackages[0].installedVersion,
wanted: fakePackages[0].installedVersion,
latest: '0.2.0',
location: fakePackages[0].packageName,
},
[fakePackages[1].packageName]: {
current: fakePackages[0].installedVersion,
wanted: fakePackages[0].installedVersion,
latest: '0.3.0',
location: fakePackages[0].packageName,
},
};
const crossedData = matchPackagesWithUpdates(fakePackages, updates);
// @ts-ignore
expect(crossedData[0].updateAvailable).toBe('0.2.0');
// @ts-ignore
expect(crossedData[1].updateAvailable).toBe('0.3.0');
});
test('Should correctly match update versions for single package', () => {
const fakePackages = generateListOfFakeInstalledPackages();
const updates: CommunityPackages.AvailableUpdates = {
[fakePackages[1].packageName]: {
current: fakePackages[0].installedVersion,
wanted: fakePackages[0].installedVersion,
latest: '0.3.0',
location: fakePackages[0].packageName,
},
};
const crossedData = matchPackagesWithUpdates(fakePackages, updates);
// @ts-ignore
expect(crossedData[0].updateAvailable).toBeUndefined();
// @ts-ignore
expect(crossedData[1].updateAvailable).toBe('0.3.0');
});
});
describe('matchMissingPackages', () => {
test('Should not match failed packages that do not exist', () => {
const fakePackages = generateListOfFakeInstalledPackages();
const notFoundPackageList = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${NODE_PACKAGE_PREFIX}another-very-long-name-that-never-is-seen`;
const matchedPackages = matchMissingPackages(fakePackages, notFoundPackageList);
expect(matchedPackages).toEqual(fakePackages);
expect(matchedPackages[0].failedLoading).toBeUndefined();
expect(matchedPackages[1].failedLoading).toBeUndefined();
});
test('Should match failed packages that should be present', () => {
const fakePackages = generateListOfFakeInstalledPackages();
const notFoundPackageList = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${fakePackages[0].packageName}@${fakePackages[0].installedVersion}`;
const matchedPackages = matchMissingPackages(fakePackages, notFoundPackageList);
expect(matchedPackages[0].failedLoading).toBe(true);
expect(matchedPackages[1].failedLoading).toBeUndefined();
});
test('Should match failed packages even if version is wrong', () => {
const fakePackages = generateListOfFakeInstalledPackages();
const notFoundPackageList = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${fakePackages[0].packageName}@123.456.789`;
const matchedPackages = matchMissingPackages(fakePackages, notFoundPackageList);
expect(matchedPackages[0].failedLoading).toBe(true);
expect(matchedPackages[1].failedLoading).toBeUndefined();
});
});
describe('checkNpmPackageStatus', () => {
test('Should call axios.post', async () => {
const packageName = NODE_PACKAGE_PREFIX + randomName();
await checkNpmPackageStatus(packageName);
expect(axios.post).toHaveBeenCalled();
});
test('Should not fail if request fails', async () => {
const packageName = NODE_PACKAGE_PREFIX + randomName();
axios.post = jest.fn(() => {
throw new Error('Something went wrong');
});
const result = await checkNpmPackageStatus(packageName);
expect(result.status).toBe(NPM_PACKAGE_STATUS_GOOD);
});
test('Should warn if package is banned', async () => {
const packageName = NODE_PACKAGE_PREFIX + randomName();
// @ts-ignore
axios.post = jest.fn(() => {
return { data: { status: 'Banned', reason: 'Not good' } };
});
const result = await checkNpmPackageStatus(packageName);
expect(result.status).toBe('Banned');
expect(result.reason).toBe('Not good');
});
});
describe('hasPackageLoadedSuccessfully', () => {
test('Should return true when failed package list does not exist', () => {
config.set('nodes.packagesMissing', undefined);
const result = hasPackageLoaded('package');
expect(result).toBe(true);
});
test('Should return true when package is not in the list of missing packages', () => {
config.set('nodes.packagesMissing', 'packageA@0.1.0 packgeB@0.1.0');
const result = hasPackageLoaded('packageC');
expect(result).toBe(true);
});
test('Should return false when package is in the list of missing packages', () => {
config.set('nodes.packagesMissing', 'packageA@0.1.0 packgeB@0.1.0');
const result = hasPackageLoaded('packageA');
expect(result).toBe(false);
});
});
describe('removePackageFromMissingList', () => {
test('Should do nothing if key does not exist', () => {
config.set('nodes.packagesMissing', undefined);
removePackageFromMissingList('packageA');
const packageList = config.get('nodes.packagesMissing');
expect(packageList).toBeUndefined();
});
test('Should remove only correct package from list', () => {
config.set('nodes.packagesMissing', 'packageA@0.1.0 packageB@0.2.0 packageBB@0.2.0');
removePackageFromMissingList('packageB');
const packageList = config.get('nodes.packagesMissing');
expect(packageList).toBe('packageA@0.1.0 packageBB@0.2.0');
});
test('Should not remove if package is not in the list', () => {
const failedToLoadList = 'packageA@0.1.0 packageB@0.2.0 packageBB@0.2.0';
config.set('nodes.packagesMissing', failedToLoadList);
removePackageFromMissingList('packageC');
const packageList = config.get('nodes.packagesMissing');
expect(packageList).toBe(failedToLoadList);
});
});
/**
* Generate a list with 2 packages, one with a single node and another with 2 nodes
*/
function generateListOfFakeInstalledPackages(): InstalledPackages[] {
const fakeInstalledPackage1 = new InstalledPackages();
Object.assign(fakeInstalledPackage1, installedPackagePayload());
const fakeInstalledNode1 = new InstalledNodes();
Object.assign(fakeInstalledNode1, installedNodePayload(fakeInstalledPackage1.packageName));
fakeInstalledPackage1.installedNodes = [fakeInstalledNode1];
const fakeInstalledPackage2 = new InstalledPackages();
Object.assign(fakeInstalledPackage2, installedPackagePayload());
const fakeInstalledNode2 = new InstalledNodes();
Object.assign(fakeInstalledNode2, installedNodePayload(fakeInstalledPackage2.packageName));
const fakeInstalledNode3 = new InstalledNodes();
Object.assign(fakeInstalledNode3, installedNodePayload(fakeInstalledPackage2.packageName));
fakeInstalledPackage2.installedNodes = [fakeInstalledNode2, fakeInstalledNode3];
return [fakeInstalledPackage1, fakeInstalledPackage2];
}

View File

@@ -0,0 +1,357 @@
import { exec } from 'child_process';
import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises';
import axios from 'axios';
import {
NODE_PACKAGE_PREFIX,
NPM_COMMAND_TOKENS,
NPM_PACKAGE_STATUS_GOOD,
RESPONSE_ERROR_MESSAGES,
} from '@/constants';
import { InstalledPackages } from '@db/entities/InstalledPackages';
import { randomName } from '../../integration/shared/random';
import config from '@/config';
import { mockInstance, mockPackageName, mockPackagePair } from '../../integration/shared/utils';
import { mocked } from 'jest-mock';
import type { CommunityPackages } from '@/Interfaces';
import { CommunityPackageService } from '@/services/communityPackage.service';
import { InstalledNodesRepository, InstalledPackagesRepository } from '@/databases/repositories';
import Container from 'typedi';
import { InstalledNodes } from '@/databases/entities/InstalledNodes';
import {
COMMUNITY_NODE_VERSION,
COMMUNITY_PACKAGE_VERSION,
} from '../../integration/shared/constants';
import type { PublicInstalledPackage } from 'n8n-workflow';
jest.mock('fs/promises');
jest.mock('child_process');
jest.mock('axios');
type ExecOptions = NonNullable<Parameters<typeof exec>[1]>;
type ExecCallback = NonNullable<Parameters<typeof exec>[2]>;
const execMock = ((...args) => {
const cb = args[args.length - 1] as ExecCallback;
cb(null, 'Done', '');
}) as typeof exec;
describe('CommunityPackageService', () => {
const installedNodesRepository = mockInstance(InstalledNodesRepository);
Container.set(InstalledNodesRepository, installedNodesRepository);
installedNodesRepository.create.mockImplementation(() => {
const nodeName = randomName();
return Object.assign(new InstalledNodes(), {
name: nodeName,
type: nodeName,
latestVersion: COMMUNITY_NODE_VERSION.CURRENT.toString(),
packageName: 'test',
});
});
const installedPackageRepository = mockInstance(InstalledPackagesRepository);
installedPackageRepository.create.mockImplementation(() => {
return Object.assign(new InstalledPackages(), {
packageName: mockPackageName(),
installedVersion: COMMUNITY_PACKAGE_VERSION.CURRENT,
});
});
const communityPackageService = new CommunityPackageService(installedPackageRepository);
beforeEach(() => {
config.load(config.default);
});
describe('parseNpmPackageName()', () => {
test('should fail with empty package name', () => {
expect(() => communityPackageService.parseNpmPackageName('')).toThrowError();
});
test('should fail with invalid package prefix name', () => {
expect(() =>
communityPackageService.parseNpmPackageName('INVALID_PREFIX@123'),
).toThrowError();
});
test('should parse valid package name', () => {
const name = mockPackageName();
const parsed = communityPackageService.parseNpmPackageName(name);
expect(parsed.rawString).toBe(name);
expect(parsed.packageName).toBe(name);
expect(parsed.scope).toBeUndefined();
expect(parsed.version).toBeUndefined();
});
test('should parse valid package name and version', () => {
const name = mockPackageName();
const version = '0.1.1';
const fullPackageName = `${name}@${version}`;
const parsed = communityPackageService.parseNpmPackageName(fullPackageName);
expect(parsed.rawString).toBe(fullPackageName);
expect(parsed.packageName).toBe(name);
expect(parsed.scope).toBeUndefined();
expect(parsed.version).toBe(version);
});
test('should parse valid package name, scope and version', () => {
const scope = '@n8n';
const name = mockPackageName();
const version = '0.1.1';
const fullPackageName = `${scope}/${name}@${version}`;
const parsed = communityPackageService.parseNpmPackageName(fullPackageName);
expect(parsed.rawString).toBe(fullPackageName);
expect(parsed.packageName).toBe(`${scope}/${name}`);
expect(parsed.scope).toBe(scope);
expect(parsed.version).toBe(version);
});
});
describe('executeCommand()', () => {
beforeEach(() => {
mocked(fsAccess).mockReset();
mocked(fsMkdir).mockReset();
mocked(exec).mockReset();
});
test('should call command with valid options', async () => {
const execMock = ((...args) => {
const arg = args[1] as ExecOptions;
expect(arg.cwd).toBeDefined();
expect(arg.env).toBeDefined();
// PATH or NODE_PATH may be undefined depending on environment so we don't check for these keys.
const cb = args[args.length - 1] as ExecCallback;
cb(null, 'Done', '');
}) as typeof exec;
mocked(exec).mockImplementation(execMock);
await communityPackageService.executeNpmCommand('ls');
expect(fsAccess).toHaveBeenCalled();
expect(exec).toHaveBeenCalled();
expect(fsMkdir).toBeCalledTimes(0);
});
test('should make sure folder exists', async () => {
mocked(exec).mockImplementation(execMock);
await communityPackageService.executeNpmCommand('ls');
expect(fsAccess).toHaveBeenCalled();
expect(exec).toHaveBeenCalled();
expect(fsMkdir).toBeCalledTimes(0);
});
test('should try to create folder if it does not exist', async () => {
mocked(exec).mockImplementation(execMock);
mocked(fsAccess).mockImplementation(() => {
throw new Error('Folder does not exist.');
});
await communityPackageService.executeNpmCommand('ls');
expect(fsAccess).toHaveBeenCalled();
expect(exec).toHaveBeenCalled();
expect(fsMkdir).toHaveBeenCalled();
});
test('should throw especial error when package is not found', async () => {
const erroringExecMock = ((...args) => {
const cb = args[args.length - 1] as ExecCallback;
const msg = `Something went wrong - ${NPM_COMMAND_TOKENS.NPM_PACKAGE_NOT_FOUND_ERROR}. Aborting.`;
cb(new Error(msg), '', '');
}) as typeof exec;
mocked(exec).mockImplementation(erroringExecMock);
const call = async () => communityPackageService.executeNpmCommand('ls');
await expect(call).rejects.toThrowError(RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND);
expect(fsAccess).toHaveBeenCalled();
expect(exec).toHaveBeenCalled();
expect(fsMkdir).toHaveBeenCalledTimes(0);
});
});
describe('crossInformationPackage()', () => {
test('should return same list if availableUpdates is undefined', () => {
const fakePkgs = mockPackagePair();
const crossedPkgs = communityPackageService.matchPackagesWithUpdates(fakePkgs);
expect(crossedPkgs).toEqual(fakePkgs);
});
test('should correctly match update versions for packages', () => {
const [pkgA, pkgB] = mockPackagePair();
const updates: CommunityPackages.AvailableUpdates = {
[pkgA.packageName]: {
current: pkgA.installedVersion,
wanted: pkgA.installedVersion,
latest: '0.2.0',
location: pkgA.packageName,
},
[pkgB.packageName]: {
current: pkgA.installedVersion,
wanted: pkgA.installedVersion,
latest: '0.3.0',
location: pkgA.packageName,
},
};
const [crossedPkgA, crossedPkgB]: PublicInstalledPackage[] =
communityPackageService.matchPackagesWithUpdates([pkgA, pkgB], updates);
expect(crossedPkgA.updateAvailable).toBe('0.2.0');
expect(crossedPkgB.updateAvailable).toBe('0.3.0');
});
test('should correctly match update versions for single package', () => {
const [pkgA, pkgB] = mockPackagePair();
const updates: CommunityPackages.AvailableUpdates = {
[pkgB.packageName]: {
current: pkgA.installedVersion,
wanted: pkgA.installedVersion,
latest: '0.3.0',
location: pkgA.packageName,
},
};
const [crossedPkgA, crossedPkgB]: PublicInstalledPackage[] =
communityPackageService.matchPackagesWithUpdates([pkgA, pkgB], updates);
expect(crossedPkgA.updateAvailable).toBeUndefined();
expect(crossedPkgB.updateAvailable).toBe('0.3.0');
});
});
describe('matchMissingPackages()', () => {
test('should not match failed packages that do not exist', () => {
const fakePkgs = mockPackagePair();
const notFoundPkgNames = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${NODE_PACKAGE_PREFIX}another-very-long-name-that-never-is-seen`;
const matchedPackages = communityPackageService.matchMissingPackages(
fakePkgs,
notFoundPkgNames,
);
expect(matchedPackages).toEqual(fakePkgs);
const [first, second] = matchedPackages;
expect(first.failedLoading).toBeUndefined();
expect(second.failedLoading).toBeUndefined();
});
test('should match failed packages that should be present', () => {
const [pkgA, pkgB] = mockPackagePair();
const notFoundPkgNames = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${pkgA.packageName}@${pkgA.installedVersion}`;
const [matchedPkgA, matchedPkgB] = communityPackageService.matchMissingPackages(
[pkgA, pkgB],
notFoundPkgNames,
);
expect(matchedPkgA.failedLoading).toBe(true);
expect(matchedPkgB.failedLoading).toBeUndefined();
});
test('should match failed packages even if version is wrong', () => {
const [pkgA, pkgB] = mockPackagePair();
const notFoundPackageList = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${pkgA.packageName}@123.456.789`;
const [matchedPkgA, matchedPkgB] = communityPackageService.matchMissingPackages(
[pkgA, pkgB],
notFoundPackageList,
);
expect(matchedPkgA.failedLoading).toBe(true);
expect(matchedPkgB.failedLoading).toBeUndefined();
});
});
describe('checkNpmPackageStatus()', () => {
test('should call axios.post', async () => {
await communityPackageService.checkNpmPackageStatus(mockPackageName());
expect(axios.post).toHaveBeenCalled();
});
test('should not fail if request fails', async () => {
mocked(axios.post).mockImplementation(() => {
throw new Error('Something went wrong');
});
const result = await communityPackageService.checkNpmPackageStatus(mockPackageName());
expect(result.status).toBe(NPM_PACKAGE_STATUS_GOOD);
});
test('should warn if package is banned', async () => {
mocked(axios.post).mockResolvedValue({ data: { status: 'Banned', reason: 'Not good' } });
const result = (await communityPackageService.checkNpmPackageStatus(
mockPackageName(),
)) as CommunityPackages.PackageStatusCheck;
expect(result.status).toBe('Banned');
expect(result.reason).toBe('Not good');
});
});
describe('hasPackageLoadedSuccessfully()', () => {
test('should return true when failed package list does not exist', () => {
config.set<string>('nodes.packagesMissing', undefined);
expect(communityPackageService.hasPackageLoaded('package')).toBe(true);
});
test('should return true when package is not in the list of missing packages', () => {
config.set('nodes.packagesMissing', 'packageA@0.1.0 packageB@0.1.0');
expect(communityPackageService.hasPackageLoaded('packageC')).toBe(true);
});
test('should return false when package is in the list of missing packages', () => {
config.set('nodes.packagesMissing', 'packageA@0.1.0 packageB@0.1.0');
expect(communityPackageService.hasPackageLoaded('packageA')).toBe(false);
});
});
describe('removePackageFromMissingList()', () => {
test('should do nothing if key does not exist', () => {
config.set<string>('nodes.packagesMissing', undefined);
communityPackageService.removePackageFromMissingList('packageA');
expect(config.get('nodes.packagesMissing')).toBeUndefined();
});
test('should remove only correct package from list', () => {
config.set('nodes.packagesMissing', 'packageA@0.1.0 packageB@0.2.0 packageC@0.2.0');
communityPackageService.removePackageFromMissingList('packageB');
expect(config.get('nodes.packagesMissing')).toBe('packageA@0.1.0 packageC@0.2.0');
});
test('should not remove if package is not in the list', () => {
const failedToLoadList = 'packageA@0.1.0 packageB@0.2.0 packageB@0.2.0';
config.set('nodes.packagesMissing', failedToLoadList);
communityPackageService.removePackageFromMissingList('packageC');
expect(config.get('nodes.packagesMissing')).toBe(failedToLoadList);
});
});
});