diff --git a/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.integration.test.tsx b/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.integration.test.tsx new file mode 100644 index 000000000..0a1142b87 --- /dev/null +++ b/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.integration.test.tsx @@ -0,0 +1,392 @@ +import * as useThreadModule from '../../../context/useThread'; +import { ChannelStateTypes, ParentMessageStateTypes, ThreadListStateTypes } from '../../../types'; +import { EmojiContainer } from '@sendbird/chat'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { LocalizationContext } from '../../../../../lib/LocalizationContext'; +import ThreadUI from '../index'; +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; + +const mockSendUserMessage = jest.fn(); + +const mockChannel = { + url: 'test-channel', + members: [{ userId: 'test-user-id', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async (message) => mockNewMessage(message)), + sendUserMessage: mockSendUserMessage, + isGroupChannel: jest.fn().mockImplementation(() => true), +}; + +const mockNewMessage = (message) => ({ + messageId: 42, + message: message ?? 'new message', +}); + +const mockMessage = { + messageId: 1, + message: 'first message', +}; + +const mockGetMessage = jest.fn().mockResolvedValue(mockMessage); +const mockGetChannel = jest.fn().mockResolvedValue(mockChannel); + +const mockState = { + stores: { + sdkStore: { + sdk: { + getMessage: mockGetMessage, + groupChannel: { + getChannel: mockGetChannel, + }, + }, + initialized: true, + }, + userStore: { user: { userId: 'test-user-id' } }, + }, + config: { + logger: console, + isOnline: true, + pubSub: { + publish: jest.fn(), + }, + groupChannel: { + enableMention: true, + enableReactions: true, + replyType: 'THREAD', + }, + }, +}; + +jest.mock('../../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(() => ({ state: mockState })), + useSendbird: jest.fn(() => ({ state: mockState })), +})); + +jest.mock('../../../context/useThread'); + +const mockStringSet = { + DATE_FORMAT__MESSAGE_CREATED_AT: 'p', +}; + +const mockLocalizationContext = { + stringSet: mockStringSet, +}; + +const defaultMockState = { + channelUrl: '', + message: null, + onHeaderActionClick: undefined, + onMoveToParentMessage: undefined, + onBeforeSendUserMessage: undefined, + onBeforeSendFileMessage: undefined, + onBeforeSendVoiceMessage: undefined, + onBeforeSendMultipleFilesMessage: undefined, + onBeforeDownloadFileMessage: undefined, + isMultipleFilesMessageEnabled: undefined, + filterEmojiCategoryIds: undefined, + currentChannel: undefined, + allThreadMessages: [ + { + messageId: 2, + message: 'threaded message 1', + isUserMessage: () => true, + }, + ], + localThreadMessages: [], + parentMessage: null, + channelState: ChannelStateTypes.INITIALIZED, + parentMessageState: ParentMessageStateTypes.INITIALIZED, + threadListState: ThreadListStateTypes.INITIALIZED, + hasMorePrev: false, + hasMoreNext: false, + emojiContainer: {} as EmojiContainer, + isMuted: false, + isChannelFrozen: false, + currentUserId: '', + typingMembers: [], + nicknamesMap: null, +}; + +const defaultMockActions = { + fetchPrevThreads: jest.fn((callback) => { + callback(); + }), + fetchNextThreads: jest.fn((callback) => { + callback(); + }), +}; + +describe('CreateChannelUI Integration Tests', () => { + const mockUseThread = useThreadModule.default as jest.Mock; + + const renderComponent = (mockState = {}, mockActions = {}) => { + mockUseThread.mockReturnValue({ + state: { ...defaultMockState, ...mockState }, + actions: { ...defaultMockActions, ...mockActions }, + }); + + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('display initial state correctly', async () => { + await act(async () => { + renderComponent( + { + parentMessage: { + messageId: 1, + message: 'parent message', + isUserMessage: () => true, + isTextMessage: true, + createdAt: 0, + sender: { + userId: 'test-user-id', + }, + }, + }, + ); + }); + + expect(screen.getByText('parent message')).toBeInTheDocument(); + expect(screen.getByText('threaded message 1')).toBeInTheDocument(); + }); + + it('fetchPrevThread is correctly called when scroll is top', async () => { + let container; + const parentMessage = { + messageId: 1, + message: 'parent message', + isUserMessage: () => true, + isTextMessage: true, + createdAt: 0, + sender: { + userId: 'test-user-id', + }, + getThreadedMessagesByTimestamp: () => ({ + parentMessage, + threadedMessages: [ + { messageId: 3, message: 'threaded message -1', isUserMessage: () => true }, + { messageId: 4, message: 'threaded message 0', isUserMessage: () => true }, + ], + }), + }; + + await act(async () => { + const result = renderComponent( + { + parentMessage, + hasMorePrev: true, + }, + ); + + container = result.container; + }); + + const scrollContainer = container.getElementsByClassName('sendbird-thread-ui--scroll')[0]; + fireEvent.scroll(scrollContainer, { target: { scrollY: -1 } }); + + await waitFor(() => { + expect(defaultMockActions.fetchPrevThreads).toBeCalledTimes(1); + }); + }); + + it('fetchNextThreads is correctly called when scroll is bottom', async () => { + let container; + const parentMessage = { + messageId: 1, + message: 'parent message', + isUserMessage: () => true, + isTextMessage: true, + createdAt: 0, + sender: { + userId: 'test-user-id', + }, + getThreadedMessagesByTimestamp: () => ({ + parentMessage, + threadedMessages: [ + { messageId: 3, message: 'threaded message -1', isUserMessage: () => true }, + { messageId: 4, message: 'threaded message 0', isUserMessage: () => true }, + ], + }), + }; + + await act(async () => { + const result = renderComponent( + { + parentMessage, + hasMoreNext: true, + }, + ); + + container = result.container; + }); + + const scrollContainer = container.getElementsByClassName('sendbird-thread-ui--scroll')[0]; + fireEvent.scroll(scrollContainer, { target: { scrollY: scrollContainer.scrollHeight + 1 } }); + + await waitFor(() => { + expect(defaultMockActions.fetchNextThreads).toBeCalledTimes(1); + }); + }); + + it('show proper placeholder when ParentMessageStateTypes is NIL', async () => { + let container; + const parentMessage = { + messageId: 1, + message: 'parent message', + isUserMessage: () => true, + isTextMessage: true, + createdAt: 0, + sender: { + userId: 'test-user-id', + }, + }; + + await act(async () => { + const result = renderComponent( + { + parentMessage, + parentMessageState: ParentMessageStateTypes.NIL, + }, + ); + + container = result.container; + }); + + await waitFor(() => { + const placeholder = container.getElementsByClassName('placeholder-nil')[0]; + expect(placeholder).not.toBe(undefined); + }); + + }); + + it('show proper placeholder when ParentMessageStateTypes is LOADING', async () => { + let container; + const parentMessage = { + messageId: 1, + message: 'parent message', + isUserMessage: () => true, + isTextMessage: true, + createdAt: 0, + sender: { + userId: 'test-user-id', + }, + }; + + await act(async () => { + const result = renderComponent( + { + parentMessage, + parentMessageState: ParentMessageStateTypes.LOADING, + }, + ); + + container = result.container; + }); + + await waitFor(() => { + const placeholder = container.getElementsByClassName('placeholder-loading')[0]; + expect(placeholder).not.toBe(undefined); + }); + + }); + + it('show proper placeholder when ParentMessageStateTypes is INVALID', async () => { + let container; + const parentMessage = { + messageId: 1, + message: 'parent message', + isUserMessage: () => true, + isTextMessage: true, + createdAt: 0, + sender: { + userId: 'test-user-id', + }, + }; + + await act(async () => { + const result = renderComponent( + { + parentMessage, + parentMessageState: ParentMessageStateTypes.INVALID, + }, + ); + + container = result.container; + }); + + await waitFor(() => { + const placeholder = container.getElementsByClassName('placeholder-invalid')[0]; + expect(placeholder).not.toBe(undefined); + }); + }); + + it('show proper placeholder when ThreadListState is LOADING', async () => { + let container; + const parentMessage = { + messageId: 1, + message: 'parent message', + isUserMessage: () => true, + isTextMessage: true, + createdAt: 0, + sender: { + userId: 'test-user-id', + }, + }; + + await act(async () => { + const result = renderComponent( + { + parentMessage, + threadListState: ThreadListStateTypes.LOADING, + }, + ); + + container = result.container; + }); + + await waitFor(() => { + const placeholder = container.getElementsByClassName('placeholder-loading')[0]; + expect(placeholder).not.toBe(undefined); + }); + }); + + it('show proper placeholder when ThreadListState is INVALID', async () => { + let container; + const parentMessage = { + messageId: 1, + message: 'parent message', + isUserMessage: () => true, + isTextMessage: true, + createdAt: 0, + sender: { + userId: 'test-user-id', + }, + }; + + await act(async () => { + const result = renderComponent( + { + parentMessage, + threadListState: ThreadListStateTypes.INVALID, + }, + ); + + container = result.container; + }); + + await waitFor(() => { + const placeholder = container.getElementsByClassName('placeholder-invalid')[0]; + expect(placeholder).not.toBe(undefined); + }); + }); + +}); diff --git a/src/modules/Thread/context/ThreadProvider.tsx b/src/modules/Thread/context/ThreadProvider.tsx index bef0ad80b..8d3b0c9b1 100644 --- a/src/modules/Thread/context/ThreadProvider.tsx +++ b/src/modules/Thread/context/ThreadProvider.tsx @@ -70,7 +70,7 @@ export interface ThreadState { nicknamesMap: Map; } -const initialState = { +const initialState: ThreadState = { channelUrl: '', message: null, onHeaderActionClick: undefined, diff --git a/src/modules/Thread/context/__test__/ThreadProvider.spec.tsx b/src/modules/Thread/context/__test__/ThreadProvider.spec.tsx index 43b0d0d66..e197dcdbf 100644 --- a/src/modules/Thread/context/__test__/ThreadProvider.spec.tsx +++ b/src/modules/Thread/context/__test__/ThreadProvider.spec.tsx @@ -1,21 +1,68 @@ import React from 'react'; import { act, waitFor } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; -import { ThreadProvider } from '../ThreadProvider'; +import { ThreadProvider, ThreadState } from '../ThreadProvider'; import useThread from '../useThread'; import { SendableMessageType } from '../../../../utils'; import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; +import { ChannelStateTypes, ParentMessageStateTypes, ThreadListStateTypes } from '../../types'; +import { EmojiContainer } from '@sendbird/chat'; + +class MockMessageMethod { + _onPending: (message: SendableMessageType) => void; + + _onFailed: (message: SendableMessageType) => void; + + _onSucceeded: (message: SendableMessageType) => void; + + constructor(message, willSucceed = true) { + this._onPending = undefined; + this._onFailed = undefined; + this._onSucceeded = undefined; + + this.init(message, willSucceed); + } + + init(message, willSucceed) { + setTimeout(() => this._onPending?.(message), 0); + setTimeout(() => { + if (willSucceed) { + this._onSucceeded?.(message); + } else { + this._onFailed?.(message); + } + }, 300); + } + + onPending(func) { + this._onPending = func; + return this; + } + + onFailed(func) { + this._onFailed = func; + return this; + } + + onSucceeded(func) { + this._onSucceeded = func; + return this; + } +} + +const mockSendUserMessage = jest.fn(); const mockChannel = { url: 'test-channel', members: [{ userId: '1', nickname: 'user1' }], - updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), + updateUserMessage: jest.fn().mockImplementation(async (message) => mockNewMessage(message)), + sendUserMessage: mockSendUserMessage, }; -const mockNewMessage = { +const mockNewMessage = (message) => ({ messageId: 42, - message: 'new message', -}; + message: message ?? 'new message', +}); const mockGetChannel = jest.fn().mockResolvedValue(mockChannel); @@ -48,26 +95,54 @@ jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ })); describe('ThreadProvider', () => { + const initialState: ThreadState = { + channelUrl: '', + message: null, + onHeaderActionClick: undefined, + onMoveToParentMessage: undefined, + onBeforeSendUserMessage: undefined, + onBeforeSendFileMessage: undefined, + onBeforeSendVoiceMessage: undefined, + onBeforeSendMultipleFilesMessage: undefined, + onBeforeDownloadFileMessage: undefined, + isMultipleFilesMessageEnabled: undefined, + filterEmojiCategoryIds: undefined, + currentChannel: null, + allThreadMessages: [], + localThreadMessages: [], + parentMessage: null, + channelState: ChannelStateTypes.NIL, + parentMessageState: ParentMessageStateTypes.NIL, + threadListState: ThreadListStateTypes.NIL, + hasMorePrev: false, + hasMoreNext: false, + emojiContainer: {} as EmojiContainer, + isMuted: false, + isChannelFrozen: false, + currentUserId: '', + typingMembers: [], + nicknamesMap: null, + }; + const initialMockMessage = { messageId: 1, } as SendableMessageType; beforeEach(() => { + jest.clearAllMocks(); const stateContextValue = { state: mockState }; - useSendbird.mockReturnValue(stateContextValue); + (useSendbird as jest.Mock).mockReturnValue(stateContextValue); renderHook(() => useSendbird()); }); it('provides the correct initial state', async () => { const wrapper = ({ children }) => ( - {children} + {children} ); await act(async () => { const { result } = renderHook(() => useThread(), { wrapper }); - await waitFor(() => { - expect(result.current.state.message).toBe(initialMockMessage); - }); + expect(result.current.state).toEqual(initialState); }); }); @@ -154,70 +229,25 @@ describe('ThreadProvider', () => { }); }); - // it('calls sendMessage correctly', async () => { - // const wrapper = ({ children }) => ( - // - // {children} - // - // ); - // - // const { result } = renderHook(() => useThreadContext(), { wrapper }); - // const sendMessageMock = jest.fn(); - // - // result.current.sendMessage({ message: 'Test Message' }); - // - // expect(sendMessageMock).toHaveBeenCalledWith({ message: 'Test Message' }); - // }); - // - // it('handles channel events correctly', () => { - // const wrapper = ({ children }) => ( - // - // {children} - // - // ); - // - // render(); - // // Add assertions for handling channel events - // }); - // - // it('updates state when nicknamesMap is updated', async () => { - // const wrapper = ({ children }) => ( - // - // {children} - // - // ); - // - // const { result } = renderHook(() => useThreadContext(), { wrapper }); - // - // await act(async () => { - // result.current.updateState({ - // nicknamesMap: new Map([['user1', 'User One'], ['user2', 'User Two']]), - // }); - // await waitFor(() => { - // expect(result.current.nicknamesMap.get('user1')).toBe('User One'); - // }); - // }); - // }); - // - // it('calls onMoveToParentMessage when provided', async () => { - // const onMoveToParentMessageMock = jest.fn(); - // const wrapper = ({ children }) => ( - // - // {children} - // - // ); - // - // const { result } = renderHook(() => useThreadContext(), { wrapper }); - // - // await act(async () => { - // result.current.onMoveToParentMessage({ message: { messageId: 1 }, channel: {} }); - // await waitFor(() => { - // expect(onMoveToParentMessageMock).toHaveBeenCalled(); - // }); - // }); - // }); + it('update state correctly when sendMessage is called', async () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + + mockSendUserMessage.mockImplementation((propsMessage) => new MockMessageMethod(mockNewMessage(propsMessage), true)); + result.current.actions.sendMessage({ message: 'Test Message' }); + + await waitFor(() => { + expect(result.current.state.localThreadMessages.at(-1)).toHaveProperty('messageId', 42); + }); + }); + }); diff --git a/src/modules/Thread/context/__test__/useDeleteMessageCallback.spec.ts b/src/modules/Thread/context/__test__/useDeleteMessageCallback.spec.ts new file mode 100644 index 000000000..709db6415 --- /dev/null +++ b/src/modules/Thread/context/__test__/useDeleteMessageCallback.spec.ts @@ -0,0 +1,140 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; + +import useDeleteMessageCallback from '../hooks/useDeleteMessageCallback'; +import { SendableMessageType } from '../../../../utils'; + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; +const mockOnMessageDeletedByReqId = jest.fn(); +const mockOnMessageDeleted = jest.fn(); +const mockDeleteMessage = jest.fn(); + +describe('useDeleteMessageCallback', () => { + const mockChannel = { + deleteMessage: mockDeleteMessage, + } as unknown as GroupChannel; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('delete failed message from local', async () => { + const { result } = renderHook(() => useDeleteMessageCallback( + { + currentChannel: mockChannel, + onMessageDeletedByReqId: mockOnMessageDeletedByReqId, + onMessageDeleted: mockOnMessageDeleted, + }, + { logger: mockLogger }, + ), + ); + + const failedMessage = { + messageId: 123, + reqId: 'test-req-id', + sendingStatus: 'failed', + }; + + await result.current(failedMessage as SendableMessageType); + + expect(mockOnMessageDeletedByReqId).toHaveBeenCalledWith('test-req-id'); + expect(mockDeleteMessage).toHaveBeenCalled(); + }); + + it('delete pending message from local', async () => { + const { result } = renderHook(() => useDeleteMessageCallback( + { + currentChannel: mockChannel, + onMessageDeletedByReqId: mockOnMessageDeletedByReqId, + onMessageDeleted: mockOnMessageDeleted, + }, + { logger: mockLogger }, + ), + ); + + const pendingMessage = { + messageId: 123, + reqId: 'test-req-id', + sendingStatus: 'pending', + }; + + await result.current(pendingMessage as SendableMessageType); + + expect(mockOnMessageDeletedByReqId).toHaveBeenCalledWith('test-req-id'); + expect(mockDeleteMessage).toHaveBeenCalled(); + }); + + it('delete success message from remote', async () => { + mockDeleteMessage.mockResolvedValueOnce(undefined); + + const { result } = renderHook(() => useDeleteMessageCallback( + { + currentChannel: mockChannel, + onMessageDeletedByReqId: mockOnMessageDeletedByReqId, + onMessageDeleted: mockOnMessageDeleted, + }, + { logger: mockLogger }, + ), + ); + + const successMessage = { + messageId: 123, + reqId: 'test-req-id', + sendingStatus: 'succeeded', + }; + + await result.current(successMessage as SendableMessageType); + + expect(mockDeleteMessage).toHaveBeenCalledWith(successMessage); + expect(mockOnMessageDeleted).toHaveBeenCalledWith(mockChannel, 123); + }); + + it('delete failed message from remote', async () => { + const errorMessage = 'Failed to delete message'; + mockDeleteMessage.mockRejectedValueOnce(new Error(errorMessage)); + + const { result } = renderHook(() => useDeleteMessageCallback( + { + currentChannel: mockChannel, + onMessageDeletedByReqId: mockOnMessageDeletedByReqId, + onMessageDeleted: mockOnMessageDeleted, + }, + { logger: mockLogger }, + ), + ); + + const message = { + messageId: 123, + reqId: 'test-req-id', + sendingStatus: 'succeeded', + }; + + await expect(result.current(message as SendableMessageType)).rejects.toThrow(errorMessage); + expect(mockLogger.warning).toHaveBeenCalled(); + }); + + it('currentChannel is null', async () => { + const { result } = renderHook(() => useDeleteMessageCallback( + { + currentChannel: null, + onMessageDeletedByReqId: mockOnMessageDeletedByReqId, + onMessageDeleted: mockOnMessageDeleted, + }, + { logger: mockLogger }, + ), + ); + + const message = { + messageId: 123, + reqId: 'test-req-id', + sendingStatus: 'succeeded', + }; + + await result.current(message as SendableMessageType); + expect(mockDeleteMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/Thread/context/__test__/useGetAllEmoji.spec.ts b/src/modules/Thread/context/__test__/useGetAllEmoji.spec.ts new file mode 100644 index 000000000..cb3db9553 --- /dev/null +++ b/src/modules/Thread/context/__test__/useGetAllEmoji.spec.ts @@ -0,0 +1,68 @@ +import { renderHook } from '@testing-library/react-hooks'; +import useGetAllEmoji from '../hooks/useGetAllEmoji'; + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + setEmojiContainer: mockSetEmojiContainer, + }, + }), +})); + +const mockSetEmojiContainer = jest.fn(); +const mockLogger = { + info: jest.fn(), + error: jest.fn(), + warning: jest.fn(), +}; + +describe('useGetAllEmoji', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('doesnt call getAllEmoji when sdk is null', () => { + renderHook(() => useGetAllEmoji( + { sdk: null }, + { logger: mockLogger }, + )); + + expect(mockSetEmojiContainer).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + }); + + it('doesnt call getAllEmoji when sdk.getAllEmoji is undefined', () => { + renderHook(() => useGetAllEmoji( + { sdk: {} }, + { logger: mockLogger }, + )); + + expect(mockSetEmojiContainer).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + }); + + it('gets emoji container successfully', async () => { + const mockEmojiContainer = { + emojis: ['😀', '🤣', '🥰'], + }; + const mockGetAllEmoji = jest.fn().mockResolvedValue(mockEmojiContainer); + const mockSdk = { + getAllEmoji: mockGetAllEmoji, + }; + + renderHook(() => useGetAllEmoji( + { sdk: mockSdk }, + { logger: mockLogger }, + )); + + await new Promise(process.nextTick); + + expect(mockGetAllEmoji).toHaveBeenCalled(); + expect(mockSetEmojiContainer).toHaveBeenCalledWith(mockEmojiContainer); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useGetAllEmoji: Getting emojis succeeded.', + mockEmojiContainer, + ); + }); +}); diff --git a/src/modules/Thread/context/__test__/useGetChannel.spec.ts b/src/modules/Thread/context/__test__/useGetChannel.spec.ts new file mode 100644 index 000000000..0096bd9b9 --- /dev/null +++ b/src/modules/Thread/context/__test__/useGetChannel.spec.ts @@ -0,0 +1,196 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import useGetChannel from '../hooks/useGetChannel'; + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + getChannelStart: mockGetChannelStart, + getChannelSuccess: mockGetChannelSuccess, + getChannelFailure: mockGetChannelFailure, + }, + }), +})); + +const mockGetChannelStart = jest.fn(); +const mockGetChannelSuccess = jest.fn(); +const mockGetChannelFailure = jest.fn(); +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +describe('useGetChannel', () => { + const mockGroupChannel = {} as GroupChannel; + const mockGetChannel = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('doesnt call getChannel when sdkInit is false', () => { + const sdk = { + groupChannel: { + getChannel: mockGetChannel, + }, + }; + + renderHook(() => useGetChannel( + { + channelUrl: 'test-channel-url', + sdkInit: false, + message: null, + }, + { + sdk, + logger: mockLogger, + }, + )); + + expect(mockGetChannelStart).not.toHaveBeenCalled(); + expect(mockGetChannel).not.toHaveBeenCalled(); + }); + + it('doesnt call getChannel when channelUrl is empty', () => { + const sdk = { + groupChannel: { + getChannel: mockGetChannel, + }, + }; + + renderHook(() => useGetChannel( + { + channelUrl: '', + sdkInit: true, + message: null, + }, + { + sdk, + logger: mockLogger, + }, + )); + + expect(mockGetChannelStart).not.toHaveBeenCalled(); + expect(mockGetChannel).not.toHaveBeenCalled(); + }); + + it('doesnt call getChannel when sdk.groupChannel is undefined', () => { + const sdk = {}; + + renderHook(() => useGetChannel( + { + channelUrl: 'test-channel-url', + sdkInit: true, + message: null, + }, + { + sdk, + logger: mockLogger, + }, + )); + + expect(mockGetChannelStart).not.toHaveBeenCalled(); + expect(mockGetChannel).not.toHaveBeenCalled(); + }); + + it('gets channel successfully', async () => { + mockGetChannel.mockResolvedValueOnce(mockGroupChannel); + const sdk = { + groupChannel: { + getChannel: mockGetChannel, + }, + }; + + renderHook(() => useGetChannel( + { + channelUrl: 'test-channel-url', + sdkInit: true, + message: null, + }, + { + sdk, + logger: mockLogger, + }, + )); + + await new Promise(process.nextTick); + + expect(mockGetChannelStart).toHaveBeenCalled(); + expect(mockGetChannel).toHaveBeenCalledWith('test-channel-url'); + expect(mockGetChannelSuccess).toHaveBeenCalledWith(mockGroupChannel); + expect(mockGetChannelFailure).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useInitialize: Get channel succeeded', + mockGroupChannel, + ); + }); + + it('handles error when getting channel fails', async () => { + const mockError = new Error('Failed to get channel'); + mockGetChannel.mockRejectedValueOnce(mockError); + const sdk = { + groupChannel: { + getChannel: mockGetChannel, + }, + }; + + renderHook(() => useGetChannel( + { + channelUrl: 'test-channel-url', + sdkInit: true, + message: null, + }, + { + sdk, + logger: mockLogger, + }, + )); + + await new Promise(process.nextTick); + + expect(mockGetChannelStart).toHaveBeenCalled(); + expect(mockGetChannel).toHaveBeenCalledWith('test-channel-url'); + expect(mockGetChannelSuccess).not.toHaveBeenCalled(); + expect(mockGetChannelFailure).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useInitialize: Get channel failed', + mockError, + ); + }); + + it('calls getChannel again when message or sdkInit changes', async () => { + mockGetChannel.mockResolvedValue(mockGroupChannel); + const sdk = { + groupChannel: { + getChannel: mockGetChannel, + }, + }; + + const { rerender } = renderHook( + ({ message, sdkInit }) => useGetChannel( + { + channelUrl: 'test-channel-url', + sdkInit, + message, + }, + { + sdk, + logger: mockLogger, + }, + ), + { + initialProps: { message: null, sdkInit: false }, + }, + ); + + expect(mockGetChannel).not.toHaveBeenCalled(); + rerender({ message: null, sdkInit: true }); + + await new Promise(process.nextTick); + + expect(mockGetChannel).toHaveBeenCalledTimes(1); + expect(mockGetChannelSuccess).toHaveBeenCalledWith(mockGroupChannel); + }); +}); diff --git a/src/modules/Thread/context/__test__/useGetParentMessage.spec.ts b/src/modules/Thread/context/__test__/useGetParentMessage.spec.ts new file mode 100644 index 000000000..0348a7dd8 --- /dev/null +++ b/src/modules/Thread/context/__test__/useGetParentMessage.spec.ts @@ -0,0 +1,220 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { BaseMessage } from '@sendbird/chat/message'; +import { ChannelType } from '@sendbird/chat'; +import useGetParentMessage from '../hooks/useGetParentMessage'; + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + getParentMessageStart: mockGetParentMessageStart, + getParentMessageSuccess: mockGetParentMessageSuccess, + getParentMessageFailure: mockGetParentMessageFailure, + }, + }), +})); + +const mockGetParentMessageStart = jest.fn(); +const mockGetParentMessageSuccess = jest.fn(); +const mockGetParentMessageFailure = jest.fn(); +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +describe('useGetParentMessage', () => { + const mockGetMessage = jest.fn(); + const mockParentMessage = { + messageId: 12345, + ogMetaData: { title: 'Test OG' }, + } as unknown as BaseMessage; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('doesnt call getParentMessage when sdkInit is false', () => { + const sdk = { + message: { + getMessage: mockGetMessage, + }, + }; + + renderHook(() => useGetParentMessage( + { + channelUrl: 'test-channel-url', + sdkInit: false, + parentMessage: mockParentMessage, + }, + { + sdk, + logger: mockLogger, + }, + )); + + expect(mockGetParentMessageStart).not.toHaveBeenCalled(); + expect(mockGetMessage).not.toHaveBeenCalled(); + }); + + it('doesnt call getParentMessage when sdk.message.getMessage is undefined', () => { + const sdk = { + message: {}, + }; + + renderHook(() => useGetParentMessage( + { + channelUrl: 'test-channel-url', + sdkInit: true, + parentMessage: mockParentMessage, + }, + { + sdk, + logger: mockLogger, + }, + )); + + expect(mockGetParentMessageStart).not.toHaveBeenCalled(); + expect(mockGetMessage).not.toHaveBeenCalled(); + }); + + it('doesnt call getParentMessage when parentMessage is null', () => { + const sdk = { + message: { + getMessage: mockGetMessage, + }, + }; + + renderHook(() => useGetParentMessage( + { + channelUrl: 'test-channel-url', + sdkInit: true, + parentMessage: null, + }, + { + sdk, + logger: mockLogger, + }, + )); + + expect(mockGetParentMessageStart).not.toHaveBeenCalled(); + expect(mockGetMessage).not.toHaveBeenCalled(); + }); + + it('gets parent message successfully', async () => { + const receivedParentMsg = { ...mockParentMessage, ogMetaData: null }; + mockGetMessage.mockResolvedValueOnce(receivedParentMsg); + + const sdk = { + message: { + getMessage: mockGetMessage, + }, + }; + + renderHook(() => useGetParentMessage( + { + channelUrl: 'test-channel-url', + sdkInit: true, + parentMessage: mockParentMessage, + }, + { + sdk, + logger: mockLogger, + }, + )); + + await new Promise(process.nextTick); + + expect(mockGetParentMessageStart).toHaveBeenCalled(); + expect(mockGetMessage).toHaveBeenCalledWith({ + channelUrl: 'test-channel-url', + channelType: ChannelType.GROUP, + messageId: mockParentMessage.messageId, + includeMetaArray: true, + includeReactions: true, + includeThreadInfo: true, + includeParentMessageInfo: true, + }); + expect(mockGetParentMessageSuccess).toHaveBeenCalledWith({ + ...receivedParentMsg, + ogMetaData: mockParentMessage.ogMetaData, + }); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useGetParentMessage: Get parent message succeeded.', + mockParentMessage, + ); + }); + + it('handles error when getting parent message fails', async () => { + const mockError = new Error('Failed to get parent message'); + mockGetMessage.mockRejectedValueOnce(mockError); + + const sdk = { + message: { + getMessage: mockGetMessage, + }, + }; + + renderHook(() => useGetParentMessage( + { + channelUrl: 'test-channel-url', + sdkInit: true, + parentMessage: mockParentMessage, + }, + { + sdk, + logger: mockLogger, + }, + )); + + await new Promise(process.nextTick); + + expect(mockGetParentMessageStart).toHaveBeenCalled(); + expect(mockGetMessage).toHaveBeenCalled(); + expect(mockGetParentMessageSuccess).not.toHaveBeenCalled(); + expect(mockGetParentMessageFailure).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useGetParentMessage: Get parent message failed.', + mockError, + ); + }); + + it('calls getParentMessage again when sdkInit or parentMessage.messageId changes', async () => { + mockGetMessage.mockResolvedValue({ ...mockParentMessage, ogMetaData: null }); + + const sdk = { + message: { + getMessage: mockGetMessage, + }, + }; + + const { rerender } = renderHook( + ({ sdkInit, parentMessage }) => useGetParentMessage( + { + channelUrl: 'test-channel-url', + sdkInit, + parentMessage, + }, + { + sdk, + logger: mockLogger, + }, + ), + { + initialProps: { sdkInit: false, parentMessage: null }, + }, + ); + + expect(mockGetMessage).not.toHaveBeenCalled(); + + rerender({ + sdkInit: true, + parentMessage: mockParentMessage, + }); + + await new Promise(process.nextTick); + + expect(mockGetMessage).toHaveBeenCalledTimes(1); + expect(mockGetParentMessageSuccess).toHaveBeenCalled(); + }); +}); diff --git a/src/modules/Thread/context/__test__/useHandleChannelEvents.spec.ts b/src/modules/Thread/context/__test__/useHandleChannelEvents.spec.ts new file mode 100644 index 000000000..340d55cd9 --- /dev/null +++ b/src/modules/Thread/context/__test__/useHandleChannelEvents.spec.ts @@ -0,0 +1,169 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { GroupChannel, GroupChannelHandler } from '@sendbird/chat/groupChannel'; +import { UserMessage } from '@sendbird/chat/message'; +import { User } from '@sendbird/chat'; +import useHandleChannelEvents from '../hooks/useHandleChannelEvents'; +import { waitFor } from '@testing-library/react'; + +const mockThreadActions = { + onMessageReceived: jest.fn(), + onMessageUpdated: jest.fn(), + onMessageDeleted: jest.fn(), + onReactionUpdated: jest.fn(), + onUserMuted: jest.fn(), + onUserUnmuted: jest.fn(), + onUserBanned: jest.fn(), + onUserUnbanned: jest.fn(), + onUserLeft: jest.fn(), + onChannelFrozen: jest.fn(), + onChannelUnfrozen: jest.fn(), + onOperatorUpdated: jest.fn(), + onTypingStatusUpdated: jest.fn(), +}; + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: mockThreadActions, + }), +})); + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +describe('useHandleChannelEvents', () => { + const mockUser = { userId: 'user1' } as User; + const mockMessage = { messageId: 1 } as UserMessage; + const mockReactionEvent = { messageId: 1, key: 'like' }; + + const createMockChannel = () => ({ + url: 'channel-url', + getTypingUsers: jest.fn().mockReturnValue([mockUser]), + }) as unknown as GroupChannel; + + const createMockSdk = (addHandler = jest.fn(), removeHandler = jest.fn()) => ({ + groupChannel: { + addGroupChannelHandler: addHandler, + removeGroupChannelHandler: removeHandler, + }, + }); + + const renderChannelEventsHook = ({ + sdk = createMockSdk(), + currentChannel = createMockChannel(), + } = {}) => { + return renderHook(() => useHandleChannelEvents( + { + sdk, + currentChannel, + }, + { + logger: mockLogger, + }, + )); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should add channel handler on mount', () => { + const mockAddHandler = jest.fn(); + const sdk = createMockSdk(mockAddHandler); + + renderChannelEventsHook({ sdk }); + + expect(mockAddHandler).toHaveBeenCalledWith( + expect.any(String), + expect.any(GroupChannelHandler), + ); + }); + + it('should remove channel handler on unmount', () => { + const mockRemoveHandler = jest.fn(); + const sdk = createMockSdk(jest.fn(), mockRemoveHandler); + + const { unmount } = renderChannelEventsHook({ sdk }); + unmount(); + + expect(mockRemoveHandler).toHaveBeenCalledWith(expect.any(String)); + }); + + it('should handle message received event', () => { + const mockAddHandler = jest.fn(); + const sdk = createMockSdk(mockAddHandler); + const channel = createMockChannel(); + + renderChannelEventsHook({ sdk, currentChannel: channel }); + + const handler = mockAddHandler.mock.calls[0][1]; + handler.onMessageReceived(channel, mockMessage); + + expect(mockThreadActions.onMessageReceived).toHaveBeenCalledWith( + channel, + mockMessage, + ); + }); + + it('should handle message updated event', () => { + const mockAddHandler = jest.fn(); + const sdk = createMockSdk(mockAddHandler); + const channel = createMockChannel(); + + renderChannelEventsHook({ sdk, currentChannel: channel }); + + const handler = mockAddHandler.mock.calls[0][1]; + handler.onMessageUpdated(channel, mockMessage); + + expect(mockThreadActions.onMessageUpdated).toHaveBeenCalledWith( + channel, + mockMessage, + ); + }); + + it('should handle reaction updated event', () => { + const mockAddHandler = jest.fn(); + const sdk = createMockSdk(mockAddHandler); + const channel = createMockChannel(); + + renderChannelEventsHook({ sdk, currentChannel: channel }); + + const handler = mockAddHandler.mock.calls[0][1]; + handler.onReactionUpdated(channel, mockReactionEvent); + + expect(mockThreadActions.onReactionUpdated).toHaveBeenCalledWith( + mockReactionEvent, + ); + }); + + it('should handle typing status updated event', () => { + const mockAddHandler = jest.fn(); + const sdk = createMockSdk(mockAddHandler); + const channel = createMockChannel(); + + renderChannelEventsHook({ sdk, currentChannel: channel }); + + const handler = mockAddHandler.mock.calls[0][1]; + handler.onTypingStatusUpdated(channel); + + expect(mockThreadActions.onTypingStatusUpdated).toHaveBeenCalledWith( + channel, + [mockUser], + ); + }); + + it('should not add handler when sdk or currentChannel is missing', async () => { + const mockAddHandler = jest.fn(); + const sdk = createMockSdk(mockAddHandler); + + await act(async () => { + renderChannelEventsHook({ sdk, currentChannel: undefined }); + await waitFor(() => { + expect(mockAddHandler).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/modules/Thread/context/__test__/useHandleThreadPubsubEvents.spec.ts b/src/modules/Thread/context/__test__/useHandleThreadPubsubEvents.spec.ts new file mode 100644 index 000000000..2e96b96bd --- /dev/null +++ b/src/modules/Thread/context/__test__/useHandleThreadPubsubEvents.spec.ts @@ -0,0 +1,167 @@ +import { renderHook } from '@testing-library/react-hooks'; +import useHandleThreadPubsubEvents from '../hooks/useHandleThreadPubsubEvents'; +import { PUBSUB_TOPICS, SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { SendableMessageType } from '../../../../utils'; + +const mockThreadActions = { + sendMessageStart: jest.fn(), + sendMessageSuccess: jest.fn(), + sendMessageFailure: jest.fn(), + onFileInfoUpdated: jest.fn(), + onMessageUpdated: jest.fn(), + onMessageDeleted: jest.fn(), +}; + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: mockThreadActions, + }), +})); + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +const mockPubSub = { + subscribe: jest.fn(), + unsubscribe: jest.fn(), +} as unknown as SBUGlobalPubSub; + +describe('useHandleThreadPubsubEvents', () => { + const mockChannel = { url: 'channel-url' } as GroupChannel; + const mockParentMessage = { messageId: 123 } as SendableMessageType; + const mockMessage = { + parentMessageId: 123, + messageId: 456, + isMultipleFilesMessage: () => false, + } as SendableMessageType; + + const renderPubsubEventsHook = ({ + sdkInit = true, + currentChannel = mockChannel, + parentMessage = mockParentMessage, + } = {}) => { + return renderHook(() => useHandleThreadPubsubEvents( + { + sdkInit, + currentChannel, + parentMessage, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should subscribe to pubsub events on mount', () => { + renderPubsubEventsHook(); + + expect(mockPubSub.subscribe).toHaveBeenCalledWith( + PUBSUB_TOPICS.SEND_MESSAGE_START, + expect.any(Function), + ); + expect(mockPubSub.subscribe).toHaveBeenCalledWith( + PUBSUB_TOPICS.ON_FILE_INFO_UPLOADED, + expect.any(Function), + ); + expect(mockPubSub.subscribe).toHaveBeenCalledWith( + PUBSUB_TOPICS.SEND_USER_MESSAGE, + expect.any(Function), + ); + expect(mockPubSub.subscribe).toHaveBeenCalledWith( + PUBSUB_TOPICS.SEND_MESSAGE_FAILED, + expect.any(Function), + ); + expect(mockPubSub.subscribe).toHaveBeenCalledWith( + PUBSUB_TOPICS.SEND_FILE_MESSAGE, + expect.any(Function), + ); + expect(mockPubSub.subscribe).toHaveBeenCalledWith( + PUBSUB_TOPICS.UPDATE_USER_MESSAGE, + expect.any(Function), + ); + expect(mockPubSub.subscribe).toHaveBeenCalledWith( + PUBSUB_TOPICS.DELETE_MESSAGE, + expect.any(Function), + ); + }); + + it('should unsubscribe from pubsub events on unmount', () => { + const { unmount } = renderPubsubEventsHook(); + unmount(); + + expect(mockPubSub.subscribe).toHaveBeenCalledTimes(7); + }); + + it('should handle SEND_MESSAGE_START event', () => { + renderPubsubEventsHook(); + + const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.SEND_MESSAGE_START)[1]; + handler({ channel: mockChannel, message: mockMessage, publishingModules: [] }); + + expect(mockThreadActions.sendMessageStart).toHaveBeenCalledWith(mockMessage); + }); + + it('should handle ON_FILE_INFO_UPLOADED event', () => { + renderPubsubEventsHook(); + + const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.ON_FILE_INFO_UPLOADED)[1]; + handler({ response: { channelUrl: mockChannel.url }, publishingModules: [] }); + + expect(mockThreadActions.onFileInfoUpdated).toHaveBeenCalled(); + }); + + it('should handle SEND_USER_MESSAGE event', () => { + renderPubsubEventsHook(); + + const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.SEND_USER_MESSAGE)[1]; + handler({ channel: mockChannel, message: mockMessage }); + + expect(mockThreadActions.sendMessageSuccess).toHaveBeenCalledWith(mockMessage); + }); + + it('should handle SEND_MESSAGE_FAILED event', () => { + renderPubsubEventsHook(); + + const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.SEND_MESSAGE_FAILED)[1]; + handler({ channel: mockChannel, message: mockMessage, publishingModules: [] }); + + expect(mockThreadActions.sendMessageFailure).toHaveBeenCalledWith(mockMessage); + }); + + it('should handle SEND_FILE_MESSAGE event', () => { + renderPubsubEventsHook(); + + const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.SEND_FILE_MESSAGE)[1]; + handler({ channel: mockChannel, message: mockMessage, publishingModules: [] }); + + expect(mockThreadActions.sendMessageSuccess).toHaveBeenCalledWith(mockMessage); + }); + + it('should handle UPDATE_USER_MESSAGE event', () => { + renderPubsubEventsHook(); + + const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.UPDATE_USER_MESSAGE)[1]; + handler({ channel: mockChannel, message: mockMessage }); + + expect(mockThreadActions.onMessageUpdated).toHaveBeenCalledWith(mockChannel, mockMessage); + }); + + it('should handle DELETE_MESSAGE event', () => { + renderPubsubEventsHook(); + + const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.DELETE_MESSAGE)[1]; + handler({ channel: mockChannel, messageId: mockMessage.messageId }); + + expect(mockThreadActions.onMessageDeleted).toHaveBeenCalledWith(mockChannel, mockMessage.messageId); + }); +}); diff --git a/src/modules/Thread/context/__test__/useResendMessageCallback.spec.ts b/src/modules/Thread/context/__test__/useResendMessageCallback.spec.ts new file mode 100644 index 000000000..adf37b84c --- /dev/null +++ b/src/modules/Thread/context/__test__/useResendMessageCallback.spec.ts @@ -0,0 +1,277 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { FileMessage, UserMessage, MessageType, SendingStatus, MultipleFilesMessage } from '@sendbird/chat/message'; +import useResendMessageCallback from '../hooks/useResendMessageCallback'; +import { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; +import { SendableMessageType } from '../../../../utils'; + +const mockSetEmojiContainer = jest.fn(); + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + setEmojiContainer: mockSetEmojiContainer, + }, + }), +})); + +const mockPubSub = { + publish: jest.fn(), +} as unknown as SBUGlobalPubSub; + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +const mockResendMessageStart = jest.fn(); +const mockSendMessageSuccess = jest.fn(); +const mockSendMessageFailure = jest.fn(); + +describe('useResendMessageCallback', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not resend when message is not resendable', () => { + const mockMessage = { + isResendable: false, + } as unknown as SendableMessageType; + + const { result } = renderHook(() => useResendMessageCallback( + { + currentChannel: {} as GroupChannel, + resendMessageStart: mockResendMessageStart, + sendMessageSuccess: mockSendMessageSuccess, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockMessage); + expect(mockResendMessageStart).not.toHaveBeenCalled(); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Thread | useResendMessageCallback: Message is not resendable.', + mockMessage, + ); + }); + + it('should resend user message successfully', async () => { + const mockUserMessage = { + isResendable: true, + messageType: MessageType.USER, + isUserMessage: () => true, + } as UserMessage; + + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockUserMessage); + return chainMethods; + }); + + chainMethods.onSucceeded.mockImplementation((cb) => { + cb(mockUserMessage); + return chainMethods; + }); + + chainMethods.onFailed.mockImplementation(() => { + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + resendMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useResendMessageCallback( + { + currentChannel: mockChannel, + resendMessageStart: mockResendMessageStart, + sendMessageSuccess: mockSendMessageSuccess, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockUserMessage); + + expect(mockChannel.resendMessage).toHaveBeenCalledWith(mockUserMessage); + expect(mockResendMessageStart).toHaveBeenCalledWith(mockUserMessage); + expect(mockSendMessageSuccess).toHaveBeenCalledWith(mockUserMessage); + expect(mockPubSub.publish).toHaveBeenCalled(); + }); + + it('should handle user message resend failure', () => { + const mockError = new Error('Failed to resend message'); + const mockUserMessage = { + isResendable: true, + messageType: MessageType.USER, + isUserMessage: () => true, + sendingStatus: SendingStatus.FAILED, + } as UserMessage; + + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockUserMessage); + return chainMethods; + }); + + chainMethods.onFailed.mockImplementation((cb) => { + cb(mockError); + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + resendMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useResendMessageCallback( + { + currentChannel: mockChannel, + resendMessageStart: mockResendMessageStart, + sendMessageSuccess: mockSendMessageSuccess, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockUserMessage); + + expect(mockSendMessageFailure).toHaveBeenCalledWith(mockUserMessage); + expect(mockLogger.warning).toHaveBeenCalled(); + }); + + it('should resend file message successfully', () => { + const mockFileMessage = { + isResendable: true, + isFileMessage: () => true, + } as FileMessage; + + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockFileMessage); + return chainMethods; + }); + + chainMethods.onSucceeded.mockImplementation((cb) => { + cb(mockFileMessage); + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + resendMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useResendMessageCallback( + { + currentChannel: mockChannel, + resendMessageStart: mockResendMessageStart, + sendMessageSuccess: mockSendMessageSuccess, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockFileMessage); + + expect(mockResendMessageStart).toHaveBeenCalledWith(mockFileMessage); + expect(mockSendMessageSuccess).toHaveBeenCalledWith(mockFileMessage); + expect(mockPubSub.publish).toHaveBeenCalled(); + }); + + it('should resend multiple files message successfully', () => { + const mockMultipleFilesMessage = { + isResendable: true, + isMultipleFilesMessage: () => true, + } as MultipleFilesMessage; + + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + onFileUploaded: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockMultipleFilesMessage); + return chainMethods; + }); + + chainMethods.onSucceeded.mockImplementation((cb) => { + cb(mockMultipleFilesMessage); + return chainMethods; + }); + + chainMethods.onFileUploaded.mockImplementation((cb) => { + cb('requestId', 0, { url: 'test-url' }, null); + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + resendMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useResendMessageCallback( + { + currentChannel: mockChannel, + resendMessageStart: mockResendMessageStart, + sendMessageSuccess: mockSendMessageSuccess, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockMultipleFilesMessage); + + expect(mockResendMessageStart).toHaveBeenCalledWith(mockMultipleFilesMessage); + expect(mockSendMessageSuccess).toHaveBeenCalledWith(mockMultipleFilesMessage); + expect(mockPubSub.publish).toHaveBeenCalledTimes(2); // onFileUploaded and onSucceeded + }); +}); diff --git a/src/modules/Thread/context/__test__/useSendFileMessage.spec.ts b/src/modules/Thread/context/__test__/useSendFileMessage.spec.ts new file mode 100644 index 000000000..167f6f403 --- /dev/null +++ b/src/modules/Thread/context/__test__/useSendFileMessage.spec.ts @@ -0,0 +1,214 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { FileMessage, SendingStatus } from '@sendbird/chat/message'; +import useSendFileMessage from '../hooks/useSendFileMessage'; +import { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; +import { SendableMessageType } from '../../../../utils'; + +const mockSetEmojiContainer = jest.fn(); + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + setEmojiContainer: mockSetEmojiContainer, + }, + }), +})); + +const mockPubSub = { + publish: jest.fn(), +} as unknown as SBUGlobalPubSub; + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +const mockSendMessageStart = jest.fn(); +const mockSendMessageFailure = jest.fn(); + +describe('useSendFileMessage', () => { + const mockFile = new File(['test'], 'test.txt', { type: 'text/plain' }); + const mockQuoteMessage = { + messageId: 12345, + } as unknown as SendableMessageType; + + beforeEach(() => { + jest.clearAllMocks(); + // URL.createObjectURL mock + global.URL.createObjectURL = jest.fn(() => 'mock-url'); + }); + + it('doesnt send file message when currentChannel is null', async () => { + const { result } = renderHook(() => useSendFileMessage( + { + currentChannel: null, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + await act(async () => { + await result.current(mockFile); + expect(mockSendMessageStart).not.toHaveBeenCalled(); + }); + }); + + it('sends file message successfully', async () => { + const mockSuccessMessage = { + messageId: 67890, + isUserMessage: false, + isFileMessage: true, + isAdminMessage: false, + isMultipleFilesMessage: false, + } as unknown as FileMessage; + + const mockPendingMessage = { + ...mockSuccessMessage, + sendingStatus: SendingStatus.PENDING, + }; + + // 체이닝 구조 개선 + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockPendingMessage); + return chainMethods; + }); + + chainMethods.onSucceeded.mockImplementation((cb) => { + cb(mockSuccessMessage); + return chainMethods; + }); + + chainMethods.onFailed.mockImplementation(() => { + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + sendFileMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendFileMessage( + { + currentChannel: mockChannel, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + const response = await result.current(mockFile, mockQuoteMessage); + + expect(mockChannel.sendFileMessage).toHaveBeenCalledWith({ + file: mockFile, + isReplyToChannel: true, + parentMessageId: mockQuoteMessage.messageId, + }); + expect(mockSendMessageStart).toHaveBeenCalledWith({ + ...mockPendingMessage, + url: 'mock-url', + }); + expect(mockPubSub.publish).toHaveBeenCalled(); + expect(response).toBe(mockSuccessMessage); + }); + + it('handles error when sending file message fails', async () => { + const mockError = new Error('Failed to send file message'); + const mockPendingMessage = { + messageId: 67890, + sendingStatus: SendingStatus.PENDING, + } as FileMessage; + + const mockSendFileMessagePromise = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + mockSendFileMessagePromise.onPending.mockImplementation((cb) => { + cb(mockPendingMessage); + return mockSendFileMessagePromise; + }); + + mockSendFileMessagePromise.onFailed.mockImplementation((cb) => { + cb(mockError, mockPendingMessage); + return mockSendFileMessagePromise; + }); + + const mockChannel = { + sendFileMessage: jest.fn().mockReturnValue(mockSendFileMessagePromise), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendFileMessage( + { + currentChannel: mockChannel, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + await expect(result.current(mockFile)).rejects.toBe(mockError); + expect(mockSendMessageFailure).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useSendFileMessageCallback: Sending file message failed.', + expect.any(Object), + ); + }); + + it('uses onBeforeSendFileMessage callback', async () => { + const mockCustomParams = { + file: mockFile, + customField: 'test', + }; + const mockOnBeforeSendFileMessage = jest.fn().mockReturnValue(mockCustomParams); + + const mockSendFileMessagePromise = { + onPending: jest.fn().mockReturnThis(), + onSucceeded: jest.fn().mockReturnThis(), + onFailed: jest.fn().mockReturnThis(), + }; + + const mockChannel = { + sendFileMessage: jest.fn().mockReturnValue(mockSendFileMessagePromise), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendFileMessage( + { + currentChannel: mockChannel, + onBeforeSendFileMessage: mockOnBeforeSendFileMessage, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockFile, mockQuoteMessage); + + expect(mockOnBeforeSendFileMessage).toHaveBeenCalledWith(mockFile, mockQuoteMessage); + expect(mockChannel.sendFileMessage).toHaveBeenCalledWith(mockCustomParams); + }); +}); diff --git a/src/modules/Thread/context/__test__/useSendUserMessageCallback.spec.ts b/src/modules/Thread/context/__test__/useSendUserMessageCallback.spec.ts new file mode 100644 index 000000000..4e13d844e --- /dev/null +++ b/src/modules/Thread/context/__test__/useSendUserMessageCallback.spec.ts @@ -0,0 +1,250 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { UserMessage, SendingStatus } from '@sendbird/chat/message'; +import { User } from '@sendbird/chat'; +import useSendUserMessageCallback from '../hooks/useSendUserMessageCallback'; +import { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; +import { SendableMessageType } from '../../../../utils'; + +const mockSetEmojiContainer = jest.fn(); + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + setEmojiContainer: mockSetEmojiContainer, + }, + }), +})); + +const mockPubSub = { + publish: jest.fn(), +} as unknown as SBUGlobalPubSub; + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +const mockSendMessageStart = jest.fn(); +const mockSendMessageFailure = jest.fn(); + +describe('useSendUserMessageCallback', () => { + const mockMessage = 'Hello, world!'; + const mockQuoteMessage = { + messageId: 12345, + } as unknown as SendableMessageType; + const mockMentionedUsers = [{ userId: 'user1' }] as User[]; + const mockMentionTemplate = '@{user1}'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not send message when currentChannel is null', async () => { + const { result } = renderHook(() => useSendUserMessageCallback( + { + isMentionEnabled: false, + currentChannel: null, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current({ message: mockMessage }); + expect(mockSendMessageStart).not.toHaveBeenCalled(); + }); + + it('should send message successfully', async () => { + const mockSuccessMessage = { + messageId: 67890, + message: mockMessage, + } as UserMessage; + + const mockPendingMessage = { + ...mockSuccessMessage, + sendingStatus: SendingStatus.PENDING, + }; + + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockPendingMessage); + return chainMethods; + }); + + chainMethods.onSucceeded.mockImplementation((cb) => { + cb(mockSuccessMessage); + return chainMethods; + }); + + chainMethods.onFailed.mockImplementation(() => { + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + sendUserMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendUserMessageCallback( + { + isMentionEnabled: false, + currentChannel: mockChannel, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current({ message: mockMessage }); + + expect(mockChannel.sendUserMessage).toHaveBeenCalledWith({ + message: mockMessage, + }); + expect(mockSendMessageStart).toHaveBeenCalledWith(mockPendingMessage); + expect(mockPubSub.publish).toHaveBeenCalled(); + }); + + it('should handle message sending failure', async () => { + const mockError = new Error('Failed to send message'); + const mockPendingMessage = { + messageId: 67890, + sendingStatus: SendingStatus.PENDING, + } as UserMessage; + + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockPendingMessage); + return chainMethods; + }); + + chainMethods.onFailed.mockImplementation((cb) => { + cb(mockError, mockPendingMessage); + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + sendUserMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendUserMessageCallback( + { + isMentionEnabled: false, + currentChannel: mockChannel, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current({ message: mockMessage }); + + expect(mockSendMessageFailure).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useSendUserMessageCallback: Sending user message failed.', + expect.any(Object), + ); + }); + + it('should handle mentions when mention is enabled', () => { + const createMockPromise = () => ({ + onPending: jest.fn().mockReturnThis(), + onSucceeded: jest.fn().mockReturnThis(), + onFailed: jest.fn().mockReturnThis(), + }); + + const mockChannel = { + sendUserMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendUserMessageCallback( + { + isMentionEnabled: true, + currentChannel: mockChannel, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current({ + message: mockMessage, + mentionedUsers: mockMentionedUsers, + mentionTemplate: mockMentionTemplate, + }); + + expect(mockChannel.sendUserMessage).toHaveBeenCalledWith({ + message: mockMessage, + mentionedUsers: mockMentionedUsers, + mentionedMessageTemplate: mockMentionTemplate, + }); + }); + + it('should use onBeforeSendUserMessage callback when provided', () => { + const mockCustomParams = { + message: mockMessage, + customField: 'test', + }; + const mockOnBeforeSendUserMessage = jest.fn().mockReturnValue(mockCustomParams); + + const createMockPromise = () => ({ + onPending: jest.fn().mockReturnThis(), + onSucceeded: jest.fn().mockReturnThis(), + onFailed: jest.fn().mockReturnThis(), + }); + + const mockChannel = { + sendUserMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendUserMessageCallback( + { + isMentionEnabled: false, + currentChannel: mockChannel, + onBeforeSendUserMessage: mockOnBeforeSendUserMessage, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current({ message: mockMessage, quoteMessage: mockQuoteMessage }); + + expect(mockOnBeforeSendUserMessage).toHaveBeenCalledWith(mockMessage, mockQuoteMessage); + expect(mockChannel.sendUserMessage).toHaveBeenCalledWith(mockCustomParams); + }); +}); diff --git a/src/modules/Thread/context/__test__/useSendVoiceMessageCallback.spec.ts b/src/modules/Thread/context/__test__/useSendVoiceMessageCallback.spec.ts new file mode 100644 index 000000000..4327fc156 --- /dev/null +++ b/src/modules/Thread/context/__test__/useSendVoiceMessageCallback.spec.ts @@ -0,0 +1,236 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { FileMessage, SendingStatus, MessageMetaArray } from '@sendbird/chat/message'; +import useSendVoiceMessageCallback from '../hooks/useSendVoiceMessageCallback'; +import { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; +import { SendableMessageType } from '../../../../utils'; +import { + META_ARRAY_MESSAGE_TYPE_KEY, + META_ARRAY_MESSAGE_TYPE_VALUE__VOICE, + META_ARRAY_VOICE_DURATION_KEY, + VOICE_MESSAGE_FILE_NAME, + VOICE_MESSAGE_MIME_TYPE, +} from '../../../../utils/consts'; + +const mockSetEmojiContainer = jest.fn(); + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + setEmojiContainer: mockSetEmojiContainer, + }, + }), +})); + +const mockPubSub = { + publish: jest.fn(), +} as unknown as SBUGlobalPubSub; + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +const mockSendMessageStart = jest.fn(); +const mockSendMessageFailure = jest.fn(); + +describe('useSendVoiceMessageCallback', () => { + const mockFile = new File(['test'], 'test.mp3', { type: 'audio/mp3' }); + const mockDuration = 10; + const mockQuoteMessage = { + messageId: 12345, + } as unknown as SendableMessageType; + + beforeEach(() => { + jest.clearAllMocks(); + global.URL.createObjectURL = jest.fn(() => 'mock-url'); + }); + + it('should not send voice message when currentChannel is null', () => { + const { result } = renderHook(() => useSendVoiceMessageCallback( + { + currentChannel: null, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockFile, mockDuration, mockQuoteMessage); + expect(mockSendMessageStart).not.toHaveBeenCalled(); + }); + + it('should send voice message successfully', async () => { + const mockSuccessMessage = { + messageId: 67890, + isUserMessage: false, + isFileMessage: true, + isAdminMessage: false, + isMultipleFilesMessage: false, + } as unknown as FileMessage; + + const mockPendingMessage = { + ...mockSuccessMessage, + sendingStatus: SendingStatus.PENDING, + }; + + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockPendingMessage); + return chainMethods; + }); + + chainMethods.onSucceeded.mockImplementation((cb) => { + cb(mockSuccessMessage); + return chainMethods; + }); + + chainMethods.onFailed.mockImplementation(() => { + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + sendFileMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendVoiceMessageCallback( + { + currentChannel: mockChannel, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockFile, mockDuration, mockQuoteMessage); + + expect(mockChannel.sendFileMessage).toHaveBeenCalledWith({ + file: mockFile, + fileName: VOICE_MESSAGE_FILE_NAME, + mimeType: VOICE_MESSAGE_MIME_TYPE, + metaArrays: [ + new MessageMetaArray({ + key: META_ARRAY_VOICE_DURATION_KEY, + value: [`${mockDuration}`], + }), + new MessageMetaArray({ + key: META_ARRAY_MESSAGE_TYPE_KEY, + value: [META_ARRAY_MESSAGE_TYPE_VALUE__VOICE], + }), + ], + isReplyToChannel: true, + parentMessageId: mockQuoteMessage.messageId, + }); + expect(mockSendMessageStart).toHaveBeenCalledWith({ + ...mockPendingMessage, + url: 'mock-url', + sendingStatus: SendingStatus.PENDING, + }); + expect(mockPubSub.publish).toHaveBeenCalled(); + }); + + it('should handle voice message sending failure', async () => { + const mockError = new Error('Failed to send voice message'); + const mockPendingMessage = { + messageId: 67890, + sendingStatus: SendingStatus.PENDING, + } as FileMessage; + + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockPendingMessage); + return chainMethods; + }); + + chainMethods.onFailed.mockImplementation((cb) => { + cb(mockError, mockPendingMessage); + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + sendFileMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendVoiceMessageCallback( + { + currentChannel: mockChannel, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockFile, mockDuration, mockQuoteMessage); + + expect(mockSendMessageFailure).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useSendVoiceMessageCallback: Sending voice message failed.', + expect.any(Object), + ); + }); + + it('should use onBeforeSendVoiceMessage callback when provided', () => { + const mockCustomParams = { + file: mockFile, + customField: 'test', + }; + const mockOnBeforeSendVoiceMessage = jest.fn().mockReturnValue(mockCustomParams); + + const createMockPromise = () => ({ + onPending: jest.fn().mockReturnThis(), + onSucceeded: jest.fn().mockReturnThis(), + onFailed: jest.fn().mockReturnThis(), + }); + + const mockChannel = { + sendFileMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendVoiceMessageCallback( + { + currentChannel: mockChannel, + onBeforeSendVoiceMessage: mockOnBeforeSendVoiceMessage, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockFile, mockDuration, mockQuoteMessage); + + expect(mockOnBeforeSendVoiceMessage).toHaveBeenCalledWith(mockFile, mockQuoteMessage); + expect(mockChannel.sendFileMessage).toHaveBeenCalledWith(mockCustomParams); + }); +}); diff --git a/src/modules/Thread/context/__test__/useThread.spec.tsx b/src/modules/Thread/context/__test__/useThread.spec.tsx new file mode 100644 index 000000000..9fb264c53 --- /dev/null +++ b/src/modules/Thread/context/__test__/useThread.spec.tsx @@ -0,0 +1,761 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import useThread from '../useThread'; +import { act, waitFor } from '@testing-library/react'; +import { ThreadProvider } from '../ThreadProvider'; +import { ChannelStateTypes, ParentMessageStateTypes, ThreadListStateTypes } from '../../types'; +import { PREV_THREADS_FETCH_SIZE } from '../../consts'; + +const mockApplyReactionEvent = jest.fn(); + +const mockChannel = { + url: 'test-channel', + members: [{ userId: '1', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), +}; + +const mockNewMessage = { + messageId: 42, + message: 'new message', +}; + +const mockParentMessage = { + messageId: 100, + parentMessageId: 0, + parentMessage: null, + message: 'parent message', + reqId: 100, + applyReactionEvent: mockApplyReactionEvent, +}; + +const mockGetChannel = jest.fn().mockResolvedValue(mockChannel); +const mockGetMessage = jest.fn().mockResolvedValue(mockParentMessage); +const mockPubSub = { publish: jest.fn(), subscribe: jest.fn() }; + +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(() => ({ + state: { + stores: { + sdkStore: { + sdk: { + message: { + getMessage: mockGetMessage, + }, + groupChannel: { + getChannel: mockGetChannel, + }, + }, + initialized: true, + }, + userStore: { user: { userId: 'test-user-id' } }, + }, + config: { + logger: console, + pubSub: mockPubSub, + groupChannel: { + enableMention: true, + enableReactions: true, + }, + }, + }, + })), +})); + +describe('useThread', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws an error if used outside of ThreadProvider', () => { + const { result } = renderHook(() => useThread()); + expect(result.error).toEqual(new Error('useThread must be used within a ThreadProvider')); + }); + + it('handles sendMessageStart action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { sendMessageStart } = result.current.actions; + + const mockMessage = { messageId: 2, message: 'Test message', reqId: 2 }; + + await act(() => { + sendMessageStart(mockMessage); + }); + + await waitFor(() => { + expect(result.current.state.localThreadMessages).toContain(mockMessage); + }); + }); + + it('handles sendMessageSuccess action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { sendMessageStart, sendMessageSuccess } = result.current.actions; + + const mockMessage = { messageId: 2, message: 'Test message', reqId: 2 }; + const mockMessage2 = { messageId: 3, message: 'Test message', reqId: 3 }; + + await act(() => { + sendMessageStart(mockMessage); + sendMessageSuccess(mockMessage); + sendMessageStart(mockMessage2); + sendMessageSuccess(mockMessage2); + }); + + await waitFor(() => { + expect(result.current.state.allThreadMessages).toContain(mockMessage); + expect(result.current.state.allThreadMessages).toContain(mockMessage2); + }); + }); + + it('handles sendMessageFailure action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { sendMessageStart, sendMessageFailure } = result.current.actions; + + const mockMessage = { messageId: 2, message: 'Test message', reqId: 2 }; + + await act(() => { + sendMessageStart(mockMessage); + sendMessageFailure(mockMessage); + }); + + await waitFor(() => { + expect(result.current.state.localThreadMessages).toContain(mockMessage); + }); + }); + + it('handles resendMessageStart action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { sendMessageStart, resendMessageStart } = result.current.actions; + + const mockMessage = { messageId: 2, message: 'Test message', reqId: 2 }; + + await act(() => { + sendMessageStart(mockMessage); + resendMessageStart(mockMessage); + }); + + await waitFor(() => { + expect(result.current.state.localThreadMessages).toContain(mockMessage); + }); + }); + + it('handles onMessageUpdated action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { sendMessageStart, sendMessageSuccess, onMessageUpdated } = result.current.actions; + + const otherChannel = { + url: 'test-channel2', + members: [{ userId: '1', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), + }; + const channel = { + url: 'test-channel', + members: [{ userId: '1', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), + }; + const mockMessage = { messageId: 1, message: 'Test message', reqId: 2 }; + + await act(() => { + sendMessageStart(mockMessage); + sendMessageSuccess(mockMessage); + onMessageUpdated(otherChannel, mockMessage); + onMessageUpdated(channel, mockMessage); + }); + + await waitFor(() => { + expect(result.current.state.allThreadMessages).toContain(mockMessage); + }); + }); + + it('handles onMessageDeleted action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { sendMessageStart, sendMessageSuccess, onMessageDeleted } = result.current.actions; + + const otherChannel = { + url: 'test-channel2', + members: [{ userId: '1', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), + }; + const channel = { + url: 'test-channel', + members: [{ userId: '1', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), + }; + const mockMessage = { messageId: 1, message: 'Test message', reqId: 2 }; + + await act(() => { + sendMessageStart(mockMessage); + sendMessageSuccess(mockMessage); + onMessageDeleted(otherChannel, mockMessage.messageId); + onMessageDeleted(channel, mockMessage.messageId); + onMessageDeleted(channel, 100); + }); + + await waitFor(() => { + expect(result.current.state.parentMessage).toBe(null); + expect(result.current.state.parentMessageState).toBe(ParentMessageStateTypes.NIL); + expect(result.current.state.allThreadMessages).toBeEmpty(); + }); + }); + + it('handles onMessageDeletedByReqId action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { sendMessageStart, onMessageDeletedByReqId } = result.current.actions; + + const mockMessage = { messageId: 1, message: 'Test message', reqId: 2 }; + + await act(() => { + sendMessageStart(mockMessage); + onMessageDeletedByReqId(mockMessage.reqId); + }); + + await waitFor(() => { + expect(result.current.state.localThreadMessages).toBeEmpty(); + }); + }); + + it('handles initializeThreadListStart action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { initializeThreadListStart } = result.current.actions; + + await act(() => { + initializeThreadListStart(); + }); + + await waitFor(() => { + expect(result.current.state.threadListState).toBe(ThreadListStateTypes.LOADING); + }); + }); + + it('handles initializeThreadListSuccess action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { initializeThreadListStart, initializeThreadListSuccess } = result.current.actions; + + await act(() => { + initializeThreadListStart(); + initializeThreadListSuccess(mockParentMessage, mockParentMessage, []); + }); + + await waitFor(() => { + expect(result.current.state.threadListState).toBe(ThreadListStateTypes.INITIALIZED); + }); + }); + + it('handles initializeThreadListFailure action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { initializeThreadListStart, initializeThreadListFailure } = result.current.actions; + + await act(() => { + initializeThreadListStart(); + initializeThreadListFailure(); + }); + + await waitFor(() => { + expect(result.current.state.threadListState).toBe(ThreadListStateTypes.LOADING); + expect(result.current.state.allThreadMessages).toBeEmpty(); + }); + }); + + it('handles getPrevMessagesSuccess action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { getPrevMessagesStart, getPrevMessagesSuccess } = result.current.actions; + + await act(() => { + getPrevMessagesStart(); + getPrevMessagesSuccess(Array(PREV_THREADS_FETCH_SIZE).map((e, i) => { + return { messageId: i + 10, message: `meesage Id: ${i + 10}`, reqId: i + 10 }; + })); + }); + + await waitFor(() => { + expect(result.current.state.hasMorePrev).toBe(true); + expect(result.current.state.allThreadMessages).toHaveLength(30); + }); + }); + + it('handles getPrevMessagesFailure action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { getPrevMessagesStart, getPrevMessagesFailure } = result.current.actions; + + await act(() => { + getPrevMessagesStart(); + getPrevMessagesFailure(); + }); + + await waitFor(() => { + expect(result.current.state.hasMorePrev).toBe(false); + }); + }); + + it('handles getNextMessagesSuccess action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { getNextMessagesStart, getNextMessagesSuccess } = result.current.actions; + + await act(() => { + getNextMessagesStart(); + getNextMessagesSuccess(Array(PREV_THREADS_FETCH_SIZE).map((e, i) => { + return { messageId: i + 10, message: `meesage Id: ${i + 10}`, reqId: i + 10 }; + })); + }); + + await waitFor(() => { + expect(result.current.state.hasMoreNext).toBe(true); + expect(result.current.state.allThreadMessages).toHaveLength(30); + }); + }); + + it('handles getNextMessagesFailure action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { getNextMessagesStart, getNextMessagesFailure } = result.current.actions; + + await act(() => { + getNextMessagesStart(); + getNextMessagesFailure(); + }); + + await waitFor(() => { + expect(result.current.state.hasMoreNext).toBe(false); + }); + }); + + it('handles setEmojiContainer action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { setEmojiContainer } = result.current.actions; + + const emojiContainer = { + emojiHash: 'test-hash', + emojiCategories: [{ + id: 'test-category-id', + name: 'test-category', + url: 'test-category-url', + emojis: [], + }], + }; + + await act(() => { + setEmojiContainer(emojiContainer); + }); + + await waitFor(() => { + expect(result.current.state.emojiContainer).toBe(emojiContainer); + }); + }); + + it('handles onMessageReceived action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onMessageReceived } = result.current.actions; + + const otherChannel = { + url: 'test-channel2', + members: [{ userId: '1', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), + }; + const channel = { + url: 'test-channel', + members: [{ userId: '1', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), + }; + const mockMessage = { messageId: 1, message: 'Test message', reqId: 2, parentMessage: mockParentMessage }; + + await act(() => { + onMessageReceived(otherChannel, mockMessage); + onMessageReceived(channel, mockMessage); + onMessageReceived(channel, mockMessage); + }); + + await waitFor(() => { + expect(result.current.state.allThreadMessages).toContain(mockMessage); + }); + }); + + it('handles onReactionUpdated action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { sendMessageStart, sendMessageSuccess, onReactionUpdated } = result.current.actions; + + const mockMessage = { messageId: 1, message: 'Test message', reqId: 2, parentMessage: mockParentMessage }; + + await act(() => { + sendMessageStart(mockMessage); + sendMessageSuccess(mockMessage); + onReactionUpdated({ + messageId: mockParentMessage.messageId, + userId: 'test-user-id', + key: '1', + operation: 'ADD', + updatedAt: 0, + }); + }); + + await waitFor(() => { + expect(mockApplyReactionEvent).toHaveBeenCalled(); + }); + }); + + it('handles onUserMuted action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onUserMuted } = result.current.actions; + + await act(() => { + onUserMuted(mockChannel, { userId: 'other-user-id' }); + onUserMuted(mockChannel, { userId: 'test-user-id' }); + }); + + await waitFor(() => { + expect(result.current.state.isMuted).toBe(true); + }); + }); + + it('handles onUserUnmuted action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onUserUnmuted } = result.current.actions; + + await act(() => { + onUserUnmuted(mockChannel, { userId: 'other-user-id' }); + onUserUnmuted(mockChannel, { userId: 'test-user-id' }); + }); + + await waitFor(() => { + expect(result.current.state.isMuted).toBe(false); + }); + }); + + it('handles onUserBanned action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onUserBanned } = result.current.actions; + + await act(() => { + onUserBanned(); + }); + + await waitFor(() => { + expect(result.current.state.channelState).toBe(ChannelStateTypes.NIL); + expect(result.current.state.threadListState).toBe(ThreadListStateTypes.NIL); + expect(result.current.state.parentMessageState).toBe(ParentMessageStateTypes.NIL); + }); + }); + + it('handles onUserUnbanned action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onUserUnbanned } = result.current.actions; + + await act(() => { + onUserUnbanned(); + }); + }); + + it('handles onUserLeft action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onUserLeft } = result.current.actions; + + await act(() => { + onUserLeft(); + }); + + await waitFor(() => { + expect(result.current.state.channelState).toBe(ChannelStateTypes.NIL); + expect(result.current.state.threadListState).toBe(ThreadListStateTypes.NIL); + expect(result.current.state.parentMessageState).toBe(ParentMessageStateTypes.NIL); + }); + }); + + it('handles onChannelFrozen action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onChannelFrozen } = result.current.actions; + + await act(() => { + onChannelFrozen(); + }); + + await waitFor(() => { + expect(result.current.state.isChannelFrozen).toBe(true); + }); + }); + + it('handles onChannelFrozen action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onChannelFrozen, onChannelUnfrozen } = result.current.actions; + + await act(() => { + onChannelFrozen(); + onChannelUnfrozen(); + }); + + await waitFor(() => { + expect(result.current.state.isChannelFrozen).toBe(false); + }); + }); + + it('handles onOperatorUpdated action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onOperatorUpdated } = result.current.actions; + + const newMockChannel = { + url: 'test-channel', + }; + await act(() => { + onOperatorUpdated(newMockChannel); + }); + + await waitFor(() => { + expect(result.current.state.currentChannel).toBe(newMockChannel); + }); + }); + + it('handles onTypingStatusUpdated action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onTypingStatusUpdated } = result.current.actions; + const mockMember = { userId: '1', nickname: 'user1' }; + + await act(() => { + onTypingStatusUpdated(mockChannel, [mockMember]); + }); + + await waitFor(() => { + expect(result.current.state.typingMembers).toContain(mockMember); + }); + }); + + it('handles onFileInfoUpdated action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { sendMessageStart, onFileInfoUpdated } = result.current.actions; + const mockMessage = { + messageId: 2, + message: 'Test message', + reqId: 2, + parentMessage: mockParentMessage, + messageParams: { + fileInfoList: [], + }, + }; + const newFileInfo = { name: 'new-file-info' }; + + await act(() => { + sendMessageStart(mockMessage); + onFileInfoUpdated({ + channelUrl: 'test-channel', + requestId: mockMessage.reqId, + index: 0, + uploadableFileInfo: newFileInfo, + }); + }); + + await waitFor(() => { + console.log(result.current.state.localThreadMessages[0]); + expect(result.current.state.localThreadMessages[0].messageParams.fileInfoList).toContain(newFileInfo); + }); + }); + +}); diff --git a/src/modules/Thread/context/__test__/useThreadFetchers.spec.ts b/src/modules/Thread/context/__test__/useThreadFetchers.spec.ts new file mode 100644 index 000000000..564276d56 --- /dev/null +++ b/src/modules/Thread/context/__test__/useThreadFetchers.spec.ts @@ -0,0 +1,161 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { BaseMessage } from '@sendbird/chat/message'; +import { useThreadFetchers } from '../hooks/useThreadFetchers'; +import { ThreadListStateTypes } from '../../types'; +import { SendableMessageType } from '../../../../utils'; + +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: () => ({ + state: { + stores: { + sdkStore: { + initialized: true, + }, + }, + }, + }), +})); + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +describe('useThreadFetchers', () => { + const mockParentMessage = { + messageId: 12345, + getThreadedMessagesByTimestamp: jest.fn(), + } as unknown as SendableMessageType; + + const mockAnchorMessage = { + messageId: 67890, + createdAt: 1234567890, + } as unknown as SendableMessageType; + + const mockThreadedMessages = [ + { messageId: 1 }, + { messageId: 2 }, + ] as BaseMessage[]; + + const createMockCallbacks = () => ({ + initializeThreadListStart: jest.fn(), + initializeThreadListSuccess: jest.fn(), + initializeThreadListFailure: jest.fn(), + getPrevMessagesStart: jest.fn(), + getPrevMessagesSuccess: jest.fn(), + getPrevMessagesFailure: jest.fn(), + getNextMessagesStart: jest.fn(), + getNextMessagesSuccess: jest.fn(), + getNextMessagesFailure: jest.fn(), + }); + + const renderThreadFetchersHook = ({ + threadListState = ThreadListStateTypes.INITIALIZED, + oldestMessageTimeStamp = 0, + latestMessageTimeStamp = 0, + callbacks = createMockCallbacks(), + } = {}) => { + return renderHook(() => useThreadFetchers({ + anchorMessage: mockAnchorMessage, + parentMessage: mockParentMessage, + isReactionEnabled: true, + logger: mockLogger, + threadListState, + oldestMessageTimeStamp, + latestMessageTimeStamp, + ...callbacks, + })); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize thread list successfully', async () => { + (mockParentMessage.getThreadedMessagesByTimestamp as jest.Mock).mockResolvedValue({ + threadedMessages: mockThreadedMessages, + parentMessage: mockParentMessage, + }); + + const callbacks = createMockCallbacks(); + const { result } = renderThreadFetchersHook({ callbacks }); + + await result.current.initializeThreadFetcher(); + + expect(callbacks.initializeThreadListStart).toHaveBeenCalled(); + expect(callbacks.initializeThreadListSuccess).toHaveBeenCalledWith( + mockParentMessage, + mockAnchorMessage, + mockThreadedMessages, + ); + }); + + it('should handle initialization failure', async () => { + const mockError = new Error('Failed to initialize'); + (mockParentMessage.getThreadedMessagesByTimestamp as jest.Mock).mockRejectedValue(mockError); + + const callbacks = createMockCallbacks(); + const { result } = renderThreadFetchersHook({ callbacks }); + + await result.current.initializeThreadFetcher(); + + expect(callbacks.initializeThreadListStart).toHaveBeenCalled(); + expect(callbacks.initializeThreadListFailure).toHaveBeenCalled(); + }); + + it('should fetch previous messages successfully', async () => { + (mockParentMessage.getThreadedMessagesByTimestamp as jest.Mock).mockResolvedValue({ + threadedMessages: mockThreadedMessages, + parentMessage: mockParentMessage, + }); + + const callbacks = createMockCallbacks(); + const { result } = renderThreadFetchersHook({ + oldestMessageTimeStamp: 1000, + latestMessageTimeStamp: 2000, + callbacks, + }); + + await result.current.fetchPrevThreads(); + + expect(callbacks.getPrevMessagesStart).toHaveBeenCalled(); + expect(callbacks.getPrevMessagesSuccess).toHaveBeenCalledWith(mockThreadedMessages); + }); + + it('should fetch next messages successfully', async () => { + (mockParentMessage.getThreadedMessagesByTimestamp as jest.Mock).mockResolvedValue({ + threadedMessages: mockThreadedMessages, + parentMessage: mockParentMessage, + }); + + const callbacks = createMockCallbacks(); + const { result } = renderThreadFetchersHook({ + oldestMessageTimeStamp: 1000, + latestMessageTimeStamp: 2000, + callbacks, + }); + + await result.current.fetchNextThreads(); + + expect(callbacks.getNextMessagesStart).toHaveBeenCalled(); + expect(callbacks.getNextMessagesSuccess).toHaveBeenCalledWith(mockThreadedMessages); + }); + + it('should not fetch when threadListState is not INITIALIZED', async () => { + const callbacks = createMockCallbacks(); + const { result } = renderThreadFetchersHook({ + threadListState: ThreadListStateTypes.LOADING, + oldestMessageTimeStamp: 1000, + latestMessageTimeStamp: 2000, + callbacks, + }); + + await result.current.fetchPrevThreads(); + await result.current.fetchNextThreads(); + + expect(callbacks.getPrevMessagesStart).not.toHaveBeenCalled(); + expect(callbacks.getNextMessagesStart).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/Thread/context/__test__/useToggleReactionsCallback.spec.ts b/src/modules/Thread/context/__test__/useToggleReactionsCallback.spec.ts new file mode 100644 index 000000000..ad8701ede --- /dev/null +++ b/src/modules/Thread/context/__test__/useToggleReactionsCallback.spec.ts @@ -0,0 +1,148 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { BaseMessage } from '@sendbird/chat/message'; +import useToggleReactionCallback from '../hooks/useToggleReactionsCallback'; + +const mockSetEmojiContainer = jest.fn(); + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + setEmojiContainer: mockSetEmojiContainer, + }, + }), +})); + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +describe('useToggleReactionCallback', () => { + const mockMessage = { + messageId: 12345, + } as BaseMessage; + const REACTION_KEY = 'thumbs_up'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not toggle reaction when currentChannel is null', () => { + const { result } = renderHook(() => useToggleReactionCallback( + { + currentChannel: null, + }, + { + logger: mockLogger, + }, + )); + + result.current(mockMessage, REACTION_KEY, true); + expect(mockLogger.warning).not.toHaveBeenCalled(); + }); + + it('should delete reaction when isReacted is true', async () => { + const mockDeleteReaction = jest.fn().mockResolvedValue({ success: true }); + const mockChannel = { + deleteReaction: mockDeleteReaction, + } as unknown as GroupChannel; + + const { result } = renderHook(() => useToggleReactionCallback( + { + currentChannel: mockChannel, + }, + { + logger: mockLogger, + }, + )); + + await result.current(mockMessage, REACTION_KEY, true); + + expect(mockDeleteReaction).toHaveBeenCalledWith(mockMessage, REACTION_KEY); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useToggleReactionsCallback: Delete reaction succeeded.', + { success: true }, + ); + }); + + it('should handle delete reaction failure', async () => { + const mockError = new Error('Failed to delete reaction'); + const mockDeleteReaction = jest.fn().mockRejectedValue(mockError); + const mockChannel = { + deleteReaction: mockDeleteReaction, + } as unknown as GroupChannel; + + const { result } = renderHook(() => useToggleReactionCallback( + { + currentChannel: mockChannel, + }, + { + logger: mockLogger, + }, + )); + + result.current(mockMessage, REACTION_KEY, true); + + await new Promise(process.nextTick); + + expect(mockDeleteReaction).toHaveBeenCalledWith(mockMessage, REACTION_KEY); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Thread | useToggleReactionsCallback: Delete reaction failed.', + mockError, + ); + }); + + it('should add reaction when isReacted is false', async () => { + const mockAddReaction = jest.fn().mockResolvedValue({ success: true }); + const mockChannel = { + addReaction: mockAddReaction, + } as unknown as GroupChannel; + + const { result } = renderHook(() => useToggleReactionCallback( + { + currentChannel: mockChannel, + }, + { + logger: mockLogger, + }, + )); + + await result.current(mockMessage, REACTION_KEY, false); + + expect(mockAddReaction).toHaveBeenCalledWith(mockMessage, REACTION_KEY); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useToggleReactionsCallback: Add reaction succeeded.', + { success: true }, + ); + }); + + it('should handle add reaction failure', async () => { + const mockError = new Error('Failed to add reaction'); + const mockAddReaction = jest.fn().mockRejectedValue(mockError); + const mockChannel = { + addReaction: mockAddReaction, + } as unknown as GroupChannel; + + const { result } = renderHook(() => useToggleReactionCallback( + { + currentChannel: mockChannel, + }, + { + logger: mockLogger, + }, + )); + + result.current(mockMessage, REACTION_KEY, false); + + await new Promise(process.nextTick); + + expect(mockAddReaction).toHaveBeenCalledWith(mockMessage, REACTION_KEY); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Thread | useToggleReactionsCallback: Add reaction failed.', + mockError, + ); + }); +}); diff --git a/src/modules/Thread/context/__test__/useUpdateMessageCallback.spec.ts b/src/modules/Thread/context/__test__/useUpdateMessageCallback.spec.ts new file mode 100644 index 000000000..ba6947499 --- /dev/null +++ b/src/modules/Thread/context/__test__/useUpdateMessageCallback.spec.ts @@ -0,0 +1,169 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { UserMessage } from '@sendbird/chat/message'; +import { User } from '@sendbird/chat'; +import useUpdateMessageCallback from '../hooks/useUpdateMessageCallback'; +import { PublishingModuleType } from '../../../internalInterfaces'; +import { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; + +const mockSetEmojiContainer = jest.fn(); + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + setEmojiContainer: mockSetEmojiContainer, + }, + }), +})); + +const mockPubSub = { + publish: jest.fn(), +} as unknown as SBUGlobalPubSub; + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +describe('useUpdateMessageCallback', () => { + const mockMessageId = 12345; + const mockMessage = 'Updated message content'; + const mockMentionedUsers = [{ userId: 'user1' }] as User[]; + const mockMentionTemplate = '@{user1}'; + + const createMockChannel = (updateUserMessage = jest.fn()) => ({ + updateUserMessage, + }) as unknown as GroupChannel; + const createMockCallbacks = () => ({ + onMessageUpdated: jest.fn(), + }); + + const renderUpdateMessageCallbackHook = ({ + currentChannel = undefined, + isMentionEnabled = false, + callbacks = createMockCallbacks(), + } = {}) => { + return renderHook(() => useUpdateMessageCallback( + { + currentChannel: currentChannel ?? null, + isMentionEnabled, + onMessageUpdated: callbacks.onMessageUpdated, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should update message successfully', async () => { + const updatedMessage = { + messageId: mockMessageId, + message: mockMessage, + } as UserMessage; + + const mockUpdateUserMessage = jest.fn().mockResolvedValue(updatedMessage); + const mockChannel = createMockChannel(mockUpdateUserMessage); + const callbacks = createMockCallbacks(); + + const { result } = renderUpdateMessageCallbackHook({ + currentChannel: mockChannel, + callbacks, + }); + + await result.current({ + messageId: mockMessageId, + message: mockMessage, + }); + + expect(mockUpdateUserMessage).toHaveBeenCalledWith( + mockMessageId, + expect.objectContaining({ + message: mockMessage, + }), + ); + expect(callbacks.onMessageUpdated).toHaveBeenCalledWith(mockChannel, updatedMessage); + expect(mockPubSub.publish).toHaveBeenCalledWith( + 'UPDATE_USER_MESSAGE', + expect.objectContaining({ + fromSelector: true, + channel: mockChannel, + message: updatedMessage, + publishingModules: [PublishingModuleType.THREAD], + }), + ); + }); + + it('should include mention data when mention is enabled', async () => { + const mockUpdateUserMessage = jest.fn().mockResolvedValue({} as UserMessage); + const mockChannel = createMockChannel(mockUpdateUserMessage); + + const { result } = renderUpdateMessageCallbackHook({ + currentChannel: mockChannel, + isMentionEnabled: true, + }); + + await result.current({ + messageId: mockMessageId, + message: mockMessage, + mentionedUsers: mockMentionedUsers, + mentionTemplate: mockMentionTemplate, + }); + + expect(mockUpdateUserMessage).toHaveBeenCalledWith( + mockMessageId, + expect.objectContaining({ + message: mockMessage, + mentionedUsers: mockMentionedUsers, + mentionedMessageTemplate: mockMentionTemplate, + }), + ); + }); + + it('should use message as mention template when template is not provided', async () => { + const mockUpdateUserMessage = jest.fn().mockResolvedValue({} as UserMessage); + const mockChannel = createMockChannel(mockUpdateUserMessage); + + const { result } = renderUpdateMessageCallbackHook({ + currentChannel: mockChannel, + isMentionEnabled: true, + }); + + await result.current({ + messageId: mockMessageId, + message: mockMessage, + mentionedUsers: mockMentionedUsers, + }); + + expect(mockUpdateUserMessage).toHaveBeenCalledWith( + mockMessageId, + expect.objectContaining({ + message: mockMessage, + mentionedUsers: mockMentionedUsers, + mentionedMessageTemplate: mockMessage, + }), + ); + }); + + it('should not update message when currentChannel is undefined', async () => { + const callbacks = createMockCallbacks(); + const { result } = renderUpdateMessageCallbackHook({ + currentChannel: undefined, + callbacks, + }); + + await result.current({ + messageId: mockMessageId, + message: mockMessage, + }); + + expect(callbacks.onMessageUpdated).not.toHaveBeenCalled(); + expect(mockPubSub.publish).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts b/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts index fb81a7def..e5d888ceb 100644 --- a/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts +++ b/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts @@ -31,9 +31,12 @@ export default function useDeleteMessageCallback({ onMessageDeletedByReqId(message.reqId); resolve(); } - + if (currentChannel == null) { + logger.info('Thread | useDeleteMessageCallback: No current channel'); + resolve(); + } logger.info('Thread | useDeleteMessageCallback: Deleting message from remote:', sendingStatus); - currentChannel?.deleteMessage?.(message) + currentChannel.deleteMessage?.(message) .then(() => { logger.info('Thread | useDeleteMessageCallback: Deleting message success!', message); onMessageDeleted(currentChannel, message.messageId); diff --git a/src/modules/Thread/context/hooks/useResendMessageCallback.ts b/src/modules/Thread/context/hooks/useResendMessageCallback.ts index 428add20e..07497fb11 100644 --- a/src/modules/Thread/context/hooks/useResendMessageCallback.ts +++ b/src/modules/Thread/context/hooks/useResendMessageCallback.ts @@ -35,107 +35,110 @@ export default function useResendMessageCallback({ pubSub, }: StaticProps): (failedMessage: SendableMessageType) => void { return useCallback((failedMessage: SendableMessageType) => { - if ((failedMessage as SendableMessageType)?.isResendable) { - logger.info('Thread | useResendMessageCallback: Resending failedMessage start.', failedMessage); - if (failedMessage?.isUserMessage?.() || failedMessage?.messageType === MessageType.USER) { - try { - currentChannel?.resendMessage(failedMessage as UserMessage) - .onPending((message) => { - logger.info('Thread | useResendMessageCallback: Resending user message started.', message); - resendMessageStart(message); - }) - .onSucceeded((message) => { - logger.info('Thread | useResendMessageCallback: Resending user message succeeded.', message); - sendMessageSuccess(message); - pubSub.publish(topics.SEND_USER_MESSAGE, { - channel: currentChannel, - message: message, - publishingModules: [PublishingModuleType.THREAD], - }); - }) - .onFailed((error) => { - logger.warning('Thread | useResendMessageCallback: Resending user message failed.', error); - failedMessage.sendingStatus = SendingStatus.FAILED; - sendMessageFailure(failedMessage); + if (!(failedMessage as SendableMessageType)?.isResendable) { + logger.warning('Thread | useResendMessageCallback: Message is not resendable.', failedMessage); + return; + } + + logger.info('Thread | useResendMessageCallback: Resending failedMessage start.', failedMessage); + if (failedMessage?.isUserMessage?.() || failedMessage?.messageType === MessageType.USER) { + try { + currentChannel?.resendMessage(failedMessage as UserMessage) + .onPending((message) => { + logger.info('Thread | useResendMessageCallback: Resending user message started.', message); + resendMessageStart(message); + }) + .onSucceeded((message) => { + logger.info('Thread | useResendMessageCallback: Resending user message succeeded.', message); + sendMessageSuccess(message); + pubSub.publish(topics.SEND_USER_MESSAGE, { + channel: currentChannel, + message: message, + publishingModules: [PublishingModuleType.THREAD], }); - } catch (err) { - logger.warning('Thread | useResendMessageCallback: Resending user message failed.', err); - failedMessage.sendingStatus = SendingStatus.FAILED; - sendMessageFailure(failedMessage); - } - } else if (failedMessage?.isFileMessage?.()) { - try { - currentChannel?.resendMessage?.(failedMessage as FileMessage) - .onPending((message) => { - logger.info('Thread | useResendMessageCallback: Resending file message started.', message); - resendMessageStart(message); - }) - .onSucceeded((message) => { - logger.info('Thread | useResendMessageCallback: Resending file message succeeded.', message); - sendMessageSuccess(message); - pubSub.publish(topics.SEND_FILE_MESSAGE, { - channel: currentChannel, - message: failedMessage, - publishingModules: [PublishingModuleType.THREAD], - }); - }) - .onFailed((error) => { - logger.warning('Thread | useResendMessageCallback: Resending file message failed.', error); - failedMessage.sendingStatus = SendingStatus.FAILED; - sendMessageFailure(failedMessage); + }) + .onFailed((error) => { + logger.warning('Thread | useResendMessageCallback: Resending user message failed.', error); + failedMessage.sendingStatus = SendingStatus.FAILED; + sendMessageFailure(failedMessage); + }); + } catch (err) { + logger.warning('Thread | useResendMessageCallback: Resending user message failed.', err); + failedMessage.sendingStatus = SendingStatus.FAILED; + sendMessageFailure(failedMessage); + } + } else if (failedMessage?.isFileMessage?.()) { + try { + currentChannel?.resendMessage?.(failedMessage as FileMessage) + .onPending((message) => { + logger.info('Thread | useResendMessageCallback: Resending file message started.', message); + resendMessageStart(message); + }) + .onSucceeded((message) => { + logger.info('Thread | useResendMessageCallback: Resending file message succeeded.', message); + sendMessageSuccess(message); + pubSub.publish(topics.SEND_FILE_MESSAGE, { + channel: currentChannel, + message: failedMessage, + publishingModules: [PublishingModuleType.THREAD], }); - } catch (err) { - logger.warning('Thread | useResendMessageCallback: Resending file message failed.', err); - failedMessage.sendingStatus = SendingStatus.FAILED; - sendMessageFailure(failedMessage); - } - } else if (failedMessage?.isMultipleFilesMessage?.()) { - try { - currentChannel?.resendMessage?.(failedMessage as MultipleFilesMessage) - .onPending((message) => { - logger.info('Thread | useResendMessageCallback: Resending multiple files message started.', message); - resendMessageStart(message); - }) - .onFileUploaded((requestId, index, uploadableFileInfo: UploadableFileInfo, error) => { - logger.info('Thread | useResendMessageCallback: onFileUploaded during resending multiple files message.', { + }) + .onFailed((error) => { + logger.warning('Thread | useResendMessageCallback: Resending file message failed.', error); + failedMessage.sendingStatus = SendingStatus.FAILED; + sendMessageFailure(failedMessage); + }); + } catch (err) { + logger.warning('Thread | useResendMessageCallback: Resending file message failed.', err); + failedMessage.sendingStatus = SendingStatus.FAILED; + sendMessageFailure(failedMessage); + } + } else if (failedMessage?.isMultipleFilesMessage?.()) { + try { + currentChannel?.resendMessage?.(failedMessage as MultipleFilesMessage) + .onPending((message) => { + logger.info('Thread | useResendMessageCallback: Resending multiple files message started.', message); + resendMessageStart(message); + }) + .onFileUploaded((requestId, index, uploadableFileInfo: UploadableFileInfo, error) => { + logger.info('Thread | useResendMessageCallback: onFileUploaded during resending multiple files message.', { + requestId, + index, + error, + uploadableFileInfo, + }); + pubSub.publish(topics.ON_FILE_INFO_UPLOADED, { + response: { + channelUrl: currentChannel.url, requestId, index, - error, uploadableFileInfo, - }); - pubSub.publish(topics.ON_FILE_INFO_UPLOADED, { - response: { - channelUrl: currentChannel.url, - requestId, - index, - uploadableFileInfo, - error, - }, - publishingModules: [PublishingModuleType.THREAD], - }); - }) - .onSucceeded((message: MultipleFilesMessage) => { - logger.info('Thread | useResendMessageCallback: Resending MFM succeeded.', message); - sendMessageSuccess(message); - pubSub.publish(topics.SEND_FILE_MESSAGE, { - channel: currentChannel, - message, - publishingModules: [PublishingModuleType.THREAD], - }); - }) - .onFailed((error, message) => { - logger.warning('Thread | useResendMessageCallback: Resending MFM failed.', error); - sendMessageFailure(message); + error, + }, + publishingModules: [PublishingModuleType.THREAD], }); - } catch (err) { - logger.warning('Thread | useResendMessageCallback: Resending MFM failed.', err); - sendMessageFailure(failedMessage); - } - } else { - logger.warning('Thread | useResendMessageCallback: Message is not resendable.', failedMessage); - failedMessage.sendingStatus = SendingStatus.FAILED; + }) + .onSucceeded((message: MultipleFilesMessage) => { + logger.info('Thread | useResendMessageCallback: Resending MFM succeeded.', message); + sendMessageSuccess(message); + pubSub.publish(topics.SEND_FILE_MESSAGE, { + channel: currentChannel, + message, + publishingModules: [PublishingModuleType.THREAD], + }); + }) + .onFailed((error, message) => { + logger.warning('Thread | useResendMessageCallback: Resending MFM failed.', error); + sendMessageFailure(message); + }); + } catch (err) { + logger.warning('Thread | useResendMessageCallback: Resending MFM failed.', err); sendMessageFailure(failedMessage); } + } else { + logger.warning('Thread | useResendMessageCallback: Message is not resendable.', failedMessage); + failedMessage.sendingStatus = SendingStatus.FAILED; + sendMessageFailure(failedMessage); } }, [currentChannel]); } diff --git a/src/modules/Thread/context/hooks/useSendFileMessage.ts b/src/modules/Thread/context/hooks/useSendFileMessage.ts index ef1b4bc14..fd38846ab 100644 --- a/src/modules/Thread/context/hooks/useSendFileMessage.ts +++ b/src/modules/Thread/context/hooks/useSendFileMessage.ts @@ -50,37 +50,42 @@ export default function useSendFileMessageCallback({ const params = onBeforeSendFileMessage?.(file, quoteMessage) ?? createParamsDefault(); logger.info('Thread | useSendFileMessageCallback: Sending file message start.', params); - currentChannel?.sendFileMessage(params) - .onPending((pendingMessage) => { + if (currentChannel == null) { + logger.warning('Thread | useSendFileMessageCallback: currentChannel is null. Skipping file message send.'); + resolve(null); + } else { + currentChannel.sendFileMessage(params) + .onPending((pendingMessage) => { // @ts-ignore - sendMessageStart({ - ...pendingMessage, - url: URL.createObjectURL(file), - // pending thumbnail message seems to be failed - sendingStatus: SendingStatus.PENDING, - isUserMessage: pendingMessage.isUserMessage, - isFileMessage: pendingMessage.isFileMessage, - isAdminMessage: pendingMessage.isAdminMessage, - isMultipleFilesMessage: pendingMessage.isMultipleFilesMessage, + sendMessageStart({ + ...pendingMessage, + url: URL.createObjectURL(file), + // pending thumbnail message seems to be failed + sendingStatus: SendingStatus.PENDING, + isUserMessage: pendingMessage.isUserMessage, + isFileMessage: pendingMessage.isFileMessage, + isAdminMessage: pendingMessage.isAdminMessage, + isMultipleFilesMessage: pendingMessage.isMultipleFilesMessage, + }); + setTimeout(() => scrollIntoLast(), SCROLL_BOTTOM_DELAY_FOR_SEND); + }) + .onFailed((error, message) => { + (message as LocalFileMessage).localUrl = URL.createObjectURL(file); + (message as LocalFileMessage).file = file; + logger.info('Thread | useSendFileMessageCallback: Sending file message failed.', { message, error }); + sendMessageFailure(message as SendableMessageType); + reject(error); + }) + .onSucceeded((message) => { + logger.info('Thread | useSendFileMessageCallback: Sending file message succeeded.', message); + pubSub.publish(topics.SEND_FILE_MESSAGE, { + channel: currentChannel, + message: message as FileMessage, + publishingModules: [PublishingModuleType.THREAD], + }); + resolve(message as FileMessage); }); - setTimeout(() => scrollIntoLast(), SCROLL_BOTTOM_DELAY_FOR_SEND); - }) - .onFailed((error, message) => { - (message as LocalFileMessage).localUrl = URL.createObjectURL(file); - (message as LocalFileMessage).file = file; - logger.info('Thread | useSendFileMessageCallback: Sending file message failed.', { message, error }); - sendMessageFailure(message as SendableMessageType); - reject(error); - }) - .onSucceeded((message) => { - logger.info('Thread | useSendFileMessageCallback: Sending file message succeeded.', message); - pubSub.publish(topics.SEND_FILE_MESSAGE, { - channel: currentChannel, - message: message as FileMessage, - publishingModules: [PublishingModuleType.THREAD], - }); - resolve(message as FileMessage); - }); + } }); }, [currentChannel], diff --git a/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts b/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts index 3a23b6658..17881c824 100644 --- a/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts +++ b/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts @@ -60,7 +60,12 @@ export default function useUpdateMessageCallback({ const params = createParamsDefault(); logger.info('Thread | useUpdateMessageCallback: Message update start.', params); - currentChannel?.updateUserMessage?.(messageId, params) + if (currentChannel == null) { + logger.warning('Thread | useUpdateMessageCallback: currentChannel is null.'); + return; + } + + currentChannel.updateUserMessage?.(messageId, params) .then((message: UserMessage) => { logger.info('Thread | useUpdateMessageCallback: Message update succeeded.', message); onMessageUpdated(currentChannel, message); diff --git a/src/modules/Thread/context/useThread.ts b/src/modules/Thread/context/useThread.ts index 7d68ebcaf..7cf276719 100644 --- a/src/modules/Thread/context/useThread.ts +++ b/src/modules/Thread/context/useThread.ts @@ -34,8 +34,7 @@ function hasReqId( const useThread = () => { const store = useContext(ThreadContext); - if (!store) throw new Error('useCreateChannel must be used within a CreateChannelProvider'); - + if (!store) throw new Error('useThread must be used within a ThreadProvider'); // SendbirdStateContext config const { state: { stores, config } } = useSendbird(); const { logger, pubSub } = config;