fix: return correct update info

This commit is contained in:
Nicolas Meienberger 2023-02-12 01:06:15 +01:00 committed by Nicolas Meienberger
parent a47606b472
commit f1c295e84d
10 changed files with 143 additions and 135 deletions

View file

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

View file

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

View file

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

View file

@ -105,7 +105,7 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
onError: (e) => addToast({ title: 'Update error', description: e.message, status: 'error' }), 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 handleInstallSubmit = async (values: FormValues) => {
const { exposed, domain, ...form } = values; 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 ( return (
<div className="card" data-testid="app-details"> <div className="card" data-testid="app-details">

View file

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

View file

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

View file

@ -241,6 +241,30 @@ export const getAvailableApps = async () => {
return apps; 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, * 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. * 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. * 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. * If the cleanup parameter is set to true, it deletes the app folder if it exists.

View file

@ -1,4 +1,5 @@
import fs from 'fs-extra'; import fs from 'fs-extra';
import waitForExpect from 'wait-for-expect';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { AppServiceClass } from './apps.service'; import { AppServiceClass } from './apps.service';
import { EventDispatcher, EVENT_TYPES } from '../../core/EventDispatcher'; 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', () => { describe('Update app', () => {
it('Should correctly update app', async () => { it('Should correctly update app', async () => {
const app1create = await createApp({ installed: true }, db); const app1create = await createApp({ installed: true }, db);
@ -646,3 +592,73 @@ describe('Update app', () => {
expect(app?.status).toBe(APP_STATUS.STOPPED); 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);
});
});
});

View file

@ -1,10 +1,11 @@
import validator from 'validator'; import validator from 'validator';
import { App, PrismaClient } from '@prisma/client'; 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 { getConfig } from '../../core/TipiConfig';
import { EventDispatcher } from '../../core/EventDispatcher'; import { EventDispatcher } from '../../core/EventDispatcher';
import { Logger } from '../../core/Logger'; 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 sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
const filterApp = (app: AppInfo): boolean => { const filterApp = (app: AppInfo): boolean => {
@ -125,14 +126,13 @@ export class AppServiceClass {
// Create app folder // Create app folder
createFolder(`/app/storage/app-data/${id}`); createFolder(`/app/storage/app-data/${id}`);
const appInfo = readJsonFile(`/runtipi/apps/${id}/config.json`); const appInfo = getAppInfo(id);
const parsedAppInfo = appInfoSchema.safeParse(appInfo);
if (!parsedAppInfo.success) { if (!appInfo) {
throw new Error(`App ${id} has invalid config.json file`); 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`); 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) { if (app) {
// Create env file // Create env file
@ -200,14 +200,13 @@ export class AppServiceClass {
throw new Error(`App ${id} not found`); throw new Error(`App ${id} not found`);
} }
const appInfo = readJsonFile(`/runtipi/apps/${id}/config.json`); const appInfo = getAppInfo(app.id, app.status);
const parsedAppInfo = appInfoSchema.safeParse(appInfo);
if (!parsedAppInfo.success) { if (!appInfo) {
throw new Error(`App ${id} has invalid config.json`); 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`); throw new Error(`App ${id} is not exposable`);
} }
@ -302,14 +301,14 @@ export class AppServiceClass {
public getApp = async (id: string) => { public getApp = async (id: string) => {
let app = await this.prisma.app.findUnique({ where: { id } }); let app = await this.prisma.app.findUnique({ where: { id } });
const info = getAppInfo(id, app?.status); const info = getAppInfo(id, app?.status);
const parsedInfo = appInfoSchema.safeParse(info); const updateInfo = getUpdateInfo(id);
if (parsedInfo.success) { if (info) {
if (!app) { if (!app) {
app = { id, status: 'missing', config: {}, exposed: false, domain: '' } as 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`); 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]); const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['update', id]);
if (success) { if (success) {
const appInfo = readJsonFile(`/runtipi/apps/${id}/config.json`); const appInfo = getAppInfo(app.id, app.status);
const parsedAppInfo = appInfoSchema.parse(appInfo);
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 { } else {
await this.prisma.app.update({ where: { id }, data: { status: 'stopped' } }); await this.prisma.app.update({ where: { id }, data: { status: 'stopped' } });
throw new Error(`App ${id} failed to update\nstdout: ${stdout}`); 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 a list of all installed apps
* *
* @returns {Promise<App[]>} - An array of app objects * @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 () => { public installedApps = async () => {
const apps = await this.prisma.app.findMany(); const apps = await this.prisma.app.findMany();
return apps.map((app) => { return apps
const info = readJsonFile(`/runtipi/apps/${app.id}/config.json`); .map((app) => {
const parsedInfo = appInfoSchema.safeParse(info); const info = getAppInfo(app.id, app.status);
const updateInfo = getUpdateInfo(app.id);
if (parsedInfo.success) { if (info) {
return { ...app, info: { ...parsedInfo.data } }; return { ...app, ...updateInfo, info };
} }
return null;
throw new Error(`App ${app.id} has invalid config.json`); })
}); .filter(notEmpty);
}; };
} }

View file

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