From 567347fd3359ecc902856635d226f15ab0e4a11b Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Fri, 6 Mar 2026 19:03:36 +0100 Subject: [PATCH 1/7] Fix: thumbnail generation and upload --- .../virtual-drive/registerFilesServices.ts | 12 +++++- .../features/thumbnails/generate-thumbnail.ts | 20 +++++++++ .../thumbnails/thumbnail.constants.ts | 2 + .../thumbnails/upload-and-create-thumbnail.ts | 32 +++++++++++++++ .../thumbnails/upload-thumbnail-to-bucket.ts | 41 +++++++++++++++++++ .../upload/TemporalFileUploader.ts | 7 ++++ .../upload/TemporalFileUploadedDomainEvent.ts | 4 ++ .../CreateFileOnTemporalFileUploaded.ts | 34 ++++++++++++++- 8 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 src/backend/features/thumbnails/generate-thumbnail.ts create mode 100644 src/backend/features/thumbnails/thumbnail.constants.ts create mode 100644 src/backend/features/thumbnails/upload-and-create-thumbnail.ts create mode 100644 src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts diff --git a/src/apps/drive/dependency-injection/virtual-drive/registerFilesServices.ts b/src/apps/drive/dependency-injection/virtual-drive/registerFilesServices.ts index a5bced061d..5bdfea887a 100644 --- a/src/apps/drive/dependency-injection/virtual-drive/registerFilesServices.ts +++ b/src/apps/drive/dependency-injection/virtual-drive/registerFilesServices.ts @@ -68,5 +68,15 @@ export async function registerFilesServices(builder: ContainerBuilder): Promise< builder.registerAndUse(FilesByPartialSearcher); // Event Handlers - builder.registerAndUse(CreateFileOnTemporalFileUploaded).addTag('event-handler'); + builder + .register(CreateFileOnTemporalFileUploaded) + .useFactory((c) => { + return new CreateFileOnTemporalFileUploaded( + c.get(FileCreator), + c.get(FileOverrider), + c.get(Environment), + user.bucket, + ); + }) + .addTag('event-handler'); } diff --git a/src/backend/features/thumbnails/generate-thumbnail.ts b/src/backend/features/thumbnails/generate-thumbnail.ts new file mode 100644 index 0000000000..56e9fb364c --- /dev/null +++ b/src/backend/features/thumbnails/generate-thumbnail.ts @@ -0,0 +1,20 @@ +import { nativeImage } from 'electron'; +import { Result } from '../../../context/shared/domain/Result'; +import { THUMBNAIL_SIZE } from './thumbnail.constants'; + +export function generateThumbnail(fileBuffer: Buffer): Result { + const image = nativeImage.createFromBuffer(fileBuffer); + + if (image.isEmpty()) { + return { error: new Error('Failed to load image from buffer') }; + } + + const { width, height } = image.getSize(); + const scale = Math.min(THUMBNAIL_SIZE / width, THUMBNAIL_SIZE / height, 1); + const newWidth = Math.round(width * scale); + const newHeight = Math.round(height * scale); + + const resized = image.resize({ width: newWidth, height: newHeight, quality: 'good' }); + + return { data: resized.toPNG() }; +} diff --git a/src/backend/features/thumbnails/thumbnail.constants.ts b/src/backend/features/thumbnails/thumbnail.constants.ts new file mode 100644 index 0000000000..33a0440833 --- /dev/null +++ b/src/backend/features/thumbnails/thumbnail.constants.ts @@ -0,0 +1,2 @@ +export const THUMBNAIL_SIZE = 256; +export const UPLOAD_TIMEOUT_MS = 30_000; diff --git a/src/backend/features/thumbnails/upload-and-create-thumbnail.ts b/src/backend/features/thumbnails/upload-and-create-thumbnail.ts new file mode 100644 index 0000000000..3ae6724142 --- /dev/null +++ b/src/backend/features/thumbnails/upload-and-create-thumbnail.ts @@ -0,0 +1,32 @@ +import { Environment } from '@internxt/inxt-js'; +import { Result } from '../../../context/shared/domain/Result'; +import { ThumbnailDto } from '../../../infra/drive-server/out/dto'; +import { createThumbnail } from '../../../infra/drive-server/services/files/services/create-thumbnail'; +import { THUMBNAIL_SIZE } from './thumbnail.constants'; +import { uploadThumbnailToBucket } from './upload-thumbnail-to-bucket'; + +type Props = { + thumbnailBuffer: Buffer; + fileUuid: string; + environment: Environment; + bucket: string; +}; + +export async function uploadAndCreateThumbnail(params: Props): Promise> { + const uploaded = await uploadThumbnailToBucket(params.environment, params.bucket, params.thumbnailBuffer); + + if (uploaded.error) { + return { error: uploaded.error }; + } + + return createThumbnail({ + fileUuid: params.fileUuid, + type: 'png', + size: params.thumbnailBuffer.length, + maxWidth: THUMBNAIL_SIZE, + maxHeight: THUMBNAIL_SIZE, + bucketId: params.bucket, + bucketFile: uploaded.data, + encryptVersion: '03-aes', + }); +} diff --git a/src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts b/src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts new file mode 100644 index 0000000000..96b20e679c --- /dev/null +++ b/src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts @@ -0,0 +1,41 @@ +import { Environment } from '@internxt/inxt-js'; +import { Readable } from 'node:stream'; +import { Result } from '../../../context/shared/domain/Result'; +import { UPLOAD_TIMEOUT_MS } from './thumbnail.constants'; + + + +export function uploadThumbnailToBucket( + environment: Environment, + bucket: string, + buffer: Buffer, +): Promise> { + const source = Readable.from(buffer); + const fileSize = buffer.length; + + let timeoutId: ReturnType; + + const upload = new Promise>((resolve) => { + environment.upload(bucket, { + source, + fileSize, + finishedCallback: (err: Error | null, contentsId: string | null) => { + clearTimeout(timeoutId); + if (err) { + return resolve({ error: err }); + } + if (!contentsId) { + return resolve({ error: new Error('Upload succeeded but no contentsId returned') }); + } + resolve({ data: contentsId }); + }, + progressCallback: () => {}, + }); + }); + + const timeout = new Promise>((resolve) => { + timeoutId = setTimeout(() => resolve({ error: new Error('Thumbnail bucket upload timed out') }), UPLOAD_TIMEOUT_MS); + }); + + return Promise.race([upload, timeout]); +} diff --git a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts index b6b5d37cd7..8561baf230 100644 --- a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts +++ b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts @@ -1,5 +1,7 @@ import { Service } from 'diod'; +import { extname } from 'node:path'; import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { thumbnableExtensions } from '../../../../apps/main/thumbnails/domain/ThumbnableExtension'; import { TemporalFileRepository } from '../../domain/TemporalFileRepository'; import { TemporalFilePath } from '../../domain/TemporalFilePath'; import { TemporalFileUploaderFactory } from '../../domain/upload/TemporalFileUploaderFactory'; @@ -40,11 +42,16 @@ export class TemporalFileUploader { logger.debug({ msg: `${documentPath.value} uploaded with id ${contentsId}` }); + const ext = extname(document.path.value).replace('.', '').toLowerCase(); + const isThumbnable = thumbnableExtensions.includes(ext as (typeof thumbnableExtensions)[number]); + const fileBuffer = isThumbnable ? await this.repository.read(documentPath) : undefined; + const contentsUploadedEvent = new TemporalFileUploadedDomainEvent({ aggregateId: contentsId, size: document.size.value, path: document.path.value, replaces: replaces?.contentsId, + fileBuffer, }); await this.eventBus.publish([contentsUploadedEvent]); diff --git a/src/context/storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent.ts b/src/context/storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent.ts index 1732f29d71..9264245779 100644 --- a/src/context/storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent.ts +++ b/src/context/storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent.ts @@ -6,17 +6,20 @@ export class TemporalFileUploadedDomainEvent extends DomainEvent { readonly size: number; readonly path: string; readonly replaces: string | undefined; + readonly fileBuffer: Buffer | undefined; constructor({ aggregateId, size, path, replaces, + fileBuffer, }: { aggregateId: string; size: number; path: string; replaces?: string; + fileBuffer?: Buffer; }) { super({ aggregateId, @@ -26,6 +29,7 @@ export class TemporalFileUploadedDomainEvent extends DomainEvent { this.size = size; this.path = path; this.replaces = replaces; + this.fileBuffer = fileBuffer; } toPrimitives() { diff --git a/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts b/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts index bb0f7b84b0..d3e0b30236 100644 --- a/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts +++ b/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts @@ -1,5 +1,8 @@ +import { Environment } from '@internxt/inxt-js'; import { Service } from 'diod'; import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { generateThumbnail } from '../../../../../backend/features/thumbnails/generate-thumbnail'; +import { uploadAndCreateThumbnail } from '../../../../../backend/features/thumbnails/upload-and-create-thumbnail'; import { TemporalFileUploadedDomainEvent } from '../../../../storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent'; import { DomainEventClass } from '../../../../shared/domain/DomainEvent'; import { DomainEventSubscriber } from '../../../../shared/domain/DomainEventSubscriber'; @@ -11,6 +14,8 @@ export class CreateFileOnTemporalFileUploaded implements DomainEventSubscriber { + if (error) { + logger.warn({ msg: `Failed to upload thumbnail for ${event.path}`, error }); + } + }); + } } async on(event: TemporalFileUploadedDomainEvent): Promise { try { this.create(event); } catch (err) { - logger.error({ msg: '[CreateFileOnOfflineFileUploaded] Error creating file:', error: err }); + logger.error({ + msg: '[CreateFileOnOfflineFileUploaded] Error creating file:', + error: err, + }); } } } From 1f150dbb3fece8b13002f7a4f1ee1eb042830055 Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Fri, 6 Mar 2026 19:05:30 +0100 Subject: [PATCH 2/7] fix: format --- src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts b/src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts index 96b20e679c..a058dfb42b 100644 --- a/src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts +++ b/src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts @@ -3,8 +3,6 @@ import { Readable } from 'node:stream'; import { Result } from '../../../context/shared/domain/Result'; import { UPLOAD_TIMEOUT_MS } from './thumbnail.constants'; - - export function uploadThumbnailToBucket( environment: Environment, bucket: string, From 97e59a9f8fd73edd8fdcbd716eba9ecadddc5c9b Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Mon, 9 Mar 2026 12:12:42 +0100 Subject: [PATCH 3/7] fix: proper extension for thumbnails since we are using nativeImage --- .../features/thumbnails/thumbnail.extensions.ts | 15 +++++++++++++++ .../application/upload/TemporalFileUploader.ts | 5 ++--- .../create/CreateFileOnTemporalFileUploaded.ts | 2 +- 3 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 src/backend/features/thumbnails/thumbnail.extensions.ts diff --git a/src/backend/features/thumbnails/thumbnail.extensions.ts b/src/backend/features/thumbnails/thumbnail.extensions.ts new file mode 100644 index 0000000000..b2829005ab --- /dev/null +++ b/src/backend/features/thumbnails/thumbnail.extensions.ts @@ -0,0 +1,15 @@ +/** + * v2.5.4 + * Alexis Mora + * As per Electron docs, only PNG and JPEG are officially supported + * https://www.electronjs.org/docs/latest/api/native-image#supported-formats + */ +export const THUMBNAIL_SUPPORTED_EXTENSIONS = new Set([ + 'jpg', + 'jpeg', + 'png', +]); + +export function canGenerateThumbnail(extension: string): boolean { + return THUMBNAIL_SUPPORTED_EXTENSIONS.has(extension.toLowerCase()); +} diff --git a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts index 8561baf230..85838fd50c 100644 --- a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts +++ b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts @@ -1,7 +1,7 @@ import { Service } from 'diod'; import { extname } from 'node:path'; import { logger } from '@internxt/drive-desktop-core/build/backend'; -import { thumbnableExtensions } from '../../../../apps/main/thumbnails/domain/ThumbnableExtension'; +import { canGenerateThumbnail } from '../../../../../backend/features/thumbnails/thumbnail.extensions'; import { TemporalFileRepository } from '../../domain/TemporalFileRepository'; import { TemporalFilePath } from '../../domain/TemporalFilePath'; import { TemporalFileUploaderFactory } from '../../domain/upload/TemporalFileUploaderFactory'; @@ -43,8 +43,7 @@ export class TemporalFileUploader { logger.debug({ msg: `${documentPath.value} uploaded with id ${contentsId}` }); const ext = extname(document.path.value).replace('.', '').toLowerCase(); - const isThumbnable = thumbnableExtensions.includes(ext as (typeof thumbnableExtensions)[number]); - const fileBuffer = isThumbnable ? await this.repository.read(documentPath) : undefined; + const fileBuffer = canGenerateThumbnail(ext) ? await this.repository.read(documentPath) : undefined; const contentsUploadedEvent = new TemporalFileUploadedDomainEvent({ aggregateId: contentsId, diff --git a/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts b/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts index d3e0b30236..66a86f4ec7 100644 --- a/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts +++ b/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts @@ -30,7 +30,7 @@ export class CreateFileOnTemporalFileUploaded implements DomainEventSubscriber Date: Mon, 9 Mar 2026 12:38:42 +0100 Subject: [PATCH 4/7] feat: add thumbanils on already existing files --- .../CreateFileOnOfflineFileUploaded.test.ts | 26 ++++++++++++++----- .../CreateFileOnTemporalFileUploaded.ts | 13 +++------- .../application/override/FileOverrider.ts | 4 ++- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts b/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts index 47055e22ec..d4349f546c 100644 --- a/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts +++ b/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts @@ -1,45 +1,57 @@ +import { Environment } from '@internxt/inxt-js'; import { CreateFileOnTemporalFileUploaded } from './CreateFileOnTemporalFileUploaded'; import { FileCreatorTestClass } from '../../__test-helpers__/FileCreatorTestClass'; import { FileOverriderTestClass } from '../../__test-helpers__/FileOverriderTestClass'; +import { FileMother } from '../../domain/__test-helpers__/FileMother'; import { OfflineContentsUploadedDomainEventMother } from '../../domain/events/__test-helpers__/OfflineContentsUploadedDomainEventMother'; +import { call } from 'tests/vitest/utils.helper'; describe('Create File On Offline File Uploaded', () => { + const environment = {} as Environment; + const bucket = 'test-bucket'; + it('creates a new file when event replaces field is undefined', async () => { const creator = new FileCreatorTestClass(); const overrider = new FileOverriderTestClass(); + const file = FileMother.noThumbnable(); + creator.mock.mockResolvedValue(file); const uploadedEvent = OfflineContentsUploadedDomainEventMother.doesNotReplace(); - const sut = new CreateFileOnTemporalFileUploaded(creator, overrider); + const sut = new CreateFileOnTemporalFileUploaded(creator, overrider, environment, bucket); await sut.on(uploadedEvent); - expect(creator.mock).toBeCalledWith(uploadedEvent.path, uploadedEvent.aggregateId, uploadedEvent.size); + call(creator.mock).toMatchObject([uploadedEvent.path, uploadedEvent.aggregateId, uploadedEvent.size]); }); - it('does not create a new file when the replaces files is defined', async () => { + it('does not create a new file when the replaces field is defined', async () => { const creator = new FileCreatorTestClass(); const overrider = new FileOverriderTestClass(); + const file = FileMother.noThumbnable(); + overrider.mock.mockResolvedValue(file); const uploadedEvent = OfflineContentsUploadedDomainEventMother.replacesContents(); - const sut = new CreateFileOnTemporalFileUploaded(creator, overrider); + const sut = new CreateFileOnTemporalFileUploaded(creator, overrider, environment, bucket); await sut.on(uploadedEvent); - expect(creator.mock).not.toBeCalled(); + expect(creator.mock).not.toHaveBeenCalled(); }); it('overrides file with contents specified on the event', async () => { const creator = new FileCreatorTestClass(); const overrider = new FileOverriderTestClass(); + const file = FileMother.noThumbnable(); + overrider.mock.mockResolvedValue(file); const uploadedEvent = OfflineContentsUploadedDomainEventMother.replacesContents(); - const sut = new CreateFileOnTemporalFileUploaded(creator, overrider); + const sut = new CreateFileOnTemporalFileUploaded(creator, overrider, environment, bucket); await sut.on(uploadedEvent); - expect(overrider.mock).toBeCalledWith(uploadedEvent.replaces, uploadedEvent.aggregateId, uploadedEvent.size); + call(overrider.mock).toMatchObject([uploadedEvent.replaces, uploadedEvent.aggregateId, uploadedEvent.size]); }); }); diff --git a/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts b/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts index 66a86f4ec7..f409c270c7 100644 --- a/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts +++ b/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts @@ -23,12 +23,9 @@ export class CreateFileOnTemporalFileUploaded implements DomainEventSubscriber { - if (event.replaces) { - await this.fileOverrider.run(event.replaces, event.aggregateId, event.size); - return; - } - - const file = await this.creator.run(event.path, event.aggregateId, event.size); + const file = event.replaces + ? await this.fileOverrider.run(event.replaces, event.aggregateId, event.size) + : await this.creator.run(event.path, event.aggregateId, event.size); if (event.fileBuffer) { const generated = generateThumbnail(event.fileBuffer); @@ -38,10 +35,8 @@ export class CreateFileOnTemporalFileUploaded implements DomainEventSubscriber { + ): Promise { const file = await this.repository.searchByContentsId(oldContentsId); if (!file) { @@ -36,5 +36,7 @@ export class FileOverrider { await this.repository.update(file); this.eventBus.publish(file.pullDomainEvents()); + + return file; } } From 391c00b816449404647a611eeb72250b96f72f36 Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Mon, 9 Mar 2026 14:09:07 +0100 Subject: [PATCH 5/7] feat: add necessary tests --- .../thumbnails/generate-thumbnail.test.ts | 54 +++++++++++++++ .../upload-and-create-thumbnail.test.ts | 60 +++++++++++++++++ .../thumbnails/upload-and-create-thumbnail.ts | 15 +++-- .../upload-thumbnail-to-bucket.test.ts | 67 +++++++++++++++++++ .../thumbnails/upload-thumbnail-to-bucket.ts | 9 +-- vitest.setup.main.ts | 9 +++ 6 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 src/backend/features/thumbnails/generate-thumbnail.test.ts create mode 100644 src/backend/features/thumbnails/upload-and-create-thumbnail.test.ts create mode 100644 src/backend/features/thumbnails/upload-thumbnail-to-bucket.test.ts diff --git a/src/backend/features/thumbnails/generate-thumbnail.test.ts b/src/backend/features/thumbnails/generate-thumbnail.test.ts new file mode 100644 index 0000000000..c8b3668db1 --- /dev/null +++ b/src/backend/features/thumbnails/generate-thumbnail.test.ts @@ -0,0 +1,54 @@ +import * as nativeImageModule from 'electron'; +import { generateThumbnail } from './generate-thumbnail'; +import { partialSpyOn } from 'tests/vitest/utils.helper'; +import { THUMBNAIL_SIZE } from './thumbnail.constants'; + +describe('generate-thumbnail', () => { + const createFromBufferMock = partialSpyOn(nativeImageModule.nativeImage, 'createFromBuffer'); + + it('returns error when nativeImage cannot decode the buffer', () => { + createFromBufferMock.mockReturnValue({ isEmpty: () => true }); + const { error } = generateThumbnail(Buffer.from('not-an-image')); + expect(error).toBeInstanceOf(Error); + }); + + it('resizes image larger than THUMBNAIL_SIZE', () => { + const imageWidth = 1024; + const imageHeight = 768; + const pngBuffer = Buffer.from('png'); + const resizeMock = vi.fn().mockReturnValue({ toPNG: () => pngBuffer }); + + createFromBufferMock.mockReturnValue({ + isEmpty: () => false, + getSize: () => ({ width: imageWidth, height: imageHeight }), + resize: resizeMock, + }); + + const { data, error } = generateThumbnail(Buffer.from('large-image')); + + expect(error).toBeUndefined(); + const scale = Math.min(THUMBNAIL_SIZE / imageWidth, THUMBNAIL_SIZE / imageHeight, 1); + expect(resizeMock).toBeCalledWith({ + width: Math.round(imageWidth * scale), + height: Math.round(imageHeight * scale), + quality: 'good', + }); + expect(data).toBe(pngBuffer); + }); + + it('does not upscale image smaller than THUMBNAIL_SIZE', () => { + const imageWidth = 100; + const imageHeight = 80; + const pngBuffer = Buffer.from('png'); + const resizeMock = vi.fn().mockReturnValue({ toPNG: () => pngBuffer }); + + createFromBufferMock.mockReturnValue({ + isEmpty: () => false, + getSize: () => ({ width: imageWidth, height: imageHeight }), + resize: resizeMock, + }); + generateThumbnail(Buffer.from('small-image')); + + expect(resizeMock).toBeCalledWith({ width: imageWidth, height: imageHeight, quality: 'good' }); + }); +}); diff --git a/src/backend/features/thumbnails/upload-and-create-thumbnail.test.ts b/src/backend/features/thumbnails/upload-and-create-thumbnail.test.ts new file mode 100644 index 0000000000..c655fcdbba --- /dev/null +++ b/src/backend/features/thumbnails/upload-and-create-thumbnail.test.ts @@ -0,0 +1,60 @@ +import * as uploadThumbnailToBucketModule from './upload-thumbnail-to-bucket'; +import * as createThumbnailModule from '../../../infra/drive-server/services/files/services/create-thumbnail'; +import { uploadAndCreateThumbnail } from './upload-and-create-thumbnail'; +import { call, partialSpyOn } from 'tests/vitest/utils.helper'; +import { THUMBNAIL_SIZE } from './thumbnail.constants'; +import { DriveServerError } from '../../../infra/drive-server/drive-server.error'; +import { Environment } from '@internxt/inxt-js'; + +describe('upload-and-create-thumbnail', () => { + const uploadThumbnailToBucketMock = partialSpyOn(uploadThumbnailToBucketModule, 'uploadThumbnailToBucket'); + const createThumbnailMock = partialSpyOn(createThumbnailModule, 'createThumbnail'); + + const environment = {} as Environment; + const bucket = 'test-bucket'; + const fileUuid = 'file-uuid'; + const thumbnailBuffer = Buffer.from('thumbnail-data'); + + it('should return error when upload to bucket fails', async () => { + const uploadError = new Error('Upload failed'); + uploadThumbnailToBucketMock.mockResolvedValue({ error: uploadError }); + + const { error } = await uploadAndCreateThumbnail({ thumbnailBuffer, fileUuid, environment, bucket }); + + expect(error).toBe(uploadError); + expect(createThumbnailMock).not.toHaveBeenCalled(); + }); + + it('should call createThumbnail with correct params after successful bucket upload', async () => { + const contentsId = 'contents-id-123'; + const thumbnailDto = { id: 1, type: 'png' }; + + uploadThumbnailToBucketMock.mockResolvedValue({ data: contentsId }); + createThumbnailMock.mockResolvedValue({ data: thumbnailDto }); + + const { data, error } = await uploadAndCreateThumbnail({ thumbnailBuffer, fileUuid, environment, bucket }); + + expect(error).toBeUndefined(); + expect(data).toStrictEqual(thumbnailDto); + call(createThumbnailMock).toStrictEqual({ + fileUuid, + type: 'png', + size: thumbnailBuffer.length, + maxWidth: THUMBNAIL_SIZE, + maxHeight: THUMBNAIL_SIZE, + bucketId: bucket, + bucketFile: contentsId, + encryptVersion: '03-aes', + }); + }); + + it('should return error when createThumbnail fails', async () => { + const createError = new DriveServerError('SERVER_ERROR'); + uploadThumbnailToBucketMock.mockResolvedValue({ data: 'contents-id' }); + createThumbnailMock.mockResolvedValue({ error: createError }); + + const { error } = await uploadAndCreateThumbnail({ thumbnailBuffer, fileUuid, environment, bucket }); + + expect(error).toBe(createError); + }); +}); diff --git a/src/backend/features/thumbnails/upload-and-create-thumbnail.ts b/src/backend/features/thumbnails/upload-and-create-thumbnail.ts index 3ae6724142..9b616ca35c 100644 --- a/src/backend/features/thumbnails/upload-and-create-thumbnail.ts +++ b/src/backend/features/thumbnails/upload-and-create-thumbnail.ts @@ -12,20 +12,25 @@ type Props = { bucket: string; }; -export async function uploadAndCreateThumbnail(params: Props): Promise> { - const uploaded = await uploadThumbnailToBucket(params.environment, params.bucket, params.thumbnailBuffer); +export async function uploadAndCreateThumbnail({ + thumbnailBuffer, + fileUuid, + environment, + bucket, +}: Props): Promise> { + const uploaded = await uploadThumbnailToBucket(environment, bucket, thumbnailBuffer); if (uploaded.error) { return { error: uploaded.error }; } return createThumbnail({ - fileUuid: params.fileUuid, + fileUuid, type: 'png', - size: params.thumbnailBuffer.length, + size: thumbnailBuffer.length, maxWidth: THUMBNAIL_SIZE, maxHeight: THUMBNAIL_SIZE, - bucketId: params.bucket, + bucketId: bucket, bucketFile: uploaded.data, encryptVersion: '03-aes', }); diff --git a/src/backend/features/thumbnails/upload-thumbnail-to-bucket.test.ts b/src/backend/features/thumbnails/upload-thumbnail-to-bucket.test.ts new file mode 100644 index 0000000000..c7b70cd97c --- /dev/null +++ b/src/backend/features/thumbnails/upload-thumbnail-to-bucket.test.ts @@ -0,0 +1,67 @@ +import { Environment } from '@internxt/inxt-js'; +import { uploadThumbnailToBucket } from './upload-thumbnail-to-bucket'; +import { UPLOAD_TIMEOUT_MS } from './thumbnail.constants'; +import { UploadOptions } from '@internxt/inxt-js/build/lib/core'; + +function environmentMock(upload: (bucket: string, options: UploadOptions) => void): Environment { + return { upload } as unknown as Environment; +} + +describe('upload-thumbnail-to-bucket', () => { + const bucket = 'test-bucket'; + const buffer = Buffer.from('image-data'); + const clearTimeoutMock = vi.spyOn(global, 'clearTimeout'); + + it('should return data with contentsId on successful upload', async () => { + const contentsId = 'contents-id-123'; + const environment = environmentMock((_bucket, { finishedCallback }) => { + finishedCallback(null, contentsId); + }); + + const { data, error } = await uploadThumbnailToBucket(environment, bucket, buffer); + + expect(error).toBeUndefined(); + expect(data).toBe(contentsId); + expect(clearTimeoutMock).toHaveBeenCalled(); + }); + + it('should return error when finishedCallback receives an error', async () => { + const uploadError = new Error('bucket error'); + const environment = environmentMock((_bucket, { finishedCallback }) => { + finishedCallback(uploadError, null); + }); + + const { error } = await uploadThumbnailToBucket(environment, bucket, buffer); + + expect(error).toBe(uploadError); + expect(clearTimeoutMock).toHaveBeenCalled(); + }); + + it('should return error when finishedCallback has no contentsId', async () => { + const environment = environmentMock((_bucket, { finishedCallback }) => { + finishedCallback(null, null); + }); + + const { error } = await uploadThumbnailToBucket(environment, bucket, buffer); + + expect(error).toBeInstanceOf(Error); + expect(clearTimeoutMock).toHaveBeenCalled(); + }); + + it('should return error when upload times out', async () => { + vi.useFakeTimers(); + + const environment = environmentMock(() => { + // never calls finishedCallback + }); + + const resultPromise = uploadThumbnailToBucket(environment, bucket, buffer); + vi.advanceTimersByTime(UPLOAD_TIMEOUT_MS); + + const { error } = await resultPromise; + + expect(error).toBeInstanceOf(Error); + + vi.useRealTimers(); + }); +}); diff --git a/src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts b/src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts index a058dfb42b..6a952e5eba 100644 --- a/src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts +++ b/src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts @@ -8,22 +8,19 @@ export function uploadThumbnailToBucket( bucket: string, buffer: Buffer, ): Promise> { - const source = Readable.from(buffer); - const fileSize = buffer.length; - let timeoutId: ReturnType; const upload = new Promise>((resolve) => { environment.upload(bucket, { - source, - fileSize, + source: Readable.from(buffer), + fileSize: buffer.length, finishedCallback: (err: Error | null, contentsId: string | null) => { clearTimeout(timeoutId); if (err) { return resolve({ error: err }); } if (!contentsId) { - return resolve({ error: new Error('Upload succeeded but no contentsId returned') }); + return resolve({ error: new Error('Upload finished but no contentsId returned') }); } resolve({ data: contentsId }); }, diff --git a/vitest.setup.main.ts b/vitest.setup.main.ts index 0fc9963669..a0d242d133 100644 --- a/vitest.setup.main.ts +++ b/vitest.setup.main.ts @@ -1,3 +1,5 @@ +import { nativeImage } from 'electron'; +import { isEmpty } from 'lodash'; import 'reflect-metadata'; import { vi } from 'vitest'; @@ -25,6 +27,13 @@ vi.mock('electron', () => ({ decryptString: vi.fn(), encryptString: vi.fn(), }, + nativeImage: { + createFromBuffer: vi.fn().mockReturnValue({ + isEmpty: () => false, + getSize: () => ({ width: 100, height: 100 }), + resize: vi.fn().mockReturnValue({ toPNG: () => Buffer.from('png') }), + }), + } })); // Mock electron-log (depends on electron) From d5cf969e0935641298dd7cb7bcf6c576c5779d92 Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Mon, 9 Mar 2026 14:09:49 +0100 Subject: [PATCH 6/7] fix: format --- src/backend/features/thumbnails/thumbnail.extensions.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/backend/features/thumbnails/thumbnail.extensions.ts b/src/backend/features/thumbnails/thumbnail.extensions.ts index b2829005ab..c29ebb38b3 100644 --- a/src/backend/features/thumbnails/thumbnail.extensions.ts +++ b/src/backend/features/thumbnails/thumbnail.extensions.ts @@ -4,11 +4,7 @@ * As per Electron docs, only PNG and JPEG are officially supported * https://www.electronjs.org/docs/latest/api/native-image#supported-formats */ -export const THUMBNAIL_SUPPORTED_EXTENSIONS = new Set([ - 'jpg', - 'jpeg', - 'png', -]); +export const THUMBNAIL_SUPPORTED_EXTENSIONS = new Set(['jpg', 'jpeg', 'png']); export function canGenerateThumbnail(extension: string): boolean { return THUMBNAIL_SUPPORTED_EXTENSIONS.has(extension.toLowerCase()); From ca3ae00f0cb505a326306f0b118d324934e75e9e Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Mon, 9 Mar 2026 14:12:35 +0100 Subject: [PATCH 7/7] chore: remove unnecesary imports --- vitest.setup.main.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/vitest.setup.main.ts b/vitest.setup.main.ts index a0d242d133..50f0c1646b 100644 --- a/vitest.setup.main.ts +++ b/vitest.setup.main.ts @@ -1,5 +1,3 @@ -import { nativeImage } from 'electron'; -import { isEmpty } from 'lodash'; import 'reflect-metadata'; import { vi } from 'vitest';