From a024b035086f7ac354514737e251d4ba5c86a896 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Wed, 5 Oct 2022 19:02:55 +0200 Subject: [PATCH] refactor: make event dispatcher a singleton and update app accordingly --- .gitignore | 2 + packages/system-api/.eslintrc.cjs | 3 + packages/system-api/.gitignore | 2 + packages/system-api/__mocks__/fs-extra.ts | 11 ++ .../src/core/config/EventDispatcher.ts | 25 +++- .../config/__tests__/EventDispatcher.test.ts | 3 - .../src/core/jobs/__tests__/jobs.test.ts | 13 +- packages/system-api/src/core/jobs/jobs.ts | 6 +- .../apps/__tests__/apps.helpers.test.ts | 45 +------ .../apps/__tests__/apps.resolver.test.ts | 2 + .../apps/__tests__/apps.service.test.ts | 123 ++++++++---------- .../src/modules/apps/apps.service.ts | 23 ++-- .../system/__tests__/system.resolver.test.ts | 33 ++++- .../system/__tests__/system.service.test.ts | 74 +++++++---- .../src/modules/system/system.service.ts | 10 +- packages/system-api/src/server.ts | 8 +- packages/system-api/src/test/jest-setup.ts | 6 + 17 files changed, 208 insertions(+), 181 deletions(-) delete mode 100644 packages/system-api/src/core/config/__tests__/EventDispatcher.test.ts diff --git a/.gitignore b/.gitignore index bef19b18..33bf581b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.swo *.swp +.DS_Store + logs .pnpm-debug.log .env* diff --git a/packages/system-api/.eslintrc.cjs b/packages/system-api/.eslintrc.cjs index a201f60d..368f2199 100644 --- a/packages/system-api/.eslintrc.cjs +++ b/packages/system-api/.eslintrc.cjs @@ -19,4 +19,7 @@ module.exports = { 'no-unused-vars': [1, { argsIgnorePattern: '^_' }], '@typescript-eslint/no-unused-vars': [1, { argsIgnorePattern: '^_' }], }, + globals: { + NodeJS: true, + }, }; diff --git a/packages/system-api/.gitignore b/packages/system-api/.gitignore index f263dd73..ae2ba72e 100644 --- a/packages/system-api/.gitignore +++ b/packages/system-api/.gitignore @@ -1,6 +1,8 @@ node_modules/ dist/ +.DS_Store + # testing coverage/ logs/ diff --git a/packages/system-api/__mocks__/fs-extra.ts b/packages/system-api/__mocks__/fs-extra.ts index ef437bea..476f40b5 100644 --- a/packages/system-api/__mocks__/fs-extra.ts +++ b/packages/system-api/__mocks__/fs-extra.ts @@ -11,6 +11,7 @@ const fs: { copyFileSync: typeof copyFileSync; copySync: typeof copyFileSync; createFileSync: typeof createFileSync; + unlinkSync: typeof unlinkSync; } = jest.genMockFromModule('fs-extra'); let mockFiles = Object.create(null); @@ -97,6 +98,16 @@ const resetAllMocks = () => { mockFiles = Object.create(null); }; +const unlinkSync = (p: string) => { + if (mockFiles[p] instanceof Array) { + mockFiles[p].forEach((file: string) => { + delete mockFiles[path.join(p, file)]; + }); + } + delete mockFiles[p]; +}; + +fs.unlinkSync = unlinkSync; fs.readdirSync = readdirSync; fs.existsSync = existsSync; fs.readFileSync = readFileSync; diff --git a/packages/system-api/src/core/config/EventDispatcher.ts b/packages/system-api/src/core/config/EventDispatcher.ts index e1b388d1..2c9535c4 100644 --- a/packages/system-api/src/core/config/EventDispatcher.ts +++ b/packages/system-api/src/core/config/EventDispatcher.ts @@ -25,12 +25,24 @@ const WATCH_FILE = '/runtipi/state/events'; // File state example: // restart 1631231231231 running "arg1 arg2" class EventDispatcher { + private static instance: EventDispatcher | null; + private queue: SystemEvent[] = []; private lock: SystemEvent | null = null; + private interval: NodeJS.Timer; + constructor() { - this.pollQueue(); + const timer = this.pollQueue(); + this.interval = timer; + } + + public static getInstance(): EventDispatcher { + if (!EventDispatcher.instance) { + EventDispatcher.instance = new EventDispatcher(); + } + return EventDispatcher.instance; } /** @@ -38,7 +50,7 @@ class EventDispatcher { * @returns - Random id */ private generateId() { - return Math.random().toString(36).substr(2, 9); + return Math.random().toString(36).substring(2, 9); } /** @@ -65,7 +77,7 @@ class EventDispatcher { */ private pollQueue() { logger.info('EventDispatcher: Polling queue...'); - setInterval(() => { + return setInterval(() => { this.runEvent(); this.collectLockStatusAndClean(); }, 1000); @@ -89,7 +101,6 @@ class EventDispatcher { // Write event to state file const args = event.args.join(' '); const line = `${event.type} ${event.id} waiting ${args}`; - console.log('Writing line: ', line); fs.writeFileSync(WATCH_FILE, `${line}`); } @@ -190,8 +201,12 @@ class EventDispatcher { public clear() { this.queue = []; this.lock = null; + clearInterval(this.interval); + EventDispatcher.instance = null; fs.writeFileSync(WATCH_FILE, ''); } } -export default new EventDispatcher(); +export const eventDispatcher = EventDispatcher.getInstance(); + +export default EventDispatcher; diff --git a/packages/system-api/src/core/config/__tests__/EventDispatcher.test.ts b/packages/system-api/src/core/config/__tests__/EventDispatcher.test.ts deleted file mode 100644 index 711b6781..00000000 --- a/packages/system-api/src/core/config/__tests__/EventDispatcher.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import EventDispatcher from '../EventDispatcher'; - -describe('EventDispatcher', () => {}); diff --git a/packages/system-api/src/core/jobs/__tests__/jobs.test.ts b/packages/system-api/src/core/jobs/__tests__/jobs.test.ts index e1f2fd73..73400ccc 100644 --- a/packages/system-api/src/core/jobs/__tests__/jobs.test.ts +++ b/packages/system-api/src/core/jobs/__tests__/jobs.test.ts @@ -1,7 +1,7 @@ import cron from 'node-cron'; -import * as repoHelpers from '../../../helpers/repo-helpers'; import { getConfig } from '../../config/TipiConfig'; import startJobs from '../jobs'; +import { eventDispatcher, EventTypes } from '../../../core/config/EventDispatcher'; jest.mock('node-cron'); jest.mock('child_process'); @@ -17,16 +17,21 @@ describe('Test: startJobs', () => { startJobs(); expect(spy).toHaveBeenCalled(); - expect(spy).toHaveBeenCalledWith('0 * * * *', expect.any(Function)); + expect(spy).toHaveBeenCalledWith('*/30 * * * *', expect.any(Function)); spy.mockRestore(); }); it('Should update apps repo on cron trigger', () => { - const spy = jest.spyOn(repoHelpers, 'updateRepo'); + const spy = jest.spyOn(eventDispatcher, 'dispatchEvent'); + // Act startJobs(); - expect(spy).toHaveBeenCalledWith(getConfig().appsRepoUrl); + // Assert + expect(spy.mock.calls.length).toBe(2); + expect(spy.mock.calls[0]).toEqual([EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]]); + expect(spy.mock.calls[1]).toEqual([EventTypes.SYSTEM_INFO, []]); + spy.mockRestore(); }); }); diff --git a/packages/system-api/src/core/jobs/jobs.ts b/packages/system-api/src/core/jobs/jobs.ts index c2aec8a0..29d99ae8 100644 --- a/packages/system-api/src/core/jobs/jobs.ts +++ b/packages/system-api/src/core/jobs/jobs.ts @@ -1,19 +1,19 @@ import cron from 'node-cron'; import logger from '../../config/logger/logger'; import { getConfig } from '../../core/config/TipiConfig'; -import EventDispatcher, { EventTypes } from '../config/EventDispatcher'; +import { eventDispatcher, EventTypes } from '../config/EventDispatcher'; const startJobs = () => { logger.info('Starting cron jobs...'); // Every 30 minutes cron.schedule('*/30 * * * *', async () => { - EventDispatcher.dispatchEvent(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]); + eventDispatcher.dispatchEvent(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]); }); // every minute cron.schedule('* * * * *', () => { - EventDispatcher.dispatchEvent(EventTypes.SYSTEM_INFO, []); + eventDispatcher.dispatchEvent(EventTypes.SYSTEM_INFO, []); }); }; diff --git a/packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts b/packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts index 0b7deb00..78fe9557 100644 --- a/packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts +++ b/packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts @@ -1,11 +1,10 @@ import { faker } from '@faker-js/faker'; import fs from 'fs-extra'; -import childProcess from 'child_process'; import { DataSource } from 'typeorm'; import logger from '../../../config/logger/logger'; import { setupConnection, teardownConnection } from '../../../test/connection'; import App from '../app.entity'; -import { checkAppRequirements, checkEnvFile, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo, runAppScript } from '../apps.helpers'; +import { checkAppRequirements, checkEnvFile, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo } from '../apps.helpers'; import { AppInfo } from '../apps.types'; import { createApp } from './apps.factory'; @@ -108,48 +107,6 @@ describe('checkEnvFile', () => { }); }); -describe('Test: runAppScript', () => { - let app1: AppInfo; - - beforeEach(async () => { - const app1create = await createApp({ installed: true }); - app1 = app1create.appInfo; - // @ts-ignore - fs.__createMockFiles(app1create.MockFiles); - }); - - it('Should run the app script', async () => { - const { MockFiles } = await createApp({ installed: true }); - // @ts-ignore - fs.__createMockFiles(MockFiles); - - await runAppScript(['install', app1.id]); - }); - - it('Should log the error if the script fails', async () => { - const log = jest.spyOn(logger, 'error'); - const spy = jest.spyOn(childProcess, 'execFile'); - const randomWord = faker.random.word(); - - // @ts-ignore - spy.mockImplementation((_path, _args, _, cb) => { - // @ts-ignore - if (cb) cb(randomWord, null, null); - }); - - try { - await runAppScript(['install', app1.id]); - expect(true).toBe(false); - } catch (e: any) { - expect(e).toBe(randomWord); - expect(log).toHaveBeenCalledWith(`Error running app script: ${randomWord}`); - } - - log.mockRestore(); - spy.mockRestore(); - }); -}); - describe('Test: generateEnvFile', () => { let app1: AppInfo; let appEntity1: App; diff --git a/packages/system-api/src/modules/apps/__tests__/apps.resolver.test.ts b/packages/system-api/src/modules/apps/__tests__/apps.resolver.test.ts index 1855d7d4..c0c2da7e 100644 --- a/packages/system-api/src/modules/apps/__tests__/apps.resolver.test.ts +++ b/packages/system-api/src/modules/apps/__tests__/apps.resolver.test.ts @@ -10,6 +10,7 @@ import { createUser } from '../../auth/__tests__/user.factory'; import User from '../../auth/user.entity'; import { installAppMutation, startAppMutation, stopAppMutation, uninstallAppMutation, updateAppConfigMutation, updateAppMutation } from '../../../test/mutations'; import { faker } from '@faker-js/faker'; +import EventDispatcher from '../../../core/config/EventDispatcher'; jest.mock('fs'); jest.mock('child_process'); @@ -36,6 +37,7 @@ beforeEach(async () => { jest.resetModules(); jest.resetAllMocks(); jest.restoreAllMocks(); + EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true }); await App.clear(); await User.clear(); }); diff --git a/packages/system-api/src/modules/apps/__tests__/apps.service.test.ts b/packages/system-api/src/modules/apps/__tests__/apps.service.test.ts index 9e30a966..cf2f012f 100644 --- a/packages/system-api/src/modules/apps/__tests__/apps.service.test.ts +++ b/packages/system-api/src/modules/apps/__tests__/apps.service.test.ts @@ -1,13 +1,12 @@ import AppsService from '../apps.service'; import fs from 'fs-extra'; -import childProcess from 'child_process'; import { AppInfo, AppStatusEnum } from '../apps.types'; import App from '../app.entity'; import { createApp } from './apps.factory'; import { setupConnection, teardownConnection } from '../../../test/connection'; import { DataSource } from 'typeorm'; import { getEnvMap } from '../apps.helpers'; -import { getConfig } from '../../../core/config/TipiConfig'; +import EventDispatcher, { eventDispatcher, EventTypes } from '../../../core/config/EventDispatcher'; jest.mock('fs-extra'); jest.mock('child_process'); @@ -23,6 +22,7 @@ beforeEach(async () => { jest.resetModules(); jest.resetAllMocks(); jest.restoreAllMocks(); + EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true }); await App.clear(); }); @@ -42,6 +42,7 @@ describe('Install app', () => { }); it('Should correctly generate env file for app', async () => { + // EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true }); await AppsService.installApp(app1.id, { TEST_FIELD: 'test' }); const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString(); @@ -59,39 +60,28 @@ describe('Install app', () => { expect(app!.status).toBe(AppStatusEnum.RUNNING); }); - it('Should correctly run app script', async () => { - const spy = jest.spyOn(childProcess, 'execFile'); - await AppsService.installApp(app1.id, { TEST_FIELD: 'test' }); - - expect(spy.mock.lastCall).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['install', app1.id], {}, expect.any(Function)]); - spy.mockRestore(); - }); - it('Should start app if already installed', async () => { - const spy = jest.spyOn(childProcess, 'execFile'); + const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync'); await AppsService.installApp(app1.id, { TEST_FIELD: 'test' }); await AppsService.installApp(app1.id, { TEST_FIELD: 'test' }); expect(spy.mock.calls.length).toBe(2); - expect(spy.mock.calls[0]).toEqual(['/runtipi/scripts/app.sh', ['install', app1.id], {}, expect.any(Function)]); - expect(spy.mock.calls[1]).toEqual(['/runtipi/scripts/app.sh', ['start', app1.id], {}, expect.any(Function)]); + expect(spy.mock.calls[0]).toEqual([EventTypes.APP, ['install', app1.id]]); + expect(spy.mock.calls[1]).toEqual([EventTypes.APP, ['start', app1.id]]); spy.mockRestore(); }); it('Should delete app if install script fails', async () => { - const spy = jest.spyOn(childProcess, 'execFile'); - spy.mockImplementation(() => { - throw new Error('Test error'); - }); + // Arrange + EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' }); - await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' })).rejects.toThrow('Test error'); + await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' })).rejects.toThrow(`App ${app1.id} failed to install\nstdout: error`); const app = await App.findOne({ where: { id: app1.id } }); expect(app).toBeNull(); - spy.mockRestore(); }); it('Should throw if required form fields are missing', async () => { @@ -175,56 +165,51 @@ describe('Uninstall app', () => { }); it('App should be installed by default', async () => { + // Act const app = await App.findOne({ where: { id: app1.id } }); + + // Assert expect(app).toBeDefined(); expect(app!.id).toBe(app1.id); expect(app!.status).toBe(AppStatusEnum.RUNNING); }); it('Should correctly remove app from database', async () => { + // Act await AppsService.uninstallApp(app1.id); - const app = await App.findOne({ where: { id: app1.id } }); + // Assert expect(app).toBeNull(); }); - it('Should correctly run app script', async () => { - const spy = jest.spyOn(childProcess, 'execFile'); - - await AppsService.uninstallApp(app1.id); - - expect(spy.mock.lastCall).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['uninstall', app1.id], {}, expect.any(Function)]); - - spy.mockRestore(); - }); - it('Should stop app if it is running', async () => { - const spy = jest.spyOn(childProcess, 'execFile'); + // Arrange + const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync'); + // Act await AppsService.uninstallApp(app1.id); + // Assert expect(spy.mock.calls.length).toBe(2); - expect(spy.mock.calls[0]).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['stop', app1.id], {}, expect.any(Function)]); - expect(spy.mock.calls[1]).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['uninstall', app1.id], {}, expect.any(Function)]); + expect(spy.mock.calls[0]).toEqual([EventTypes.APP, ['stop', app1.id]]); + expect(spy.mock.calls[1]).toEqual([EventTypes.APP, ['uninstall', app1.id]]); spy.mockRestore(); }); it('Should throw if app is not installed', async () => { + // Act & Assert await expect(AppsService.uninstallApp('any')).rejects.toThrowError('App any not found'); }); it('Should throw if uninstall script fails', async () => { - // Update app + // Arrange + EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' }); await App.update({ id: app1.id }, { status: AppStatusEnum.UPDATING }); - const spy = jest.spyOn(childProcess, 'execFile'); - spy.mockImplementation(() => { - throw new Error('Test error'); - }); - - await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow('Test error'); + // Act & Assert + await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to uninstall\nstdout: test`); const app = await App.findOne({ where: { id: app1.id } }); expect(app!.status).toBe(AppStatusEnum.STOPPED); }); @@ -240,12 +225,12 @@ describe('Start app', () => { fs.__createMockFiles(Object.assign(app1create.MockFiles)); }); - it('Should correctly run app script', async () => { - const spy = jest.spyOn(childProcess, 'execFile'); + it('Should correctly dispatch event', async () => { + const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync'); await AppsService.startApp(app1.id); - expect(spy.mock.lastCall).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['start', app1.id], {}, expect.any(Function)]); + expect(spy.mock.lastCall).toEqual([EventTypes.APP, ['start', app1.id]]); spy.mockRestore(); }); @@ -255,7 +240,7 @@ describe('Start app', () => { }); it('Should restart if app is already running', async () => { - const spy = jest.spyOn(childProcess, 'execFile'); + const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync'); await AppsService.startApp(app1.id); expect(spy.mock.calls.length).toBe(1); @@ -276,12 +261,11 @@ describe('Start app', () => { }); it('Should throw if start script fails', async () => { - const spy = jest.spyOn(childProcess, 'execFile'); - spy.mockImplementation(() => { - throw new Error('Test error'); - }); + // Arrange + EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' }); - await expect(AppsService.startApp(app1.id)).rejects.toThrow('Test error'); + // Act & Assert + await expect(AppsService.startApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to start\nstdout: test`); const app = await App.findOne({ where: { id: app1.id } }); expect(app!.status).toBe(AppStatusEnum.STOPPED); }); @@ -297,12 +281,12 @@ describe('Stop app', () => { fs.__createMockFiles(Object.assign(app1create.MockFiles)); }); - it('Should correctly run app script', async () => { - const spy = jest.spyOn(childProcess, 'execFile'); + it('Should correctly dispatch stop event', async () => { + const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync'); await AppsService.stopApp(app1.id); - expect(spy.mock.lastCall).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['stop', app1.id], {}, expect.any(Function)]); + expect(spy.mock.lastCall).toEqual([EventTypes.APP, ['stop', app1.id]]); }); it('Should throw if app is not installed', async () => { @@ -310,12 +294,11 @@ describe('Stop app', () => { }); it('Should throw if stop script fails', async () => { - const spy = jest.spyOn(childProcess, 'execFile'); - spy.mockImplementation(() => { - throw new Error('Test error'); - }); + // Arrange + EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' }); - await expect(AppsService.stopApp(app1.id)).rejects.toThrow('Test error'); + // Act & Assert + await expect(AppsService.stopApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to stop\nstdout: test`); const app = await App.findOne({ where: { id: app1.id } }); expect(app!.status).toBe(AppStatusEnum.RUNNING); }); @@ -464,19 +447,19 @@ describe('Start all apps', () => { }); it('Should correctly start all apps', async () => { - const spy = jest.spyOn(childProcess, 'execFile'); + const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync'); await AppsService.startAllApps(); expect(spy.mock.calls.length).toBe(2); expect(spy.mock.calls).toEqual([ - [`${getConfig().rootFolder}/scripts/app.sh`, ['start', app1.id], {}, expect.any(Function)], - [`${getConfig().rootFolder}/scripts/app.sh`, ['start', app2.id], {}, expect.any(Function)], + [EventTypes.APP, ['start', app1.id]], + [EventTypes.APP, ['start', app2.id]], ]); }); it('Should not start app which has not status RUNNING', async () => { - const spy = jest.spyOn(childProcess, 'execFile'); + const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync'); await createApp({ installed: true, status: AppStatusEnum.STOPPED }); await AppsService.startAllApps(); @@ -487,16 +470,14 @@ describe('Start all apps', () => { }); it('Should put app status to STOPPED if start script fails', async () => { - const spy = jest.spyOn(childProcess, 'execFile'); - spy.mockImplementation(() => { - throw new Error('Test error'); - }); + // Arrange + EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' }); + // Act await AppsService.startAllApps(); - const apps = await App.find(); - expect(spy.mock.calls.length).toBe(2); + // Assert expect(apps.length).toBe(2); expect(apps[0].status).toBe(AppStatusEnum.STOPPED); expect(apps[1].status).toBe(AppStatusEnum.STOPPED); @@ -529,12 +510,10 @@ describe('Update app', () => { }); it('Should throw if update script fails', async () => { - const spy = jest.spyOn(childProcess, 'execFile'); - spy.mockImplementation(() => { - throw new Error('Test error'); - }); + // Arrange + EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' }); - await expect(AppsService.updateApp(app1.id)).rejects.toThrow('Test error'); + await expect(AppsService.updateApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to update\nstdout: error`); const app = await App.findOne({ where: { id: app1.id } }); expect(app!.status).toBe(AppStatusEnum.STOPPED); }); diff --git a/packages/system-api/src/modules/apps/apps.service.ts b/packages/system-api/src/modules/apps/apps.service.ts index 537d1bb6..88f7edc3 100644 --- a/packages/system-api/src/modules/apps/apps.service.ts +++ b/packages/system-api/src/modules/apps/apps.service.ts @@ -6,7 +6,7 @@ import App from './app.entity'; import logger from '../../config/logger/logger'; import { Not } from 'typeorm'; import { getConfig } from '../../core/config/TipiConfig'; -import EventDispatcher, { EventTypes } from '../../core/config/EventDispatcher'; +import { eventDispatcher, EventTypes } from '../../core/config/EventDispatcher'; const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name); @@ -26,9 +26,13 @@ const startAllApps = async (): Promise => { await App.update({ id: app.id }, { status: AppStatusEnum.STARTING }); - EventDispatcher.dispatchEvent(EventTypes.APP, ['start', app.id]); - - await App.update({ id: app.id }, { status: AppStatusEnum.RUNNING }); + eventDispatcher.dispatchEventAsync(EventTypes.APP, ['start', app.id]).then(({ success }) => { + if (success) { + App.update({ id: app.id }, { status: AppStatusEnum.RUNNING }); + } else { + App.update({ id: app.id }, { status: AppStatusEnum.STOPPED }); + } + }); } catch (e) { await App.update({ id: app.id }, { status: AppStatusEnum.STOPPED }); logger.error(e); @@ -55,7 +59,7 @@ const startApp = async (appName: string): Promise => { checkEnvFile(appName); await App.update({ id: appName }, { status: AppStatusEnum.STARTING }); - const { success, stdout } = await EventDispatcher.dispatchEventAsync(EventTypes.APP, ['start', app.id]); + const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['start', app.id]); if (success) { await App.update({ id: appName }, { status: AppStatusEnum.RUNNING }); @@ -120,7 +124,7 @@ const installApp = async (id: string, form: Record, exposed?: bo generateEnvFile(app); // Run script - const { success, stdout } = await EventDispatcher.dispatchEventAsync(EventTypes.APP, ['install', id]); + const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['install', id]); if (!success) { await App.delete({ id }); @@ -221,7 +225,7 @@ const stopApp = async (id: string): Promise => { // Run script await App.update({ id }, { status: AppStatusEnum.STOPPING }); - const { success, stdout } = await EventDispatcher.dispatchEventAsync(EventTypes.APP, ['stop', id]); + const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['stop', id]); if (success) { await App.update({ id }, { status: AppStatusEnum.STOPPED }); @@ -255,7 +259,7 @@ const uninstallApp = async (id: string): Promise => { await App.update({ id }, { status: AppStatusEnum.UNINSTALLING }); - const { success, stdout } = await EventDispatcher.dispatchEventAsync(EventTypes.APP, ['uninstall', id]); + const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['uninstall', id]); if (!success) { await App.update({ id }, { status: AppStatusEnum.STOPPED }); @@ -299,12 +303,13 @@ const updateApp = async (id: string) => { await App.update({ id }, { status: AppStatusEnum.UPDATING }); - const { success, stdout } = await EventDispatcher.dispatchEventAsync(EventTypes.APP, ['update', id]); + const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['update', id]); if (success) { const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`); await App.update({ id }, { status: AppStatusEnum.RUNNING, version: Number(appInfo?.tipi_version) }); } else { + await App.update({ id }, { status: AppStatusEnum.STOPPED }); throw new Error(`App ${id} failed to update\nstdout: ${stdout}`); } diff --git a/packages/system-api/src/modules/system/__tests__/system.resolver.test.ts b/packages/system-api/src/modules/system/__tests__/system.resolver.test.ts index 3ab627b9..e684080e 100644 --- a/packages/system-api/src/modules/system/__tests__/system.resolver.test.ts +++ b/packages/system-api/src/modules/system/__tests__/system.resolver.test.ts @@ -12,6 +12,7 @@ import { systemInfoQuery, versionQuery } from '../../../test/queries'; import User from '../../auth/user.entity'; import { createUser } from '../../auth/__tests__/user.factory'; import { SystemInfoResponse } from '../system.types'; +import EventDispatcher from '../../../core/config/EventDispatcher'; jest.mock('fs-extra'); jest.mock('axios'); @@ -133,38 +134,50 @@ describe('Test: restart', () => { }); it('Should return true', async () => { + // Arrange + EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true }); + + // Act const user = await createUser(); const { data } = await gcall<{ restart: boolean }>({ source: restartMutation, userId: user.id }); + // Assert expect(data?.restart).toBeDefined(); expect(data?.restart).toBe(true); }); it("Should return an error if user doesn't exist", async () => { + // Arrange const { data, errors } = await gcall<{ restart: boolean }>({ source: restartMutation, userId: 1, }); + // Assert expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!'); expect(data?.restart).toBeUndefined(); }); it('Should throw an error if no userId is not provided', async () => { + // Arrange const { data, errors } = await gcall<{ restart: boolean }>({ source: restartMutation }); + // Assert expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!'); expect(data?.restart).toBeUndefined(); }); it('Should set app status to restarting', async () => { + // Arrange + EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true }); const spy = jest.spyOn(TipiConfig, 'setConfig'); - const user = await createUser(); + + // Act await gcall<{ restart: boolean }>({ source: restartMutation, userId: user.id }); + // Assert expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenNthCalledWith(1, 'status', 'RESTARTING'); expect(spy).toHaveBeenNthCalledWith(2, 'status', 'RUNNING'); @@ -180,35 +193,47 @@ describe('Test: update', () => { }); it('Should return true', async () => { + // Arrange + EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true }); const user = await createUser(); + + // Act const { data } = await gcall<{ update: boolean }>({ source: updateMutation, userId: user.id }); + // Assert expect(data?.update).toBeDefined(); expect(data?.update).toBe(true); }); it("Should return an error if user doesn't exist", async () => { + // Act const { data, errors } = await gcall<{ update: boolean }>({ source: updateMutation, userId: 1 }); + // Assert expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!'); expect(data?.update).toBeUndefined(); }); it('Should throw an error if no userId is not provided', async () => { + // Act const { data, errors } = await gcall<{ update: boolean }>({ source: updateMutation }); + // Assert expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!'); expect(data?.update).toBeUndefined(); }); it('Should set app status to updating', async () => { + // Arrange + EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true }); const spy = jest.spyOn(TipiConfig, 'setConfig'); - const user = await createUser(); + + // Act await gcall<{ update: boolean }>({ source: updateMutation, userId: user.id }); + // Assert expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenNthCalledWith(1, 'status', 'UPDATING'); expect(spy).toHaveBeenNthCalledWith(2, 'status', 'RUNNING'); diff --git a/packages/system-api/src/modules/system/__tests__/system.service.test.ts b/packages/system-api/src/modules/system/__tests__/system.service.test.ts index 7f93a1ae..1a81f825 100644 --- a/packages/system-api/src/modules/system/__tests__/system.service.test.ts +++ b/packages/system-api/src/modules/system/__tests__/system.service.test.ts @@ -1,15 +1,14 @@ import fs from 'fs-extra'; import semver from 'semver'; -import childProcess from 'child_process'; import axios from 'axios'; import SystemService from '../system.service'; import { faker } from '@faker-js/faker'; import TipiCache from '../../../config/TipiCache'; import { setConfig } from '../../../core/config/TipiConfig'; import logger from '../../../config/logger/logger'; +import EventDispatcher from '../../../core/config/EventDispatcher'; jest.mock('fs-extra'); -jest.mock('child_process'); jest.mock('axios'); beforeEach(async () => { @@ -21,14 +20,14 @@ describe('Test: systemInfo', () => { it('Should throw if system-info.json does not exist', () => { try { SystemService.systemInfo(); - } catch (e) { + } catch (e: any) { expect(e).toBeDefined(); - // @ts-ignore expect(e.message).toBe('Error parsing system info'); } }); it('It should return system info', async () => { + // Arrange const info = { cpu: { load: 0.1 }, memory: { available: 1000, total: 2000, used: 1000 }, @@ -42,8 +41,10 @@ describe('Test: systemInfo', () => { // @ts-ignore fs.__createMockFiles(MockFiles); + // Act const systemInfo = SystemService.systemInfo(); + // Assert expect(systemInfo).toBeDefined(); expect(systemInfo.cpu).toBeDefined(); expect(systemInfo.memory).toBeDefined(); @@ -60,11 +61,15 @@ describe('Test: getVersion', () => { }); it('It should return version', async () => { + // Arrange const spy = jest.spyOn(axios, 'get').mockResolvedValue({ data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` }, }); + + // Act const version = await SystemService.getVersion(); + // Assert expect(version).toBeDefined(); expect(version.current).toBeDefined(); expect(semver.valid(version.latest)).toBeTruthy(); @@ -85,17 +90,20 @@ describe('Test: getVersion', () => { }); it('Should return cached version', async () => { + // Arrange const spy = jest.spyOn(axios, 'get').mockResolvedValue({ data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` }, }); - const version = await SystemService.getVersion(); + // Act + const version = await SystemService.getVersion(); + const version2 = await SystemService.getVersion(); + + // Assert expect(version).toBeDefined(); expect(version.current).toBeDefined(); expect(semver.valid(version.latest)).toBeTruthy(); - const version2 = await SystemService.getVersion(); - expect(version2.latest).toBe(version.latest); expect(version2.current).toBeDefined(); expect(semver.valid(version2.latest)).toBeTruthy(); @@ -108,88 +116,98 @@ describe('Test: getVersion', () => { describe('Test: restart', () => { it('Should return true', async () => { + // Arrange + EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true }); + + // Act const restart = await SystemService.restart(); + // Assert expect(restart).toBeTruthy(); }); it('Should log error if fails', async () => { - // @ts-ignore - const spy = jest.spyOn(childProcess, 'execFile').mockImplementation((_path, _args, _, cb) => { - // @ts-ignore - if (cb) cb('error', null, null); - }); + // Arrange + EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'fake' }); const log = jest.spyOn(logger, 'error'); + // Act const restart = await SystemService.restart(); - expect(restart).toBeTruthy(); - expect(log).toHaveBeenCalledWith('Error restarting: error'); - - spy.mockRestore(); + // Assert + expect(restart).toBeFalsy(); + expect(log).toHaveBeenCalledWith('Error restarting system: fake'); + log.mockRestore(); }); }); describe('Test: update', () => { it('Should return true', async () => { + // Arrange + EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true }); setConfig('version', '0.0.1'); TipiCache.set('latestVersion', '0.0.2'); + // Act const update = await SystemService.update(); + // Assert expect(update).toBeTruthy(); }); it('Should throw an error if latest version is not set', async () => { + // Arrange TipiCache.del('latestVersion'); const spy = jest.spyOn(axios, 'get').mockResolvedValue({ data: { name: null }, }); - setConfig('version', '0.0.1'); + // Act & Assert await expect(SystemService.update()).rejects.toThrow('Could not get latest version'); - spy.mockRestore(); }); it('Should throw if current version is higher than latest', async () => { + // Arrange setConfig('version', '0.0.2'); TipiCache.set('latestVersion', '0.0.1'); + // Act & Assert await expect(SystemService.update()).rejects.toThrow('Current version is newer than latest version'); }); it('Should throw if current version is equal to latest', async () => { + // Arrange setConfig('version', '0.0.1'); TipiCache.set('latestVersion', '0.0.1'); + // Act & Assert await expect(SystemService.update()).rejects.toThrow('Current version is already up to date'); }); it('Should throw an error if there is a major version difference', async () => { + // Arrange setConfig('version', '0.0.1'); TipiCache.set('latestVersion', '1.0.0'); + // Act & Assert await expect(SystemService.update()).rejects.toThrow('The major version has changed. Please update manually'); }); it('Should log error if fails', async () => { - // @ts-ignore - const spy = jest.spyOn(childProcess, 'execFile').mockImplementation((_path, _args, _, cb) => { - // @ts-ignore - if (cb) cb('error', null, null); - }); + // Arrange + EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'fake2' }); const log = jest.spyOn(logger, 'error'); + // Act setConfig('version', '0.0.1'); TipiCache.set('latestVersion', '0.0.2'); - const update = await SystemService.update(); - expect(update).toBeTruthy(); - expect(log).toHaveBeenCalledWith('Error updating: error'); - - spy.mockRestore(); + // Assert + expect(update).toBeFalsy(); + expect(log).toHaveBeenCalledWith('Error updating system: fake2'); + log.mockRestore(); }); }); diff --git a/packages/system-api/src/modules/system/system.service.ts b/packages/system-api/src/modules/system/system.service.ts index d3ed387d..f824f3ef 100644 --- a/packages/system-api/src/modules/system/system.service.ts +++ b/packages/system-api/src/modules/system/system.service.ts @@ -5,7 +5,7 @@ import logger from '../../config/logger/logger'; import TipiCache from '../../config/TipiCache'; import { getConfig, setConfig } from '../../core/config/TipiConfig'; import { readJsonFile } from '../fs/fs.helpers'; -import EventDispatcher, { EventTypes } from '../../core/config/EventDispatcher'; +import { eventDispatcher, EventTypes } from '../../core/config/EventDispatcher'; const systemInfoSchema = z.object({ cpu: z.object({ @@ -58,10 +58,10 @@ const getVersion = async (): Promise<{ current: string; latest?: string }> => { const restart = async (): Promise => { setConfig('status', 'RESTARTING'); - const { success } = await EventDispatcher.dispatchEventAsync(EventTypes.RESTART); + const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.RESTART); if (!success) { - logger.error('Error restarting system'); + logger.error(`Error restarting system: ${stdout}`); return false; } @@ -91,10 +91,10 @@ const update = async (): Promise => { setConfig('status', 'UPDATING'); - const { success } = await EventDispatcher.dispatchEventAsync(EventTypes.UPDATE); + const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.UPDATE); if (!success) { - logger.error('Error updating system'); + logger.error(`Error updating system: ${stdout}`); return false; } diff --git a/packages/system-api/src/server.ts b/packages/system-api/src/server.ts index 657e4c6a..50f7703d 100644 --- a/packages/system-api/src/server.ts +++ b/packages/system-api/src/server.ts @@ -18,7 +18,7 @@ import startJobs from './core/jobs/jobs'; import { applyJsonConfig, getConfig, setConfig } from './core/config/TipiConfig'; import { ZodError } from 'zod'; import systemController from './modules/system/system.controller'; -import EventDispatcher, { EventTypes } from './core/config/EventDispatcher'; +import { eventDispatcher, EventTypes } from './core/config/EventDispatcher'; let corsOptions = { credentials: true, @@ -53,7 +53,7 @@ const applyCustomConfig = () => { const main = async () => { try { - EventDispatcher.clear(); + eventDispatcher.clear(); applyCustomConfig(); const app = express(); @@ -94,8 +94,8 @@ const main = async () => { await runUpdates(); httpServer.listen(port, async () => { - await EventDispatcher.dispatchEventAsync(EventTypes.CLONE_REPO, [getConfig().appsRepoUrl]); - await EventDispatcher.dispatchEventAsync(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]); + await eventDispatcher.dispatchEventAsync(EventTypes.CLONE_REPO, [getConfig().appsRepoUrl]); + await eventDispatcher.dispatchEventAsync(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]); startJobs(); setConfig('status', 'RUNNING'); diff --git a/packages/system-api/src/test/jest-setup.ts b/packages/system-api/src/test/jest-setup.ts index 2ae4f3fd..f97708f3 100644 --- a/packages/system-api/src/test/jest-setup.ts +++ b/packages/system-api/src/test/jest-setup.ts @@ -1,5 +1,11 @@ +import { eventDispatcher } from '../core/config/EventDispatcher'; + jest.mock('../config/logger/logger', () => ({ error: jest.fn(), info: jest.fn(), warn: jest.fn(), })); + +afterAll(() => { + eventDispatcher.clear(); +});