Add tests for chats query

environments/review-chats-g56n7m/deployments/1218
Chewbacca 2 years ago
parent 3e2888eb75
commit 24bca458bd

@ -18,8 +18,8 @@ const chat: any = {
id: '12332423234', id: '12332423234',
unread: true, unread: true,
}, },
created_at: new Date('2022-09-09T16:02:26.186Z'), created_at: '2022-09-09T16:02:26.186Z',
updated_at: new Date('2022-09-09T16:02:26.186Z'), updated_at: '2022-09-09T16:02:26.186Z',
accepted: true, accepted: true,
discarded_at: null, discarded_at: null,
account: { account: {

@ -28,7 +28,7 @@ const chatMessages: IChatMessage[] = [
account_id: '1', account_id: '1',
chat_id: '14', chat_id: '14',
content: 'this is the first chat', content: 'this is the first chat',
created_at: new Date('2022-09-09T16:02:26.186Z'), created_at: '2022-09-09T16:02:26.186Z',
id: '1', id: '1',
unread: false, unread: false,
pending: false, pending: false,
@ -37,7 +37,7 @@ const chatMessages: IChatMessage[] = [
account_id: '2', account_id: '2',
chat_id: '14', chat_id: '14',
content: 'this is the second chat', content: 'this is the second chat',
created_at: new Date('2022-09-09T16:04:26.186Z'), created_at: '2022-09-09T16:04:26.186Z',
id: '2', id: '2',
unread: true, unread: true,
pending: false, pending: false,

@ -1,5 +1,5 @@
import { configureMockStore } from '@jedmao/redux-mock-store'; import { configureMockStore } from '@jedmao/redux-mock-store';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { render, RenderOptions } from '@testing-library/react'; import { render, RenderOptions } from '@testing-library/react';
import { renderHook, RenderHookOptions } from '@testing-library/react-hooks'; import { renderHook, RenderHookOptions } from '@testing-library/react-hooks';
import { merge } from 'immutable'; import { merge } from 'immutable';
@ -11,6 +11,10 @@ import { Action, applyMiddleware, createStore } from 'redux';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { ChatProvider } from 'soapbox/contexts/chat-context';
import { StatProvider } from 'soapbox/contexts/stat-context';
import { queryClient } from 'soapbox/queries/client';
import NotificationsContainer from '../features/ui/containers/notifications_container'; import NotificationsContainer from '../features/ui/containers/notifications_container';
import { default as rootReducer } from '../reducers'; import { default as rootReducer } from '../reducers';
@ -27,23 +31,6 @@ const applyActions = (state: any, actions: any, reducer: any) => {
return actions.reduce((state: any, action: any) => reducer(state, action), state); return actions.reduce((state: any, action: any) => reducer(state, action), state);
}; };
/** React Query client for tests. */
const queryClient = new QueryClient({
logger: {
// eslint-disable-next-line no-console
log: console.log,
warn: console.warn,
error: () => { },
},
defaultOptions: {
queries: {
staleTime: 0,
cacheTime: Infinity,
retry: false,
},
},
});
const createTestStore = (initialState: any) => createStore(rootReducer, initialState, applyMiddleware(thunk)); const createTestStore = (initialState: any) => createStore(rootReducer, initialState, applyMiddleware(thunk));
const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => { const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
let store: ReturnType<typeof createTestStore>; let store: ReturnType<typeof createTestStore>;
@ -63,15 +50,19 @@ const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
return ( return (
<Provider store={props.store}> <Provider store={props.store}>
<QueryClientProvider client={queryClient}> <StatProvider>
<IntlProvider locale={props.locale}> <ChatProvider>
<MemoryRouter {...routerProps}> <QueryClientProvider client={queryClient}>
{children} <IntlProvider locale={props.locale}>
<MemoryRouter {...routerProps}>
<NotificationsContainer /> {children}
</MemoryRouter>
</IntlProvider> <NotificationsContainer />
</QueryClientProvider> </MemoryRouter>
</IntlProvider>
</QueryClientProvider>
</ChatProvider>
</StatProvider>
</Provider> </Provider>
); );
}; };

@ -6,6 +6,9 @@ import { __clear as clearApiMocks } from '../__mocks__/api';
jest.mock('soapbox/api'); jest.mock('soapbox/api');
afterEach(() => clearApiMocks()); afterEach(() => clearApiMocks());
// Query mocking
jest.mock('soapbox/queries/client');
// Mock IndexedDB // Mock IndexedDB
// https://dev.to/andyhaskell/testing-your-indexeddb-code-with-jest-2o17 // https://dev.to/andyhaskell/testing-your-indexeddb-code-with-jest-2o17
require('fake-indexeddb/auto'); require('fake-indexeddb/auto');

@ -0,0 +1,19 @@
import { QueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient({
logger: {
// eslint-disable-next-line no-console
log: console.log,
warn: console.warn,
error: () => { },
},
defaultOptions: {
queries: {
staleTime: 0,
cacheTime: Infinity,
retry: false,
},
},
});
export { queryClient };

@ -0,0 +1,271 @@
import { Map as ImmutableMap } from 'immutable';
import sumBy from 'lodash/sumBy';
import { useEffect } from 'react';
import { __stub } from 'soapbox/api';
import { mockStore, queryClient, renderHook, rootState, waitFor } from 'soapbox/jest/test-helpers';
import { normalizeRelationship } from 'soapbox/normalizers';
import { flattenPages } from 'soapbox/utils/queries';
import { IAccount } from '../accounts';
import { ChatKeys, IChat, IChatMessage, useChat, useChatActions, useChatMessages, useChats } from '../chats';
jest.mock('soapbox/utils/queries');
const chat: IChat = {
accepted: true,
account: {
username: 'username',
verified: true,
id: '1',
acct: 'acct',
avatar: 'avatar',
avatar_static: 'avatar',
display_name: 'my name',
} as IAccount,
created_at: '2020-06-10T02:05:06.000Z',
created_by_account: '1',
discarded_at: null,
id: '1',
last_message: null,
latest_read_message_by_account: null,
latest_read_message_created_at: null,
message_expiration: 1209600,
unread: 0,
};
const buildChatMessage = (id: string): IChatMessage => ({
id,
chat_id: '1',
account_id: '1',
content: `chat message #${id}`,
created_at: '2020-06-10T02:05:06.000Z',
unread: true,
});
describe('useChatMessages', () => {
let store: ReturnType<typeof mockStore>;
beforeEach(() => {
queryClient.clear();
});
describe('when the user is blocked', () => {
beforeEach(() => {
const state = rootState
.set(
'relationships',
ImmutableMap({ '1': normalizeRelationship({ blocked_by: true }) }),
);
store = mockStore(state);
});
it('is does not fetch the endpoint', async () => {
const { result } = renderHook(() => useChatMessages(chat), undefined, store);
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.data?.length).toBeUndefined();
});
});
describe('when the user is not blocked', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/pleroma/chats/${chat.id}/messages`)
.reply(200, [
buildChatMessage('2'),
], {
link: `<https://example.com/api/v1/pleroma/chats/${chat.id}/messages?since_id=2>; rel="prev"`,
});
});
});
it('is successful', async () => {
const { result } = renderHook(() => useChatMessages(chat));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.data?.length).toBe(1);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/pleroma/chats/${chat.id}/messages`).networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(() => useChatMessages(chat));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.error).toBeDefined();
});
});
});
});
describe('useChats', () => {
let store: ReturnType<typeof mockStore>;
beforeEach(() => {
queryClient.clear();
});
describe('with a successful request', () => {
beforeEach(() => {
store = mockStore(ImmutableMap());
__stub((mock) => {
mock.onGet('/api/v1/pleroma/chats')
.reply(200, [
chat,
], {
link: '<https://example.com/api/v1/pleroma/chats?since_id=2>; rel="prev"',
});
});
});
it('is successful', async () => {
const { result } = renderHook(() => useChats().chatsQuery, undefined, store);
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.data?.length).toBe(1);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/pleroma/chats').networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(() => useChats().chatsQuery);
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.error).toBeDefined();
});
});
});
describe('useChat()', () => {
beforeEach(() => {
queryClient.clear();
});
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/pleroma/chats/${chat.id}`).reply(200, chat);
});
});
it('is successful', async () => {
const { result } = renderHook(() => useChat(chat.id).chat);
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.data.id).toBe(chat.id);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/pleroma/chats/${chat.id}`).networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(() => useChat(chat.id).chat);
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.error).toBeDefined();
});
});
});
describe('useChatActions', () => {
beforeEach(() => {
queryClient.clear();
});
describe('markChatAsRead()', () => {
const nextUnreadCount = 5;
beforeEach(() => {
__stub((mock) => {
mock
.onPost(`/api/v1/pleroma/chats/${chat.id}/read`, { last_read_id: '2' })
.reply(200, { ...chat, unread: nextUnreadCount });
});
});
it('updates the queryCache', async() => {
const initialQueryData = {
pages: [
{ result: [chat], hasMore: false, link: undefined },
],
pageParams: [undefined],
};
const initialFlattenedData = flattenPages(initialQueryData);
expect(sumBy(initialFlattenedData, (chat: IChat) => chat.unread)).toBe(0);
queryClient.setQueryData(ChatKeys.chatSearch(), initialQueryData);
const { result } = renderHook(() => useChatActions(chat.id).markChatAsRead('2'));
await waitFor(() => {
expect(result.current).resolves.toBeDefined();
});
const nextQueryData = queryClient.getQueryData(ChatKeys.chatSearch());
const nextFlattenedData = flattenPages(nextQueryData as any);
expect(sumBy(nextFlattenedData as any, (chat: IChat) => chat.unread)).toBe(nextUnreadCount);
});
});
describe('updateChat()', () => {
const nextUnreadCount = 5;
beforeEach(() => {
__stub((mock) => {
mock
.onPatch(`/api/v1/pleroma/chats/${chat.id}`)
.reply(200, { ...chat, unread: nextUnreadCount });
});
});
it('updates the queryCache for the chat', async() => {
const initialQueryData = { ...chat };
expect(initialQueryData.message_expiration).toBe(1209600);
queryClient.setQueryData(ChatKeys.chat(chat.id), initialQueryData);
const { result } = renderHook(() => {
const { updateChat } = useChatActions(chat.id);
useEffect(() => {
updateChat.mutate({ message_expiration: 1200 });
}, []);
return updateChat;
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
const nextQueryData = queryClient.getQueryData(ChatKeys.chat(chat.id));
expect((nextQueryData as any).message_expiration).toBe(1200);
});
});
});

@ -27,7 +27,7 @@ export enum MessageExpirationValues {
export interface IChat { export interface IChat {
accepted: boolean accepted: boolean
account: IAccount account: IAccount
created_at: Date created_at: string
created_by_account: string created_by_account: string
discarded_at: null | string discarded_at: null | string
id: string id: string
@ -40,10 +40,10 @@ export interface IChat {
id: string id: string
unread: boolean unread: boolean
} }
latest_read_message_by_account: { latest_read_message_by_account: null | {
[id: number]: string [id: number]: string
}[] }[]
latest_read_message_created_at: string latest_read_message_created_at: null | string
message_expiration: MessageExpirationValues message_expiration: MessageExpirationValues
unread: number unread: number
} }
@ -52,7 +52,7 @@ export interface IChatMessage {
account_id: string account_id: string
chat_id: string chat_id: string
content: string content: string
created_at: Date created_at: string
id: string id: string
unread: boolean unread: boolean
pending?: boolean pending?: boolean
@ -193,11 +193,12 @@ const useChatActions = (chatId: string) => {
const { chat, setChat, setEditing } = useChatContext(); const { chat, setChat, setEditing } = useChatContext();
const markChatAsRead = (lastReadId: string) => { const markChatAsRead = async (lastReadId: string) => {
api.post<IChat>(`/api/v1/pleroma/chats/${chatId}/read`, { last_read_id: lastReadId }) return api.post<IChat>(`/api/v1/pleroma/chats/${chatId}/read`, { last_read_id: lastReadId })
.then(({ data }) => { .then(({ data }) => {
updatePageItem(ChatKeys.chatSearch(), data, (o, n) => o.id === n.id); updatePageItem(ChatKeys.chatSearch(), data, (o, n) => o.id === n.id);
const queryData = queryClient.getQueryData<InfiniteData<PaginatedResult<unknown>>>(ChatKeys.chatSearch()); const queryData = queryClient.getQueryData<InfiniteData<PaginatedResult<unknown>>>(ChatKeys.chatSearch());
if (queryData) { if (queryData) {
const flattenedQueryData: any = flattenPages(queryData)?.map((chat: any) => { const flattenedQueryData: any = flattenPages(queryData)?.map((chat: any) => {
if (chat.id === data.id) { if (chat.id === data.id) {
@ -208,6 +209,8 @@ const useChatActions = (chatId: string) => {
}); });
setUnreadChatsCount(sumBy(flattenedQueryData, (chat: IChat) => chat.unread)); setUnreadChatsCount(sumBy(flattenedQueryData, (chat: IChat) => chat.unread));
} }
return data;
}) })
.catch(() => null); .catch(() => null);
}; };

@ -0,0 +1,55 @@
import { InfiniteData, QueryKey, UseInfiniteQueryResult } from '@tanstack/react-query';
import { queryClient } from 'soapbox/jest/test-helpers';
import { PaginatedResult } from '../queries';
const flattenPages = <T>(queryData: UseInfiniteQueryResult<PaginatedResult<T>>['data']) => {
return queryData?.pages.reduce<T[]>(
(prev: T[], curr) => [...curr.result, ...prev],
[],
);
};
const updatePageItem = <T>(queryKey: QueryKey, newItem: T, isItem: (item: T, newItem: T) => boolean) => {
queryClient.setQueriesData<InfiniteData<PaginatedResult<T>>>(queryKey, (data) => {
if (data) {
const pages = data.pages.map(page => {
const result = page.result.map(item => isItem(item, newItem) ? newItem : item);
return { ...page, result };
});
return { ...data, pages };
}
});
};
/** Insert the new item at the beginning of the first page. */
const appendPageItem = <T>(queryKey: QueryKey, newItem: T) => {
queryClient.setQueryData<InfiniteData<PaginatedResult<T>>>(queryKey, (data) => {
if (data) {
const pages = [...data.pages];
pages[0] = { ...pages[0], result: [...pages[0].result, newItem] };
return { ...data, pages };
}
});
};
/** Remove an item inside if found. */
const removePageItem = <T>(queryKey: QueryKey, itemToRemove: T, isItem: (item: T, newItem: T) => boolean) => {
queryClient.setQueriesData<InfiniteData<PaginatedResult<T>>>(queryKey, (data) => {
if (data) {
const pages = data.pages.map(page => {
const result = page.result.filter(item => !isItem(item, itemToRemove));
return { ...page, result };
});
return { ...data, pages };
}
});
};
export {
flattenPages,
updatePageItem,
appendPageItem,
removePageItem,
};
Loading…
Cancel
Save