From ed94b01c2f50b01c093c53ea09d916228b34ff0e Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 9 Apr 2026 10:21:55 +0200 Subject: [PATCH] test: add missing tests --- ...nelListItemActionButtons.defaults.test.tsx | 180 +++++++++++++ .../__tests__/useReactionsFetcher.test.tsx | 50 ++++ .../__tests__/MessageActions.test.tsx | 244 ++++++++++++++++++ .../hooks/__tests__/useSubmitHandler.test.tsx | 104 ++++++++ .../__tests__/Notification.test.tsx | 90 +++++++ .../__tests__/NotificationList.test.tsx | 30 +++ .../__tests__/EndPollAlert.test.tsx | 93 +++++++ 7 files changed, 791 insertions(+) create mode 100644 src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx create mode 100644 src/components/Message/hooks/__tests__/useReactionsFetcher.test.tsx create mode 100644 src/components/MessageComposer/hooks/__tests__/useSubmitHandler.test.tsx create mode 100644 src/components/Notifications/__tests__/Notification.test.tsx create mode 100644 src/components/Poll/PollActions/__tests__/EndPollAlert.test.tsx diff --git a/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx b/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx new file mode 100644 index 0000000000..28361a80f7 --- /dev/null +++ b/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; + +import { Chat } from '../../Chat'; +import { ChannelListItem } from '../ChannelListItem'; +import { + generateMember, + generateUser, + initClientWithChannels, +} from '../../../mock-builders'; +import { ResizeObserverMock } from '../../../mock-builders/browser'; + +const ResizeObserverConstructor = + ResizeObserverMock as unknown as typeof window.ResizeObserver; + +describe('ChannelListItemActionButtons defaults', () => { + const alice = generateUser({ id: 'alice-id' }); + const bob = generateUser({ id: 'bob-id' }); + + const ownCapabilities = [ + 'ban-channel-members', + 'leave-channel', + 'mute-channel', + 'read-events', + 'send-message', + ]; + + beforeEach(() => { + window.ResizeObserver = ResizeObserverConstructor; + }); + + afterEach(() => { + vi.clearAllMocks(); + delete window.ResizeObserver; + }); + + const setupTwoMemberGroupChannel = async () => { + const { channels, client } = await initClientWithChannels({ + channelsData: [ + { + channel: { + id: 'two-member-group', + member_count: 2, + own_capabilities: ownCapabilities, + }, + members: [generateMember({ user: alice }), generateMember({ user: bob })], + messages: [], + }, + ], + customUser: alice, + }); + return { channel: channels[0], client }; + }; + + it('mutes channel from quick action with success notification', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + vi.spyOn(channel, 'mute').mockResolvedValue(undefined); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Mute' })); + }); + await waitFor(() => { + expect(channel.mute).toHaveBeenCalledTimes(1); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Channel muted', + options: expect.objectContaining({ severity: 'success' }), + }), + ); + }); + }); + + it('shows mute error notification when mute fails', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + vi.spyOn(channel, 'mute').mockRejectedValueOnce(new Error('mute failed')); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Mute' })); + }); + + await waitFor(() => { + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to update channel mute status', + options: expect.objectContaining({ severity: 'error' }), + }), + ); + }); + }); + + it('blocks the other member from dropdown', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + vi.spyOn(channel, 'banUser').mockResolvedValue(undefined); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + const toggle = document.querySelector( + '.str-chat__channel-list-item__action-buttons .str-chat__button--circular', + ) as HTMLButtonElement; + act(() => { + fireEvent.click(toggle); + }); + + const menu = document.querySelector('.str-chat__context-menu') as HTMLElement; + act(() => { + fireEvent.click(within(menu).getByRole('button', { name: 'Block User' })); + }); + + await waitFor(() => { + expect(channel.banUser).toHaveBeenCalledWith(bob.id, {}); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User blocked', + options: expect.objectContaining({ severity: 'success' }), + }), + ); + }); + }); + + it('leaves channel from dropdown', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + vi.spyOn(channel, 'removeMembers').mockResolvedValue(undefined); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + const toggle = document.querySelector( + '.str-chat__channel-list-item__action-buttons .str-chat__button--circular', + ) as HTMLButtonElement; + act(() => { + fireEvent.click(toggle); + }); + + const menu = document.querySelector('.str-chat__context-menu') as HTMLElement; + act(() => { + fireEvent.click(within(menu).getByRole('button', { name: 'Leave Channel' })); + }); + + await waitFor(() => { + expect(channel.removeMembers).toHaveBeenCalledWith([alice.id]); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Left channel', + options: expect.objectContaining({ severity: 'success' }), + }), + ); + }); + }); +}); diff --git a/src/components/Message/hooks/__tests__/useReactionsFetcher.test.tsx b/src/components/Message/hooks/__tests__/useReactionsFetcher.test.tsx new file mode 100644 index 0000000000..eac45420e6 --- /dev/null +++ b/src/components/Message/hooks/__tests__/useReactionsFetcher.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; + +import { useReactionsFetcher } from '../useReactionsFetcher'; +import { ChatProvider } from '../../../../context/ChatContext'; +import { + generateMessage, + getTestClientWithUser, + mockChatContext, +} from '../../../../mock-builders'; +import type { LocalMessage, MessageResponse } from 'stream-chat'; + +describe('useReactionsFetcher', () => { + it('paginates until next is empty', async () => { + const client = await getTestClientWithUser({ id: 'u1' }); + const queryReactions = vi + .spyOn(client, 'queryReactions') + .mockResolvedValueOnce({ + duration: '0', + next: 'page-2', + reactions: [{ created_at: new Date(), type: 'like', updated_at: new Date() }], + } as never) + .mockResolvedValueOnce({ + duration: '0', + reactions: [{ created_at: new Date(), type: 'love', updated_at: new Date() }], + } as never); + + const message = generateMessage() as MessageResponse & LocalMessage; + + const wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useReactionsFetcher(message), { wrapper }); + + let reactions: unknown[]; + await act(async () => { + reactions = await result.current(); + }); + + expect(queryReactions).toHaveBeenCalledTimes(2); + expect(queryReactions.mock.calls[0]?.[3]).toEqual( + expect.objectContaining({ limit: 25, next: undefined }), + ); + expect(queryReactions.mock.calls[1]?.[3]).toEqual( + expect.objectContaining({ limit: 25, next: 'page-2' }), + ); + expect(reactions!).toHaveLength(2); + }); +}); diff --git a/src/components/MessageActions/__tests__/MessageActions.test.tsx b/src/components/MessageActions/__tests__/MessageActions.test.tsx index bd940f5711..4fdcc3f8fd 100644 --- a/src/components/MessageActions/__tests__/MessageActions.test.tsx +++ b/src/components/MessageActions/__tests__/MessageActions.test.tsx @@ -1206,4 +1206,248 @@ describe('', () => { expect(contextMenu).toBeInTheDocument(); }); }); + + describe('Default message actions edge coverage', () => { + it('should copy message text to clipboard when Copy is used', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + const message = generateMessage({ text: 'hello copy', user: alice }); + await renderMessageActions({ + customMessageContext: { message }, + }); + await toggleOpenMessageActions(); + await act(async () => { + await fireEvent.click(screen.getByText('Copy Message')); + }); + expect(writeText).toHaveBeenCalledWith('hello copy'); + }); + + it('should call handleRetry when Resend is used on a failed message', async () => { + const handleRetry = vi.fn(); + const message = generateMessage({ + error: { status: 400 }, + status: 'failed', + user: alice, + }); + await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'send-message': true }, + }, + customMessageContext: { handleRetry, message }, + }); + await toggleOpenMessageActions(); + await act(async () => { + await fireEvent.click(screen.getByText('Resend')); + }); + expect(handleRetry).toHaveBeenCalledWith(message); + }); + + it('should call client.blockUser when Block User is used', async () => { + const otherUser = generateUser(); + const message = generateMessage({ user: otherUser }); + const chatClient = await getTestClientWithUser(alice); + const blockUserSpy = vi.spyOn(chatClient, 'blockUser').mockImplementation(() => {}); + await renderMessageActions({ + chatClient, + customMessageContext: { message }, + }); + await toggleOpenMessageActions(); + await act(async () => { + await fireEvent.click(screen.getByText('Block User')); + }); + expect(blockUserSpy).toHaveBeenCalledWith(otherUser.id); + }); + + it('should notify on delete failure from the confirmation modal', async () => { + const handleDelete = vi.fn().mockRejectedValueOnce(new Error('delete failed')); + const message = generateMessage({ user: alice }); + const chatClient = await getTestClientWithUser(alice); + const addSpy = vi.spyOn(chatClient.notifications, 'add'); + await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'delete-own-message': true }, + }, + chatClient, + customMessageContext: { handleDelete, message }, + }); + await toggleOpenMessageActions(); + await act(async () => { + await fireEvent.click(screen.getByText('Delete message')); + }); + await act(async () => { + await fireEvent.click(screen.getByTestId('delete-message-alert-delete-button')); + }); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'delete failed', + options: expect.objectContaining({ severity: 'error' }), + }), + ); + }); + + it('should pass through non-Error pin failures for notifications', async () => { + const handlePin = vi.fn().mockRejectedValueOnce({ message: 'pin rejected' }); + const message = generateMessage({ pinned: false, user: alice }); + const chatClient = await getTestClientWithUser(alice); + const addSpy = vi.spyOn(chatClient.notifications, 'add'); + await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'pin-message': true }, + }, + chatClient, + customMessageContext: { handlePin, message }, + }); + await toggleOpenMessageActions(); + await act(async () => { + await fireEvent.click(screen.getByText('Pin')); + }); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error pinning message', + options: expect.objectContaining({ + originalError: expect.objectContaining({ message: 'pin rejected' }), + severity: 'error', + }), + }), + ); + }); + + it('should return to parent menu from Remind me submenu header', async () => { + const client = await getTestClientWithUser(alice); + await renderMessageActions({ + channelConfig: { replies: true, user_message_reminders: true }, + channelStateOpts: { + channelCapabilities: { 'send-reply': true }, + }, + chatClient: client, + customMessageContext: { + getMessageActions: () => [ + 'delete', + 'edit', + 'flag', + 'mute', + 'pin', + 'quote', + 'react', + 'remindMe', + 'reply', + 'saveForLater', + ], + }, + }); + await toggleOpenMessageActions(); + await act(async () => { + await fireEvent.click(screen.getByText('Remind me')); + }); + await act(async () => { + await fireEvent.click( + document.querySelector( + '.str-chat__context-menu__back-button', + ) as HTMLButtonElement, + ); + }); + expect(screen.getByText('Thread Reply')).toBeInTheDocument(); + }); + + it('should surface string rejection from reminder upsert as notification message', async () => { + const client = await getTestClientWithUser(alice); + vi.spyOn(client.reminders, 'upsertReminder').mockRejectedValueOnce('string-fail'); + const addNotificationSpy = vi.spyOn(client.notifications, 'add'); + await renderMessageActions({ + channelConfig: { user_message_reminders: true }, + chatClient: client, + customMessageContext: { + getMessageActions: () => [ + 'delete', + 'edit', + 'flag', + 'mute', + 'pin', + 'quote', + 'react', + 'remindMe', + 'reply', + 'saveForLater', + ], + }, + }); + await toggleOpenMessageActions(); + await act(async () => { + await fireEvent.click(screen.getByText('Remind me')); + }); + const remindMeOption = document.querySelector( + '.str-chat__message-actions-box__submenu .str-chat__message-actions-list-item-button', + ) as HTMLButtonElement; + await act(async () => { + await fireEvent.click(remindMeOption); + }); + expect(addNotificationSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error setting reminder', + options: expect.objectContaining({ + originalError: expect.objectContaining({ message: 'string-fail' }), + severity: 'error', + }), + }), + ); + }); + + it('should focus thread textarea when quoting from a thread message', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels(); + const message = generateMessage({ parent_id: 'parent-1', user: client.user }); + const textarea = document.createElement('textarea'); + textarea.className = 'str-chat__textarea__textarea'; + const threadRoot = document.createElement('div'); + threadRoot.className = 'str-chat__thread'; + threadRoot.appendChild(textarea); + document.body.appendChild(threadRoot); + const focusSpy = vi.spyOn(textarea, 'focus'); + + await act(async () => { + await render( + + + + + + + + + + + + + + + , + ); + }); + + await toggleOpenMessageActions(); + await act(async () => { + await fireEvent.click(screen.getByText('Quote Reply')); + }); + expect(focusSpy).toHaveBeenCalled(); + document.body.removeChild(threadRoot); + }); + }); }); diff --git a/src/components/MessageComposer/hooks/__tests__/useSubmitHandler.test.tsx b/src/components/MessageComposer/hooks/__tests__/useSubmitHandler.test.tsx new file mode 100644 index 0000000000..e4e43a0ca4 --- /dev/null +++ b/src/components/MessageComposer/hooks/__tests__/useSubmitHandler.test.tsx @@ -0,0 +1,104 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useSubmitHandler } from '../useSubmitHandler'; + +const submitMocks = vi.hoisted(() => { + const stateApi = () => ({ + getLatestValue: () => ({}), + next: vi.fn(), + partialNext: vi.fn(), + }); + const messageComposerMock = { + attachmentManager: { state: stateApi() }, + channel: { cid: 'messaging:test', stopTyping: vi.fn().mockResolvedValue(undefined) }, + clear: vi.fn(), + compose: vi.fn(), + config: { text: { publishTypingEvents: false } }, + customDataManager: { state: stateApi() }, + editedMessage: undefined as unknown, + linkPreviewsManager: { state: stateApi() }, + pollComposer: { state: stateApi() }, + state: stateApi(), + textComposer: { state: stateApi() }, + }; + return { + addNotification: vi.fn(), + editMessage: vi.fn(), + messageComposerMock, + sendMessage: vi.fn(), + }; +}); + +vi.mock('../../../Notifications', () => ({ + useNotificationApi: () => ({ + addNotification: submitMocks.addNotification, + addSystemNotification: vi.fn(), + removeNotification: vi.fn(), + startNotificationTimeout: vi.fn(), + }), +})); + +vi.mock('../../../../context/ChannelActionContext', () => ({ + useChannelActionContext: () => ({ + editMessage: submitMocks.editMessage, + sendMessage: submitMocks.sendMessage, + }), +})); + +vi.mock('../../../../context/TranslationContext', () => ({ + useTranslationContext: () => ({ t: (k: string) => k }), +})); + +vi.mock('../useMessageComposerController', () => ({ + useMessageComposerController: () => submitMocks.messageComposerMock, +})); + +describe('useSubmitHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + submitMocks.messageComposerMock.editedMessage = undefined; + vi.spyOn(submitMocks.messageComposerMock, 'compose') + .mockImplementation() + .mockResolvedValue({ + localMessage: { id: 'lm1', type: 'regular' }, + message: { id: 'm1', text: 'hi' }, + sendOptions: {}, + }); + }); + + it('notifies when editMessage fails', async () => { + submitMocks.messageComposerMock.editedMessage = { id: 'e1' }; + submitMocks.editMessage.mockRejectedValueOnce(new Error('edit failed')); + + const { result } = renderHook(() => useSubmitHandler({})); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(submitMocks.addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Edit message request failed', + severity: 'error', + }), + ); + }); + + it('restores composer state and notifies when sendMessage fails', async () => { + submitMocks.sendMessage.mockRejectedValueOnce(new Error('send failed')); + + const { result } = renderHook(() => useSubmitHandler({})); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(submitMocks.messageComposerMock.state.next).toHaveBeenCalled(); + expect(submitMocks.addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Send message request failed', + severity: 'error', + }), + ); + }); +}); diff --git a/src/components/Notifications/__tests__/Notification.test.tsx b/src/components/Notifications/__tests__/Notification.test.tsx new file mode 100644 index 0000000000..15576a3733 --- /dev/null +++ b/src/components/Notifications/__tests__/Notification.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import { Notification } from '../Notification'; +import { useNotificationApi } from '../hooks/useNotificationApi'; +import { TranslationProvider } from '../../../context'; +import { mockTranslationContextValue } from '../../../mock-builders'; + +import type { Notification as NotificationModel } from 'stream-chat'; + +vi.mock('../hooks/useNotificationApi', () => ({ + useNotificationApi: vi.fn(), +})); + +const mockedUseNotificationApi = vi.mocked(useNotificationApi); + +describe('Notification', () => { + const removeNotification = vi.fn(); + + beforeEach(() => { + mockedUseNotificationApi.mockReturnValue({ + addNotification: vi.fn(), + addSystemNotification: vi.fn(), + removeNotification, + startNotificationTimeout: vi.fn(), + }); + removeNotification.mockClear(); + }); + + const baseNotification: NotificationModel = { + actions: undefined, + createdAt: Date.now(), + id: 'n1', + message: 'Hello', + origin: { emitter: 'test' }, + severity: 'info', + }; + + it('removes by id when dismiss is triggered without onDismiss', () => { + render( + opts?.value ?? _k })} + > + + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(removeNotification).toHaveBeenCalledWith('n1'); + }); + + it('renders action buttons and invokes handler', () => { + const handler = vi.fn(); + render( + opts?.value ?? _k })} + > + + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('calls onDismiss when provided instead of removeNotification', () => { + const onDismiss = vi.fn(); + render( + opts?.value ?? _k })} + > + + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(removeNotification).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Notifications/__tests__/NotificationList.test.tsx b/src/components/Notifications/__tests__/NotificationList.test.tsx index 1478e42429..7c2c2d6089 100644 --- a/src/components/Notifications/__tests__/NotificationList.test.tsx +++ b/src/components/Notifications/__tests__/NotificationList.test.tsx @@ -215,6 +215,36 @@ describe('NotificationList', () => { ); }); + it('uses origin.context.entryDirection when metadata is absent', () => { + currentNotifications = [ + { + ...notifications[0], + origin: { + context: { entryDirection: 'right' }, + emitter: 'test', + }, + }, + ]; + + render(); + + expect(screen.getByTestId('notification-list')).toHaveClass( + 'str-chat__notification-list--enter-from-right', + ); + expect(screen.getByTestId('notification-n-1')).toHaveAttribute( + 'data-entry-direction', + 'right', + ); + }); + + it('supports top vertical alignment', () => { + render(); + + expect(screen.getByTestId('notification-list')).toHaveClass( + 'str-chat__notification-list--position-top', + ); + }); + it('uses custom Notification component from ComponentContext', () => { const CustomNotification = React.forwardRef< HTMLDivElement, diff --git a/src/components/Poll/PollActions/__tests__/EndPollAlert.test.tsx b/src/components/Poll/PollActions/__tests__/EndPollAlert.test.tsx new file mode 100644 index 0000000000..d6aa9366fb --- /dev/null +++ b/src/components/Poll/PollActions/__tests__/EndPollAlert.test.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { EndPollAlert } from '../EndPollAlert'; +import { + ChatProvider, + ModalContextProvider, + PollProvider, + TranslationProvider, +} from '../../../../context'; +import { + generatePoll, + generateUser, + getTestClientWithUser, + mockChatContext, + mockTranslationContextValue, +} from '../../../../mock-builders'; +import { Poll } from 'stream-chat'; + +describe('EndPollAlert', () => { + it('closes modal and notifies on successful poll end', async () => { + const user = generateUser(); + const client = await getTestClientWithUser(user); + const pollData = generatePoll({ created_by_id: user.id, is_closed: false }); + const poll = new Poll({ client, poll: pollData }); + vi.spyOn(poll, 'close').mockResolvedValue(undefined); + const close = vi.fn(); + const addSpy = vi.spyOn(client.notifications, 'add'); + + render( + + k })}> + + + + + + + , + ); + + act(() => { + fireEvent.click(screen.getByTestId('end-poll-alert-end-vote-button')); + }); + + await waitFor(() => { + expect(poll.close).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Poll ended', + options: expect.objectContaining({ severity: 'success' }), + }), + ); + }); + }); + + it('notifies on poll end failure without closing', async () => { + const user = generateUser(); + const client = await getTestClientWithUser(user); + const pollData = generatePoll({ created_by_id: user.id, is_closed: false }); + const poll = new Poll({ client, poll: pollData }); + vi.spyOn(poll, 'close').mockRejectedValueOnce(new Error('end failed')); + const close = vi.fn(); + const addSpy = vi.spyOn(client.notifications, 'add'); + + render( + + k })}> + + + + + + + , + ); + + act(() => { + fireEvent.click(screen.getByTestId('end-poll-alert-end-vote-button')); + }); + + await waitFor(() => { + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to end the poll', + options: expect.objectContaining({ severity: 'error' }), + }), + ); + }); + expect(close).not.toHaveBeenCalled(); + }); +});