Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/services/file/repositories/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ export class S3FileRepository implements FileRepository {
});

try {
await Promise.all(uploads.map((upload) => upload.done()));
await Promise.allSettled(uploads.map((upload) => upload.done()));

console.debug(
'Upload successfully at',
Expand Down
155 changes: 155 additions & 0 deletions src/services/thumbnail/thumbnail.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { PassThrough } from 'node:stream';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';

import { MOCK_LOGGER } from '../../../test/app.vitest';
import { AccountType } from '../../types';
import { THUMBNAIL_MIMETYPE, ThumbnailSizeFormat } from './constants';
import { ThumbnailService } from './thumbnail.service';

const MockedFileService = vi.fn(function () {
this.uploadMany = vi.fn();
});

const AUTHENTICATED_USER = {
id: 'user-1',
name: 'user 1',
type: AccountType.Individual,
isValidated: true,
};

const ITEM_ID = 'item-1';

describe('ThumbnailService.upload', () => {
let thumbnailService: ThumbnailService;
let mockFileService;

beforeEach(() => {
mockFileService = new MockedFileService();
thumbnailService = new ThumbnailService(mockFileService, MOCK_LOGGER);
});

afterEach(() => {
MockedFileService.mockClear();
});

describe('successful upload', () => {
test('should upload thumbnails for all sizes', async () => {
const mockFile = new PassThrough();
mockFileService.uploadMany.mockResolvedValue([]);

const uploadPromise = thumbnailService.upload(AUTHENTICATED_USER, ITEM_ID, mockFile);
// Feed some data to the file stream
mockFile.write(Buffer.from('test image data'));
mockFile.end();

await uploadPromise;

// Verify uploadMany was called with correct structure
expect(mockFileService.uploadMany).toHaveBeenCalledWith(
AUTHENTICATED_USER,
expect.arrayContaining([
expect.objectContaining({
filepath: expect.stringContaining(ITEM_ID),
mimetype: THUMBNAIL_MIMETYPE,
file: expect.any(Object),
}),
]),
);

// Should have uploads for each size in ThumbnailSizeFormat
const calls = mockFileService.uploadMany.mock.calls[0];
expect(calls[1]).toHaveLength(Object.keys(ThumbnailSizeFormat).length);
});

test('should create thumbnails with correct filepaths', async () => {
const mockFile = new PassThrough();
mockFileService.uploadMany.mockResolvedValue([]);

const uploadPromise = thumbnailService.upload(AUTHENTICATED_USER, ITEM_ID, mockFile);
mockFile.write(Buffer.from('test'));
mockFile.end();

await uploadPromise;

const [, filesToUpload] = mockFileService.uploadMany.mock.calls[0];
const filepaths = filesToUpload.map((f: { filepath: string }) => f.filepath);

// All paths should include the ITEM_ID
expect(filepaths.every((p: string) => p.includes(ITEM_ID))).toBe(true);
// All paths should include the thumbnails prefix
expect(filepaths.every((p: string) => p.includes('thumbnails'))).toBe(true);
});
});

describe('error handling', () => {
test('should handle file stream errors gracefully', async () => {
const mockFile = new PassThrough();

mockFileService.uploadMany.mockImplementation(() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve([]);
}, 1000);
});
});

// Should handle error and log it
await expect(async () => {
thumbnailService.upload(AUTHENTICATED_USER, ITEM_ID, mockFile);
// Emit error on file stream
// rejects with undefined error to avoid image stream error to be caught by vitest
mockFile.emit('error');
}).rejects.toThrow();

mockFile.destroy();
});

test('should handle upload service errors', async () => {
const mockFile = new PassThrough();

// rejects with undefined to avoid image stream error to be caught by vitest
mockFileService.uploadMany.mockRejectedValue();

await expect(() =>
thumbnailService.upload(AUTHENTICATED_USER, ITEM_ID, mockFile),
).rejects.toThrow('S3 upload failed');
mockFile.destroy();
});
});

describe('listener cleanup', () => {
test('should remove all listeners after successful upload', async () => {
const mockFile = new PassThrough();
mockFileService.uploadMany.mockResolvedValue([]);

const uploadPromise = thumbnailService.upload(AUTHENTICATED_USER, ITEM_ID, mockFile);

expect(mockFile.listenerCount('error')).toEqual(1);

mockFile.write(Buffer.from('test'));
mockFile.end();
await uploadPromise;

expect(mockFile.listenerCount('error')).toEqual(0);
});

test('should remove all listeners even on error', async () => {
const mockFile = new PassThrough();

// rejects with undefined to avoid image stream error to be caught by vitest
mockFileService.uploadMany.mockRejectedValue();

const uploadPromise = thumbnailService.upload(AUTHENTICATED_USER, ITEM_ID, mockFile);
expect(mockFile.listenerCount('error')).toEqual(1);
mockFile.write(Buffer.from('test'));
mockFile.end();

// trigger errors
await expect(uploadPromise).rejects.toThrow('Upload failed');

expect(mockFile.listenerCount('error')).toBe(0);

mockFile.destroy();
});
});
});
82 changes: 63 additions & 19 deletions src/services/thumbnail/thumbnail.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import path from 'path';
import sharp from 'sharp';
import sharp, { type Sharp } from 'sharp';
import { Readable } from 'stream';
import { pipeline as streamPipeline } from 'stream/promises';
import { injectable } from 'tsyringe';

import { BaseLogger } from '../../logger';
Expand Down Expand Up @@ -35,27 +34,72 @@ export class ThumbnailService {
return path.join(this._prefix, itemId);
}

// cleanup helper for original input file and sharp image
private destroyAll(file: Readable, image: Sharp, err?: Error) {
try {
try {
file.unpipe(image);
file.destroy();
} catch (err) {
this.logger.debug(`Failed to unpipe file from image: ${err}`);
}
image.destroy(err);
} catch (err) {
this.logger.debug(`Failed to to destroy image: ${err}`);
}
}

// ensure errors on the source propagate
private attachListeners(image: Sharp, file: Readable) {
const onFileError = (err: Error) => {
try {
image.destroy(err);
} catch (e) {
this.logger.debug(`Failed to destroy image: ${e}`);
}
throw err;
};
file.on('error', onFileError);

return { onFileError };
}

// remove listeners to avoid leaks
private removeListeners(file: Readable, listeners: { onFileError }) {
try {
file.removeListener('error', listeners.onFileError);
} catch (err) {
this.logger.debug(`Failed to remove error listener from file: ${err}`);
}
}

async upload(authenticatedUser: AuthenticatedUser, id: string, file: Readable) {
// upload all thumbnails in parallel
const filesToUpload = await Promise.all(
Object.entries(ThumbnailSizeFormat).map(async ([sizeName, width]) => {
// create thumbnail from image stream
const pipeline = sharp().resize({ width }).toFormat(THUMBNAIL_FORMAT);
await streamPipeline(file, pipeline);

return {
file: pipeline,
filepath: this.buildFilePath(id, sizeName),
mimetype: THUMBNAIL_MIMETYPE,
};
}),
);

// upload the thumbnails
// pipe incoming file into a sharp instance for further clone
const image = sharp();
file.pipe(image);

// prepare pipelines per size
const pipelines = Object.entries(ThumbnailSizeFormat).map(([sizeName, width]) => {
const transform = image.clone().resize({ width }).toFormat(THUMBNAIL_FORMAT);
return { transform, filepath: this.buildFilePath(id, sizeName), sizeName };
});

const listeners = this.attachListeners(image, file);
try {
// prepare upload payloads (the fileService will consume the readable sides)
const filesToUpload = pipelines.map(({ transform, filepath }) => ({
file: transform,
filepath,
mimetype: THUMBNAIL_MIMETYPE,
}));

await this.fileService.uploadMany(authenticatedUser, filesToUpload);
} catch (_err) {
} catch (err) {
this.destroyAll(file, image, err as Error);
this.logger.debug(`Could not upload the ${id} item thumbnails`);
throw err;
} finally {
this.removeListeners(file, listeners);
}
}

Expand Down
Loading