diff --git a/src/messageComposer/attachmentManager.ts b/src/messageComposer/attachmentManager.ts index cefc5a839..17d10cbe1 100644 --- a/src/messageComposer/attachmentManager.ts +++ b/src/messageComposer/attachmentManager.ts @@ -14,6 +14,10 @@ import { isFileReference, isImageFile, } from './fileUtils'; +import { + AttachmentPostUploadMiddlewareExecutor, + AttachmentPreUploadMiddlewareExecutor, +} from './middleware/attachmentManager'; import { StateStore } from '../store'; import { generateUUIDv4 } from '../utils'; import { DEFAULT_UPLOAD_SIZE_LIMIT_BYTES } from '../constants'; @@ -22,23 +26,14 @@ import type { FileLike, FileReference, LocalAttachment, - LocalAudioAttachment, - LocalFileAttachment, + LocalNotImageAttachment, LocalUploadAttachment, - LocalVideoAttachment, - LocalVoiceRecordingAttachment, UploadPermissionCheckResult, } from './types'; import type { ChannelResponse, DraftMessage, LocalMessage } from '../types'; import type { MessageComposer } from './messageComposer'; import { mergeWithDiff } from '../utils/mergeWith'; -type LocalNotImageAttachment = - | LocalFileAttachment - | LocalAudioAttachment - | LocalVideoAttachment - | LocalVoiceRecordingAttachment; - export type FileUploadFilter = (file: Partial) => boolean; export type AttachmentManagerState = { @@ -71,6 +66,8 @@ const initState = ({ export class AttachmentManager { readonly state: StateStore; readonly composer: MessageComposer; + readonly preUploadMiddlewareExecutor: AttachmentPreUploadMiddlewareExecutor; + readonly postUploadMiddlewareExecutor: AttachmentPostUploadMiddlewareExecutor; private attachmentsByIdGetterCache: { attachmentsById: Record; attachments: LocalAttachment[]; @@ -80,6 +77,13 @@ export class AttachmentManager { this.composer = composer; this.state = new StateStore(initState({ message })); this.attachmentsByIdGetterCache = { attachmentsById: {}, attachments: [] }; + + this.preUploadMiddlewareExecutor = new AttachmentPreUploadMiddlewareExecutor({ + composer, + }); + this.postUploadMiddlewareExecutor = new AttachmentPostUploadMiddlewareExecutor({ + composer, + }); } get attachmentsById() { @@ -122,10 +126,16 @@ export class AttachmentManager { this.composer.updateConfig({ attachments: { acceptedFiles } }); } + /* + @deprecated attachments can be filtered using injecting pre-upload middleware + */ get fileUploadFilter() { return this.config.fileUploadFilter; } + /* + @deprecated attachments can be filtered using injecting pre-upload middleware + */ set fileUploadFilter(fileUploadFilter: AttachmentManagerConfig['fileUploadFilter']) { this.composer.updateConfig({ attachments: { fileUploadFilter } }); } @@ -333,9 +343,9 @@ export class AttachmentManager { return { uploadBlocked: false }; }; - fileToLocalUploadAttachment = async ( + static toLocalUploadAttachment = ( fileLike: FileReference | FileLike, - ): Promise => { + ): LocalUploadAttachment => { const file = isFileReference(fileLike) || isFile(fileLike) ? fileLike @@ -345,16 +355,13 @@ export class AttachmentManager { mimeType: fileLike.type, }); - const uploadPermissionCheck = await this.getUploadConfigCheck(file); - const localAttachment: LocalUploadAttachment = { file_size: file.size, mime_type: file.type, localMetadata: { file, id: generateUUIDv4(), - uploadPermissionCheck, - uploadState: uploadPermissionCheck.uploadBlocked ? 'blocked' : 'pending', + uploadState: 'pending', }, type: getAttachmentTypeFromMimeType(file.type), }; @@ -383,10 +390,26 @@ export class AttachmentManager { return localAttachment; }; + // @deprecated use AttachmentManager.toLocalUploadAttachment(file) + fileToLocalUploadAttachment = async ( + fileLike: FileReference | FileLike, + ): Promise => { + const localAttachment = AttachmentManager.toLocalUploadAttachment(fileLike); + const uploadPermissionCheck = await this.getUploadConfigCheck( + localAttachment.localMetadata.file, + ); + localAttachment.localMetadata.uploadPermissionCheck = uploadPermissionCheck; + localAttachment.localMetadata.uploadState = uploadPermissionCheck.uploadBlocked + ? 'blocked' + : 'pending'; + + return localAttachment; + }; + private ensureLocalUploadAttachment = async ( attachment: Partial, ) => { - if (!attachment.localMetadata?.file || !attachment.localMetadata.id) { + if (!attachment.localMetadata?.file) { this.client.notifications.addError({ message: 'File is required for upload attachment', origin: { emitter: 'AttachmentManager', context: { attachment } }, @@ -395,6 +418,15 @@ export class AttachmentManager { return; } + if (!attachment.localMetadata.id) { + this.client.notifications.addError({ + message: 'Local upload attachment missing local id', + origin: { emitter: 'AttachmentManager', context: { attachment } }, + options: { type: 'validation:attachment:id:missing' }, + }); + return; + } + if (!this.fileUploadFilter(attachment)) return; const newAttachment = await this.fileToLocalUploadAttachment( @@ -446,6 +478,7 @@ export class AttachmentManager { return this.doDefaultUploadRequest(fileLike); }; + // @deprecated use attachmentManager.uploadFile(file) uploadAttachment = async (attachment: LocalUploadAttachment) => { if (!this.isUploadEnabled) return; @@ -546,20 +579,78 @@ export class AttachmentManager { return uploadedAttachment; }; + uploadFile = async (file: FileReference | FileLike) => { + const preUpload = await this.preUploadMiddlewareExecutor.execute({ + eventName: 'prepare', + initialValue: { + attachment: AttachmentManager.toLocalUploadAttachment(file), + }, + mode: 'concurrent', + }); + + let attachment: LocalUploadAttachment = preUpload.state.attachment; + + if (preUpload.status === 'discard') return attachment; + // todo: remove with the next major release as filtering can be done in middleware + // should we return the attachment object? + if (!this.fileUploadFilter(attachment)) return attachment; + + if (attachment.localMetadata.uploadState === 'blocked') { + this.upsertAttachments([attachment]); + return preUpload.state.attachment; + } + + attachment = { + ...attachment, + localMetadata: { + ...attachment.localMetadata, + uploadState: 'uploading', + }, + }; + this.upsertAttachments([attachment]); + + let response: MinimumUploadRequestResult | undefined; + let error: Error | undefined; + try { + response = await this.doUploadRequest(file); + } catch (err) { + error = err instanceof Error ? err : undefined; + } + + const postUpload = await this.postUploadMiddlewareExecutor.execute({ + eventName: 'postProcess', + initialValue: { + attachment: { + ...attachment, + localMetadata: { + ...attachment.localMetadata, + uploadState: error ? 'failed' : 'finished', + }, + }, + error, + response, + }, + mode: 'concurrent', + }); + attachment = postUpload.state.attachment; + + if (postUpload.status === 'discard') { + this.removeAttachments([attachment.localMetadata.id]); + return attachment; + } + + this.updateAttachment(attachment); + return attachment; + }; + uploadFiles = async (files: FileReference[] | FileList | FileLike[]) => { if (!this.isUploadEnabled) return; const iterableFiles: FileReference[] | FileLike[] = isFileList(files) ? Array.from(files) : files; - const attachments = await Promise.all( - iterableFiles.map(this.fileToLocalUploadAttachment), - ); - return Promise.all( - attachments - .filter(this.fileUploadFilter) - .slice(0, this.availableUploadSlots) - .map(this.uploadAttachment), + return await Promise.all( + iterableFiles.slice(0, this.availableUploadSlots).map(this.uploadFile), ); }; } diff --git a/src/messageComposer/configuration/types.ts b/src/messageComposer/configuration/types.ts index e94d17f78..756f63f88 100644 --- a/src/messageComposer/configuration/types.ts +++ b/src/messageComposer/configuration/types.ts @@ -2,7 +2,9 @@ import type { LinkPreview } from '../linkPreviewsManager'; import type { FileUploadFilter } from '../attachmentManager'; import type { FileLike, FileReference } from '../types'; -export type MinimumUploadRequestResult = { file: string; thumb_url?: string }; +export type MinimumUploadRequestResult = { file: string; thumb_url?: string } & Partial< + Record +>; export type UploadRequestFn = ( fileLike: FileReference | FileLike, @@ -39,7 +41,6 @@ export type AttachmentManagerConfig = { * describing which file types are allowed to be selected when uploading files. */ acceptedFiles: string[]; - // todo: refactor this. We want a pipeline where it would be possible to customize the preparation, upload, and post-upload steps. /** Function that allows to customize the upload request. */ doUploadRequest?: UploadRequestFn; }; diff --git a/src/messageComposer/middleware/attachmentManager/index.ts b/src/messageComposer/middleware/attachmentManager/index.ts new file mode 100644 index 000000000..ba463565e --- /dev/null +++ b/src/messageComposer/middleware/attachmentManager/index.ts @@ -0,0 +1,3 @@ +export * from './postUpload'; +export * from './preUpload'; +export * from './types'; diff --git a/src/messageComposer/middleware/attachmentManager/postUpload/AttachmentPostUploadMiddlewareExecutor.ts b/src/messageComposer/middleware/attachmentManager/postUpload/AttachmentPostUploadMiddlewareExecutor.ts new file mode 100644 index 000000000..6289baa96 --- /dev/null +++ b/src/messageComposer/middleware/attachmentManager/postUpload/AttachmentPostUploadMiddlewareExecutor.ts @@ -0,0 +1,20 @@ +import { MiddlewareExecutor } from '../../../../middleware'; +import type { + AttachmentPostUploadMiddlewareExecutorOptions, + AttachmentPostUploadMiddlewareState, +} from '../types'; +import { createPostUploadAttachmentEnrichmentMiddleware } from './attachmentEnrichment'; +import { createUploadErrorHandlerMiddleware } from './uploadErrorHandler'; + +export class AttachmentPostUploadMiddlewareExecutor extends MiddlewareExecutor< + AttachmentPostUploadMiddlewareState, + 'postProcess' +> { + constructor({ composer }: AttachmentPostUploadMiddlewareExecutorOptions) { + super(); + this.use([ + createUploadErrorHandlerMiddleware(composer), + createPostUploadAttachmentEnrichmentMiddleware(), + ]); + } +} diff --git a/src/messageComposer/middleware/attachmentManager/postUpload/attachmentEnrichment.ts b/src/messageComposer/middleware/attachmentManager/postUpload/attachmentEnrichment.ts new file mode 100644 index 000000000..18737851e --- /dev/null +++ b/src/messageComposer/middleware/attachmentManager/postUpload/attachmentEnrichment.ts @@ -0,0 +1,43 @@ +import type { MiddlewareHandlerParams } from '../../../../middleware'; +import type { + AttachmentPostUploadMiddleware, + AttachmentPostUploadMiddlewareState, +} from '../types'; +import { isLocalImageAttachment } from '../../../attachmentIdentity'; +import type { LocalNotImageAttachment } from '../../../types'; + +export const createPostUploadAttachmentEnrichmentMiddleware = + (): AttachmentPostUploadMiddleware => ({ + id: 'stream-io/attachment-manager-middleware/post-upload-enrichment', + handlers: { + postProcess: ({ + state, + discard, + forward, + next, + }: MiddlewareHandlerParams) => { + const { attachment, error, response } = state; + if (error) return forward(); + if (!attachment || !response) return discard(); + + const enrichedAttachment = { ...attachment }; + if (isLocalImageAttachment(attachment)) { + if (attachment.localMetadata.previewUri) { + URL.revokeObjectURL(attachment.localMetadata.previewUri); + delete enrichedAttachment.localMetadata.previewUri; + } + enrichedAttachment.image_url = response.file; + } else { + (enrichedAttachment as LocalNotImageAttachment).asset_url = response.file; + } + if (response.thumb_url) { + (enrichedAttachment as LocalNotImageAttachment).thumb_url = response.thumb_url; + } + + return next({ + ...state, + attachment: enrichedAttachment, + }); + }, + }, + }); diff --git a/src/messageComposer/middleware/attachmentManager/postUpload/index.ts b/src/messageComposer/middleware/attachmentManager/postUpload/index.ts new file mode 100644 index 000000000..9454b9bab --- /dev/null +++ b/src/messageComposer/middleware/attachmentManager/postUpload/index.ts @@ -0,0 +1,3 @@ +export * from './attachmentEnrichment'; +export * from './AttachmentPostUploadMiddlewareExecutor'; +export * from './uploadErrorHandler'; diff --git a/src/messageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.ts b/src/messageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.ts new file mode 100644 index 000000000..d33d7354a --- /dev/null +++ b/src/messageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.ts @@ -0,0 +1,39 @@ +import type { MiddlewareHandlerParams } from '../../../../middleware'; +import type { MessageComposer } from '../../../messageComposer'; +import type { + AttachmentPostUploadMiddleware, + AttachmentPostUploadMiddlewareState, +} from '../types'; + +export const createUploadErrorHandlerMiddleware = ( + composer: MessageComposer, +): AttachmentPostUploadMiddleware => ({ + id: 'stream-io/attachment-manager-middleware/upload-error', + handlers: { + postProcess: ({ + state, + discard, + forward, + }: MiddlewareHandlerParams) => { + const { attachment, error } = state; + if (!error) return forward(); + if (!attachment) return discard(); + + const reason = error instanceof Error ? error.message : 'unknown error'; + composer.client.notifications.addError({ + message: 'Error uploading attachment', + origin: { + emitter: 'AttachmentManager', + context: { attachment }, + }, + options: { + type: 'api:attachment:upload:failed', + metadata: { reason }, + originalError: error, + }, + }); + + return forward(); + }, + }, +}); diff --git a/src/messageComposer/middleware/attachmentManager/preUpload/AttachmentPreUploadMiddlewareExecutor.ts b/src/messageComposer/middleware/attachmentManager/preUpload/AttachmentPreUploadMiddlewareExecutor.ts new file mode 100644 index 000000000..9d190df57 --- /dev/null +++ b/src/messageComposer/middleware/attachmentManager/preUpload/AttachmentPreUploadMiddlewareExecutor.ts @@ -0,0 +1,20 @@ +import { MiddlewareExecutor } from '../../../../middleware'; +import type { + AttachmentPreUploadMiddlewareExecutorOptions, + AttachmentPreUploadMiddlewareState, +} from '../types'; +import { createUploadConfigCheckMiddleware } from './serverUploadConfigCheck'; +import { createBlockedAttachmentUploadNotificationMiddleware } from './blockedUploadNotification'; + +export class AttachmentPreUploadMiddlewareExecutor extends MiddlewareExecutor< + AttachmentPreUploadMiddlewareState, + 'prepare' +> { + constructor({ composer }: AttachmentPreUploadMiddlewareExecutorOptions) { + super(); + this.use([ + createUploadConfigCheckMiddleware(composer), + createBlockedAttachmentUploadNotificationMiddleware(composer), + ]); + } +} diff --git a/src/messageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.ts b/src/messageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.ts new file mode 100644 index 000000000..ff676a0c0 --- /dev/null +++ b/src/messageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.ts @@ -0,0 +1,38 @@ +import type { MiddlewareHandlerParams } from '../../../../middleware'; +import type { MessageComposer } from '../../../messageComposer'; +import type { + AttachmentPreUploadMiddleware, + AttachmentPreUploadMiddlewareState, +} from '../types'; + +export const createBlockedAttachmentUploadNotificationMiddleware = ( + composer: MessageComposer, +): AttachmentPreUploadMiddleware => ({ + id: 'stream-io/attachment-manager-middleware/blocked-upload-notification', + handlers: { + prepare: ({ + state: { attachment }, + forward, + }: MiddlewareHandlerParams) => { + if (!attachment) return forward(); + + if (attachment.localMetadata.uploadPermissionCheck?.uploadBlocked) { + composer.client.notifications.addError({ + message: `The attachment upload was blocked`, + origin: { + emitter: 'AttachmentManager', + context: { blockedAttachment: attachment }, + }, + options: { + type: 'validation:attachment:upload:blocked', + metadata: { + reason: attachment.localMetadata.uploadPermissionCheck?.reason, + }, + }, + }); + } + + return forward(); + }, + }, +}); diff --git a/src/messageComposer/middleware/attachmentManager/preUpload/index.ts b/src/messageComposer/middleware/attachmentManager/preUpload/index.ts new file mode 100644 index 000000000..8ba7b8f3c --- /dev/null +++ b/src/messageComposer/middleware/attachmentManager/preUpload/index.ts @@ -0,0 +1,3 @@ +export * from './AttachmentPreUploadMiddlewareExecutor'; +export * from './blockedUploadNotification'; +export * from './serverUploadConfigCheck'; diff --git a/src/messageComposer/middleware/attachmentManager/preUpload/serverUploadConfigCheck.ts b/src/messageComposer/middleware/attachmentManager/preUpload/serverUploadConfigCheck.ts new file mode 100644 index 000000000..ec9fe84c5 --- /dev/null +++ b/src/messageComposer/middleware/attachmentManager/preUpload/serverUploadConfigCheck.ts @@ -0,0 +1,40 @@ +import type { MiddlewareHandlerParams } from '../../../../middleware'; +import type { MessageComposer } from '../../../messageComposer'; +import type { + AttachmentPreUploadMiddleware, + AttachmentPreUploadMiddlewareState, +} from '../types'; +import type { LocalUploadAttachment } from '../../../types'; + +export const createUploadConfigCheckMiddleware = ( + composer: MessageComposer, +): AttachmentPreUploadMiddleware => ({ + id: 'stream-io/attachment-manager-middleware/file-upload-config-check', + handlers: { + prepare: async ({ + state, + next, + discard, + }: MiddlewareHandlerParams) => { + const { attachmentManager } = composer; + if (!attachmentManager || !state.attachment) return discard(); + const uploadPermissionCheck = await attachmentManager.getUploadConfigCheck( + state.attachment.localMetadata.file, + ); + + const attachment: LocalUploadAttachment = { + ...state.attachment, + localMetadata: { + ...state.attachment.localMetadata, + uploadPermissionCheck, + uploadState: uploadPermissionCheck.uploadBlocked ? 'blocked' : 'pending', + }, + }; + + return next({ + ...state, + attachment, + }); + }, + }, +}); diff --git a/src/messageComposer/middleware/attachmentManager/types.ts b/src/messageComposer/middleware/attachmentManager/types.ts new file mode 100644 index 000000000..269fcba7d --- /dev/null +++ b/src/messageComposer/middleware/attachmentManager/types.ts @@ -0,0 +1,32 @@ +import type { LocalUploadAttachment } from '../../types'; +import type { Middleware } from '../../../middleware'; +import type { MessageComposer } from '../../messageComposer'; +import type { MinimumUploadRequestResult } from '../../configuration'; + +export type AttachmentPreUploadMiddlewareState = { + attachment: LocalUploadAttachment; +}; + +export type AttachmentPostUploadMiddlewareState = { + attachment: LocalUploadAttachment; + error?: Error; + response?: MinimumUploadRequestResult; +}; + +export type AttachmentPreUploadMiddleware = Middleware< + AttachmentPreUploadMiddlewareState, + 'prepare' +>; + +export type AttachmentPostUploadMiddleware = Middleware< + AttachmentPostUploadMiddlewareState, + 'postProcess' +>; + +export type AttachmentPostUploadMiddlewareExecutorOptions = { + composer: MessageComposer; +}; + +export type AttachmentPreUploadMiddlewareExecutorOptions = { + composer: MessageComposer; +}; diff --git a/src/messageComposer/types.ts b/src/messageComposer/types.ts index ce5b50cea..76caab4b3 100644 --- a/src/messageComposer/types.ts +++ b/src/messageComposer/types.ts @@ -113,6 +113,12 @@ export type LocalImageAttachmentUploadMetadata = LocalAttachmentUploadMetadata & previewUri?: string; }; +export type LocalNotImageAttachment = + | LocalFileAttachment + | LocalAudioAttachment + | LocalVideoAttachment + | LocalVoiceRecordingAttachment; + export type AttachmentLoadingState = | 'uploading' | 'finished' diff --git a/src/middleware.ts b/src/middleware.ts index 5eda8b430..5347454c1 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -21,6 +21,12 @@ export type MiddlewareExecutionResult = { export type ExecuteParams = { eventName: string; initialValue: TValue; + /* + Determines how the concurrently run middleware handlers will be executed: + - async - all handlers are executed even though the same handler is invoked more than once + - cancelable - previously invoked handlers of the same eventName that have not yet resolved are canceled + */ + mode?: 'concurrent' | 'cancelable'; // default 'cancelable' }; export type MiddlewareHandlerParams = { @@ -107,6 +113,7 @@ export class MiddlewareExecutor { protected async executeMiddlewareChain({ eventName, initialValue, + mode = 'cancelable', }: ExecuteParams): Promise> { let index = -1; @@ -148,16 +155,19 @@ export class MiddlewareExecutor { }); }; - const result = await withCancellation( - `middleware-execution-${this.id}-${eventName}`, - async (abortSignal) => { - const result = await execute(0, initialValue); - if (abortSignal.aborted) { - return 'canceled'; - } - return result; - }, - ); + const result = + mode === 'cancelable' + ? await withCancellation( + `middleware-execution-${this.id}-${eventName}`, + async (abortSignal) => { + const result = await execute(0, initialValue); + if (abortSignal.aborted) { + return 'canceled'; + } + return result; + }, + ) + : await execute(0, initialValue); return result === 'canceled' ? { state: initialValue, status: 'discard' } : result; } @@ -165,10 +175,12 @@ export class MiddlewareExecutor { async execute({ eventName, initialValue: initialState, + mode, }: ExecuteParams): Promise> { return await this.executeMiddlewareChain({ eventName, initialValue: initialState, + mode, }); } } diff --git a/test/unit/MessageComposer/attachmentManager.test.ts b/test/unit/MessageComposer/attachmentManager.test.ts index 3c69e9447..f8663fc12 100644 --- a/test/unit/MessageComposer/attachmentManager.test.ts +++ b/test/unit/MessageComposer/attachmentManager.test.ts @@ -64,9 +64,7 @@ vi.mock('../../../src/utils', async (importOriginal) => { const original: object = await importOriginal(); return { ...original, - generateUUIDv4: vi.fn().mockReturnValue('test-uuid'), mergeWith: vi.fn().mockImplementation((target, source) => ({ ...target, ...source })), - randomId: vi.fn().mockReturnValue('test-uuid'), }; }); @@ -171,7 +169,7 @@ describe('AttachmentManager', () => { type: 'image', image_url: 'test-image-url', localMetadata: { - id: 'test-uuid', + id: expect.any(String), uploadState: 'finished', }, }, @@ -205,7 +203,7 @@ describe('AttachmentManager', () => { type: 'image', image_url: 'test-image-url', localMetadata: { - id: 'test-uuid', + id: expect.any(String), uploadState: 'finished', }, }, @@ -481,7 +479,7 @@ describe('AttachmentManager', () => { { image_url: 'test-url', localMetadata: { - id: 'test-uuid', + id: expect.any(String), uploadState: 'finished', }, type: 'image', @@ -999,14 +997,16 @@ describe('AttachmentManager', () => { }); }); - describe('uploadFiles', () => { + describe('uploadAttachment', () => { it('should upload files successfully', async () => { const { messageComposer: { attachmentManager }, } = setup(); const file = new File([''], 'test.jpg', { type: 'image/jpeg' }); - await attachmentManager.uploadFiles([file]); + await attachmentManager.uploadAttachment( + await attachmentManager.fileToLocalUploadAttachment(file), + ); expect(attachmentManager.successfulUploadsCount).toBe(1); }); @@ -1020,23 +1020,25 @@ describe('AttachmentManager', () => { mockChannel.sendImage.mockRejectedValueOnce(new Error('Upload failed')); const file = new File([''], 'test.jpg', { type: 'image/jpeg' }); - await expect(attachmentManager.uploadFiles([file])).resolves.toEqual([ - { - fallback: 'test.jpg', - file_size: 0, - localMetadata: { - id: 'test-uuid', - file, - uploadState: 'failed', - previewUri: expect.any(String), - uploadPermissionCheck: { - uploadBlocked: false, - }, + await expect( + attachmentManager.uploadAttachment( + await attachmentManager.fileToLocalUploadAttachment(file), + ), + ).resolves.toEqual({ + fallback: 'test.jpg', + file_size: 0, + localMetadata: { + id: expect.any(String), + file, + uploadState: 'failed', + previewUri: expect.any(String), + uploadPermissionCheck: { + uploadBlocked: false, }, - mime_type: 'image/jpeg', - type: 'image', }, - ]); + mime_type: 'image/jpeg', + type: 'image', + }); expect(attachmentManager.failedUploadsCount).toBe(1); expect(mockClient.notifications.addError).toHaveBeenCalledWith({ @@ -1111,7 +1113,6 @@ describe('AttachmentManager', () => { // Create a file to upload const file = new File([''], 'test.jpg', { type: 'image/jpeg' }); - // Mock fileToLocalUploadAttachment to return a valid attachment const attachment = { type: 'image', localMetadata: { @@ -1132,6 +1133,114 @@ describe('AttachmentManager', () => { expect(customUploadFn).toHaveBeenCalledWith(file); expect(mockChannel.sendImage).not.toHaveBeenCalled(); }); + }); + + describe('uploadFiles', () => { + it('should upload files successfully', async () => { + const { + messageComposer: { attachmentManager }, + } = setup(); + const file = new File([''], 'test.jpg', { type: 'image/jpeg' }); + + await attachmentManager.uploadFiles([file]); + + expect(attachmentManager.successfulUploadsCount).toBe(1); + }); + + it('should handle upload failures', async () => { + const { + messageComposer: { attachmentManager }, + mockChannel, + mockClient, + } = setup(); + mockChannel.sendImage.mockRejectedValueOnce(new Error('Upload failed')); + const file = new File([''], 'test.jpg', { type: 'image/jpeg' }); + + await expect(attachmentManager.uploadFiles([file])).resolves.toEqual([ + { + fallback: 'test.jpg', + file_size: 0, + localMetadata: { + id: expect.any(String), + file, + uploadState: 'failed', + previewUri: expect.any(String), + uploadPermissionCheck: { + uploadBlocked: false, + }, + }, + mime_type: 'image/jpeg', + type: 'image', + }, + ]); + + expect(attachmentManager.failedUploadsCount).toBe(1); + expect(mockClient.notifications.addError).toHaveBeenCalledWith({ + message: 'Error uploading attachment', + origin: { + emitter: 'AttachmentManager', + context: { + attachment: expect.any(Object), + }, + }, + options: { + type: 'api:attachment:upload:failed', + metadata: { reason: 'Upload failed' }, + originalError: expect.any(Error), + }, + }); + }); + + it('should register notification for blocked file', async () => { + const { + messageComposer: { attachmentManager }, + mockClient, + } = setup(); + + vi.spyOn(attachmentManager, 'getUploadConfigCheck').mockResolvedValue({ + uploadBlocked: true, + reason: 'size_limit', + }); + + const [blockedAttachment] = await attachmentManager.uploadFiles([ + new File([''], 'test.jpg', { type: 'image/jpeg' }), + ]); + + expect(mockClient.notifications.addError).toHaveBeenCalledWith({ + message: 'The attachment upload was blocked', + origin: { + emitter: 'AttachmentManager', + context: { + blockedAttachment, + }, + }, + options: { + type: 'validation:attachment:upload:blocked', + metadata: { reason: 'size_limit' }, + }, + }); + }); + + it('should use custom upload function when provided', async () => { + const { + messageComposer: { attachmentManager }, + mockChannel, + } = setup(); + + // Create a custom upload function + const customUploadFn = vi.fn().mockResolvedValue({ file: 'custom-upload-url' }); + + // Set the custom upload function + attachmentManager.setCustomUploadFn(customUploadFn); + + const file = new File([''], 'test.jpg', { type: 'image/jpeg' }); + // Upload the attachment + await attachmentManager.uploadFiles([file]); + + // Verify the custom upload function was called + expect(customUploadFn).toHaveBeenCalledWith(file); + expect(mockChannel.sendImage).not.toHaveBeenCalled(); + }); it('should respect maxNumberOfFilesPerMessage', async () => { const { @@ -1139,11 +1248,11 @@ describe('AttachmentManager', () => { } = setup(); const files = Array(API_MAX_FILES_ALLOWED_PER_MESSAGE + 1) .fill(null) - .map(() => new File([''], 'test.jpg', { type: 'image/jpeg' })); + .map((_, i) => new File([''], `test-${i}.jpg`, { type: 'image/jpeg' })); - await attachmentManager.uploadFiles(files); + const result = await attachmentManager.uploadFiles(files); - expect(attachmentManager.successfulUploadsCount).toBeLessThanOrEqual( + expect(attachmentManager.successfulUploadsCount).toBe( API_MAX_FILES_ALLOWED_PER_MESSAGE, ); }); @@ -1201,9 +1310,9 @@ describe('AttachmentManager', () => { }); expect(mockClient.notifications.addError).toHaveBeenCalledWith({ - message: 'File is required for upload attachment', + message: 'Local upload attachment missing local id', options: { - type: 'validation:attachment:file:missing', + type: 'validation:attachment:id:missing', }, origin: { emitter: 'AttachmentManager', @@ -1250,7 +1359,6 @@ describe('AttachmentManager', () => { 'fileToLocalUploadAttachment', ); - // Set a fileUploadFilter that allows all files attachmentManager.fileUploadFilter = () => true; const file = new File([''], 'test.jpg', { type: 'image/jpeg' }); @@ -1274,7 +1382,6 @@ describe('AttachmentManager', () => { // Set a fileUploadFilter that allows all files attachmentManager.fileUploadFilter = () => true; - // Mock the fileToLocalUploadAttachment method to return a specific value const expectedAttachment = { type: 'image', image_url: 'test-url', @@ -1284,7 +1391,7 @@ describe('AttachmentManager', () => { uploadState: 'finished', }, }; - vi.spyOn(attachmentManager, 'fileToLocalUploadAttachment').mockResolvedValue( + vi.spyOn(attachmentManager, 'fileToLocalUploadAttachment').mockReturnValue( expectedAttachment, ); @@ -1313,7 +1420,6 @@ describe('AttachmentManager', () => { const originalId = 'original-test-id'; const file = new File([''], 'test.jpg', { type: 'image/jpeg' }); - // Mock fileToLocalUploadAttachment to return a new attachment const newAttachment = { type: 'image', image_url: 'test-url', @@ -1324,7 +1430,7 @@ describe('AttachmentManager', () => { }, }; - vi.spyOn(attachmentManager, 'fileToLocalUploadAttachment').mockResolvedValue( + vi.spyOn(attachmentManager, 'fileToLocalUploadAttachment').mockReturnValue( newAttachment, ); @@ -1346,11 +1452,14 @@ describe('AttachmentManager', () => { const { messageComposer: { attachmentManager }, } = setup(); - vi.spyOn(Utils, 'generateUUIDv4').mockReturnValue('mock-uuid'); - vi.spyOn(attachmentManager, 'getUploadConfigCheck').mockResolvedValue({ + const uploadConfigCheckResult = { uploadBlocked: false, reason: '', - }); + }; + vi.spyOn(Utils, 'generateUUIDv4').mockReturnValue('mock-uuid'); + vi.spyOn(attachmentManager, 'getUploadConfigCheck').mockResolvedValue( + uploadConfigCheckResult, + ); // Create a file of size 1234 bytes const fileContent = new Uint8Array(1234); const file = new File([fileContent], 'test.jpg', { type: 'image/jpeg' }); @@ -1366,7 +1475,7 @@ describe('AttachmentManager', () => { }), fallback: 'test.jpg', }); - expect(result.localMetadata.uploadPermissionCheck).toBeDefined(); + expect(result.localMetadata.uploadPermissionCheck).toEqual(uploadConfigCheckResult); expect(result.localMetadata.uploadState).toMatch(/pending|blocked/); expect(result.localMetadata.previewUri).toBeDefined(); }); @@ -1376,10 +1485,13 @@ describe('AttachmentManager', () => { messageComposer: { attachmentManager }, } = setup(); vi.spyOn(Utils, 'generateUUIDv4').mockReturnValue('mock-uuid'); - vi.spyOn(attachmentManager, 'getUploadConfigCheck').mockResolvedValue({ + const uploadConfigCheckResult = { uploadBlocked: false, reason: '', - }); + }; + vi.spyOn(attachmentManager, 'getUploadConfigCheck').mockResolvedValue( + uploadConfigCheckResult, + ); const fileReference = { name: 'test.jpg', type: 'image/jpeg', @@ -1402,7 +1514,7 @@ describe('AttachmentManager', () => { original_height: 1000, original_width: 1200, }); - expect(result.localMetadata.uploadPermissionCheck).toBeDefined(); + expect(result.localMetadata.uploadPermissionCheck).toEqual(uploadConfigCheckResult); expect(result.localMetadata.uploadState).toMatch(/pending|blocked/); }); @@ -1411,10 +1523,13 @@ describe('AttachmentManager', () => { messageComposer: { attachmentManager }, } = setup(); vi.spyOn(Utils, 'generateUUIDv4').mockReturnValue('mock-uuid'); - vi.spyOn(attachmentManager, 'getUploadConfigCheck').mockResolvedValue({ + const uploadConfigCheckResult = { uploadBlocked: false, reason: '', - }); + }; + vi.spyOn(attachmentManager, 'getUploadConfigCheck').mockResolvedValue( + uploadConfigCheckResult, + ); const fileReference = { name: 'test.mp4', type: 'video/mp4', @@ -1439,8 +1554,7 @@ describe('AttachmentManager', () => { duration: 12.34, thumb_url: 'file://thumb.jpg', }); - expect(result.localMetadata.uploadPermissionCheck).toBeDefined(); - expect(result.localMetadata.uploadState).toMatch(/pending|blocked/); + expect(result.localMetadata.uploadPermissionCheck).toEqual(uploadConfigCheckResult); }); }); }); diff --git a/test/unit/MessageComposer/middleware/attachmentManager/postUpload/attachmentEnrichment.test.ts b/test/unit/MessageComposer/middleware/attachmentManager/postUpload/attachmentEnrichment.test.ts new file mode 100644 index 000000000..df1d18823 --- /dev/null +++ b/test/unit/MessageComposer/middleware/attachmentManager/postUpload/attachmentEnrichment.test.ts @@ -0,0 +1,138 @@ +import { + AttachmentManager, + MessageComposer, + MiddlewareStatus, +} from '../../../../../../src'; +import { describe, expect, it, vi } from 'vitest'; +import { + AttachmentPostUploadMiddlewareState, + createPostUploadAttachmentEnrichmentMiddleware, +} from '../../../../../../src/messageComposer/middleware/attachmentManager'; + +vi.mock('../../../../../src/utils', () => ({ + generateUUIDv4: vi.fn().mockReturnValue('test-uuid'), +})); + +const setupHandlerParams = (initialState: AttachmentPostUploadMiddlewareState) => { + return { + state: initialState, + next: async (state: AttachmentPostUploadMiddlewareState) => ({ state }), + complete: async (state: AttachmentPostUploadMiddlewareState) => ({ + state, + status: 'complete' as MiddlewareStatus, + }), + discard: async () => ({ state: initialState, status: 'discard' as MiddlewareStatus }), + forward: async () => ({ state: initialState }), + }; +}; + +const getInitialState = ( + composer: MessageComposer, +): AttachmentPostUploadMiddlewareState => ({ + attachment: AttachmentManager.toLocalUploadAttachment( + new File([''], 'test.jpg', { type: 'image/jpeg' }), + ), +}); + +describe('createPostUploadAttachmentEnrichmentMiddleware', () => { + it('discards if attachment is not present in middleware state', async () => { + const middleware = createPostUploadAttachmentEnrichmentMiddleware(); + const initialAttachment = undefined; + const initialResponse = {}; + const { + status, + state: { attachment, response }, + } = await middleware.handlers.postProcess( + setupHandlerParams({ attachment: initialAttachment, response: initialResponse }), + ); + expect(status).toBe('discard'); + expect(attachment).toEqual(initialAttachment); + expect(response).toEqual(initialResponse); + }); + it('discards if response is not present in middleware state', async () => { + const middleware = createPostUploadAttachmentEnrichmentMiddleware(); + const initialAttachment = {}; + const initialResponse = undefined; + const { + status, + state: { attachment, response }, + } = await middleware.handlers.postProcess( + setupHandlerParams({ attachment: initialAttachment, response: initialResponse }), + ); + expect(status).toBe('discard'); + expect(attachment).toEqual(initialAttachment); + expect(response).toEqual(initialResponse); + }); + it('forwards if error is present in middleware state', async () => { + const middleware = createPostUploadAttachmentEnrichmentMiddleware(); + const initialAttachment = {}; + const { + status, + state: { attachment }, + } = await middleware.handlers.postProcess( + setupHandlerParams({ attachment: initialAttachment, error: new Error() }), + ); + expect(status).toBeUndefined(); + expect(attachment).toEqual(initialAttachment); + }); + + it('enriches image attachment', async () => { + const middleware = createPostUploadAttachmentEnrichmentMiddleware(); + const initialAttachment = { + localMetadata: { + id: 'id', + file: new File([''], 'test.jpg', { type: 'image/jpeg' }), + previewUri: 'previewUri', + uploadPermissionCheck: { uploadBlocked: false, reason: '' }, + }, + type: 'image', + }; + const response = { + file: 'https://example.com/file/url', + }; + const { + status, + state: { attachment }, + } = await middleware.handlers.postProcess( + setupHandlerParams({ attachment: initialAttachment, response }), + ); + expect(status).toBeUndefined(); + expect(attachment).toEqual({ + ...initialAttachment, + image_url: response.file, + localMetadata: { + id: 'id', + file: initialAttachment.localMetadata.file, + uploadPermissionCheck: { uploadBlocked: false, reason: '' }, + }, + }); + }); + + it('enriches non-image attachment', async () => { + const middleware = createPostUploadAttachmentEnrichmentMiddleware(); + const initialAttachment = { + localMetadata: { + id: 'id', + file: new File([''], 'test.jpg', { type: 'image/jpeg' }), + uploadPermissionCheck: { uploadBlocked: false, reason: '' }, + }, + // type: 'file', + }; + const response = { + file: 'https://example.com/file/url', + thumb_url: 'https://example.com/thumb/url', + }; + const { + status, + state: { attachment }, + } = await middleware.handlers.postProcess( + setupHandlerParams({ attachment: initialAttachment, response }), + ); + expect(status).toBeUndefined(); + expect(attachment).toEqual({ + ...initialAttachment, + asset_url: response.file, + thumb_url: response.thumb_url, + }); + }); +}); diff --git a/test/unit/MessageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.test.ts b/test/unit/MessageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.test.ts new file mode 100644 index 000000000..2442426f0 --- /dev/null +++ b/test/unit/MessageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.test.ts @@ -0,0 +1,88 @@ +import { MessageComposer, MiddlewareStatus } from '../../../../../../src'; +import { describe, expect, it, vi } from 'vitest'; +import { + AttachmentPostUploadMiddlewareState, + createUploadErrorHandlerMiddleware, +} from '../../../../../../src/messageComposer/middleware/attachmentManager'; +import { getClientWithUser } from '../../../../test-utils/getClient'; + +vi.mock('../../../../../src/utils', () => ({ + generateUUIDv4: vi.fn().mockReturnValue('test-uuid'), +})); + +const setupHandlerParams = (initialState: AttachmentPostUploadMiddlewareState) => { + return { + state: initialState, + next: async (state: AttachmentPostUploadMiddlewareState) => ({ state }), + complete: async (state: AttachmentPostUploadMiddlewareState) => ({ + state, + status: 'complete' as MiddlewareStatus, + }), + discard: async () => ({ state: initialState, status: 'discard' as MiddlewareStatus }), + forward: async () => ({ state: initialState }), + }; +}; + +const setup = () => { + const client = getClientWithUser({ id: 'user-id' }); + const composer = new MessageComposer({ + client, + compositionContext: client.channel('type', 'id'), + }); + return { composer, middleware: createUploadErrorHandlerMiddleware(composer) }; +}; + +describe('createUploadErrorHandlerMiddleware', () => { + it('discards if attachment is not present in middleware state', async () => { + const { composer, middleware } = setup(); + const addErrorNotificationSpy = vi + .spyOn(composer.client.notifications, 'addError') + .mockImplementation(); + const { status } = await middleware.handlers.postProcess( + setupHandlerParams({ error: new Error() }), + ); + expect(status).toBe('discard'); + expect(addErrorNotificationSpy).not.toHaveBeenCalled(); + }); + it('forwards if error is not present in middleware state', async () => { + const { composer, middleware } = setup(); + const addErrorNotificationSpy = vi + .spyOn(composer.client.notifications, 'addError') + .mockImplementation(); + const { status } = await middleware.handlers.postProcess(setupHandlerParams({})); + expect(status).toBeUndefined(); + expect(addErrorNotificationSpy).not.toHaveBeenCalled(); + }); + + it('publishes error notification if the attachment upload was blocked', async () => { + const { composer, middleware } = setup(); + const addErrorNotificationSpy = vi + .spyOn(composer.client.notifications, 'addError') + .mockImplementation(); + const attachment = { + localMetadata: { + id: 'id', + file: {}, + uploadPermissionCheck: { uploadBlocked: true, reason: 'reason' }, + }, + type: 'file', + }; + const error = new Error('message'); + const { status } = await middleware.handlers.postProcess( + setupHandlerParams({ attachment, error }), + ); + expect(status).toBeUndefined(); + expect(addErrorNotificationSpy).toHaveBeenCalledWith({ + message: 'Error uploading attachment', + origin: { + emitter: 'AttachmentManager', + context: { attachment }, + }, + options: { + type: 'api:attachment:upload:failed', + metadata: { reason: error.message }, + originalError: error, + }, + }); + }); +}); diff --git a/test/unit/MessageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.test.ts b/test/unit/MessageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.test.ts new file mode 100644 index 000000000..f7ce1435d --- /dev/null +++ b/test/unit/MessageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.test.ts @@ -0,0 +1,108 @@ +import { + AttachmentManager, + MessageComposer, + MiddlewareStatus, +} from '../../../../../../src'; +import { describe, expect, it, vi } from 'vitest'; +import { + AttachmentPreUploadMiddlewareState, + createBlockedAttachmentUploadNotificationMiddleware, +} from '../../../../../../src/messageComposer/middleware/attachmentManager'; +import { getClientWithUser } from '../../../../test-utils/getClient'; + +vi.mock('../../../../../src/utils', () => ({ + generateUUIDv4: vi.fn().mockReturnValue('test-uuid'), +})); + +const setupHandlerParams = (initialState: AttachmentPreUploadMiddlewareState) => { + return { + state: initialState, + next: async (state: AttachmentPreUploadMiddlewareState) => ({ state }), + complete: async (state: AttachmentPreUploadMiddlewareState) => ({ + state, + status: 'complete' as MiddlewareStatus, + }), + discard: async () => ({ state: initialState, status: 'discard' as MiddlewareStatus }), + forward: async () => ({ state: initialState }), + }; +}; + +const setup = () => { + const client = getClientWithUser({ id: 'user-id' }); + const composer = new MessageComposer({ + client, + compositionContext: client.channel('type', 'id'), + }); + return { + composer, + middleware: createBlockedAttachmentUploadNotificationMiddleware(composer), + }; +}; + +const getInitialState = ( + composer: MessageComposer, +): AttachmentPreUploadMiddlewareState => ({ + attachment: AttachmentManager.toLocalUploadAttachment( + new File([''], 'test.jpg', { type: 'image/jpeg' }), + ), +}); + +describe('createBlockedAttachmentUploadNotificationMiddleware', () => { + it('forwards if attachment is not present in middleware state', async () => { + const middleware = createBlockedAttachmentUploadNotificationMiddleware({}); + const { status } = await middleware.handlers.prepare(setupHandlerParams({})); + expect(status).toBeUndefined(); + }); + + it('publishes error notification if the attachment upload was blocked', async () => { + const { composer, middleware } = setup(); + const addErrorNotificationSpy = vi + .spyOn(composer.client.notifications, 'addError') + .mockImplementation(); + const attachment = { + localMetadata: { + id: 'id', + file: {}, + uploadPermissionCheck: { uploadBlocked: true, reason: 'reason' }, + }, + type: 'file', + }; + const { status } = await middleware.handlers.prepare( + setupHandlerParams({ attachment }), + ); + expect(status).toBeUndefined(); + expect(addErrorNotificationSpy).toHaveBeenCalledWith({ + message: `The attachment upload was blocked`, + origin: { + emitter: 'AttachmentManager', + context: { blockedAttachment: attachment }, + }, + options: { + type: 'validation:attachment:upload:blocked', + metadata: { + reason: attachment.localMetadata.uploadPermissionCheck?.reason, + }, + }, + }); + }); + + it('does not publish error notification if the attachment upload was not blocked', async () => { + const { composer, middleware } = setup(); + const addErrorNotificationSpy = vi + .spyOn(composer.client.notifications, 'addError') + .mockImplementation(); + const attachment = { + localMetadata: { + id: 'id', + file: {}, + uploadPermissionCheck: { uploadBlocked: false, reason: '' }, + }, + type: 'file', + }; + const { status } = await middleware.handlers.prepare( + setupHandlerParams({ attachment }), + ); + expect(status).toBeUndefined(); + expect(addErrorNotificationSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/MessageComposer/middleware/attachmentManager/preUpload/serverUploadConfigCheck.test.ts b/test/unit/MessageComposer/middleware/attachmentManager/preUpload/serverUploadConfigCheck.test.ts new file mode 100644 index 000000000..bb463c1e8 --- /dev/null +++ b/test/unit/MessageComposer/middleware/attachmentManager/preUpload/serverUploadConfigCheck.test.ts @@ -0,0 +1,94 @@ +import { + AttachmentManager, + MessageComposer, + MiddlewareStatus, +} from '../../../../../../src'; +import { describe, expect, it, vi } from 'vitest'; +import { + AttachmentPreUploadMiddlewareState, + createUploadConfigCheckMiddleware, +} from '../../../../../../src/messageComposer/middleware/attachmentManager'; +import { getClientWithUser } from '../../../../test-utils/getClient'; + +const setupHandlerParams = (initialState: AttachmentPreUploadMiddlewareState) => { + return { + state: initialState, + next: async (state: AttachmentPreUploadMiddlewareState) => ({ state }), + complete: async (state: AttachmentPreUploadMiddlewareState) => ({ + state, + status: 'complete' as MiddlewareStatus, + }), + discard: async () => ({ state: initialState, status: 'discard' as MiddlewareStatus }), + forward: async () => ({ state: initialState }), + }; +}; + +// Mock dependencies +vi.mock('../../../../../src/utils', () => ({ + generateUUIDv4: vi.fn().mockReturnValue('test-uuid'), +})); + +const setup = () => { + const client = getClientWithUser({ id: 'user-id' }); + const composer = new MessageComposer({ + client, + compositionContext: client.channel('type', 'id'), + }); + return { composer, middleware: createUploadConfigCheckMiddleware(composer) }; +}; + +const getInitialState = ( + composer: MessageComposer, +): AttachmentPreUploadMiddlewareState => ({ + attachment: AttachmentManager.toLocalUploadAttachment( + new File([''], 'test.jpg', { type: 'image/jpeg' }), + ), +}); + +describe('createUploadConfigCheckMiddleware', () => { + it('discards when attachment manager is not in message composer', async () => { + const middleware = createUploadConfigCheckMiddleware({}); + const { status } = await middleware.handlers.prepare( + setupHandlerParams({ + attachment: { + localMetadata: { id: 'id', file: {}, uploadState: '' }, + type: 'file', + }, + }), + ); + expect(status).toBe('discard'); + }); + it('discards when attachment is missing in the state', async () => { + const { middleware } = setup(); + const { status } = await middleware.handlers.prepare(setupHandlerParams({})); + expect(status).toBe('discard'); + }); + it('enriches the attachment with uploadPermissionCheck and updates the attachment.uploadState to blocked', async () => { + const { composer, middleware } = setup(); + const uploadPermissionCheck = { uploadBlocked: true }; + vi.spyOn(composer.attachmentManager, 'getUploadConfigCheck').mockResolvedValue( + uploadPermissionCheck, + ); + const { + status, + state: { attachment }, + } = await middleware.handlers.prepare(setupHandlerParams(getInitialState(composer))); + expect(status).toBeUndefined(); + expect(attachment.localMetadata.uploadPermissionCheck).toBe(uploadPermissionCheck); + expect(attachment.localMetadata.uploadState).toBe('blocked'); + }); + it('updates the attachment.uploadState to pending', async () => { + const { composer, middleware } = setup(); + const uploadPermissionCheck = { uploadBlocked: false }; + vi.spyOn(composer.attachmentManager, 'getUploadConfigCheck').mockResolvedValue( + uploadPermissionCheck, + ); + const { + status, + state: { attachment }, + } = await middleware.handlers.prepare(setupHandlerParams(getInitialState(composer))); + expect(status).toBeUndefined(); + expect(attachment.localMetadata.uploadPermissionCheck).toBe(uploadPermissionCheck); + expect(attachment.localMetadata.uploadState).toBe('pending'); + }); +}); diff --git a/test/unit/middleware.test.ts b/test/unit/middleware.test.ts index 579ab6cfb..49d0ad73c 100644 --- a/test/unit/middleware.test.ts +++ b/test/unit/middleware.test.ts @@ -546,6 +546,52 @@ describe('MiddlewareExecutor', () => { expect(secondResult.state.value).toBe(11); // 10 + 1 }); + it('should handle concurrent execute calls in async mode by not discarding the first one', async () => { + // Create a middleware that delays execution + const middleware: Middleware<{ value: number }, 'test'> = { + id: 'delayed-middleware', + handlers: { + test: async ({ state, next }) => { + // Simulate a longer delay to ensure the first execution is still in progress + await new Promise((resolve) => setTimeout(resolve, 500)); + return next({ ...state, value: state.value + 1 }); + }, + }, + }; + + executor.use(middleware); + + // Start the first execution + const firstExecution = executor.execute({ + eventName: 'test', + initialValue: { value: 5 }, + }); + + // Wait a short time to ensure the first execution has started but not completed + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Start the second execution before the first one completes + const secondExecution = executor.execute({ + eventName: 'test', + initialValue: { value: 10 }, + mode: 'concurrent', + }); + + // Wait for both executions to complete + const [firstResult, secondResult] = await Promise.all([ + firstExecution, + secondExecution, + ]); + + // The first execution should be discarded + expect(firstResult.status).toBeUndefined(); + expect(firstResult.state.value).toBe(6); // 5 + 1 + + // The second execution should complete successfully + expect(secondResult.status).toBeUndefined(); + expect(secondResult.state.value).toBe(11); // 10 + 1 + }); + it('should handle concurrent execute calls with different event names', async () => { // Create middleware that handles different event names const middleware: Middleware<{ value: number }, 'test1' | 'test2'> = {