Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions examples/vite/src/CustomMessageUi/variants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,28 @@ const CustomMessageUiActions = () => {
return (
<div className='custom-message-ui__actions'>
<div className='custom-message-ui__actions-group'>
<button onClick={handlePin} title={message.pinned ? 'Unpin' : 'Pin'}>
<button
onClick={async (event) => {
try {
await handlePin(event);
} catch (error) {
console.error('Failed to update the message pin', error);
}
}}
title={message.pinned ? 'Unpin' : 'Pin'}
>
{message.pinned ? '📍' : '📌'}
</button>
<button onClick={() => handleDelete()} title='Delete'>
<button
onClick={async () => {
try {
await handleDelete();
} catch (error) {
console.error('Failed to delete the message', error);
}
}}
title='Delete'
>
🗑️
</button>
<button onClick={handleOpenThread} title='Open thread'>
Expand Down
4 changes: 2 additions & 2 deletions src/components/Message/__tests__/Message.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ describe('<Message /> component', () => {
message,
});

await context.handleMute(mouseEventMock);
await expect(context.handleMute(mouseEventMock)).rejects.toThrow('mute failed');

expect(muteUser).toHaveBeenCalledWith({ target_ids: [bob.id] });
});
Expand Down Expand Up @@ -521,7 +521,7 @@ describe('<Message /> component', () => {
message,
});

await context.handleMute(mouseEventMock);
await expect(context.handleMute(mouseEventMock)).rejects.toThrow('unmute failed');

expect(unmuteUser).toHaveBeenCalledWith({ target_ids: [bob.id] });
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,25 @@ describe('useDeleteHandler custom hook', () => {
});
});

it('should notify (and not throw) when the delete request fails', async () => {
it('should notify and throw when the delete request fails', async () => {
vi.spyOn(channel.messageOperations, 'delete').mockRejectedValue(
new Error('delete failed'),
);
const notify = vi.fn();
const handleDelete = await renderUseDeleteHandler(testMessage, { notify });
await act(async () => {
await expect(handleDelete()).resolves.toBeUndefined();
await expect(handleDelete()).rejects.toThrow('delete failed');
});
expect(notify).toHaveBeenCalledWith('Error deleting message', 'error');
});

it('should throw when the delete request fails without notifications configured', async () => {
vi.spyOn(channel.messageOperations, 'delete').mockRejectedValue(
new Error('delete failed'),
);
const handleDelete = await renderUseDeleteHandler(testMessage);
await act(async () => {
await expect(handleDelete()).rejects.toThrow('delete failed');
});
});
});
39 changes: 31 additions & 8 deletions src/components/Message/hooks/__tests__/useMuteHandler.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ const mouseEventMock = fromPartial<React.BaseSyntheticEvent>({

async function renderUseHandleMuteHook(
message: LocalMessage | undefined = generateMessage() as MessageResponse & LocalMessage,
{ mutes = [] as UserMuteResponse[] }: { mutes?: UserMuteResponse[] } = {},
{
mutes = [] as UserMuteResponse[],
withNotify = true,
}: { mutes?: UserMuteResponse[]; withNotify?: boolean } = {},
) {
const client = await getTestClientWithUser(alice);
client.moderation.mute = mute;
Expand All @@ -36,9 +39,12 @@ async function renderUseHandleMuteHook(
<ChatProvider value={mockChatContext({ client })}>{children}</ChatProvider>
);

const { result } = renderHook(() => useMuteHandler(message, { notify }), {
wrapper,
});
const { result } = renderHook(
() => useMuteHandler(message, withNotify ? { notify } : {}),
{
wrapper,
},
);
return result.current;
}

Expand All @@ -63,11 +69,11 @@ describe('useHandleMute custom hook', () => {
expect(mute).toHaveBeenCalledWith({ target_ids: [bob.id] });
});

it('should notify (and not throw) when muting a user fails', async () => {
it('should notify and throw when muting a user fails', async () => {
const message = generateMessage({ user: bob }) as MessageResponse & LocalMessage;
mute.mockImplementationOnce(() => Promise.reject(new Error('mute failed')));
const handleMute = await renderUseHandleMuteHook(message);
await expect(handleMute(mouseEventMock)).resolves.toBeUndefined();
await expect(handleMute(mouseEventMock)).rejects.toThrow('mute failed');
expect(mute).toHaveBeenCalledWith({ target_ids: [bob.id] });
expect(notify).toHaveBeenCalledWith(expect.any(String), 'error');
});
Expand All @@ -82,14 +88,31 @@ describe('useHandleMute custom hook', () => {
expect(unmute).toHaveBeenCalledWith({ target_ids: [bob.id] });
});

it('should notify (and not throw) when unmuting a user fails', async () => {
it('should notify and throw when unmuting a user fails', async () => {
const message = generateMessage({ user: bob }) as MessageResponse & LocalMessage;
unmute.mockImplementationOnce(() => Promise.reject(new Error('unmute failed')));
const handleMute = await renderUseHandleMuteHook(message, {
mutes: [fromPartial<UserMuteResponse>({ target: { id: bob.id } })],
});
await expect(handleMute(mouseEventMock)).resolves.toBeUndefined();
await expect(handleMute(mouseEventMock)).rejects.toThrow('unmute failed');
expect(unmute).toHaveBeenCalledWith({ target_ids: [bob.id] });
expect(notify).toHaveBeenCalledWith(expect.any(String), 'error');
});

it('should throw when muting a user fails without notifications configured', async () => {
const message = generateMessage({ user: bob }) as MessageResponse & LocalMessage;
mute.mockImplementationOnce(() => Promise.reject(new Error('mute failed')));
const handleMute = await renderUseHandleMuteHook(message, { withNotify: false });
await expect(handleMute(mouseEventMock)).rejects.toThrow('mute failed');
});

it('should throw when unmuting a user fails without notifications configured', async () => {
const message = generateMessage({ user: bob }) as MessageResponse & LocalMessage;
unmute.mockImplementationOnce(() => Promise.reject(new Error('unmute failed')));
const handleMute = await renderUseHandleMuteHook(message, {
mutes: [fromPartial<UserMuteResponse>({ target: { id: bob.id } })],
withNotify: false,
});
await expect(handleMute(mouseEventMock)).rejects.toThrow('unmute failed');
});
});
228 changes: 228 additions & 0 deletions src/components/Message/hooks/__tests__/usePinHandler.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import React from 'react';
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { nowNs } from 'stream-chat';
import type { Channel as ChannelType, LocalMessage, StreamChat } from 'stream-chat';

import { usePinHandler } from '../usePinHandler';
import {
generateChannel,
generateMessage,
generateUser,
getOrCreateChannelApi,
getTestClientWithUser,
useMockedApis,
} from '../../../../mock-builders';
import { Channel } from '../../../Channel';
import { Chat } from '../../../Chat';

// `usePinHandler` updates the message optimistically through `messagePaginator.ingestItem`,
// rolls back only the pin fields when the request fails, and rethrows so callers (the Pin
// message action) can show an error notification instead of a success one.

let channel: ChannelType;
let client: StreamChat;
const mouseEventMock = fromPartial<React.BaseSyntheticEvent>({
preventDefault: vi.fn(() => {}),
});

async function renderUsePinHandler(
message: LocalMessage,
notifications?: Parameters<typeof usePinHandler>[1],
) {
const wrapper = ({ children }: React.PropsWithChildren) => (
<Chat client={client}>
<Channel channel={channel}>{children}</Channel>
</Chat>
);
let rendered: RenderHookResult<ReturnType<typeof usePinHandler>, unknown>;
await act(async () => {
rendered = await renderHook(() => usePinHandler(message, notifications), {
wrapper,
});
});

return rendered!.result.current;
}

// Keeps the pin/unpin request pending; the returned function rejects it.
const mockPendingRequest = (method: 'pinMessage' | 'unpinMessage') => {
let rejectRequest: (error: Error) => void = () => {};
vi.spyOn(client, method).mockReturnValue(
new Promise((_, reject) => {
rejectRequest = reject;
}),
);
return rejectRequest;
};

// Starts `handlePin` without awaiting it, so assertions can run while the request is pending.
const startHandlePin = (handlePin: ReturnType<typeof usePinHandler>['handlePin']) => {
let pending: ReturnType<typeof handlePin> = undefined;
act(() => {
pending = handlePin(mouseEventMock);
});
return pending;
};

const getStoredMessage = (id: string) => channel.messagePaginator.getItem(id);

describe('usePinHandler custom hook', () => {
beforeAll(async () => {
client = await getTestClientWithUser(generateUser());
const channelData = generateChannel();
useMockedApis(client, [getOrCreateChannelApi(channelData)]);
channel = client.channel('messaging', channelData.channel.id);
});

afterEach(() => {
vi.restoreAllMocks();
});

it.each([
{ method: 'pinMessage', pinned: false },
{ method: 'unpinMessage', pinned: true },
] as const)(
'should update the message optimistically when $method succeeds',
async ({ method, pinned }) => {
const requestSpy = vi.spyOn(client, method).mockResolvedValue(fromPartial({}));
const message = generateMessage({ cid: channel.cid, pinned }) as LocalMessage;
const { handlePin } = await renderUsePinHandler(message);
await act(async () => {
await handlePin(mouseEventMock);
});
expect(requestSpy).toHaveBeenCalledWith(message);
expect(getStoredMessage(message.id)).toEqual(
expect.objectContaining({ pinned: !pinned }),
);
},
);

it.each([
{ method: 'pinMessage', pinned: false },
{ method: 'unpinMessage', pinned: true },
] as const)(
'should roll back and throw when $method fails',
async ({ method, pinned }) => {
const rejectRequest = mockPendingRequest(method);
const message = generateMessage({ cid: channel.cid, pinned }) as LocalMessage;
const { handlePin } = await renderUsePinHandler(message);
const pending = startHandlePin(handlePin);
expect(getStoredMessage(message.id)).toEqual(
expect.objectContaining({ pinned: !pinned }),
);
await act(async () => {
rejectRequest(new Error('request failed'));
await expect(pending).rejects.toThrow('request failed');
});
expect(getStoredMessage(message.id)).toEqual(expect.objectContaining({ pinned }));
},
);

it('should keep updates that arrived while the request was pending when rolling back', async () => {
const rejectRequest = mockPendingRequest('pinMessage');
const message = generateMessage({
cid: channel.cid,
pinned: false,
text: 'original',
}) as LocalMessage;
const { handlePin } = await renderUsePinHandler(message);
const pending = startHandlePin(handlePin);
act(() => {
channel.messagePaginator.ingestItem({
...getStoredMessage(message.id)!,
text: 'edited while pinning',
});
});
await act(async () => {
rejectRequest(new Error('pin failed'));
await expect(pending).rejects.toThrow('pin failed');
});
expect(getStoredMessage(message.id)).toEqual(
expect.objectContaining({ pinned: false, text: 'edited while pinning' }),
);
});

it('should keep a pin state that changed while the request was pending', async () => {
const rejectRequest = mockPendingRequest('unpinMessage');
const originalPinner = generateUser();
const newerPinner = generateUser();
const message = generateMessage({
cid: channel.cid,
pinned: true,
pinned_by: originalPinner,
}) as LocalMessage;
const { handlePin } = await renderUsePinHandler(message);
const pending = startHandlePin(handlePin);
act(() => {
channel.messagePaginator.ingestItem({
...getStoredMessage(message.id)!,
pinned: true,
pinned_by: newerPinner,
});
});
await act(async () => {
rejectRequest(new Error('unpin failed'));
await expect(pending).rejects.toThrow('unpin failed');
});
expect(getStoredMessage(message.id)?.pinned_by).toEqual(newerPinner);
});

it('should keep a newer pin with the same pinned value that arrived while pinning', async () => {
const rejectRequest = mockPendingRequest('pinMessage');
const newerPinner = generateUser();
const newerPinnedAt = nowNs();
const message = generateMessage({ cid: channel.cid, pinned: false }) as LocalMessage;
const { handlePin } = await renderUsePinHandler(message);
const pending = startHandlePin(handlePin);
act(() => {
channel.messagePaginator.ingestItem({
...getStoredMessage(message.id)!,
pinned: true,
pinned_at: newerPinnedAt,
pinned_by: newerPinner,
});
});
await act(async () => {
rejectRequest(new Error('pin failed'));
await expect(pending).rejects.toThrow('pin failed');
});
expect(getStoredMessage(message.id)).toEqual(
expect.objectContaining({
pinned: true,
pinned_at: newerPinnedAt,
pinned_by: newerPinner,
}),
);
});

it('should notify and throw when pinning fails with notifications configured', async () => {
vi.spyOn(client, 'pinMessage').mockRejectedValue(new Error('pin failed'));
const notify = vi.fn();
const message = generateMessage({ cid: channel.cid, pinned: false }) as LocalMessage;
const { handlePin } = await renderUsePinHandler(message, {
getErrorNotification: () => 'custom pin error',
notify,
});
await act(async () => {
await expect(handlePin(mouseEventMock)).rejects.toThrow('pin failed');
});
expect(notify).toHaveBeenCalledWith('custom pin error', 'error');
});

it('should roll back even when the notify callback throws', async () => {
vi.spyOn(client, 'pinMessage').mockRejectedValue(new Error('pin failed'));
const message = generateMessage({ cid: channel.cid, pinned: false }) as LocalMessage;
const { handlePin } = await renderUsePinHandler(message, {
notify: () => {
throw new Error('notify failed');
},
});
await act(async () => {
await expect(handlePin(mouseEventMock)).rejects.toThrow('notify failed');
});
expect(getStoredMessage(message.id)).toEqual(
expect.objectContaining({ pinned: false }),
);
});
});
1 change: 1 addition & 0 deletions src/components/Message/hooks/useDeleteHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const useDeleteHandler = (
t('common.errorDeletingMessage.label', 'Error deleting message'),
'error',
);
throw e;
}
};
};
Loading
Loading