diff --git a/apps/server/src/infra/preview-generator/interface/error-status.enum.ts b/apps/server/src/infra/preview-generator/interface/error-status.enum.ts new file mode 100644 index 00000000000..0f018ba720f --- /dev/null +++ b/apps/server/src/infra/preview-generator/interface/error-status.enum.ts @@ -0,0 +1,3 @@ +export enum ErrorType { + CREATE_PREVIEW_NOT_POSSIBLE = 'CREATE_PREVIEW_NOT_POSSIBLE', +} diff --git a/apps/server/src/infra/preview-generator/loggable/preview-exception.spec.ts b/apps/server/src/infra/preview-generator/loggable/preview-exception.spec.ts new file mode 100644 index 00000000000..708607b3556 --- /dev/null +++ b/apps/server/src/infra/preview-generator/loggable/preview-exception.spec.ts @@ -0,0 +1,40 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { PreviewNotPossibleException } from './preview-exception'; + +describe(PreviewNotPossibleException.name, () => { + describe('WHEN getLogMessage is called', () => { + const setup = () => { + const payload = { + originFilePath: 'originFilePath', + previewFilePath: 'previewFilePath', + previewOptions: { + format: 'format', + width: 100, + }, + }; + const error = new Error('error'); + + return { payload, error }; + }; + + it('should return error log message', () => { + const { payload, error } = setup(); + + const exception = new PreviewNotPossibleException(payload, error); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: InternalServerErrorException.name, + stack: exception.stack, + error, + data: { + originFilePath: 'originFilePath', + previewFilePath: 'previewFilePath', + format: 'format', + width: 100, + }, + }); + }); + }); +}); diff --git a/apps/server/src/infra/preview-generator/loggable/preview-exception.ts b/apps/server/src/infra/preview-generator/loggable/preview-exception.ts new file mode 100644 index 00000000000..fcfd1c023dc --- /dev/null +++ b/apps/server/src/infra/preview-generator/loggable/preview-exception.ts @@ -0,0 +1,27 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; +import { PreviewFileOptions } from '../interface'; +import { ErrorType } from '../interface/error-status.enum'; + +export class PreviewNotPossibleException extends InternalServerErrorException implements Loggable { + constructor(private readonly payload: PreviewFileOptions, private readonly error?: Error) { + super(ErrorType.CREATE_PREVIEW_NOT_POSSIBLE); + } + + getLogMessage(): ErrorLogMessage { + const { originFilePath, previewFilePath, previewOptions } = this.payload; + const message: ErrorLogMessage = { + type: InternalServerErrorException.name, + stack: this.stack, + error: this.error, + data: { + originFilePath, + previewFilePath, + format: previewOptions.format, + width: previewOptions.width, + }, + }; + + return message; + } +} diff --git a/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts b/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts index 203e4aa9b56..eb8344e22f2 100644 --- a/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts +++ b/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts @@ -1,12 +1,14 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { GetFile, S3ClientAdapter } from '@infra/s3-client'; -import { UnprocessableEntityException } from '@nestjs/common'; +import { InternalServerErrorException, UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Logger } from '@src/core/logger'; -import { Readable } from 'node:stream'; +import { PassThrough, Readable } from 'node:stream'; +import { ErrorType } from './interface/error-status.enum'; import { PreviewGeneratorService } from './preview-generator.service'; -const streamMock = jest.fn(); +let streamMock = jest.fn(); const resizeMock = jest.fn(); const coalesceMock = jest.fn(); const selectFrameMock = jest.fn(); @@ -16,7 +18,7 @@ const imageMagickMock = () => { resize: resizeMock, selectFrame: selectFrameMock, coalesce: coalesceMock, - data: Readable.from('text'), + data: Buffer.from('text'), }; }; jest.mock('gm', () => { @@ -40,6 +42,21 @@ const createFile = (contentRange?: string, contentType?: string): GetFile => { return fileResponse; }; +const createMockStream = (err: Error | null = null) => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + + streamMock = jest + .fn() + .mockImplementation( + (_format: string, callback: (err: Error | null, stdout: PassThrough, stderr: PassThrough) => void) => { + callback(err, stdout, stderr); + } + ); + + return { stdout, stderr }; +}; + describe('PreviewGeneratorService', () => { let module: TestingModule; let service: PreviewGeneratorService; @@ -92,15 +109,14 @@ describe('PreviewGeneratorService', () => { const originFile = createFile(undefined, 'image/jpeg'); s3ClientAdapter.get.mockResolvedValueOnce(originFile); - const data = Readable.from('text'); - streamMock.mockReturnValueOnce(data); + const data = Buffer.from('text'); + const { stdout } = createMockStream(); - const expectedFileData = { - data, - mimeType: params.previewOptions.format, - }; + process.nextTick(() => { + stdout.write(data); + }); - return { params, originFile, expectedFileData }; + return { params, originFile }; }; it('should call storageClient get method with originFilePath', async () => { @@ -125,12 +141,18 @@ describe('PreviewGeneratorService', () => { await service.generatePreview(params); - expect(streamMock).toHaveBeenCalledWith(params.previewOptions.format); + expect(streamMock).toHaveBeenCalledWith(params.previewOptions.format, expect.any(Function)); expect(streamMock).toHaveBeenCalledTimes(1); }); it('should call S3ClientAdapters create method', async () => { - const { params, expectedFileData } = setup(); + const { params } = setup(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const expectedFileData = expect.objectContaining({ + data: expect.any(PassThrough), + mimeType: params.previewOptions.format, + }); await service.generatePreview(params); @@ -161,15 +183,14 @@ describe('PreviewGeneratorService', () => { const originFile = createFile(undefined, 'application/pdf'); s3ClientAdapter.get.mockResolvedValueOnce(originFile); - const data = Readable.from('text'); - streamMock.mockReturnValueOnce(data); + const data = Buffer.from('text'); + const { stdout } = createMockStream(); - const expectedFileData = { - data, - mimeType: params.previewOptions.format, - }; + process.nextTick(() => { + stdout.write(data); + }); - return { params, originFile, expectedFileData }; + return { params, originFile }; }; it('should call imagemagicks selectFrameMock method', async () => { @@ -195,15 +216,14 @@ describe('PreviewGeneratorService', () => { const originFile = createFile(undefined, 'image/gif'); s3ClientAdapter.get.mockResolvedValueOnce(originFile); - const data = Readable.from('text'); - streamMock.mockReturnValueOnce(data); + const data = Buffer.from('text'); + const { stdout } = createMockStream(); - const expectedFileData = { - data, - mimeType: params.previewOptions.format, - }; + process.nextTick(() => { + stdout.write(data); + }); - return { params, originFile, expectedFileData }; + return { params, originFile }; }; it('should call imagemagicks coalesce method', async () => { @@ -237,7 +257,7 @@ describe('PreviewGeneratorService', () => { it('should throw UnprocessableEntityException', async () => { const { params } = setup(); - const error = new UnprocessableEntityException(); + const error = new UnprocessableEntityException(ErrorType.CREATE_PREVIEW_NOT_POSSIBLE); await expect(service.generatePreview(params)).rejects.toThrowError(error); }); }); @@ -246,7 +266,7 @@ describe('PreviewGeneratorService', () => { it('should throw UnprocessableEntityException', async () => { const { params } = setup('text/plain'); - const error = new UnprocessableEntityException(); + const error = new UnprocessableEntityException(ErrorType.CREATE_PREVIEW_NOT_POSSIBLE); await expect(service.generatePreview(params)).rejects.toThrowError(error); }); }); @@ -266,15 +286,14 @@ describe('PreviewGeneratorService', () => { const originFile = createFile(undefined, 'image/jpeg'); s3ClientAdapter.get.mockResolvedValueOnce(originFile); - const data = Readable.from('text'); - streamMock.mockReturnValueOnce(data); + const data = Buffer.from('text'); + const { stdout } = createMockStream(); - const expectedFileData = { - data, - mimeType: params.previewOptions.format, - }; + process.nextTick(() => { + stdout.write(data); + }); - return { params, originFile, expectedFileData }; + return { params, originFile }; }; it('should not call imagemagicks resize method', async () => { @@ -286,5 +305,75 @@ describe('PreviewGeneratorService', () => { expect(resizeMock).not.toHaveBeenCalledTimes(1); }); }); + + describe('WHEN STDERR stream has an error', () => { + const setup = () => { + const params = { + originFilePath: 'file/test.jpeg', + previewFilePath: 'preview/text.webp', + previewOptions: { + format: 'webp', + }, + }; + const originFile = createFile(undefined, 'image/jpeg'); + s3ClientAdapter.get.mockResolvedValueOnce(originFile); + + const data1 = Buffer.from('imagemagick '); + const data2 = Buffer.from('is not found'); + const { stderr } = createMockStream(); + + process.nextTick(() => { + stderr.write(data1); + stderr.write(data2); + stderr.end(); + }); + + const expectedError = new InternalServerErrorException(ErrorType.CREATE_PREVIEW_NOT_POSSIBLE); + + return { params, originFile, expectedError }; + }; + + it('should throw error', async () => { + const { params, expectedError } = setup(); + + await expect(service.generatePreview(params)).rejects.toThrowError(expectedError); + }); + + it('should have external error in getLogMessage', async () => { + const { params } = setup(); + try { + await service.generatePreview(params); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(error.getLogMessage().error).toEqual(new Error('imagemagick is not found')); + } + }); + }); + + describe('WHEN GM library has an error', () => { + const setup = () => { + const params = { + originFilePath: 'file/test.jpeg', + previewFilePath: 'preview/text.webp', + previewOptions: { + format: 'webp', + }, + }; + const originFile = createFile(undefined, 'image/jpeg'); + s3ClientAdapter.get.mockResolvedValueOnce(originFile); + + createMockStream(new Error('imagemagic is not found')); + + const expectedError = new InternalServerErrorException(ErrorType.CREATE_PREVIEW_NOT_POSSIBLE); + + return { params, originFile, expectedError }; + }; + + it('should throw error', async () => { + const { params, expectedError } = setup(); + + await expect(service.generatePreview(params)).rejects.toThrowError(expectedError); + }); + }); }); }); diff --git a/apps/server/src/infra/preview-generator/preview-generator.service.ts b/apps/server/src/infra/preview-generator/preview-generator.service.ts index 5fd9fc8fb5e..d16cc5c8e0d 100644 --- a/apps/server/src/infra/preview-generator/preview-generator.service.ts +++ b/apps/server/src/infra/preview-generator/preview-generator.service.ts @@ -1,10 +1,11 @@ import { GetFile, S3ClientAdapter } from '@infra/s3-client'; import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { Logger } from '@src/core/logger'; -import { subClass } from 'gm'; +import m, { subClass } from 'gm'; import { PassThrough } from 'stream'; import { PreviewFileOptions, PreviewInputMimeTypes, PreviewOptions, PreviewResponseMessage } from './interface'; import { PreviewActionsLoggable } from './loggable/preview-actions.loggable'; +import { PreviewNotPossibleException } from './loggable/preview-exception'; import { PreviewGeneratorBuilder } from './preview-generator.builder'; @Injectable() @@ -16,25 +17,28 @@ export class PreviewGeneratorService { } public async generatePreview(params: PreviewFileOptions): Promise { - this.logger.info(new PreviewActionsLoggable('PreviewGeneratorService.generatePreview:start', params)); - const { originFilePath, previewFilePath, previewOptions } = params; + try { + this.logger.info(new PreviewActionsLoggable('PreviewGeneratorService.generatePreview:start', params)); + const { originFilePath, previewFilePath, previewOptions } = params; - const original = await this.downloadOriginFile(originFilePath); + const original = await this.downloadOriginFile(originFilePath); - this.checkIfPreviewPossible(original, params); + this.checkIfPreviewPossible(original, params); - const preview = this.resizeAndConvert(original, previewOptions); + const preview = await this.resizeAndConvert(original, previewOptions); + const file = PreviewGeneratorBuilder.buildFile(preview, params.previewOptions); - const file = PreviewGeneratorBuilder.buildFile(preview, params.previewOptions); + await this.storageClient.create(previewFilePath, file); - await this.storageClient.create(previewFilePath, file); + this.logger.info(new PreviewActionsLoggable('PreviewGeneratorService.generatePreview:end', params)); - this.logger.info(new PreviewActionsLoggable('PreviewGeneratorService.generatePreview:end', params)); - - return { - previewFilePath, - status: true, - }; + return { + previewFilePath, + status: true, + }; + } catch (error) { + throw new PreviewNotPossibleException(params, error as Error); + } } private checkIfPreviewPossible(original: GetFile, params: PreviewFileOptions): void | UnprocessableEntityException { @@ -43,7 +47,7 @@ export class PreviewGeneratorService { if (!isPreviewPossible) { this.logger.warning(new PreviewActionsLoggable('PreviewGeneratorService.previewNotPossible', params)); - throw new UnprocessableEntityException(); + throw new UnprocessableEntityException('Unsupported file type for preview generation'); } } @@ -53,7 +57,7 @@ export class PreviewGeneratorService { return file; } - private resizeAndConvert(original: GetFile, previewParams: PreviewOptions): PassThrough { + private async resizeAndConvert(original: GetFile, previewParams: PreviewOptions): Promise { const { format, width } = previewParams; const preview = this.imageMagick(original.data); @@ -70,8 +74,38 @@ export class PreviewGeneratorService { preview.resize(width, undefined, '>'); } - const result = preview.stream(format); + return this.convert(preview, format); + } - return result; + private convert(preview: m.State, format: string): Promise { + const promise = new Promise((resolve, reject) => { + preview.stream(format, (err, stdout, stderr) => { + if (err) { + reject(err); + } + + const throughStream = new PassThrough(); + stdout.pipe(throughStream); + stdout.on('data', () => { + resolve(throughStream); + }); + + const errorChunks: Array = []; + stderr.on('data', (chunk: Uint8Array) => { + errorChunks.push(chunk); + }); + + stderr.on('end', () => { + let errorMessage = ''; + Buffer.concat(errorChunks).forEach((chunk) => { + errorMessage += String.fromCharCode(chunk); + }); + + reject(new Error(errorMessage)); + }); + }); + }); + + return promise; } }