diff --git a/app/actions/websocket/posts.test.ts b/app/actions/websocket/posts.test.ts new file mode 100644 index 0000000000..1e63538383 --- /dev/null +++ b/app/actions/websocket/posts.test.ts @@ -0,0 +1,474 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {DeviceEventEmitter} from 'react-native'; + +import {markChannelAsViewed, markChannelAsUnread, storeMyChannelsForTeam, updateLastPostAt} from '@actions/local/channel'; +import {addPostAcknowledgement, markPostAsDeleted, removePostAcknowledgement} from '@actions/local/post'; +import {updateThread} from '@actions/local/thread'; +import {fetchChannelStats, fetchMyChannel, type MyChannelsRequest} from '@actions/remote/channel'; +import {fetchPostAuthors} from '@actions/remote/post'; +import {fetchThread} from '@actions/remote/thread'; +import {fetchMissingProfilesByIds} from '@actions/remote/user'; +import {Events, Screens} from '@constants'; +import DatabaseManager from '@database/manager'; +import {PostsInChannelModel} from '@database/models/server'; +import {getChannelById, getMyChannel} from '@queries/servers/channel'; +import {getPostById} from '@queries/servers/post'; +import {getCurrentUserId, getCurrentChannelId} from '@queries/servers/system'; +import {getIsCRTEnabled} from '@queries/servers/thread'; +import EphemeralStore from '@store/ephemeral_store'; +import NavigationStore from '@store/navigation_store'; +import MyChannelModel from '@typings/database/models/servers/my_channel'; +import {isTablet} from '@utils/helpers'; +import {shouldIgnorePost} from '@utils/post'; + +import {handleNewPostEvent, handlePostEdited, handlePostDeleted, handlePostUnread, handlePostAcknowledgementAdded, handlePostAcknowledgementRemoved} from './posts'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type PostModel from '@typings/database/models/servers/post'; +import type ThreadModel from '@typings/database/models/servers/thread'; +import type UserModel from '@typings/database/models/servers/user'; + +jest.mock('@queries/servers/post'); +jest.mock('@queries/servers/channel'); +jest.mock('@queries/servers/system'); +jest.mock('@queries/servers/thread'); +jest.mock('@actions/local/channel'); +jest.mock('@actions/local/post'); +jest.mock('@actions/local/thread'); +jest.mock('@actions/remote/channel'); +jest.mock('@actions/remote/post'); +jest.mock('@actions/remote/thread'); +jest.mock('@actions/remote/user'); +jest.mock('@utils/helpers'); +jest.mock('@utils/post', () => ({ + ...jest.requireActual('@utils/post'), + shouldIgnorePost: jest.fn(), +})); + +const serverUrl = 'baseHandler.test.com'; + +describe('WebSocket Post Actions', () => { + let operator: ServerDataOperator; + + const post = {id: 'post1', channel_id: 'channel1', user_id: 'user1', create_at: 12345, message: 'hello'} as Post; + const postModels = [{channelId: post.channel_id, userId: post.user_id, message: post.message} as PostModel]; + const myChannelModel = {id: 'channel1', manuallyUnread: false, messageCount: 4, mentionsCount: 0, lastViewedAt: 1} as MyChannelModel; + + const mockedGetPostById = jest.mocked(getPostById); + const mockedUpdateLastPostAt = jest.mocked(updateLastPostAt); + const mockedMarkChannelAsViewed = jest.mocked(markChannelAsViewed); + const mockedGetCurrentUserId = jest.mocked(getCurrentUserId); + const mockedGetCurrentChannelId = jest.mocked(getCurrentChannelId); + const mockedFetchPostAuthors = jest.mocked(fetchPostAuthors); + const mockedIsTablet = jest.mocked(isTablet); + const mockedGetMyChannel = jest.mocked(getMyChannel); + const mockedGetIsCRTEnabled = jest.mocked(getIsCRTEnabled); + const mockedShouldIgnorePost = jest.mocked(shouldIgnorePost); + const mockedFetchMyChannel = jest.mocked(fetchMyChannel); + const mockedStoreMyChannelsForTeam = jest.mocked(storeMyChannelsForTeam); + const mockedFetchChannelStats = jest.mocked(fetchChannelStats); + const mockedGetChannelById = jest.mocked(getChannelById); + const mockedMarkPostAsDeleted = jest.mocked(markPostAsDeleted); + const mockedUpdateThread = jest.mocked(updateThread); + const mockedFetchThread = jest.mocked(fetchThread); + const mockedMarkChannelAsUnread = jest.mocked(markChannelAsUnread); + const mockedAddPostAcknowledgement = jest.mocked(addPostAcknowledgement); + const mockedFetchMissingProfilesByIds = jest.mocked(fetchMissingProfilesByIds); + const mockedRemovePostAcknowledgement = jest.mocked(removePostAcknowledgement); + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + operator = DatabaseManager.serverDatabases[serverUrl]!.operator; + jest.clearAllMocks(); + }); + + describe('handleNewPostEvent', () => { + const msg = { + data: { + post: JSON.stringify(post), + mentions: ['user1'], + }, + } as WebSocketMessage; + + const mockedGetScreensInStack = jest.spyOn(NavigationStore, 'getScreensInStack'); + + mockedGetPostById.mockResolvedValue(undefined); + mockedUpdateLastPostAt.mockResolvedValue({member: myChannelModel}); + mockedMarkChannelAsViewed.mockResolvedValue({member: myChannelModel}); + mockedGetCurrentUserId.mockResolvedValue('user1'); + mockedGetCurrentChannelId.mockResolvedValue('channel1'); + mockedFetchPostAuthors.mockResolvedValue({authors: []}); + mockedGetScreensInStack.mockReturnValue([Screens.CHANNEL]); + mockedIsTablet.mockReturnValue(false); + + it('should handle new post event - channel membership present', async () => { + const userModel = {id: 'user1', username: 'username1'} as UserModel; + const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit'); + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn()); + jest.spyOn(operator, 'handlePosts').mockResolvedValue(postModels); + jest.spyOn(operator, 'handleUsers').mockResolvedValue([userModel]); + + mockedFetchPostAuthors.mockResolvedValueOnce({authors: [{id: 'user1', username: 'username1'} as UserProfile]}); + mockedGetMyChannel.mockResolvedValue(myChannelModel); + mockedGetIsCRTEnabled.mockResolvedValue(false); + mockedShouldIgnorePost.mockReturnValue(false); + + await handleNewPostEvent(serverUrl, msg); + + expect(emitSpy).toHaveBeenCalledWith(Events.USER_STOP_TYPING, { + channelId: 'channel1', + rootId: undefined, + userId: 'user1', + now: expect.any(Number), + }); + + expect(batchRecordsSpy).toHaveBeenCalledWith([userModel, {_preparedState: null, id: 'channel1', lastViewedAt: 1, manuallyUnread: false, mentionsCount: 0, messageCount: 4}, postModels[0]], 'handleNewPostEvent'); + }); + + it('should handle new post event - without channel membership present', async () => { + const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit'); + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn()); + jest.spyOn(operator, 'handlePosts').mockResolvedValue(postModels); + + mockedGetMyChannel.mockResolvedValueOnce(undefined); + mockedFetchMyChannel.mockResolvedValue({teamId: 'team1', memberships: [{user_id: 'user1', channel_id: 'channel1'}]} as MyChannelsRequest); + mockedStoreMyChannelsForTeam.mockResolvedValue({models: [myChannelModel]}); + mockedGetMyChannel.mockResolvedValueOnce(myChannelModel); + mockedGetIsCRTEnabled.mockResolvedValue(false); + mockedShouldIgnorePost.mockReturnValue(true); + + await handleNewPostEvent(serverUrl, msg); + + expect(emitSpy).toHaveBeenCalledWith(Events.USER_STOP_TYPING, { + channelId: 'channel1', + rootId: undefined, + userId: 'user1', + now: expect.any(Number), + }); + + expect(batchRecordsSpy).toHaveBeenCalledWith(postModels, 'handleNewPostEvent'); + }); + + it('should handle new post event - CRT enabled, manually unread', async () => { + const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit'); + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn()); + jest.spyOn(operator, 'handlePosts').mockResolvedValue(postModels); + + mockedGetMyChannel.mockResolvedValue({...myChannelModel, manuallyUnread: true} as MyChannelModel); + mockedGetIsCRTEnabled.mockResolvedValue(true); + mockedShouldIgnorePost.mockReturnValue(false); + mockedMarkChannelAsUnread.mockResolvedValue({member: myChannelModel}); + + await handleNewPostEvent(serverUrl, msg); + + expect(emitSpy).toHaveBeenCalledWith(Events.USER_STOP_TYPING, { + channelId: 'channel1', + rootId: undefined, + userId: 'user1', + now: expect.any(Number), + }); + + expect(batchRecordsSpy).toHaveBeenCalledWith([{_preparedState: null, id: 'channel1', lastViewedAt: 1, manuallyUnread: false, mentionsCount: 0, messageCount: 4}, postModels[0]], 'handleNewPostEvent'); + }); + + it('should handle new post event - out of order ws, CRT on, root id', async () => { + const newPost = {...post, root_id: 'post2'} as Post; + jest.spyOn(EphemeralStore, 'getLastPostWebsocketEvent').mockReturnValueOnce({deleted: true, post: newPost}); + const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit'); + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); + jest.spyOn(operator, 'handlePosts').mockResolvedValue(postModels); + + mockedGetMyChannel.mockResolvedValue(myChannelModel); + mockedGetIsCRTEnabled.mockResolvedValue(true); + mockedShouldIgnorePost.mockReturnValue(false); + + await handleNewPostEvent(serverUrl, {data: {post: JSON.stringify(newPost), mentions: []}} as WebSocketMessage); + + expect(emitSpy).toHaveBeenCalledWith(Events.USER_STOP_TYPING, { + channelId: 'channel1', + rootId: 'post2', + userId: 'user1', + now: expect.any(Number), + }); + + expect(batchRecordsSpy).not.toHaveBeenCalled(); + }); + + it('should handle new post event - from webhook and is tablet', async () => { + const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit'); + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn()); + jest.spyOn(operator, 'handlePosts').mockResolvedValue(postModels); + + mockedGetMyChannel.mockResolvedValue(myChannelModel); + mockedGetIsCRTEnabled.mockResolvedValue(false); + mockedShouldIgnorePost.mockReturnValue(false); + mockedIsTablet.mockReturnValueOnce(true); + mockedGetScreensInStack.mockReturnValue([]); + + await handleNewPostEvent(serverUrl, {data: {post: JSON.stringify({...post, props: {from_webhook: 'true'}}), mentions: []}} as WebSocketMessage); + + expect(mockedGetScreensInStack).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith(Events.USER_STOP_TYPING, { + channelId: 'channel1', + rootId: undefined, + userId: 'user1', + now: expect.any(Number), + }); + + expect(batchRecordsSpy).toHaveBeenCalledWith([{_preparedState: null, id: 'channel1', lastViewedAt: 1, manuallyUnread: false, mentionsCount: 0, messageCount: 4}, postModels[0]], 'handleNewPostEvent'); + }); + + it('should handle new post event - no operator', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); + + await handleNewPostEvent('junk', msg); + + expect(mockedGetCurrentUserId).not.toHaveBeenCalled(); + expect(batchRecordsSpy).not.toHaveBeenCalled(); + }); + + it('should handle new post event - existing post', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); + + mockedGetPostById.mockResolvedValueOnce(postModels[0]); + + await handleNewPostEvent(serverUrl, msg); + + expect(mockedGetIsCRTEnabled).not.toHaveBeenCalled(); + expect(batchRecordsSpy).not.toHaveBeenCalled(); + }); + + it('should handle new post event - malformed post data', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); + + mockedGetPostById.mockResolvedValueOnce(postModels[0]); + + await handleNewPostEvent(serverUrl, {data: undefined} as WebSocketMessage); + + expect(mockedGetCurrentUserId).not.toHaveBeenCalled(); + expect(batchRecordsSpy).not.toHaveBeenCalled(); + }); + }); + + describe('handlePostEdited', () => { + const msg = { + data: { + post: JSON.stringify({id: 'post1', channel_id: 'channel1', user_id: 'user1', is_pinned: false}), + }, + } as WebSocketMessage; + + it('should handle post edited event', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn()); + + mockedGetPostById.mockResolvedValue(postModels[0]); + mockedFetchPostAuthors.mockResolvedValue({authors: []}); + mockedGetIsCRTEnabled.mockResolvedValue(false); + + await handlePostEdited(serverUrl, msg); + + expect(mockedFetchChannelStats).toHaveBeenCalledWith(serverUrl, 'channel1'); + expect(batchRecordsSpy).toHaveBeenCalledWith([expect.any(PostsInChannelModel)], 'handlePostEdited'); + }); + + it('should handle post edited event - no operator', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); + + await handlePostEdited('junk', msg); + + expect(mockedGetPostById).not.toHaveBeenCalled(); + expect(batchRecordsSpy).not.toHaveBeenCalled(); + }); + + it('should handle post edited event - malformed post data', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); + + mockedGetPostById.mockResolvedValueOnce(postModels[0]); + + await handlePostEdited(serverUrl, {data: undefined} as WebSocketMessage); + + expect(mockedGetPostById).not.toHaveBeenCalled(); + expect(batchRecordsSpy).not.toHaveBeenCalled(); + }); + }); + + describe('handlePostDeleted', () => { + const deletedPost = {id: 'post1', channel_id: 'channel1', user_id: 'user1', root_id: 'root1', reply_count: 1}; + const msg = { + data: { + post: JSON.stringify(deletedPost), + }, + } as WebSocketMessage; + + const threadModel = {viewedAt: 1, id: 'thread1'} as ThreadModel; + + it('should handle post deleted event', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn()); + + mockedGetIsCRTEnabled.mockResolvedValue(true); + mockedGetPostById.mockResolvedValue(postModels[0]); + mockedMarkPostAsDeleted.mockResolvedValue({model: postModels[0]}); + mockedUpdateThread.mockResolvedValue({model: threadModel}); + mockedGetChannelById.mockResolvedValue({id: 'channel1', teamId: 'team1'} as ChannelModel); + + await handlePostDeleted(serverUrl, msg); + + expect(mockedMarkPostAsDeleted).toHaveBeenCalledWith(serverUrl, expect.any(Object), true); + expect(mockedUpdateThread).toHaveBeenCalledWith(serverUrl, 'root1', {reply_count: 0}, true); + expect(mockedFetchThread).toHaveBeenCalledWith(serverUrl, 'team1', 'root1'); + + expect(batchRecordsSpy).toHaveBeenCalledWith([postModels[0], threadModel], 'handlePostDeleted'); + }); + + it('should handle post deleted event - missing thread and channel', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn()); + + mockedGetIsCRTEnabled.mockResolvedValue(true); + mockedGetPostById.mockResolvedValue(postModels[0]); + mockedMarkPostAsDeleted.mockResolvedValue({model: postModels[0]}); + mockedUpdateThread.mockResolvedValue({error: 'not found'}); + mockedGetChannelById.mockResolvedValue(undefined); + + await handlePostDeleted(serverUrl, msg); + + expect(mockedMarkPostAsDeleted).toHaveBeenCalledWith(serverUrl, expect.any(Object), true); + expect(mockedUpdateThread).toHaveBeenCalledWith(serverUrl, 'root1', {reply_count: 0}, true); + expect(mockedFetchThread).not.toHaveBeenCalled(); + + expect(batchRecordsSpy).toHaveBeenCalledWith(postModels, 'handlePostDeleted'); + }); + + it('should handle post deleted event - missing delete model and no root id', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn()); + + mockedGetIsCRTEnabled.mockResolvedValue(true); + mockedGetPostById.mockResolvedValue(postModels[0]); + mockedMarkPostAsDeleted.mockResolvedValue({error: 'not found'}); + + await handlePostDeleted(serverUrl, {data: {post: JSON.stringify({...deletedPost, root_id: ''})}} as WebSocketMessage); + + expect(mockedMarkPostAsDeleted).toHaveBeenCalledWith(serverUrl, expect.any(Object), true); + expect(batchRecordsSpy).not.toHaveBeenCalled(); + }); + }); + + describe('handlePostUnread', () => { + const msg = { + broadcast: { + channel_id: 'channel1', + team_id: 'team1', + }, + data: { + mention_count: 1, + mention_count_root: 1, + msg_count: 1, + msg_count_root: 1, + last_viewed_at: 12345, + }, + } as WebSocketMessage; + + it('should handle post unread event', async () => { + mockedGetMyChannel.mockResolvedValue(myChannelModel); + mockedGetIsCRTEnabled.mockResolvedValue(false); + mockedFetchMyChannel.mockResolvedValue({teamId: 'team1', memberships: [{user_id: 'user1', channel_id: 'channel1'}]} as MyChannelsRequest); + mockedMarkChannelAsUnread.mockResolvedValue({member: myChannelModel}); + + await handlePostUnread(serverUrl, msg); + + expect(mockedMarkChannelAsUnread).toHaveBeenCalledWith(serverUrl, 'channel1', 1, 1, 12345); + }); + + it('should handle post unread event - CRT enabled, manually marked read', async () => { + mockedGetMyChannel.mockResolvedValue({...myChannelModel, manuallyUnread: true} as MyChannelModel); + mockedGetIsCRTEnabled.mockResolvedValue(true); + mockedFetchMyChannel.mockResolvedValue({teamId: 'team1', memberships: [{user_id: 'user1', channel_id: 'channel1'}]} as MyChannelsRequest); + mockedMarkChannelAsUnread.mockResolvedValue({member: myChannelModel}); + + await handlePostUnread(serverUrl, msg); + + expect(mockedMarkChannelAsUnread).not.toHaveBeenCalled(); + }); + + it('should handle post unread event - no database', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); + + await handlePostUnread('junk', msg); + + expect(mockedGetMyChannel).not.toHaveBeenCalled(); + expect(batchRecordsSpy).not.toHaveBeenCalled(); + }); + }); + + describe('handlePostAcknowledgementAdded', () => { + const msg = { + data: { + acknowledgement: JSON.stringify({user_id: 'user1', post_id: 'post1', acknowledged_at: 12345}), + }, + } as WebSocketMessage; + + mockedGetCurrentUserId.mockResolvedValue('user1'); + + it('should handle post acknowledgement added event', async () => { + jest.spyOn(EphemeralStore, 'isAcknowledgingPost').mockReturnValue(false); + + await handlePostAcknowledgementAdded(serverUrl, msg); + + expect(mockedAddPostAcknowledgement).toHaveBeenCalledWith(serverUrl, 'post1', 'user1', 12345); + expect(mockedFetchMissingProfilesByIds).toHaveBeenCalledWith(serverUrl, ['user1']); + }); + + it('should handle post acknowledgement added event - already acknowledging', async () => { + jest.spyOn(EphemeralStore, 'isAcknowledgingPost').mockReturnValue(true); + + await handlePostAcknowledgementAdded(serverUrl, msg); + + expect(mockedAddPostAcknowledgement).not.toHaveBeenCalled(); + }); + + it('should handle post acknowledgement added event - no database', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); + + await handlePostAcknowledgementAdded('junk', msg); + + expect(mockedGetCurrentUserId).not.toHaveBeenCalled(); + expect(batchRecordsSpy).not.toHaveBeenCalled(); + }); + }); + + describe('handlePostAcknowledgementRemoved', () => { + const msg = { + data: { + acknowledgement: JSON.stringify({user_id: 'user1', post_id: 'post1'}), + }, + } as WebSocketMessage; + + mockedGetCurrentUserId.mockResolvedValue('user1'); + + it('should handle post acknowledgement removed event', async () => { + jest.spyOn(EphemeralStore, 'isUnacknowledgingPost').mockReturnValue(false); + + await handlePostAcknowledgementRemoved(serverUrl, msg); + + expect(mockedRemovePostAcknowledgement).toHaveBeenCalledWith(serverUrl, 'post1', 'user1'); + }); + + it('should handle post acknowledgement added event - already unacknowledging', async () => { + jest.spyOn(EphemeralStore, 'isUnacknowledgingPost').mockReturnValue(true); + + await handlePostAcknowledgementRemoved(serverUrl, msg); + + expect(mockedRemovePostAcknowledgement).not.toHaveBeenCalled(); + }); + + it('should handle post acknowledgement removed event - no database', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); + + await handlePostAcknowledgementRemoved('junk', msg); + + expect(mockedGetCurrentUserId).not.toHaveBeenCalled(); + expect(batchRecordsSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/actions/websocket/posts.ts b/app/actions/websocket/posts.ts index 52e0f20bf6..2e98dc074e 100644 --- a/app/actions/websocket/posts.ts +++ b/app/actions/websocket/posts.ts @@ -335,7 +335,7 @@ export async function handlePostAcknowledgementAdded(serverUrl: string, msg: Web if (!database) { return; } - const currentUserId = getCurrentUserId(database); + const currentUserId = await getCurrentUserId(database); if (EphemeralStore.isAcknowledgingPost(post_id) && currentUserId === user_id) { return; } @@ -355,7 +355,7 @@ export async function handlePostAcknowledgementRemoved(serverUrl: string, msg: W if (!database) { return; } - const currentUserId = getCurrentUserId(database); + const currentUserId = await getCurrentUserId(database); if (EphemeralStore.isUnacknowledgingPost(post_id) && currentUserId === user_id) { return; }