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-node": "^10.9.1",
"typescript": "4.9.4",
"wait-for-expect": "^3.0.2",
"whatwg-fetch": "^3.6.2"
},
"msw": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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