ソースを参照

Merge pull request #15 from meienberger/tests/jest-setup

Tests/jest setup
Nicolas Meienberger 3 年 前
コミット
fa0a7d0764
35 ファイル変更1190 行追加1848 行削除
  1. 48 0
      .github/workflows/ci.yml
  2. 1 0
      .gitignore
  3. 1 0
      apps/filebrowser/config.json
  4. 1 0
      apps/freshrss/config.json
  5. 1 0
      apps/invidious/config.json
  6. 2 3
      apps/jackett/config.json
  7. 1 0
      apps/jellyfin/config.json
  8. 1 0
      apps/joplin/config.json
  9. 1 0
      apps/n8n/config.json
  10. 1 0
      apps/nextcloud/config.json
  11. 22 21
      apps/pihole/config.json
  12. 1 0
      apps/radarr/config.json
  13. 2 3
      apps/sonarr/config.json
  14. 1 0
      apps/syncthing/config.json
  15. 1 0
      apps/tailscale/config.json
  16. 1 0
      apps/transmission/config.json
  17. 1 0
      apps/wg-easy/config.json
  18. 0 4
      clean.sh
  19. 3 0
      packages/dashboard/package.json
  20. 1 1
      packages/system-api/.eslintignore
  21. 1 1
      packages/system-api/.eslintrc.cjs
  22. 9 0
      packages/system-api/__mocks__/child_process.ts
  23. 86 0
      packages/system-api/__mocks__/fs.ts
  24. 7 0
      packages/system-api/jest.config.cjs
  25. 16 9
      packages/system-api/package.json
  26. 1 0
      packages/system-api/src/config/types.ts
  27. 258 0
      packages/system-api/src/modules/apps/__tests__/apps.service.test.ts
  28. 12 108
      packages/system-api/src/modules/apps/apps.controller.ts
  29. 47 4
      packages/system-api/src/modules/apps/apps.helpers.ts
  30. 101 0
      packages/system-api/src/modules/apps/apps.service.ts
  31. 2 0
      packages/system-api/src/modules/fs/fs.helpers.ts
  32. 2 2
      packages/system-api/src/modules/network/network.controller.ts
  33. 3 0
      packages/system-api/tests/dotenv-config.ts
  34. 3 3
      packages/system-api/tsconfig.json
  35. 551 1689
      pnpm-lock.yaml

+ 48 - 0
.github/workflows/ci.yml

@@ -0,0 +1,48 @@
+name: Tipi CI
+on:
+  push:
+
+env:
+  ROOT_FOLDER: /test    
+    
+jobs:
+  cache-and-install:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+
+      - name: Install Node.js
+        uses: actions/setup-node@v3
+        with:
+          node-version: 16
+
+      - uses: pnpm/action-setup@v2.0.1
+        name: Install pnpm
+        id: pnpm-install
+        with:
+          version: 7
+          run_install: false
+
+      - name: Get pnpm store directory
+        id: pnpm-cache
+        run: |
+          echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
+
+      - uses: actions/cache@v3
+        name: Setup pnpm cache
+        with:
+          path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
+          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
+          restore-keys: |
+            ${{ runner.os }}-pnpm-store-
+
+      - name: Install dependencies
+        run: pnpm install
+
+      - name: Run tests
+        run: pnpm -r lint
+      
+      - name: Run tests
+        run: pnpm -r test

+ 1 - 0
.gitignore

@@ -1,3 +1,4 @@
+.pnpm-debug.log
 .env
 .env*
 node_modules/

+ 1 - 0
apps/filebrowser/config.json

@@ -1,5 +1,6 @@
 {
   "name": "File Browser",
+  "available": true,
   "port": 8096,
   "id": "filebrowser",
   "description": "Reliable and Performant File Management Desktop Sync and File Sharing",

+ 1 - 0
apps/freshrss/config.json

@@ -1,5 +1,6 @@
 {
   "name": "FreshRSS",
+  "available": true,
   "port": 8086,
   "id": "freshrss",
   "description": "FreshRSS is a self-hosted RSS feed aggregator like Leed or Kriss Feed.\nIt is lightweight, easy to work with, powerful, and customizable.\n\nIt is a multi-user application with an anonymous reading mode. It supports custom tags. There is an API for (mobile) clients, and a Command-Line Interface.\n\nThanks to the WebSub standard (formerly PubSubHubbub), FreshRSS is able to receive instant push notifications from compatible sources, such as Mastodon, Friendica, WordPress, Blogger, FeedBurner, etc.\n\nFreshRSS natively supports basic Web scraping, based on XPath, for Web sites not providing any RSS / Atom feed.\n\nFinally, it supports extensions for further tuning.",

+ 1 - 0
apps/invidious/config.json

@@ -1,5 +1,6 @@
 {
   "name": "Invidious",
+  "available": true,
   "port": 8095,
   "id": "invidious",
   "description": "",

+ 2 - 3
apps/jackett/config.json

@@ -1,5 +1,6 @@
 {
   "name": "Jackett",
+  "available": true,
   "port": 8097,
   "id": "jackett",
   "description": "Jackett works as a proxy server: it translates queries from apps (Sonarr, Radarr, SickRage, CouchPotato, Mylar3, Lidarr, DuckieTV, qBittorrent, Nefarious etc.) into tracker-site-specific http queries, parses the html or json response, and then sends results back to the requesting software. This allows for getting recent uploads (like RSS) and performing searches.",
@@ -7,7 +8,5 @@
   "author": "",
   "source": "https://github.com/Jackett/Jackett",
   "image": "https://avatars.githubusercontent.com/u/15383019?s=200&v=4",
-  "form_fields": {
-    
-  }
+  "form_fields": {}
 }

+ 1 - 0
apps/jellyfin/config.json

@@ -1,5 +1,6 @@
 {
   "name": "Jellyfin",
+  "available": true,
   "port": 8091,
   "id": "jellyfin",
   "description": "",

+ 1 - 0
apps/joplin/config.json

@@ -1,5 +1,6 @@
 {
   "name": "Joplin Server",
+  "available": true,
   "port": 8099,
   "id": "joplin",
   "description": "",

+ 1 - 0
apps/n8n/config.json

@@ -1,5 +1,6 @@
 {
   "name": "n8n",
+  "available": true,
   "port": 8094,
   "id": "n8n",
   "description": "n8n is an extendable workflow automation tool. With a fair-code distribution model, n8n will always have visible source code, be available to self-host, and allow you to add your own custom functions, logic and apps. n8n's node-based approach makes it highly versatile, enabling you to connect anything to everything.",

+ 1 - 0
apps/nextcloud/config.json

@@ -1,5 +1,6 @@
 {
   "name": "Nextcloud",
+  "available": true,
   "port": 8083,
   "id": "nextcloud",
   "description": "Nextcloud is a self-hosted, open source, and fully-featured cloud storage solution for your personal files, office documents, and photos.",

+ 22 - 21
apps/pihole/config.json

@@ -1,23 +1,24 @@
 {
-    "name": "PiHole",
-    "port": 8081,
-    "requirements": {
-        "ports": [53]
-    },
-    "id": "pihole",
-    "description": "",
-    "short_desc": "",
-    "author": "",
-    "source": "",
-    "image": "https://avatars.githubusercontent.com/u/16827203?s=200&v=4",
-    "form_fields": {
-        "password": {
-            "type": "password",
-            "label": "Password",
-            "max": 50,
-            "min": 3,
-            "required": true,
-            "env_variable": "APP_PASSWORD"
-        }
+  "name": "PiHole",
+  "available": true,
+  "port": 8081,
+  "requirements": {
+    "ports": [53]
+  },
+  "id": "pihole",
+  "description": "",
+  "short_desc": "",
+  "author": "",
+  "source": "",
+  "image": "https://avatars.githubusercontent.com/u/16827203?s=200&v=4",
+  "form_fields": {
+    "password": {
+      "type": "password",
+      "label": "Password",
+      "max": 50,
+      "min": 3,
+      "required": true,
+      "env_variable": "APP_PASSWORD"
     }
-}
+  }
+}

+ 1 - 0
apps/radarr/config.json

@@ -1,5 +1,6 @@
 {
   "name": "Radarr",
+  "available": true,
   "port": 8088,
   "id": "radarr",
   "description": "",

+ 2 - 3
apps/sonarr/config.json

@@ -1,5 +1,6 @@
 {
   "name": "Sonarr",
+  "available": true,
   "port": 8098,
   "id": "sonarr",
   "description": "",
@@ -7,7 +8,5 @@
   "author": "",
   "source": "",
   "image": "https://avatars.githubusercontent.com/u/1082903?s=200&v=4",
-  "form_fields": {
-
-  }
+  "form_fields": {}
 }

+ 1 - 0
apps/syncthing/config.json

@@ -1,5 +1,6 @@
 {
   "name": "Syncthing",
+  "available": true,
   "port": 8090,
   "id": "syncthing",
   "description": "Syncthing is a peer-to-peer continuous file synchronization program. It synchronizes files between two or more computers in real time, safely protected from prying eyes. Your data is your data alone and you deserve to choose where it is stored, whether it is shared with some third party, and how it's transmitted over the internet.\n\nInstall the Syncthing app on your Umbrel and pair it with the Syncthing app on your phone or computer for a self hosted peer-to-peer backup solution.",

+ 1 - 0
apps/tailscale/config.json

@@ -1,5 +1,6 @@
 {
   "name": "Tailscale",
+  "available": true,
   "port": 8093,
   "id": "tailscale",
   "description": "",

+ 1 - 0
apps/transmission/config.json

@@ -1,5 +1,6 @@
 {
   "name": "Transmission",
+  "available": true,
   "port": 8089,
   "requirements": {
     "ports": [51413]

+ 1 - 0
apps/wg-easy/config.json

@@ -1,5 +1,6 @@
 {
   "name": "Wireguard",
+  "available": true,
   "port": 8082,
   "requirements": {
     "ports": [51820]

+ 0 - 4
clean.sh

@@ -1,4 +0,0 @@
-# Script to clean up the setup
-./scripts/stop.sh
-
-sudo rm -rf app-data/**

+ 3 - 0
packages/dashboard/package.json

@@ -30,12 +30,15 @@
     "zustand": "^3.7.2"
   },
   "devDependencies": {
+    "@babel/core": "^7.0.0",
     "@types/js-cookie": "^3.0.2",
     "@types/node": "17.0.31",
     "@types/react": "18.0.8",
     "@types/react-dom": "18.0.3",
     "@types/validator": "^13.7.2",
     "@typescript-eslint/eslint-plugin": "^5.18.0",
+    "@typescript-eslint/parser": "^5.0.0",
+    "eslint-plugin-import": "^2.25.3",
     "autoprefixer": "^10.4.4",
     "eslint": "8.12.0",
     "eslint-config-airbnb-typescript": "^17.0.0",

+ 1 - 1
packages/system-api/.eslintignore

@@ -1,3 +1,3 @@
 node_modules/
 dist/
-*.cjs
+*.cjs

+ 1 - 1
packages/system-api/.eslintrc.cjs

@@ -1,5 +1,5 @@
 module.exports = {
-  env: { node: true },
+  env: { node: true, jest: true },
   extends: ['airbnb-typescript', 'eslint:recommended', 'plugin:import/typescript'],
   parser: '@typescript-eslint/parser',
   parserOptions: {

+ 9 - 0
packages/system-api/__mocks__/child_process.ts

@@ -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;

+ 86 - 0
packages/system-api/__mocks__/fs.ts

@@ -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;

+ 7 - 0
packages/system-api/jest.config.cjs

@@ -0,0 +1,7 @@
+/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
+module.exports = {
+  preset: 'ts-jest',
+  testEnvironment: 'node',
+  testMatch: ['**/__tests__/**/*.test.ts'],
+  setupFiles: ['<rootDir>/tests/dotenv-config.ts'],
+};

+ 16 - 9
packages/system-api/package.json

@@ -2,12 +2,15 @@
   "name": "system-api",
   "version": "1.0.0",
   "description": "",
-  "main": "src/server.ts",
+  "exports": "./dist/server.js",
   "type": "module",
+  "engines": {
+    "node": ">=14.16"
+  },
   "scripts": {
     "clean": "rimraf dist",
     "lint": "eslint . --ext .ts",
-    "test": "echo \"Error: no test specified\" && exit 1",
+    "test": "jest",
     "build-prod": "esbuild --bundle src/server.ts --outdir=dist --allow-overwrite --sourcemap --platform=node --minify --analyze=verbose --external:./node_modules/* --format=esm",
     "build:watch": "esbuild --bundle src/server.ts --outdir=dist --allow-overwrite --sourcemap --platform=node --external:./node_modules/* --format=esm --watch",
     "start:dev": "NODE_ENV=development nodemon --trace-deprecation --trace-warnings --watch dist dist/server.js",
@@ -24,7 +27,7 @@
     "dotenv": "^16.0.0",
     "express": "^4.17.3",
     "helmet": "^5.0.2",
-    "internal-ip": "^7.0.0",
+    "internal-ip": "^6.0.0",
     "jsonwebtoken": "^8.5.1",
     "node-port-scanner": "^3.0.1",
     "p-iteration": "^1.1.8",
@@ -33,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",
@@ -41,23 +45,26 @@
     "@types/cookie-parser": "^1.4.3",
     "@types/cors": "^2.8.12",
     "@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",
     "@types/validator": "^13.7.2",
+    "@typescript-eslint/eslint-plugin": "^5.18.0",
+    "@typescript-eslint/parser": "^5.22.0",
     "concurrently": "^7.1.0",
     "esbuild": "^0.14.32",
     "eslint": "^8.13.0",
     "eslint-config-airbnb-typescript": "^17.0.0",
-    "eslint-config-hardcore": "^24.5.0",
     "eslint-config-prettier": "^8.5.0",
-    "eslint-config-react": "^1.1.7",
     "eslint-plugin-import": "^2.26.0",
     "eslint-plugin-prettier": "^4.0.0",
-    "eslint-plugin-react": "^7.29.4",
-    "eslint-plugin-unicorn": "^42.0.0",
+    "jest": "^28.1.0",
     "nodemon": "^2.0.15",
-    "prettier": "2.6.2"
+    "prettier": "2.6.2",
+    "ts-jest": "^28.0.2",
+    "typescript": "4.6.4"
   }
 }

+ 1 - 0
packages/system-api/src/config/types.ts

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

+ 258 - 0
packages/system-api/src/modules/apps/__tests__/apps.service.test.ts

@@ -0,0 +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', () => {
+  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');
+  });
+});

+ 12 - 108
packages/system-api/src/modules/apps/apps.controller.ts

@@ -1,34 +1,7 @@
 import { NextFunction, Request, Response } from 'express';
-import si from 'systeminformation';
-import { appNames } from '../../config/apps';
+import AppsService from './apps.service';
 import { AppConfig } from '../../config/types';
-import { createFolder, fileExists, readJsonFile, writeFile, readFile } from '../fs/fs.helpers';
-import { checkAppExists, checkAppRequirements, checkEnvFile, ensureAppState, getInitalFormValues, runAppScript } from './apps.helpers';
-
-type AppsState = { installed: string };
-
-const getStateFile = (): AppsState => {
-  return readJsonFile('/state/apps.json');
-};
-
-const generateEnvFile = (appName: string, form: Record<string, string>) => {
-  const configFile: AppConfig = readJsonFile(`/apps/${appName}/config.json`);
-  const baseEnvFile = readFile('/.env').toString();
-  let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
-
-  Object.keys(configFile.form_fields).forEach((key) => {
-    const value = form[key];
-
-    if (value) {
-      const envVar = configFile.form_fields[key].env_variable;
-      envFile += `${envVar}=${value}\n`;
-    } else if (configFile.form_fields[key].required) {
-      throw new Error(`Variable ${key} is required`);
-    }
-  });
-
-  writeFile(`/app-data/${appName}/app.env`, envFile);
-};
+import { getInitalFormValues } from './apps.helpers';
 
 const uninstallApp = async (req: Request, res: Response, next: NextFunction) => {
   try {
@@ -38,11 +11,7 @@ const uninstallApp = async (req: Request, res: Response, next: NextFunction) =>
       throw new Error('App name is required');
     }
 
-    checkAppExists(appName);
-    ensureAppState(appName, false);
-
-    // Run script
-    await runAppScript(['uninstall', appName]);
+    await AppsService.uninstallApp(appName);
 
     res.status(200).json({ message: 'App uninstalled successfully' });
   } catch (e) {
@@ -58,9 +27,7 @@ const stopApp = async (req: Request, res: Response, next: NextFunction) => {
       throw new Error('App name is required');
     }
 
-    checkAppExists(appName);
-    // Run script
-    await runAppScript(['stop', appName]);
+    await AppsService.stopApp(appName);
 
     res.status(200).json({ message: 'App stopped successfully' });
   } catch (e) {
@@ -77,8 +44,7 @@ const updateAppConfig = async (req: Request, res: Response, next: NextFunction)
       throw new Error('App name is required');
     }
 
-    checkAppExists(appName);
-    generateEnvFile(appName, form);
+    AppsService.updateAppConfig(appName, form);
 
     res.status(200).json({ message: 'App updated successfully' });
   } catch (e) {
@@ -94,15 +60,9 @@ const getAppInfo = async (req: Request, res: Response<AppConfig>, next: NextFunc
       throw new Error('App name is required');
     }
 
-    const dockerContainers = await si.dockerContainers();
-    const configFile: AppConfig = readJsonFile(`/apps/${id}/config.json`);
-
-    const state = getStateFile();
-    const installed: string[] = state.installed.split(' ').filter(Boolean);
-    configFile.installed = installed.includes(id);
-    configFile.status = (dockerContainers.find((container) => container.name === `${id}`)?.state as 'running') || 'stopped';
+    const appInfo = await AppsService.getAppInfo(id);
 
-    res.status(200).json(configFile);
+    res.status(200).json(appInfo);
   } catch (e) {
     next(e);
   }
@@ -110,25 +70,7 @@ const getAppInfo = async (req: Request, res: Response<AppConfig>, next: NextFunc
 
 const listApps = async (req: Request, res: Response, next: NextFunction) => {
   try {
-    const apps = appNames
-      .map((app) => {
-        try {
-          return readJsonFile(`/apps/${app}/config.json`);
-        } catch {
-          return null;
-        }
-      })
-      .filter(Boolean);
-
-    const dockerContainers = await si.dockerContainers();
-
-    const state = getStateFile();
-    const installed: string[] = state.installed.split(' ').filter(Boolean);
-
-    apps.forEach((app) => {
-      app.installed = installed.includes(app.id);
-      app.status = dockerContainers.find((container) => container.name === `${app.id}`)?.state || 'stopped';
-    });
+    const apps = await AppsService.listApps();
 
     res.status(200).json(apps);
   } catch (e) {
@@ -138,23 +80,13 @@ const listApps = async (req: Request, res: Response, next: NextFunction) => {
 
 const startApp = async (req: Request, res: Response, next: NextFunction) => {
   try {
-    const { id: appName } = req.params;
+    const { id } = req.params;
 
-    if (!appName) {
+    if (!id) {
       throw new Error('App name is required');
     }
 
-    checkAppExists(appName);
-    checkEnvFile(appName);
-
-    // Regenerate env file
-    const form = getInitalFormValues(appName);
-    generateEnvFile(appName, form);
-
-    // Run script
-    await runAppScript(['start', appName]);
-
-    ensureAppState(appName, true);
+    await AppsService.startApp(id);
 
     res.status(200).json({ message: 'App started successfully' });
   } catch (e) {
@@ -171,35 +103,7 @@ const installApp = async (req: Request, res: Response, next: NextFunction) => {
       throw new Error('App name is required');
     }
 
-    const appIsAvailable = appNames.includes(id);
-
-    if (!appIsAvailable) {
-      throw new Error(`App ${id} not available`);
-    }
-
-    const appExists = fileExists(`/app-data/${id}`);
-
-    if (appExists) {
-      await startApp(req, res, next);
-    } else {
-      const appIsValid = await checkAppRequirements(id);
-
-      if (!appIsValid) {
-        throw new Error(`App ${id} requirements not met`);
-      }
-
-      // Create app folder
-      createFolder(`/app-data/${id}`);
-
-      // Create env file
-      generateEnvFile(id, form);
-      ensureAppState(id, true);
-
-      // Run script
-      await runAppScript(['install', id]);
-
-      res.status(200).json({ message: 'App installed successfully' });
-    }
+    await AppsService.installApp(id, form);
   } catch (e) {
     next(e);
   }

+ 47 - 4
packages/system-api/src/modules/apps/apps.helpers.ts

@@ -1,8 +1,10 @@
 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 { internalIpV4 } from 'internal-ip';
+import { fileExists, readdirSync, readFile, readJsonFile, runScript, writeFile } from '../fs/fs.helpers';
+import InternalIp from 'internal-ip';
+
+type AppsState = { installed: string };
 
 export const checkAppRequirements = async (appName: string) => {
   let valid = true;
@@ -10,7 +12,7 @@ export const checkAppRequirements = async (appName: string) => {
 
   if (configFile.requirements?.ports) {
     await p.forEachSeries(configFile.requirements.ports, async (port: number) => {
-      const ip = await internalIpV4();
+      const ip = await InternalIp.v4();
       const used = await portUsed.check(port, ip);
 
       if (used) valid = false;
@@ -94,8 +96,49 @@ 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));
     }
   }
 };
+
+export const generateEnvFile = (appName: string, form: Record<string, string>) => {
+  const configFile: AppConfig = readJsonFile(`/apps/${appName}/config.json`);
+  const baseEnvFile = readFile('/.env').toString();
+  let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
+
+  Object.keys(configFile.form_fields).forEach((key) => {
+    const value = form[key];
+
+    if (value) {
+      const envVar = configFile.form_fields[key].env_variable;
+      envFile += `${envVar}=${value}\n`;
+    } else if (configFile.form_fields[key].required) {
+      throw new Error(`Variable ${key} is required`);
+    }
+  });
+
+  writeFile(`/app-data/${appName}/app.env`, envFile);
+};
+
+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;
+};

+ 101 - 0
packages/system-api/src/modules/apps/apps.service.ts

@@ -0,0 +1,101 @@
+import si from 'systeminformation';
+import { AppConfig } from '../../config/types';
+import { createFolder, fileExists, readJsonFile } from '../fs/fs.helpers';
+import { checkAppExists, checkAppRequirements, checkEnvFile, ensureAppState, generateEnvFile, getAvailableApps, getInitalFormValues, getStateFile, runAppScript } from './apps.helpers';
+
+const startApp = async (appName: string): Promise<void> => {
+  checkAppExists(appName);
+  checkEnvFile(appName);
+
+  // Regenerate env file
+  const form = getInitalFormValues(appName);
+  generateEnvFile(appName, form);
+
+  // Run script
+  await runAppScript(['start', appName]);
+
+  ensureAppState(appName, true);
+};
+
+const installApp = async (id: string, form: Record<string, string>): Promise<void> => {
+  const appExists = fileExists(`/app-data/${id}`);
+
+  if (appExists) {
+    await startApp(id);
+  } else {
+    const appIsValid = await checkAppRequirements(id);
+
+    if (!appIsValid) {
+      throw new Error(`App ${id} requirements not met`);
+    }
+
+    // Create app folder
+    createFolder(`/app-data/${id}`);
+
+    // Create env file
+    generateEnvFile(id, form);
+    ensureAppState(id, true);
+
+    // Run script
+    await runAppScript(['install', id]);
+  }
+
+  return Promise.resolve();
+};
+
+const listApps = async (): Promise<AppConfig[]> => {
+  const apps: AppConfig[] = getAvailableApps()
+    .map((app) => {
+      try {
+        return readJsonFile(`/apps/${app}/config.json`);
+      } catch {
+        return null;
+      }
+    })
+    .filter(Boolean);
+
+  const dockerContainers = await si.dockerContainers();
+
+  const state = getStateFile();
+  const installed: string[] = state.installed.split(' ').filter(Boolean);
+
+  apps.forEach((app) => {
+    app.installed = installed.includes(app.id);
+    app.status = (dockerContainers.find((container) => container.name === `${app.id}`)?.state as 'running') || 'stopped';
+  });
+
+  return apps;
+};
+
+const getAppInfo = async (id: string): Promise<AppConfig> => {
+  const dockerContainers = await si.dockerContainers();
+  const configFile: AppConfig = readJsonFile(`/apps/${id}/config.json`);
+
+  const state = getStateFile();
+  const installed: string[] = state.installed.split(' ').filter(Boolean);
+  configFile.installed = installed.includes(id);
+  configFile.status = (dockerContainers.find((container) => container.name === `${id}`)?.state as 'running') || 'stopped';
+
+  return configFile;
+};
+
+const updateAppConfig = async (id: string, form: Record<string, string>): Promise<void> => {
+  checkAppExists(id);
+  generateEnvFile(id, form);
+};
+
+const stopApp = async (id: string): Promise<void> => {
+  checkAppExists(id);
+  // Run script
+  await runAppScript(['stop', id]);
+};
+
+const uninstallApp = async (id: string): Promise<void> => {
+  checkAppExists(id);
+  ensureAppState(id, false);
+
+  // Run script
+  await runAppScript(['uninstall', id]);
+};
+
+export default { installApp, startApp, listApps, getAppInfo, updateAppConfig, stopApp, uninstallApp };

+ 2 - 0
packages/system-api/src/modules/fs/fs.helpers.ts

@@ -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);

+ 2 - 2
packages/system-api/src/modules/network/network.controller.ts

@@ -1,7 +1,7 @@
 import { Request, Response } from 'express';
 import publicIp from 'public-ip';
 import portScanner from 'node-port-scanner';
-import { internalIpV4 } from 'internal-ip';
+import internalIp from 'internal-ip';
 
 const isPortOpen = async (req: Request, res: Response<boolean>) => {
   const { port } = req.params;
@@ -14,7 +14,7 @@ const isPortOpen = async (req: Request, res: Response<boolean>) => {
 };
 
 const getInternalIp = async (req: Request, res: Response<string>) => {
-  const ip = await internalIpV4();
+  const ip = await internalIp.v4();
 
   res.status(200).send(ip);
 };

+ 3 - 0
packages/system-api/tests/dotenv-config.ts

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

+ 3 - 3
packages/system-api/tsconfig.json

@@ -8,13 +8,13 @@
     "forceConsistentCasingInFileNames": true,
     "noEmit": true,
     "esModuleInterop": true,
-    "module": "esnext",
+    "module": "commonjs",
     "moduleResolution": "node",
     "resolveJsonModule": true,
-    "isolatedModules": true,
+    "isolatedModules": false,
     "jsx": "preserve",
     "incremental": true
   },
-  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs"],
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "jest.config.cjs"],
   "exclude": ["node_modules"]
 }

ファイルの差分が大きいため隠しています
+ 551 - 1689
pnpm-lock.yaml


この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません