Forráskód Böngészése

fix: return correct update info

Nicolas Meienberger 2 éve
szülő
commit
f1c295e84d

+ 1 - 0
packages/dashboard/package.json

@@ -101,6 +101,7 @@
     "ts-jest": "^29.0.3",
     "ts-node": "^10.9.1",
     "typescript": "4.9.4",
+    "wait-for-expect": "^3.0.2",
     "whatwg-fetch": "^3.6.2"
   },
   "msw": {

+ 2 - 0
packages/dashboard/src/client/mocks/fixtures/app.fixtures.ts

@@ -54,6 +54,8 @@ export const createAppEntity = (params: CreateAppEntityParams): AppWithInfo => {
     numOpened: 0,
     createdAt: faker.date.past(),
     updatedAt: faker.date.past(),
+    latestVersion: 1,
+    latestDockerVersion: '1.0.0',
     ...overrides,
   };
 };

+ 3 - 3
packages/dashboard/src/client/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.test.tsx

@@ -19,7 +19,7 @@ describe('Test: AppDetailsContainer', () => {
 
     it('should display update button when update is available', async () => {
       // Arrange
-      const app = createAppEntity({ overrides: { version: 2 }, overridesInfo: { tipi_version: 3 } });
+      const app = createAppEntity({ overrides: { version: 2, latestVersion: 3 } });
       render(<AppDetailsContainer app={app} />);
 
       // Assert
@@ -173,7 +173,7 @@ describe('Test: AppDetailsContainer', () => {
   describe('Test: Update app', () => {
     it('should display toast success when update success', async () => {
       // Arrange
-      const app = createAppEntity({ overrides: { version: 2 }, overridesInfo: { tipi_version: 3 } });
+      const app = createAppEntity({ overrides: { version: 2, latestVersion: 3 } });
       server.use(getTRPCMock({ path: ['app', 'updateApp'], type: 'mutation', response: app }));
       const { result } = renderHook(() => useToastStore());
       render(<AppDetailsContainer app={app} />);
@@ -195,7 +195,7 @@ describe('Test: AppDetailsContainer', () => {
       // Arrange
       const { result } = renderHook(() => useToastStore());
       server.use(getTRPCMockError({ path: ['app', 'updateApp'], type: 'mutation', message: 'my big error' }));
-      const app = createAppEntity({ overrides: { version: 2 }, overridesInfo: { tipi_version: 3 } });
+      const app = createAppEntity({ overrides: { version: 2, latestVersion: 3 }, overridesInfo: { tipi_version: 3 } });
       render(<AppDetailsContainer app={app} />);
 
       // Act

+ 2 - 2
packages/dashboard/src/client/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.tsx

@@ -105,7 +105,7 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
     onError: (e) => addToast({ title: 'Update error', description: e.message, status: 'error' }),
   });
 
-  const updateAvailable = Number(app?.version || 0) < Number(app?.info.tipi_version);
+  const updateAvailable = Number(app.version || 0) < Number(app?.latestVersion || 0);
 
   const handleInstallSubmit = async (values: FormValues) => {
     const { exposed, domain, ...form } = values;
@@ -144,7 +144,7 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
     }
   };
 
-  const newVersion = [app?.info.version ? `${app?.info.version}` : '', `(${String(app?.info.tipi_version)})`].join(' ');
+  const newVersion = [app?.latestDockerVersion ? `${app?.latestDockerVersion}` : '', `(${String(app?.latestVersion)})`].join(' ');
 
   return (
     <div className="card" data-testid="app-details">

+ 1 - 1
packages/dashboard/src/client/modules/Apps/pages/AppsPage/AppsPage.tsx

@@ -12,7 +12,7 @@ export const AppsPage: NextPage = () => {
   const { data, isLoading, error } = trpc.app.installedApps.useQuery();
 
   const renderApp = (app: AppRouterOutput['installedApps'][number]) => {
-    const updateAvailable = Number(app.version) < Number(app.info.tipi_version);
+    const updateAvailable = Number(app.version) < Number(app.latestVersion);
 
     if (app.info?.available) return <AppTile key={app.id} app={app.info} status={app.status} updateAvailable={updateAvailable} />;
 

+ 8 - 15
packages/dashboard/src/server/services/apps/apps.helpers.test.ts

@@ -403,33 +403,26 @@ describe('getUpdateInfo', () => {
   });
 
   it('Should return update info', async () => {
-    const updateInfo = await getUpdateInfo(app1.id, 1);
+    const updateInfo = getUpdateInfo(app1.id);
 
-    expect(updateInfo?.latest).toBe(app1.tipi_version);
-    expect(updateInfo?.current).toBe(1);
+    expect(updateInfo?.latestVersion).toBe(app1.tipi_version);
   });
 
-  it('Should return null if app is not installed', async () => {
-    const updateInfo = await getUpdateInfo(faker.random.word(), 1);
+  it('Should return default values if app is not installed', async () => {
+    const updateInfo = getUpdateInfo(faker.random.word());
 
-    expect(updateInfo).toBeNull();
+    expect(updateInfo).toEqual({ latestVersion: 0, latestDockerVersion: '0.0.0' });
   });
 
-  it('Should return null if config.json is invalid', async () => {
+  it('Should return default values if config.json is invalid', async () => {
     const { appInfo, MockFiles } = await createApp({ installed: true }, db);
     MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = 'invalid json';
     // @ts-expect-error - Mocking fs
     fs.__createMockFiles(MockFiles);
 
-    const updateInfo = await getUpdateInfo(appInfo.id, 1);
+    const updateInfo = getUpdateInfo(appInfo.id);
 
-    expect(updateInfo).toBeNull();
-  });
-
-  it('should return null if version is not provided', async () => {
-    const updateInfo = await getUpdateInfo(app1.id);
-
-    expect(updateInfo).toBe(null);
+    expect(updateInfo).toEqual({ latestVersion: 0, latestDockerVersion: '0.0.0' });
   });
 });
 

+ 24 - 31
packages/dashboard/src/server/services/apps/apps.helpers.ts

@@ -241,6 +241,30 @@ export const getAvailableApps = async () => {
   return apps;
 };
 
+/**
+ *  This function returns an object containing information about the updates available for the app with the provided id.
+ *  It checks if the app is installed or not and looks for the config.json file in the appropriate directory.
+ *  If the config.json file is invalid, it returns null.
+ *  If the app is not found, it returns null.
+ *
+ *  @param {string} id - The app id.
+ *  @param {number} [version] - The current version of the app.
+ *  @returns {Promise<{current: number, latest: number, dockerVersion: string} | null>} - Returns an object containing information about the updates available for the app or null if the app is not found or has an invalid config.json file.
+ */
+export const getUpdateInfo = (id: string) => {
+  const repoConfig = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
+  const parsedConfig = appInfoSchema.safeParse(repoConfig);
+
+  if (parsedConfig.success) {
+    return {
+      latestVersion: parsedConfig.data.tipi_version,
+      latestDockerVersion: parsedConfig.data.version,
+    };
+  }
+
+  return { latestVersion: 0, latestDockerVersion: '0.0.0' };
+};
+
 /**
  *  This function reads the config.json and metadata/description.md files for the app with the provided id,
  *  parses the config file and returns an object with app information.
@@ -284,37 +308,6 @@ export const getAppInfo = (id: string, status?: App['status']) => {
   }
 };
 
-/**
- *  This function returns an object containing information about the updates available for the app with the provided id.
- *  It checks if the app is installed or not and looks for the config.json file in the appropriate directory.
- *  If the config.json file is invalid, it returns null.
- *  If the app is not found, it returns null.
- *
- *  @param {string} id - The app id.
- *  @param {number} [version] - The current version of the app.
- *  @returns {Promise<{current: number, latest: number, dockerVersion: string} | null>} - Returns an object containing information about the updates available for the app or null if the app is not found or has an invalid config.json file.
- */
-export const getUpdateInfo = async (id: string, version?: number) => {
-  const doesFileExist = fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}`);
-
-  if (!doesFileExist || !version) {
-    return null;
-  }
-
-  const repoConfig = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
-  const parsedConfig = appInfoSchema.safeParse(repoConfig);
-
-  if (parsedConfig.success) {
-    return {
-      current: version || 0,
-      latest: parsedConfig.data.tipi_version,
-      dockerVersion: parsedConfig.data.version,
-    };
-  }
-
-  return null;
-};
-
 /**
  *  This function ensures that the app folder for the app with the provided name exists.
  *  If the cleanup parameter is set to true, it deletes the app folder if it exists.

+ 71 - 55
packages/dashboard/src/server/services/apps/apps.service.test.ts

@@ -1,4 +1,5 @@
 import fs from 'fs-extra';
+import waitForExpect from 'wait-for-expect';
 import { PrismaClient } from '@prisma/client';
 import { AppServiceClass } from './apps.service';
 import { EventDispatcher, EVENT_TYPES } from '../../core/EventDispatcher';
@@ -557,61 +558,6 @@ describe('List apps', () => {
   });
 });
 
-describe.skip('Start all apps', () => {
-  it('Should correctly start all apps', async () => {
-    // arrange
-    const app1create = await createApp({ installed: true }, db);
-    const app2create = await createApp({ installed: true }, db);
-    const app1 = app1create.appInfo;
-    const app2 = app2create.appInfo;
-    const apps = [app1, app2].sort((a, b) => a.id.localeCompare(b.id));
-    const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
-    // @ts-expect-error - Mocking fs
-    fs.__createMockFiles(Object.assign(app1create.MockFiles, app2create.MockFiles));
-
-    await AppsService.startAllApps();
-
-    expect(spy.mock.calls.length).toBe(2);
-
-    const expectedCalls = apps.map((app) => [EVENT_TYPES.APP, ['start', app.id]]);
-
-    expect(spy.mock.calls).toEqual(expect.arrayContaining(expectedCalls));
-  });
-
-  it('Should not start apps which have not status RUNNING', async () => {
-    // arrange
-    const app1 = await createApp({ installed: true, status: 'running' }, db);
-    const app2 = await createApp({ installed: true, status: 'running' }, db);
-    const app3 = await createApp({ installed: true, status: 'stopped' }, db);
-    const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
-    // @ts-expect-error - Mocking fs
-    fs.__createMockFiles(Object.assign(app1.MockFiles, app2.MockFiles, app3.MockFiles));
-
-    await AppsService.startAllApps();
-    const apps = await db.app.findMany();
-
-    expect(spy.mock.calls.length).toBe(2);
-    expect(apps.length).toBe(3);
-  });
-
-  it('Should put app status to STOPPED if start script fails', async () => {
-    // Arrange
-    await createApp({ installed: true }, db);
-    await createApp({ installed: true }, db);
-    EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
-
-    // Act
-    await AppsService.startAllApps();
-
-    const apps = await db.app.findMany();
-
-    // Assert
-    expect(apps.length).toBe(2);
-    expect(apps[0]?.status).toBe(APP_STATUS.STOPPED);
-    expect(apps[1]?.status).toBe(APP_STATUS.STOPPED);
-  });
-});
-
 describe('Update app', () => {
   it('Should correctly update app', async () => {
     const app1create = await createApp({ installed: true }, db);
@@ -646,3 +592,73 @@ describe('Update app', () => {
     expect(app?.status).toBe(APP_STATUS.STOPPED);
   });
 });
+
+describe('installedApps', () => {
+  it('Should list installed apps', async () => {
+    // Arrange
+    const app1 = await createApp({ installed: true }, db);
+    const app2 = await createApp({ installed: true }, db);
+    const app3 = await createApp({ installed: true }, db);
+    const app4 = await createApp({ installed: false }, db);
+    // @ts-expect-error - Mocking fs
+    fs.__createMockFiles(Object.assign(app1.MockFiles, app2.MockFiles, app3.MockFiles, app4.MockFiles));
+
+    // Act
+    const apps = await AppsService.installedApps();
+
+    // Assert
+    expect(apps.length).toBe(3);
+  });
+
+  it('Should not list app with invalid config', async () => {
+    // Arrange
+    const app1 = await createApp({ installed: true }, db);
+    const app2 = await createApp({ installed: true }, db);
+    const app3 = await createApp({ installed: true }, db);
+    const app4 = await createApp({ installed: false }, db);
+    // @ts-expect-error - Mocking fs
+    fs.__createMockFiles(Object.assign(app2.MockFiles, app3.MockFiles, app4.MockFiles, { [`/runtipi/repos/repo-id/apps/${app1.appInfo.id}/config.json`]: 'invalid json' }));
+
+    // Act
+    const apps = await AppsService.installedApps();
+
+    // Assert
+    expect(apps.length).toBe(2);
+  });
+});
+
+describe('startAllApps', () => {
+  it('should start all apps with status RUNNING', async () => {
+    // Arrange
+    const app1 = await createApp({ installed: true, status: 'running' }, db);
+    const app2 = await createApp({ installed: true, status: 'running' }, db);
+    const app3 = await createApp({ installed: true, status: 'stopped' }, db);
+    const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
+    // @ts-expect-error - Mocking fs
+    fs.__createMockFiles(Object.assign(app1.MockFiles, app2.MockFiles, app3.MockFiles));
+
+    // Act
+    await AppsService.startAllApps();
+
+    // Assert
+    expect(spy.mock.calls.length).toBe(2);
+  });
+
+  it('should put status to STOPPED if start script fails', async () => {
+    // Arrange
+    const app1 = await createApp({ installed: true, status: 'running' }, db);
+    const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
+    // @ts-expect-error - Mocking fs
+    fs.__createMockFiles(Object.assign(app1.MockFiles));
+    spy.mockResolvedValueOnce({ success: false, stdout: 'error' });
+
+    // Act
+    await AppsService.startAllApps();
+
+    // Assert
+    await waitForExpect(async () => {
+      const apps = await db.app.findMany();
+      expect(apps[0]?.status).toBe(APP_STATUS.STOPPED);
+    });
+  });
+});

+ 25 - 28
packages/dashboard/src/server/services/apps/apps.service.ts

@@ -1,10 +1,11 @@
 import validator from 'validator';
 import { App, PrismaClient } from '@prisma/client';
-import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, ensureAppFolder, appInfoSchema, AppInfo, getAppInfo } from './apps.helpers';
+import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, ensureAppFolder, AppInfo, getAppInfo, getUpdateInfo } from './apps.helpers';
 import { getConfig } from '../../core/TipiConfig';
 import { EventDispatcher } from '../../core/EventDispatcher';
 import { Logger } from '../../core/Logger';
-import { createFolder, readJsonFile } from '../../common/fs.helpers';
+import { createFolder } from '../../common/fs.helpers';
+import { notEmpty } from '../../common/typescript.helpers';
 
 const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
 const filterApp = (app: AppInfo): boolean => {
@@ -125,14 +126,13 @@ export class AppServiceClass {
       // Create app folder
       createFolder(`/app/storage/app-data/${id}`);
 
-      const appInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
-      const parsedAppInfo = appInfoSchema.safeParse(appInfo);
+      const appInfo = getAppInfo(id);
 
-      if (!parsedAppInfo.success) {
+      if (!appInfo) {
         throw new Error(`App ${id} has invalid config.json file`);
       }
 
-      if (!parsedAppInfo.data.exposable && exposed) {
+      if (!appInfo.exposable && exposed) {
         throw new Error(`App ${id} is not exposable`);
       }
 
@@ -143,7 +143,7 @@ export class AppServiceClass {
         }
       }
 
-      app = await this.prisma.app.create({ data: { id, status: 'installing', config: form, version: parsedAppInfo.data.tipi_version, exposed: exposed || false, domain } });
+      app = await this.prisma.app.create({ data: { id, status: 'installing', config: form, version: appInfo.tipi_version, exposed: exposed || false, domain } });
 
       if (app) {
         // Create env file
@@ -200,14 +200,13 @@ export class AppServiceClass {
       throw new Error(`App ${id} not found`);
     }
 
-    const appInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
-    const parsedAppInfo = appInfoSchema.safeParse(appInfo);
+    const appInfo = getAppInfo(app.id, app.status);
 
-    if (!parsedAppInfo.success) {
+    if (!appInfo) {
       throw new Error(`App ${id} has invalid config.json`);
     }
 
-    if (!parsedAppInfo.data.exposable && exposed) {
+    if (!appInfo.exposable && exposed) {
       throw new Error(`App ${id} is not exposable`);
     }
 
@@ -302,14 +301,14 @@ export class AppServiceClass {
   public getApp = async (id: string) => {
     let app = await this.prisma.app.findUnique({ where: { id } });
     const info = getAppInfo(id, app?.status);
-    const parsedInfo = appInfoSchema.safeParse(info);
+    const updateInfo = getUpdateInfo(id);
 
-    if (parsedInfo.success) {
+    if (info) {
       if (!app) {
         app = { id, status: 'missing', config: {}, exposed: false, domain: '' } as App;
       }
 
-      return { ...app, info: { ...parsedInfo.data } };
+      return { ...app, ...updateInfo, info };
     }
 
     throw new Error(`App ${id} has invalid config.json`);
@@ -337,10 +336,9 @@ export class AppServiceClass {
     const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['update', id]);
 
     if (success) {
-      const appInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
-      const parsedAppInfo = appInfoSchema.parse(appInfo);
+      const appInfo = getAppInfo(app.id, app.status);
 
-      await this.prisma.app.update({ where: { id }, data: { status: 'running', version: parsedAppInfo.tipi_version } });
+      await this.prisma.app.update({ where: { id }, data: { status: 'running', version: appInfo?.tipi_version } });
     } else {
       await this.prisma.app.update({ where: { id }, data: { status: 'stopped' } });
       throw new Error(`App ${id} failed to update\nstdout: ${stdout}`);
@@ -354,20 +352,19 @@ export class AppServiceClass {
    * Returns a list of all installed apps
    *
    * @returns {Promise<App[]>} - An array of app objects
-   * @throws {Error} - If the app is not found or if the update process fails.
    */
   public installedApps = async () => {
     const apps = await this.prisma.app.findMany();
 
-    return apps.map((app) => {
-      const info = readJsonFile(`/runtipi/apps/${app.id}/config.json`);
-      const parsedInfo = appInfoSchema.safeParse(info);
-
-      if (parsedInfo.success) {
-        return { ...app, info: { ...parsedInfo.data } };
-      }
-
-      throw new Error(`App ${app.id} has invalid config.json`);
-    });
+    return apps
+      .map((app) => {
+        const info = getAppInfo(app.id, app.status);
+        const updateInfo = getUpdateInfo(app.id);
+        if (info) {
+          return { ...app, ...updateInfo, info };
+        }
+        return null;
+      })
+      .filter(notEmpty);
   };
 }

+ 6 - 0
pnpm-lock.yaml

@@ -99,6 +99,7 @@ importers:
       typescript: 4.9.4
       uuid: ^9.0.0
       validator: ^13.7.0
+      wait-for-expect: ^3.0.2
       whatwg-fetch: ^3.6.2
       winston: ^3.7.2
       zod: ^3.19.1
@@ -189,6 +190,7 @@ importers:
       ts-jest: 29.0.3_iyz3vhhlowkpp2xbqliblzwv3y
       ts-node: 10.9.1_awa2wsr5thmg3i7jqycphctjfq
       typescript: 4.9.4
+      wait-for-expect: 3.0.2
       whatwg-fetch: 3.6.2
 
   packages/system-api:
@@ -11306,6 +11308,10 @@ packages:
       xml-name-validator: 4.0.0
     dev: true
 
+  /wait-for-expect/3.0.2:
+    resolution: {integrity: sha512-cfS1+DZxuav1aBYbaO/kE06EOS8yRw7qOFoD3XtjTkYvCvh3zUvNST8DXK/nPaeqIzIv3P3kL3lRJn8iwOiSag==}
+    dev: true
+
   /walker/1.0.8:
     resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
     dependencies: