🤖 Tests: AppsService

This commit is contained in:
Nicolas Meienberger 2022-05-09 22:52:00 +02:00
parent 96555d884b
commit 20b32526ef
13 changed files with 2290 additions and 22 deletions

1892
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
const childProcess: { execFile: typeof execFile } = jest.genMockFromModule('child_process');
const execFile = (path: string, args: string[], thing: any, callback: Function) => {
callback();
};
childProcess.execFile = execFile;
module.exports = childProcess;

View file

@ -0,0 +1,86 @@
import path from 'path';
const fs: {
__createMockFiles: typeof createMockFiles;
readFileSync: typeof readFileSync;
existsSync: typeof existsSync;
writeFileSync: typeof writeFileSync;
mkdirSync: typeof mkdirSync;
rmSync: typeof rmSync;
readdirSync: typeof readdirSync;
copyFileSync: typeof copyFileSync;
} = jest.genMockFromModule('fs');
let mockFiles = Object.create(null);
const createMockFiles = (newMockFiles: Record<string, string>) => {
mockFiles = Object.create(null);
// Create folder tree
for (const file in newMockFiles) {
const dir = path.dirname(file);
if (!mockFiles[dir]) {
mockFiles[dir] = [];
}
mockFiles[dir].push(path.basename(file));
mockFiles[file] = newMockFiles[file];
}
};
const readFileSync = (p: string) => {
return mockFiles[p];
};
const existsSync = (p: string) => {
return mockFiles[p] !== undefined;
};
const writeFileSync = (p: string, data: any) => {
mockFiles[p] = data;
};
const mkdirSync = (p: string) => {
mockFiles[p] = Object.create(null);
};
const rmSync = (p: string, options: { recursive: boolean }) => {
if (options.recursive) {
delete mockFiles[p];
} else {
delete mockFiles[p][Object.keys(mockFiles[p])[0]];
}
};
const readdirSync = (p: string) => {
const files: string[] = [];
const depth = p.split('/').length;
Object.keys(mockFiles).forEach((file) => {
if (file.startsWith(p)) {
const fileDepth = file.split('/').length;
if (fileDepth === depth + 1) {
files.push(file.split('/').pop() || '');
}
}
});
return files;
};
const copyFileSync = (source: string, destination: string) => {
mockFiles[destination] = mockFiles[source];
};
fs.readdirSync = readdirSync;
fs.existsSync = existsSync;
fs.readFileSync = readFileSync;
fs.writeFileSync = writeFileSync;
fs.mkdirSync = mkdirSync;
fs.rmSync = rmSync;
fs.copyFileSync = copyFileSync;
fs.__createMockFiles = createMockFiles;
module.exports = fs;

View file

@ -3,5 +3,5 @@ module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
setupFiles: ['dotenv/config'],
setupFiles: ['<rootDir>/tests/dotenv-config.ts'],
};

View file

@ -36,7 +36,8 @@
"passport-http-bearer": "^1.0.1",
"public-ip": "^5.0.0",
"systeminformation": "^5.11.9",
"tcp-port-used": "^1.0.2"
"tcp-port-used": "^1.0.2",
"mock-fs": "^5.1.2"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",
@ -46,6 +47,7 @@
"@types/express": "^4.17.13",
"@types/jest": "^27.5.0",
"@types/jsonwebtoken": "^8.5.8",
"@types/mock-fs": "^4.13.1",
"@types/passport": "^1.0.7",
"@types/passport-http-bearer": "^1.0.37",
"@types/tcp-port-used": "^1.0.1",

View file

@ -7,11 +7,7 @@ interface IConfig {
CLIENT_URLS: string[];
}
if (process.env.NODE_ENV === 'test') {
dotenv.config({ path: '.env.test' });
} else {
dotenv.config();
}
dotenv.config();
const { NODE_ENV = 'development', ROOT_FOLDER = '', JWT_SECRET = '', INTERNAL_IP = '' } = process.env;

View file

@ -19,6 +19,7 @@ export type Maybe<T> = T | null | undefined;
export interface AppConfig {
id: string;
available: boolean;
port: number;
name: string;
requirements?: {

View file

@ -1,7 +1,258 @@
import AppsService from '../apps.service';
import fs from 'fs';
import config from '../../../config';
import { AppConfig, FieldTypes } from '../../../config/types';
import childProcess from 'child_process';
jest.mock('fs');
jest.mock('child_process');
beforeEach(() => {
jest.resetModules();
jest.resetAllMocks();
});
const testApp: Partial<AppConfig> = {
id: 'test-app',
port: 3000,
available: true,
form_fields: {
test: {
type: FieldTypes.text,
label: 'Test field',
required: true,
env_variable: 'TEST_FIELD',
},
test2: {
type: FieldTypes.text,
label: 'Test field 2',
required: false,
env_variable: 'TEST_FIELD_2',
},
},
};
const testApp2: Partial<AppConfig> = {
available: true,
id: 'test-app2',
};
const MOCK_FILE_EMPTY = {
[`${config.ROOT_FOLDER}/apps/test-app/config.json`]: JSON.stringify(testApp),
[`${config.ROOT_FOLDER}/.env`]: 'TEST=test',
[`${config.ROOT_FOLDER}/state/apps.json`]: '{"installed": ""}',
};
const MOCK_FILE_INSTALLED = {
[`${config.ROOT_FOLDER}/apps/test-app/config.json`]: JSON.stringify(testApp),
[`${config.ROOT_FOLDER}/apps/test-app2/config.json`]: JSON.stringify(testApp2),
[`${config.ROOT_FOLDER}/.env`]: 'TEST=test',
[`${config.ROOT_FOLDER}/state/apps.json`]: '{"installed": "test-app"}',
[`${config.ROOT_FOLDER}/app-data/test-app`]: '',
[`${config.ROOT_FOLDER}/app-data/test-app/app.env`]: 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test',
};
describe('Install app', () => {
it('Should throw when app is not available', () => {
expect(AppsService.installApp('not-available', {})).rejects.toThrow('App not-available not available');
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_FILE_EMPTY);
});
it('Should correctly generate env file for app', async () => {
await AppsService.installApp('test-app', { test: 'test' });
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/test-app/app.env`).toString();
expect(envFile.trim()).toBe('TEST=test\nAPP_PORT=3000\nTEST_FIELD=test');
});
it('Should add app to state file', async () => {
await AppsService.installApp('test-app', { test: 'test' });
const stateFile = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/apps.json`).toString());
expect(stateFile.installed).toBe(' test-app');
});
it('Should correctly run app script', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
await AppsService.installApp('test-app', { test: 'test' });
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['install', 'test-app'], {}, expect.any(Function)]);
spy.mockRestore();
});
it('Should start app if already installed', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
await AppsService.installApp('test-app', { test: 'test' });
await AppsService.installApp('test-app', { test: 'test' });
expect(spy.mock.calls.length).toBe(2);
expect(spy.mock.calls[0]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['install', 'test-app'], {}, expect.any(Function)]);
expect(spy.mock.calls[1]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', 'test-app'], {}, expect.any(Function)]);
spy.mockRestore();
});
it('Should throw if required form fields are missing', async () => {
await expect(AppsService.installApp('test-app', {})).rejects.toThrowError('Variable test is required');
});
});
describe('Uninstall app', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_FILE_INSTALLED);
});
it('Should correctly remove app from state file', async () => {
await AppsService.uninstallApp('test-app');
const stateFile = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/apps.json`).toString());
expect(stateFile.installed).toBe('');
});
it('Should correctly run app script', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
await AppsService.uninstallApp('test-app');
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['uninstall', 'test-app'], {}, expect.any(Function)]);
spy.mockRestore();
});
it('Should throw if app is not installed', async () => {
await expect(AppsService.uninstallApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
});
});
describe('Start app', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_FILE_INSTALLED);
});
it('Should correctly run app script', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
await AppsService.startApp('test-app');
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', 'test-app'], {}, expect.any(Function)]);
spy.mockRestore();
});
it('Should throw if app is not installed', async () => {
await expect(AppsService.startApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
});
it('Should restart if app is already running', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
await AppsService.startApp('test-app');
expect(spy.mock.calls.length).toBe(1);
await AppsService.startApp('test-app');
expect(spy.mock.calls.length).toBe(2);
spy.mockRestore();
});
it('Should throw if app is not installed', async () => {
await expect(AppsService.startApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
});
it('Regenerate env file', async () => {
fs.writeFile(`${config.ROOT_FOLDER}/app-data/test-app/app.env`, 'TEST=test\nAPP_PORT=3000', () => {});
await AppsService.startApp('test-app');
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/test-app/app.env`).toString();
expect(envFile.trim()).toBe('TEST=test\nAPP_PORT=3000\nTEST_FIELD=test');
});
});
describe('Stop app', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_FILE_INSTALLED);
});
it('Should correctly run app script', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
await AppsService.stopApp('test-app');
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['stop', 'test-app'], {}, expect.any(Function)]);
});
it('Should throw if app is not installed', async () => {
await expect(AppsService.stopApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
});
});
describe('Update app config', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_FILE_INSTALLED);
});
it('Should correctly update app config', async () => {
await AppsService.updateAppConfig('test-app', { test: 'test', test2: 'test2' });
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/test-app/app.env`).toString();
expect(envFile.trim()).toBe('TEST=test\nAPP_PORT=3000\nTEST_FIELD=test\nTEST_FIELD_2=test2');
});
it('Should throw if app is not installed', async () => {
await expect(AppsService.updateAppConfig('test-app-2', { test: 'test' })).rejects.toThrowError('App test-app-2 not installed');
});
it('Should throw if required form fields are missing', async () => {
await expect(AppsService.updateAppConfig('test-app', {})).rejects.toThrowError('Variable test is required');
});
});
describe('Get app config', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_FILE_INSTALLED);
});
it('Should correctly get app config', async () => {
const appconfig = await AppsService.getAppInfo('test-app');
expect(appconfig).toEqual({ ...testApp, installed: true, status: 'stopped' });
});
it('Should have installed false if app is not installed', async () => {
const appconfig = await AppsService.getAppInfo('test-app2');
expect(appconfig).toEqual({ ...testApp2, installed: false, status: 'stopped' });
});
});
describe('List apps', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_FILE_INSTALLED);
});
it('Should correctly list apps', async () => {
const apps = await AppsService.listApps();
expect(apps).toEqual([
{ ...testApp, installed: true, status: 'stopped' },
{ ...testApp2, installed: false, status: 'stopped' },
]);
expect(apps.length).toBe(2);
expect(apps[0].id).toBe('test-app');
expect(apps[1].id).toBe('test-app2');
});
});

View file

@ -1,7 +1,7 @@
import portUsed from 'tcp-port-used';
import p from 'p-iteration';
import { AppConfig } from '../../config/types';
import { fileExists, readFile, readJsonFile, runScript, writeFile } from '../fs/fs.helpers';
import { fileExists, readdirSync, readFile, readJsonFile, runScript, writeFile } from '../fs/fs.helpers';
import InternalIp from 'internal-ip';
type AppsState = { installed: string };
@ -96,7 +96,7 @@ export const ensureAppState = (appName: string, installed: boolean) => {
}
} else {
if (state.installed.indexOf(appName) !== -1) {
state.installed = state.installed.replace(` ${appName}`, '');
state.installed = state.installed.replace(`${appName}`, '');
writeFile('/state/apps.json', JSON.stringify(state));
}
}
@ -124,3 +124,21 @@ export const generateEnvFile = (appName: string, form: Record<string, string>) =
export const getStateFile = (): AppsState => {
return readJsonFile('/state/apps.json');
};
export const getAvailableApps = (): string[] => {
const apps: string[] = [];
const appsDir = readdirSync('/apps');
appsDir.forEach((app) => {
if (fileExists(`/apps/${app}/config.json`)) {
const configFile: AppConfig = readJsonFile(`/apps/${app}/config.json`);
if (configFile.available) {
apps.push(app);
}
}
});
return apps;
};

View file

@ -1,8 +1,7 @@
import si from 'systeminformation';
import { appNames } from '../../config/apps';
import { AppConfig } from '../../config/types';
import { createFolder, fileExists, readJsonFile } from '../fs/fs.helpers';
import { checkAppExists, checkAppRequirements, checkEnvFile, ensureAppState, generateEnvFile, getInitalFormValues, getStateFile, runAppScript } from './apps.helpers';
import { checkAppExists, checkAppRequirements, checkEnvFile, ensureAppState, generateEnvFile, getAvailableApps, getInitalFormValues, getStateFile, runAppScript } from './apps.helpers';
const startApp = async (appName: string): Promise<void> => {
checkAppExists(appName);
@ -19,12 +18,6 @@ const startApp = async (appName: string): Promise<void> => {
};
const installApp = async (id: string, form: Record<string, string>): Promise<void> => {
const appIsAvailable = appNames.includes(id);
if (!appIsAvailable) {
throw new Error(`App ${id} not available`);
}
const appExists = fileExists(`/app-data/${id}`);
if (appExists) {
@ -46,10 +39,12 @@ const installApp = async (id: string, form: Record<string, string>): Promise<voi
// Run script
await runAppScript(['install', id]);
}
return Promise.resolve();
};
const listApps = async (): Promise<AppConfig[]> => {
const apps: AppConfig[] = appNames
const apps: AppConfig[] = getAvailableApps()
.map((app) => {
try {
return readJsonFile(`/apps/${app}/config.json`);

View file

@ -11,6 +11,8 @@ export const readJsonFile = (path: string): any => {
export const readFile = (path: string): string => fs.readFileSync(getAbsolutePath(path)).toString();
export const readdirSync = (path: string): string[] => fs.readdirSync(getAbsolutePath(path));
export const fileExists = (path: string): boolean => fs.existsSync(getAbsolutePath(path));
export const writeFile = (path: string, data: any) => fs.writeFileSync(getAbsolutePath(path), data);

View file

@ -0,0 +1,3 @@
import * as dotenv from 'dotenv';
dotenv.config({ path: '.env.test' });

View file

@ -86,6 +86,7 @@ importers:
'@types/express': ^4.17.13
'@types/jest': ^27.5.0
'@types/jsonwebtoken': ^8.5.8
'@types/mock-fs': ^4.13.1
'@types/passport': ^1.0.7
'@types/passport-http-bearer': ^1.0.37
'@types/tcp-port-used': ^1.0.1
@ -109,6 +110,7 @@ importers:
internal-ip: ^6.0.0
jest: ^28.1.0
jsonwebtoken: ^8.5.1
mock-fs: ^5.1.2
node-port-scanner: ^3.0.1
nodemon: ^2.0.15
p-iteration: ^1.1.8
@ -131,6 +133,7 @@ importers:
helmet: 5.0.2
internal-ip: 6.2.0
jsonwebtoken: 8.5.1
mock-fs: 5.1.2
node-port-scanner: 3.0.1
p-iteration: 1.1.8
passport: 0.5.2
@ -147,6 +150,7 @@ importers:
'@types/express': 4.17.13
'@types/jest': 27.5.0
'@types/jsonwebtoken': 8.5.8
'@types/mock-fs': 4.13.1
'@types/passport': 1.0.7
'@types/passport-http-bearer': 1.0.37
'@types/tcp-port-used': 1.0.1
@ -2188,6 +2192,12 @@ packages:
resolution: {integrity: sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==}
dev: true
/@types/mock-fs/4.13.1:
resolution: {integrity: sha512-m6nFAJ3lBSnqbvDZioawRvpLXSaPyn52Srf7OfzjubYbYX8MTUdIgDxQl0wEapm4m/pNYSd9TXocpQ0TvZFlYA==}
dependencies:
'@types/node': 17.0.31
dev: true
/@types/node/17.0.31:
resolution: {integrity: sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q==}
@ -3968,8 +3978,6 @@ packages:
dependencies:
debug: 3.2.7
resolve: 1.22.0
transitivePeerDependencies:
- supports-color
dev: true
/eslint-import-resolver-typescript/2.4.0_l3k33lf43msdtqtpwrwceacqke:
@ -6080,6 +6088,11 @@ packages:
hasBin: true
dev: false
/mock-fs/5.1.2:
resolution: {integrity: sha512-YkjQkdLulFrz0vD4BfNQdQRVmgycXTV7ykuHMlyv+C8WCHazpkiQRDthwa02kSyo8wKnY9wRptHfQLgmf0eR+A==}
engines: {node: '>=12.0.0'}
dev: false
/ms/2.0.0:
resolution: {integrity: sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=}