From 31b8320afd298cd9fb6002bbe9e1f7419ab80d89 Mon Sep 17 00:00:00 2001 From: axel7083 <42176370+axel7083@users.noreply.github.com> Date: Thu, 7 Mar 2024 15:56:25 +0100 Subject: [PATCH] feat(models): download to tmp file Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> --- packages/backend/src/utils/downloader.spec.ts | 134 ++++++++++++++++++ packages/backend/src/utils/downloader.ts | 45 +++--- 2 files changed, 163 insertions(+), 16 deletions(-) create mode 100644 packages/backend/src/utils/downloader.spec.ts diff --git a/packages/backend/src/utils/downloader.spec.ts b/packages/backend/src/utils/downloader.spec.ts new file mode 100644 index 000000000..772a95a55 --- /dev/null +++ b/packages/backend/src/utils/downloader.spec.ts @@ -0,0 +1,134 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { vi, test, expect, beforeEach } from 'vitest'; +import { Downloader } from './downloader'; +import { EventEmitter } from '@podman-desktop/api'; +import { createWriteStream, promises, type WriteStream } from 'node:fs'; + +vi.mock('@podman-desktop/api', () => { + return { + EventEmitter: vi.fn(), + }; +}); + +vi.mock('node:https', () => { + return { + default: { + get: vi.fn(), + }, + }; +}); + +vi.mock('node:fs', () => { + return { + createWriteStream: vi.fn(), + existsSync: vi.fn(), + promises: { + rename: vi.fn(), + rm: vi.fn(), + }, + }; +}); + +beforeEach(() => { + const listeners: ((value: unknown) => void)[] = []; + + vi.mocked(EventEmitter).mockReturnValue({ + event: vi.fn().mockImplementation(callback => { + listeners.push(callback); + }), + fire: vi.fn().mockImplementation((content: unknown) => { + listeners.forEach(listener => listener(content)); + }), + } as unknown as EventEmitter); +}); + +test('Downloader constructor', async () => { + const downloader = new Downloader('dummyUrl', 'dummyTarget'); + expect(downloader.getTarget()).toBe('dummyTarget'); +}); + +test('perform download failed', async () => { + const downloader = new Downloader('dummyUrl', 'dummyTarget'); + + const closeMock = vi.fn(); + const onMock = vi.fn(); + vi.mocked(createWriteStream).mockReturnValue({ + close: closeMock, + on: onMock, + } as unknown as WriteStream); + + onMock.mockImplementation((event: string, callback: () => void) => { + if (event === 'error') { + callback(); + } + }); + // capture downloader event(s) + const listenerMock = vi.fn(); + downloader.onEvent(listenerMock); + + // perform download logic + await downloader.perform('followUpId'); + + expect(downloader.completed).toBeTruthy(); + expect(listenerMock).toHaveBeenCalledWith({ + id: 'followUpId', + message: expect.anything(), + status: 'error', + }); +}); + +test('perform download successfully', async () => { + const downloader = new Downloader('dummyUrl', 'dummyTarget'); + vi.spyOn(promises, 'rename').mockResolvedValue(undefined); + vi.spyOn(promises, 'rm').mockResolvedValue(undefined); + + const closeMock = vi.fn(); + const onMock = vi.fn(); + vi.mocked(createWriteStream).mockReturnValue({ + close: closeMock, + on: onMock, + } as unknown as WriteStream); + + onMock.mockImplementation((event: string, callback: () => void) => { + if (event === 'finish') { + callback(); + } + }); + + // capture downloader event(s) + const listenerMock = vi.fn(); + downloader.onEvent(listenerMock); + + // perform download logic + await downloader.perform('followUpId'); + + expect(downloader.completed).toBeTruthy(); + expect(listenerMock).toHaveBeenCalledWith({ + id: 'followUpId', + duration: expect.anything(), + message: expect.anything(), + status: 'completed', + }); + + await vi.waitFor(() => { + expect(promises.rename).toHaveBeenCalledWith('dummyTarget.tmp', 'dummyTarget'); + expect(promises.rm).toHaveBeenCalledWith('dummyTarget.tmp'); + }); +}); diff --git a/packages/backend/src/utils/downloader.ts b/packages/backend/src/utils/downloader.ts index 7489df4c3..f06283f3f 100644 --- a/packages/backend/src/utils/downloader.ts +++ b/packages/backend/src/utils/downloader.ts @@ -17,7 +17,7 @@ ***********************************************************************/ import { getDurationSecondsSince } from './utils'; -import fs from 'fs'; +import { createWriteStream, promises } from 'node:fs'; import https from 'node:https'; import { EventEmitter, type Event } from '@podman-desktop/api'; @@ -117,7 +117,33 @@ export class Downloader { } private followRedirects(url: string, callback: (message: { ok?: boolean; error?: string }) => void) { - const file = fs.createWriteStream(this.target); + const tmpFile = `${this.target}.tmp`; + const stream = createWriteStream(tmpFile); + + stream.on('finish', () => { + stream.close(); + // Rename from tmp to expected file name. + promises + .rename(tmpFile, this.target) + .then(() => { + callback({ ok: true }); + }) + .catch((err: unknown) => { + callback({ error: `Something went wrong while trying to rename downloaded file: ${String(err)}.` }); + }) + .finally(() => { + // Finally delete the tmp file + promises.rm(tmpFile).catch((err: unknown) => { + console.error('Something went wrong while trying to delete the temporary file', err); + }); + }); + }); + stream.on('error', e => { + callback({ + error: e.message, + }); + }); + let totalFileSize = 0; let progress = 0; https.get(url, { signal: this.abortSignal }, resp => { @@ -143,21 +169,8 @@ export class Downloader { value: progressValue, } as ProgressEvent); } - - // send progress in percentage (ex. 1.2%, 2.6%, 80.1%) to frontend - if (progressValue === 100) { - callback({ ok: true }); - } - }); - file.on('finish', () => { - file.close(); - }); - file.on('error', e => { - callback({ - error: e.message, - }); }); - resp.pipe(file); + resp.pipe(stream); }); } }