From a063caf676e4e0de5e8a26eaf9c63fd4334f1fa0 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Fri, 6 Dec 2024 12:56:17 +0530 Subject: [PATCH] tests: add tests for the mark as unread feature --- .../Channel/__tests__/Channel.test.js | 167 ++++++---- .../useMessageListPagination.test.js | 290 +++++++++++++++--- .../hooks/useMessageListPagination.tsx | 1 + .../components/MessageList/MessageList.tsx | 2 +- 4 files changed, 363 insertions(+), 97 deletions(-) diff --git a/package/src/components/Channel/__tests__/Channel.test.js b/package/src/components/Channel/__tests__/Channel.test.js index 4254bcabb..c58018621 100644 --- a/package/src/components/Channel/__tests__/Channel.test.js +++ b/package/src/components/Channel/__tests__/Channel.test.js @@ -29,6 +29,7 @@ import { useChannelDataState, useChannelMessageDataState, } from '../hooks/useChannelDataState'; +import * as MessageListPaginationHooks from '../hooks/useMessageListPagination'; // This component is used for performing effects in a component that consumes ChannelContext, // i.e. making use of the callbacks & values provided by the Channel component. @@ -87,6 +88,7 @@ describe('Channel', () => { const nullChannel = { ...channel, cid: null, + countUnread: () => 0, off: () => {}, on: () => ({ unsubscribe: () => null, @@ -464,79 +466,128 @@ describe('Channel initial load useEffect', () => { ); }); - it("should not call loadChannelAtFirstUnreadMessage if channel's unread count is 0", async () => { - const mockedChannel = generateChannelResponse({ - messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })), + describe('initialScrollToFirstUnreadMessage', () => { + afterEach(() => { + // Clear all mocks after each test + jest.clearAllMocks(); + // Restore all mocks to their original implementation + jest.restoreAllMocks(); + cleanup(); }); + const mockedHook = (values) => + jest.spyOn(MessageListPaginationHooks, 'useMessageListPagination').mockImplementation(() => ({ + copyMessagesStateFromChannel: jest.fn(), + loadChannelAroundMessage: jest.fn(), + loadChannelAtFirstUnreadMessage: jest.fn(), + loadInitialMessagesStateFromChannel: jest.fn(), + loadLatestMessages: jest.fn(), + loadMore: jest.fn(), + loadMoreRecent: jest.fn(), + state: { ...channelInitialState }, + ...values, + })); + it("should not call loadChannelAtFirstUnreadMessage if channel's unread count is 0", async () => { + const mockedChannel = generateChannelResponse({ + messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.id); - await channel.watch(); - const messages = Array.from({ length: 100 }, (_, i) => generateMessage({ id: i })); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + const user = generateUser(); + const read_data = {}; - const loadMessageIntoState = jest.fn(); - channel.state = { - ...channelInitialState, - loadMessageIntoState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - messages, - }; - channel.countUnread = jest.fn(() => 0); + read_data[chatClient.user.id] = { + user: user, + last_read: new Date(), + }; - renderComponent({ channel, initialScrollToFirstUnreadMessage: true }); + channel.state = { + ...channelInitialState, + read: read_data, + }; + channel.countUnread = jest.fn(() => 0); - await waitFor(() => { - expect(loadMessageIntoState).not.toHaveBeenCalled(); - }); - }); + const loadChannelAtFirstUnreadMessageFn = jest.fn(); - it("should call loadChannelAtFirstUnreadMessage if channel's unread count is greater than 0", async () => { - const mockedChannel = generateChannelResponse({ - messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })), - }); + mockedHook({ loadChannelAtFirstUnreadMessage: loadChannelAtFirstUnreadMessageFn }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.id); - await channel.watch(); - const messages = Array.from({ length: 100 }, (_, i) => generateMessage({ id: i })); + renderComponent({ channel, initialScrollToFirstUnreadMessage: true }); - let targetedMessageId = 0; - const loadMessageIntoState = jest.fn((id) => { - targetedMessageId = id; - const newMessages = getElementsAround(messages, 'id', id); - channel.state.messages = newMessages; + await waitFor(() => { + expect(loadChannelAtFirstUnreadMessageFn).not.toHaveBeenCalled(); + }); }); - channel.state = { - ...channelInitialState, - loadMessageIntoState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - messages, - messageSets: [{ isCurrent: true, isLatest: true }], - }; + it("should call loadChannelAtFirstUnreadMessage if channel's unread count is greater than 0", async () => { + const mockedChannel = generateChannelResponse({ + messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); - channel.countUnread = jest.fn(() => 15); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); - renderComponent({ channel, initialScrollToFirstUnreadMessage: true }); + const user = generateUser(); + const numberOfUnreadMessages = 15; + const read_data = {}; - await waitFor(() => { - expect(loadMessageIntoState).toHaveBeenCalledTimes(1); + read_data[chatClient.user.id] = { + user: user, + last_read: new Date(), + unread_messages: numberOfUnreadMessages, + }; + channel.state = { + ...channelInitialState, + read: read_data, + }; + + channel.countUnread = jest.fn(() => numberOfUnreadMessages); + const loadChannelAtFirstUnreadMessageFn = jest.fn(); + + mockedHook({ loadChannelAtFirstUnreadMessage: loadChannelAtFirstUnreadMessageFn }); + + renderComponent({ channel, initialScrollToFirstUnreadMessage: true }); + + await waitFor(() => { + expect(loadChannelAtFirstUnreadMessageFn).toHaveBeenCalled(); + }); }); - const { result: channelMessageState } = renderHook(() => useChannelMessageDataState(channel)); - await waitFor(() => - expect( - channelMessageState.current.state.messages.find( - (message) => message.id === targetedMessageId, - ), - ).toBeDefined(), - ); + it("should not call loadChannelAtFirstUnreadMessage if channel's unread count is greater than 0 lesser than scrollToFirstUnreadThreshold", async () => { + const mockedChannel = generateChannelResponse({ + messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); + + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + + const user = generateUser(); + const numberOfUnreadMessages = 2; + const read_data = {}; + + read_data[chatClient.user.id] = { + user: user, + last_read: new Date(), + unread_messages: numberOfUnreadMessages, + }; + channel.state = { + ...channelInitialState, + read: read_data, + }; + + channel.countUnread = jest.fn(() => numberOfUnreadMessages); + const loadChannelAtFirstUnreadMessageFn = jest.fn(); + + mockedHook({ loadChannelAtFirstUnreadMessage: loadChannelAtFirstUnreadMessageFn }); + + renderComponent({ channel, initialScrollToFirstUnreadMessage: true }); + + await waitFor(() => { + expect(loadChannelAtFirstUnreadMessageFn).not.toHaveBeenCalled(); + }); + }); }); it('should call resyncChannel when connection changed event is triggered', async () => { diff --git a/package/src/components/Channel/__tests__/useMessageListPagination.test.js b/package/src/components/Channel/__tests__/useMessageListPagination.test.js index 3c9dbcca8..7dc82b47b 100644 --- a/package/src/components/Channel/__tests__/useMessageListPagination.test.js +++ b/package/src/components/Channel/__tests__/useMessageListPagination.test.js @@ -337,10 +337,11 @@ describe('useMessageListPagination', () => { }); it('should not do anything when the unread count is 0', async () => { + const messages = Array.from({ length: 20 }, (_, i) => + generateMessage({ text: `message-${i}` }), + ); const loadMessageIntoState = jest.fn(() => { - channel.state.messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); + channel.state.messages = messages; channel.state.messagePagination.hasPrev = true; }); channel.state = { @@ -352,68 +353,281 @@ describe('useMessageListPagination', () => { }, }; - channel.countUnread = jest.fn(() => 0); + const user = generateUser(); + const channelUnreadState = { + user, + unread_messages: 0, + }; + + const jumpToMessageFinishedMock = jest.fn(); + mockedHook(channelInitialState, { jumpToMessageFinished: jumpToMessageFinishedMock }); const { result } = renderHook(() => useMessageListPagination({ channel })); await act(async () => { - await result.current.loadChannelAtFirstUnreadMessage({}); + await result.current.loadChannelAtFirstUnreadMessage({ channelUnreadState }); }); await waitFor(() => { - expect(loadMessageIntoState).toHaveBeenCalledTimes(0); + expect(jumpToMessageFinishedMock).toHaveBeenCalledTimes(0); }); }); - function getElementsAround(array, key, id, limit) { - const index = array.findIndex((obj) => obj[key] === id); - - if (index === -1) { - return []; - } - - const start = Math.max(0, index - limit); // 12 before the index - const end = Math.min(array.length, index + limit); // 12 after the index - return array.slice(start, end); - } + const generateMessageArray = (length = 20) => + Array.from({ length }, (_, i) => generateMessage({ text: `message-${i}`, id: i })); + + // Test cases with different scenarios + const testCases = [ + { + name: 'first_unread_message_id present in current message set', + initialMessages: generateMessageArray(), + channelUnreadState: (messages) => ({ + unread_messages: 2, + first_unread_message_id: messages[2].id, + }), + expectedCalls: { + loadMessageIntoStateCalls: 0, + jumpToMessageFinishedCalls: 1, + setChannelUnreadStateCalls: 0, + setTargetedMessageIdCalls: 1, + targetedMessageId: (messages) => messages[2].id, + }, + setupLoadMessageIntoState: null, + }, + { + name: 'first_unread_message_id not present in current message set', + initialMessages: generateMessageArray(), + channelUnreadState: () => ({ + unread_messages: 2, + first_unread_message_id: 21, + }), + expectedCalls: { + loadMessageIntoStateCalls: 1, + jumpToMessageFinishedCalls: 1, + setChannelUnreadStateCalls: 0, + setTargetedMessageIdCalls: 1, + targetedMessageId: () => 21, + }, + setupLoadMessageIntoState: (channel) => { + const loadMessageIntoState = jest.fn(() => { + const newMessages = Array.from({ length: 20 }, (_, i) => + generateMessage({ text: `message-${i + 21}`, id: i + 21 }), + ); + channel.state.messages = newMessages; + channel.state.messagePagination.hasPrev = true; + }); + channel.state.loadMessageIntoState = loadMessageIntoState; + return loadMessageIntoState; + }, + }, + { + name: 'last_read_message_id present in current message set', + initialMessages: generateMessageArray(), + channelUnreadState: (messages) => ({ + unread_messages: 2, + last_read_message_id: messages[2].id, + }), + expectedCalls: { + loadMessageIntoStateCalls: 0, + jumpToMessageFinishedCalls: 1, + setChannelUnreadStateCalls: 1, + setTargetedMessageIdCalls: 1, + targetedMessageId: (messages) => messages[3].id, + }, + setupLoadMessageIntoState: null, + }, + { + name: 'last_read_message_id not present in current message set', + initialMessages: generateMessageArray(), + channelUnreadState: () => ({ + unread_messages: 2, + last_read_message_id: 21, + }), + expectedCalls: { + loadMessageIntoStateCalls: 1, + jumpToMessageFinishedCalls: 1, + setChannelUnreadStateCalls: 1, + setTargetedMessageIdCalls: 1, + targetedMessageId: () => 22, + }, + setupLoadMessageIntoState: (channel) => { + const loadMessageIntoState = jest.fn(() => { + const newMessages = Array.from({ length: 20 }, (_, i) => + generateMessage({ text: `message-${i + 21}`, id: i + 21 }), + ); + channel.state.messages = newMessages; + channel.state.messagePagination.hasPrev = true; + }); + channel.state.loadMessageIntoState = loadMessageIntoState; + return loadMessageIntoState; + }, + }, + ]; - it('should call the loadMessageIntoState function when the unread count is greater than 0 and set the state', async () => { - const messages = Array.from({ length: 30 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - const loadMessageIntoState = jest.fn((messageId) => { - channel.state.messages = getElementsAround(messages, 'id', messageId, 5); - channel.state.messagePagination.hasPrev = true; - }); + it.each(testCases)('$name', async (testCase) => { + // Setup channel state + const messages = testCase.initialMessages; channel.state = { ...channelInitialState, - loadMessageIntoState, + messages, messagePagination: { - hasNext: false, + hasNext: true, hasPrev: true, }, - messages, - messageSets: [{ isCurrent: true, isLatest: true }], }; - const unreadCount = 5; - channel.countUnread = jest.fn(() => unreadCount); + // Setup additional mocks if needed + const loadMessageIntoStateMock = testCase.setupLoadMessageIntoState + ? testCase.setupLoadMessageIntoState(channel) + : null; + + // Generate user and channel unread state + const user = generateUser(); + const channelUnreadState = { + user, + ...testCase.channelUnreadState(messages), + }; + + // Setup mocks + const jumpToMessageFinishedMock = jest.fn(); + mockedHook(channelInitialState, { jumpToMessageFinished: jumpToMessageFinishedMock }); const { result } = renderHook(() => useMessageListPagination({ channel })); + const setChannelUnreadStateMock = jest.fn(); + const setTargetedMessageIdMock = jest.fn((message) => message); + + // Execute the method await act(async () => { - await result.current.loadChannelAtFirstUnreadMessage({}); + await result.current.loadChannelAtFirstUnreadMessage({ + channelUnreadState, + setChannelUnreadState: setChannelUnreadStateMock, + setTargetedMessage: setTargetedMessageIdMock, + }); }); + // Verify expectations await waitFor(() => { - expect(loadMessageIntoState).toHaveBeenCalledTimes(1); - expect(result.current.state.hasMore).toBe(true); - expect(result.current.state.hasMoreNewer).toBe(false); - expect(result.current.state.messages.length).toBe(10); - expect(result.current.state.targetedMessageId).toBe( - messages[messages.length - unreadCount].id, + if (loadMessageIntoStateMock) { + expect(loadMessageIntoStateMock).toHaveBeenCalledTimes( + testCase.expectedCalls.loadMessageIntoStateCalls, + ); + } + + expect(jumpToMessageFinishedMock).toHaveBeenCalledTimes( + testCase.expectedCalls.jumpToMessageFinishedCalls, + ); + + expect(setChannelUnreadStateMock).toHaveBeenCalledTimes( + testCase.expectedCalls.setChannelUnreadStateCalls, + ); + + expect(setTargetedMessageIdMock).toHaveBeenCalledTimes( + testCase.expectedCalls.setTargetedMessageIdCalls, ); + + if (testCase.expectedCalls.targetedMessageId) { + const expectedMessageId = testCase.expectedCalls.targetedMessageId(messages); + expect(setTargetedMessageIdMock).toHaveBeenCalledWith(expectedMessageId); + } }); }); + + const messages = Array.from({ length: 20 }, (_, i) => + generateMessage({ + text: `message-${i}`, + id: i, + created_at: new Date(`2021-09-01T00:00:00.000Z`), + }), + ); + + const user = generateUser(); + + it.each([ + { + name: 'when last_read matches a message', + channelUnreadState: { + last_read: new Date(messages[10].created_at), + user, + unread_messages: 2, + }, + expectedQueryCalls: 0, + expectedJumpToMessageFinishedCalls: 1, + expectedSetChannelUnreadStateCalls: 1, + expectedSetTargetedMessageCalls: 1, + expectedTargetedMessageId: 10, + }, + { + name: 'when last_read does not match any message', + channelUnreadState: { + last_read: new Date('2021-09-02T00:00:00.000Z'), + user, + unread_messages: 2, + }, + expectedQueryCalls: 1, + expectedJumpToMessageFinishedCalls: 0, + expectedSetChannelUnreadStateCalls: 0, + expectedSetTargetedMessageCalls: 0, + expectedTargetedMessageId: undefined, + }, + ])( + '$name', + async ({ + channelUnreadState, + expectedQueryCalls, + expectedJumpToMessageFinishedCalls, + expectedSetChannelUnreadStateCalls, + expectedSetTargetedMessageCalls, + expectedTargetedMessageId, + }) => { + // Set up channel state + channel.state = { + ...channelInitialState, + messages, + messagePagination: { + hasNext: true, + hasPrev: true, + }, + }; + + // Mock query if needed + const queryMock = jest.fn(); + channel.query = queryMock; + + // Set up mocks + const jumpToMessageFinishedMock = jest.fn(); + mockedHook(channelInitialState, { jumpToMessageFinished: jumpToMessageFinishedMock }); + const setChannelUnreadStateMock = jest.fn(); + const setTargetedMessageIdMock = jest.fn((message) => message); + + // Render hook + const { result } = renderHook(() => useMessageListPagination({ channel })); + + // Act + await act(async () => { + await result.current.loadChannelAtFirstUnreadMessage({ + channelUnreadState, + setChannelUnreadState: setChannelUnreadStateMock, + setTargetedMessage: setTargetedMessageIdMock, + }); + }); + + // Assert + await waitFor(() => { + expect(queryMock).toHaveBeenCalledTimes(expectedQueryCalls); + expect(jumpToMessageFinishedMock).toHaveBeenCalledTimes( + expectedJumpToMessageFinishedCalls, + ); + expect(setChannelUnreadStateMock).toHaveBeenCalledTimes( + expectedSetChannelUnreadStateCalls, + ); + expect(setTargetedMessageIdMock).toHaveBeenCalledTimes(expectedSetTargetedMessageCalls); + + if (expectedTargetedMessageId !== undefined) { + expect(setTargetedMessageIdMock).toHaveBeenCalledWith(expectedTargetedMessageId); + } + }); + }, + ); }); }); diff --git a/package/src/components/Channel/hooks/useMessageListPagination.tsx b/package/src/components/Channel/hooks/useMessageListPagination.tsx index 22c4c05da..8a3e8e4d5 100644 --- a/package/src/components/Channel/hooks/useMessageListPagination.tsx +++ b/package/src/components/Channel/hooks/useMessageListPagination.tsx @@ -294,6 +294,7 @@ export const useMessageListPagination = < last_read_message_id: lastReadMessageId, }); } + jumpToMessageFinished(channel.state.messagePagination.hasNext, firstUnreadMessageId); if (setTargetedMessage) { setTargetedMessage(firstUnreadMessageId); diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 7824ffbac..06706d97c 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -469,7 +469,6 @@ const MessageListWithContext = < // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // TODO: Think if the useEffect is really needed? useEffect(() => { const lastReceivedMessage = getLastReceivedMessage(processedMessageList); setLastReceivedId(lastReceivedMessage?.id); @@ -514,6 +513,7 @@ const MessageListWithContext = < } }; + // TODO: Think about if this is really needed? if (threadList) { scrollToBottomIfNeeded(); }