diff --git a/app/soapbox/features/chats/components/__tests__/chat-list-item.test.tsx b/app/soapbox/features/chats/components/__tests__/chat-list-item.test.tsx index 0d0e59cae6..1342bb526d 100644 --- a/app/soapbox/features/chats/components/__tests__/chat-list-item.test.tsx +++ b/app/soapbox/features/chats/components/__tests__/chat-list-item.test.tsx @@ -18,8 +18,8 @@ const chat: any = { id: '12332423234', unread: true, }, - created_at: new Date('2022-09-09T16:02:26.186Z'), - updated_at: new Date('2022-09-09T16:02:26.186Z'), + created_at: '2022-09-09T16:02:26.186Z', + updated_at: '2022-09-09T16:02:26.186Z', accepted: true, discarded_at: null, account: { diff --git a/app/soapbox/features/chats/components/__tests__/chat-message-list.test.tsx b/app/soapbox/features/chats/components/__tests__/chat-message-list.test.tsx index 6238f5e31f..0633025236 100644 --- a/app/soapbox/features/chats/components/__tests__/chat-message-list.test.tsx +++ b/app/soapbox/features/chats/components/__tests__/chat-message-list.test.tsx @@ -28,7 +28,7 @@ const chatMessages: IChatMessage[] = [ account_id: '1', chat_id: '14', 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', unread: false, pending: false, @@ -37,7 +37,7 @@ const chatMessages: IChatMessage[] = [ account_id: '2', chat_id: '14', 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', unread: true, pending: false, diff --git a/app/soapbox/jest/test-helpers.tsx b/app/soapbox/jest/test-helpers.tsx index 721783879d..6bc3111374 100644 --- a/app/soapbox/jest/test-helpers.tsx +++ b/app/soapbox/jest/test-helpers.tsx @@ -1,5 +1,5 @@ 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 { renderHook, RenderHookOptions } from '@testing-library/react-hooks'; import { merge } from 'immutable'; @@ -11,6 +11,10 @@ import { Action, applyMiddleware, createStore } from 'redux'; import thunk from 'redux-thunk'; 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 { 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); }; -/** 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 TestApp: FC = ({ children, storeProps, routerProps = {} }) => { let store: ReturnType; @@ -63,15 +50,19 @@ const TestApp: FC = ({ children, storeProps, routerProps = {} }) => { return ( - - - - {children} + + + + + + {children} - - - - + + + + + + ); }; diff --git a/app/soapbox/jest/test-setup.ts b/app/soapbox/jest/test-setup.ts index 0052388b05..9d37173d6b 100644 --- a/app/soapbox/jest/test-setup.ts +++ b/app/soapbox/jest/test-setup.ts @@ -6,6 +6,9 @@ import { __clear as clearApiMocks } from '../__mocks__/api'; jest.mock('soapbox/api'); afterEach(() => clearApiMocks()); +// Query mocking +jest.mock('soapbox/queries/client'); + // Mock IndexedDB // https://dev.to/andyhaskell/testing-your-indexeddb-code-with-jest-2o17 require('fake-indexeddb/auto'); diff --git a/app/soapbox/queries/__mocks__/client.ts b/app/soapbox/queries/__mocks__/client.ts new file mode 100644 index 0000000000..ab9de8f4bf --- /dev/null +++ b/app/soapbox/queries/__mocks__/client.ts @@ -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 }; diff --git a/app/soapbox/queries/__tests__/chats.test.ts b/app/soapbox/queries/__tests__/chats.test.ts new file mode 100644 index 0000000000..ea2dc99ae3 --- /dev/null +++ b/app/soapbox/queries/__tests__/chats.test.ts @@ -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; + + 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: `; 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; + + beforeEach(() => { + queryClient.clear(); + }); + + describe('with a successful request', () => { + beforeEach(() => { + store = mockStore(ImmutableMap()); + + __stub((mock) => { + mock.onGet('/api/v1/pleroma/chats') + .reply(200, [ + chat, + ], { + link: '; 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); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/queries/chats.ts b/app/soapbox/queries/chats.ts index b6fa3e66ff..2e15214513 100644 --- a/app/soapbox/queries/chats.ts +++ b/app/soapbox/queries/chats.ts @@ -27,7 +27,7 @@ export enum MessageExpirationValues { export interface IChat { accepted: boolean account: IAccount - created_at: Date + created_at: string created_by_account: string discarded_at: null | string id: string @@ -40,10 +40,10 @@ export interface IChat { id: string unread: boolean } - latest_read_message_by_account: { + latest_read_message_by_account: null | { [id: number]: string }[] - latest_read_message_created_at: string + latest_read_message_created_at: null | string message_expiration: MessageExpirationValues unread: number } @@ -52,7 +52,7 @@ export interface IChatMessage { account_id: string chat_id: string content: string - created_at: Date + created_at: string id: string unread: boolean pending?: boolean @@ -193,11 +193,12 @@ const useChatActions = (chatId: string) => { const { chat, setChat, setEditing } = useChatContext(); - const markChatAsRead = (lastReadId: string) => { - api.post(`/api/v1/pleroma/chats/${chatId}/read`, { last_read_id: lastReadId }) + const markChatAsRead = async (lastReadId: string) => { + return api.post(`/api/v1/pleroma/chats/${chatId}/read`, { last_read_id: lastReadId }) .then(({ data }) => { updatePageItem(ChatKeys.chatSearch(), data, (o, n) => o.id === n.id); const queryData = queryClient.getQueryData>>(ChatKeys.chatSearch()); + if (queryData) { const flattenedQueryData: any = flattenPages(queryData)?.map((chat: any) => { if (chat.id === data.id) { @@ -208,6 +209,8 @@ const useChatActions = (chatId: string) => { }); setUnreadChatsCount(sumBy(flattenedQueryData, (chat: IChat) => chat.unread)); } + + return data; }) .catch(() => null); }; diff --git a/app/soapbox/utils/__mocks__/queries.ts b/app/soapbox/utils/__mocks__/queries.ts new file mode 100644 index 0000000000..efc7447a35 --- /dev/null +++ b/app/soapbox/utils/__mocks__/queries.ts @@ -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 = (queryData: UseInfiniteQueryResult>['data']) => { + return queryData?.pages.reduce( + (prev: T[], curr) => [...curr.result, ...prev], + [], + ); +}; + +const updatePageItem = (queryKey: QueryKey, newItem: T, isItem: (item: T, newItem: T) => boolean) => { + queryClient.setQueriesData>>(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 = (queryKey: QueryKey, newItem: T) => { + queryClient.setQueryData>>(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 = (queryKey: QueryKey, itemToRemove: T, isItem: (item: T, newItem: T) => boolean) => { + queryClient.setQueriesData>>(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, +}; \ No newline at end of file