diff --git a/app/constants/post.ts b/app/constants/post.ts index caefdbf3655..cc09dfb6b01 100644 --- a/app/constants/post.ts +++ b/app/constants/post.ts @@ -77,4 +77,5 @@ export default { PostTypes.ADD_TO_TEAM, PostTypes.REMOVE_FROM_TEAM, ], + POST_TIME_TO_FAIL, }; diff --git a/app/utils/post/index.test.ts b/app/utils/post/index.test.ts index 8fd8bd93c0e..463577f2bd4 100644 --- a/app/utils/post/index.test.ts +++ b/app/utils/post/index.test.ts @@ -1,52 +1,585 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import * as utils from './index'; +import {createIntl} from 'react-intl'; +import {Alert} from 'react-native'; + +import {getUsersCountFromMentions} from '@actions/local/post'; +import {General, Post} from '@constants'; +import {DEFAULT_LOCALE, getTranslations} from '@i18n'; +import {getUserById} from '@queries/servers/user'; +import {toMilliseconds} from '@utils/datetime'; + +import { + areConsecutivePosts, + isFromWebhook, + isEdited, + isPostEphemeral, + isPostFailed, + isPostPendingOrFailed, + isSystemMessage, + fromAutoResponder, + postUserDisplayName, + shouldIgnorePost, + processPostsFetched, + getLastFetchedAtFromPosts, + moreThan5minAgo, + hasSpecialMentions, + persistentNotificationsConfirmation, +} from '.'; + +import type PostModel from '@typings/database/models/servers/post'; +import type UserModel from '@typings/database/models/servers/user'; + +jest.mock('@actions/local/post', () => ({ + getUsersCountFromMentions: jest.fn(), +})); + +jest.mock('@queries/servers/user', () => ({ + getUserById: jest.fn(), +})); + +jest.mock('@database/manager', () => ({ + getServerDatabaseAndOperator: jest.fn().mockReturnValue({ + database: {}, + }), +})); describe('post utils', () => { - test.each([ - ['@here where is Jessica Hyde', true], - ['@all where is Jessica Hyde', true], - ['@channel where is Jessica Hyde', true], - - ['where is Jessica Hyde @here', true], - ['where is Jessica Hyde @all', true], - ['where is Jessica Hyde @channel', true], - - ['where is Jessica @here Hyde', true], - ['where is Jessica @all Hyde', true], - ['where is Jessica @channel Hyde', true], - - ['where is Jessica Hyde\n@here', true], - ['where is Jessica Hyde\n@all', true], - ['where is Jessica Hyde\n@channel', true], - - ['where is Jessica\n@here Hyde', true], - ['where is Jessica\n@all Hyde', true], - ['where is Jessica\n@channel Hyde', true], - - ['where is Jessica Hyde @her', false], - ['where is Jessica Hyde @al', false], - ['where is Jessica Hyde @chann', false], - - ['where is Jessica Hyde@here', false], - ['where is Jessica Hyde@all', false], - ['where is Jessica Hyde@channel', false], - - ['where is Jessica @hereHyde', false], - ['where is Jessica @allHyde', false], - ['where is Jessica @channelHyde', false], - - ['@herewhere is Jessica Hyde@here', false], - ['@allwhere is Jessica Hyde@all', false], - ['@channelwhere is Jessica Hyde@channel', false], - - ['where is Jessica Hyde here', false], - ['where is Jessica Hyde all', false], - ['where is Jessica Hyde channel', false], - - ['where is Jessica Hyde', false], - ])('hasSpecialMentions: %s => %s', (message, expected) => { - expect(utils.hasSpecialMentions(message)).toBe(expected); + describe('areConsecutivePosts', () => { + it('should return true for consecutive posts from the same user within the collapse timeout', () => { + const post = { + userId: 'user1', + createAt: 1000, + props: {}, + } as PostModel; + const previousPost = { + userId: 'user1', + createAt: 500, + props: {}, + } as PostModel; + + const result = areConsecutivePosts(post, previousPost); + expect(result).toBe(true); + }); + + it('should return false for posts from different users', () => { + const post = { + userId: 'user1', + createAt: 1000, + props: {}, + } as PostModel; + const previousPost = { + userId: 'user2', + createAt: 500, + props: {}, + } as PostModel; + + const result = areConsecutivePosts(post, previousPost); + expect(result).toBe(false); + }); + }); + + describe('isFromWebhook', () => { + it('should return true for posts from a webhook', () => { + const post = { + props: { + from_webhook: 'true', + }, + } as PostModel; + + const result = isFromWebhook(post); + expect(result).toBe(true); + }); + + it('should return false for posts not from a webhook', () => { + const post = { + props: { + from_webhook: 'false', + }, + } as PostModel; + + const result = isFromWebhook(post); + expect(result).toBe(false); + }); + }); + + describe('isEdited', () => { + it('should return true if the post is edited', () => { + const post = { + editAt: 1000, + } as PostModel; + + const result = isEdited(post); + expect(result).toBe(true); + }); + + it('should return false if the post is not edited', () => { + const post = { + editAt: 0, + } as PostModel; + + const result = isEdited(post); + expect(result).toBe(false); + }); + }); + + describe('isPostEphemeral', () => { + it('should return true for an ephemeral post', () => { + const post = { + type: Post.POST_TYPES.EPHEMERAL, + } as PostModel; + + const result = isPostEphemeral(post); + expect(result).toBe(true); + }); + + it('should return false for a non-ephemeral post', () => { + const post = { + type: 'normal', + } as PostModel; + + const result = isPostEphemeral(post); + expect(result).toBe(false); + }); + }); + + describe('isPostFailed', () => { + it('should return true if the post has failed prop', () => { + const post = { + props: { + failed: true, + }, + pendingPostId: 'id', + id: 'id', + updateAt: Date.now() - Post.POST_TIME_TO_FAIL - 1000, + } as PostModel; + + const result = isPostFailed(post); + expect(result).toBe(true); + }); + + it('should return true if the post is pending and the update time has exceeded the failure time', () => { + const post = { + props: {}, + pendingPostId: 'id', + id: 'id', + updateAt: Date.now() - Post.POST_TIME_TO_FAIL - 1000, + } as PostModel; + + const result = isPostFailed(post); + expect(result).toBe(true); + }); + + it('should return false if the post is not failed', () => { + const post = { + props: {}, + pendingPostId: 'id', + id: 'id', + updateAt: Date.now(), + } as PostModel; + + const result = isPostFailed(post); + expect(result).toBe(false); + }); + }); + + describe('isPostPendingOrFailed', () => { + it('should return true if the post is pending', () => { + const post = { + pendingPostId: 'id', + id: 'id', + props: {}, + } as PostModel; + + const result = isPostPendingOrFailed(post); + expect(result).toBe(true); + }); + + it('should return true if the post has failed', () => { + const post = { + pendingPostId: 'id', + id: 'id', + updateAt: Date.now() - Post.POST_TIME_TO_FAIL - 1000, + props: {}, + } as PostModel; + + const result = isPostPendingOrFailed(post); + expect(result).toBe(true); + }); + + it('should return false if the post is neither pending nor failed', () => { + const post = { + pendingPostId: 'differentId', + id: 'id', + props: {}, + } as PostModel; + + const result = isPostPendingOrFailed(post); + expect(result).toBe(false); + }); + }); + + describe('isSystemMessage', () => { + it('should return true if the post is a system message', () => { + const post = { + type: `${Post.POST_TYPES.SYSTEM_MESSAGE_PREFIX}any_type`, + } as PostModel; + + const result = isSystemMessage(post); + expect(result).toBe(true); + }); + + it('should return false if the post is not a system message', () => { + const post = { + type: 'normal_type', + } as PostModel; + + const result = isSystemMessage(post); + expect(result).toBe(false); + }); + }); + + describe('hasSpecialMentions', () => { + test.each([ + ['@here where is Jessica Hyde', true], + ['@all where is Jessica Hyde', true], + ['@channel where is Jessica Hyde', true], + + ['where is Jessica Hyde @here', true], + ['where is Jessica Hyde @all', true], + ['where is Jessica Hyde @channel', true], + + ['where is Jessica @here Hyde', true], + ['where is Jessica @all Hyde', true], + ['where is Jessica @channel Hyde', true], + + ['where is Jessica Hyde\n@here', true], + ['where is Jessica Hyde\n@all', true], + ['where is Jessica Hyde\n@channel', true], + + ['where is Jessica\n@here Hyde', true], + ['where is Jessica\n@all Hyde', true], + ['where is Jessica\n@channel Hyde', true], + + ['where is Jessica Hyde @her', false], + ['where is Jessica Hyde @al', false], + ['where is Jessica Hyde @chann', false], + + ['where is Jessica Hyde@here', false], + ['where is Jessica Hyde@all', false], + ['where is Jessica Hyde@channel', false], + + ['where is Jessica @hereHyde', false], + ['where is Jessica @allHyde', false], + ['where is Jessica @channelHyde', false], + + ['@herewhere is Jessica Hyde@here', false], + ['@allwhere is Jessica Hyde@all', false], + ['@channelwhere is Jessica Hyde@channel', false], + + ['where is Jessica Hyde here', false], + ['where is Jessica Hyde all', false], + ['where is Jessica Hyde channel', false], + + ['where is Jessica Hyde', false], + ])('hasSpecialMentions: %s => %s', (message, expected) => { + expect(hasSpecialMentions(message)).toBe(expected); + }); + }); + + describe('fromAutoResponder', () => { + it('should return true if the post is from an auto responder', () => { + const post = { + type: Post.POST_TYPES.SYSTEM_AUTO_RESPONDER, + } as PostModel; + + const result = fromAutoResponder(post); + expect(result).toBe(true); + }); + + it('should return false if the post is not from an auto responder', () => { + const post = { + type: 'normal_type', + } as PostModel; + + const result = fromAutoResponder(post); + expect(result).toBe(false); + }); + }); + + describe('persistentNotificationsConfirmation', () => { + const serverUrl = 'http://server'; + const value = '@user'; + const mentionsList = ['@user']; + const sendMessage = jest.fn(); + const persistentNotificationMaxRecipients = 10; + const persistentNotificationInterval = 5; + const currentUserId = 'current_user_id'; + const channelName = 'channel_id__teammate_id'; + const intl = createIntl({locale: DEFAULT_LOCALE, messages: getTranslations(DEFAULT_LOCALE)}); + + it('should show alert with DM channel description when channelType is DM_CHANNEL', async () => { + const mockUser = {username: 'teammate'}; + (getUserById as jest.Mock).mockResolvedValue(mockUser); + + await persistentNotificationsConfirmation( + serverUrl, + value, + mentionsList, + intl, + sendMessage, + persistentNotificationMaxRecipients, + persistentNotificationInterval, + currentUserId, + channelName, + General.DM_CHANNEL, + ); + + expect(Alert.alert).toHaveBeenCalledWith( + intl.formatMessage({ + id: 'persistent_notifications.confirm.title', + defaultMessage: 'Send persistent notifications', + }), + intl.formatMessage({ + id: 'persistent_notifications.dm_channel.description', + defaultMessage: '@{username} will be notified every {interval, plural, one {minute} other {{interval} minutes}} until they’ve acknowledged or replied to the message.', + }, { + interval: persistentNotificationInterval, + username: mockUser.username, + }), + expect.any(Array), + ); + }); + + it('should show alert when special mentions are present', async () => { + await persistentNotificationsConfirmation( + serverUrl, + '@channel', + mentionsList, + intl, + sendMessage, + persistentNotificationMaxRecipients, + persistentNotificationInterval, + currentUserId, + channelName, + ); + + expect(Alert.alert).toHaveBeenCalledWith( + '', + intl.formatMessage({ + id: 'persistent_notifications.error.special_mentions', + defaultMessage: 'Cannot use @channel, @all or @here to mention recipients of persistent notifications.', + }), + expect.any(Array), + ); + }); + + it('should show alert when no mentions found', async () => { + (getUsersCountFromMentions as jest.Mock).mockResolvedValue(0); + + await persistentNotificationsConfirmation( + serverUrl, + value, + mentionsList, + intl, + sendMessage, + persistentNotificationMaxRecipients, + persistentNotificationInterval, + currentUserId, + channelName, + ); + + expect(Alert.alert).toHaveBeenCalledWith( + intl.formatMessage({ + id: 'persistent_notifications.error.no_mentions.title', + defaultMessage: 'Recipients must be @mentioned', + }), + intl.formatMessage({ + id: 'persistent_notifications.error.no_mentions.description', + defaultMessage: 'There are no recipients mentioned in your message. You’ll need add mentions to be able to send persistent notifications.', + }), + expect.any(Array), + ); + }); + + it('should show alert when mentions exceed max recipients', async () => { + (getUsersCountFromMentions as jest.Mock).mockResolvedValue(15); + + await persistentNotificationsConfirmation( + serverUrl, + value, + mentionsList, + intl, + sendMessage, + persistentNotificationMaxRecipients, + persistentNotificationInterval, + currentUserId, + channelName, + ); + + expect(Alert.alert).toHaveBeenCalledWith( + intl.formatMessage({ + id: 'persistent_notifications.error.max_recipients.title', + defaultMessage: 'Too many recipients', + }), + intl.formatMessage({ + id: 'persistent_notifications.error.max_recipients.description', + defaultMessage: 'You can send persistent notifications to a maximum of {max} recipients. There are {count} recipients mentioned in your message. You’ll need to change who you’ve mentioned before you can send.', + }, { + max: persistentNotificationMaxRecipients, + count: mentionsList.length, + }), + expect.any(Array), + ); + }); + + it('should show confirmation alert for valid mentions within limit', async () => { + (getUsersCountFromMentions as jest.Mock).mockResolvedValue(5); + + await persistentNotificationsConfirmation( + serverUrl, + value, + mentionsList, + intl, + sendMessage, + persistentNotificationMaxRecipients, + persistentNotificationInterval, + currentUserId, + channelName, + ); + + expect(Alert.alert).toHaveBeenCalledWith( + intl.formatMessage({ + id: 'persistent_notifications.confirm.title', + defaultMessage: 'Send persistent notifications', + }), + intl.formatMessage({ + id: 'persistent_notifications.confirm.description', + defaultMessage: 'Mentioned recipients will be notified every {interval, plural, one {minute} other {{interval} minutes}} until they’ve acknowledged or replied to the message.', + }, { + interval: persistentNotificationInterval, + }), + expect.any(Array), + ); + }); + }); + + describe('postUserDisplayName', () => { + it('should return the override username if from webhook and override is enabled', () => { + const post = { + props: { + from_webhook: 'true', + override_username: 'webhook_user', + }, + } as PostModel; + + const result = postUserDisplayName(post, undefined, undefined, true); + expect(result).toBe('webhook_user'); + }); + + it('should return the author’s display name if not from webhook or override is disabled', () => { + const post = { + props: { + from_webhook: 'false', + }, + } as PostModel; + const author = { + username: 'user1', + locale: 'en', + } as UserModel; + + const result = postUserDisplayName(post, author, undefined, false); + expect(result).toBe('user1'); + }); + + it('should return the author’s display name using the teammate name display', () => { + const post = { + props: { + from_webhook: 'false', + }, + } as PostModel; + const author = { + username: 'user1', + locale: 'en', + } as UserModel; + + const result = postUserDisplayName(post, author, 'nickname', false); + expect(result).toBe('user1'); + }); + }); + + describe('shouldIgnorePost', () => { + it('should return true if the post type is in the ignore list', () => { + const post = { + type: Post.POST_TYPES.CHANNEL_DELETED, + } as Post; + + const result = shouldIgnorePost(post); + expect(result).toBe(true); + }); + + it('should return false if the post type is not in the ignore list', () => { + const post = { + type: Post.POST_TYPES.EPHEMERAL, + } as Post; + + const result = shouldIgnorePost(post); + expect(result).toBe(false); + }); + }); + + describe('processPostsFetched', () => { + it('should process the fetched posts correctly', () => { + const data = { + order: ['post1', 'post2'], + posts: { + post1: {id: 'post1', message: 'First post'}, + post2: {id: 'post2', message: 'Second post'}, + }, + prev_post_id: 'post0', + } as unknown as PostResponse; + + const result = processPostsFetched(data); + expect(result).toEqual({ + posts: [ + {id: 'post1', message: 'First post'}, + {id: 'post2', message: 'Second post'}, + ], + order: ['post1', 'post2'], + previousPostId: 'post0', + }); + }); + }); + + describe('getLastFetchedAtFromPosts', () => { + it('should return the maximum timestamp from the posts', () => { + const posts = [ + {create_at: 1000, update_at: 2000, delete_at: 0}, + {create_at: 1500, update_at: 2500, delete_at: 3000}, + ] as Post[]; + + const result = getLastFetchedAtFromPosts(posts); + expect(result).toBe(3000); + }); + + it('should return 0 if no posts are provided', () => { + const result = getLastFetchedAtFromPosts(); + expect(result).toBe(0); + }); + }); + + describe('moreThan5minAgo', () => { + it('should return true if the time is more than 5 minutes ago', () => { + const time = Date.now() - toMilliseconds({minutes: 6}); + const result = moreThan5minAgo(time); + expect(result).toBe(true); + }); + + it('should return false if the time is within 5 minutes', () => { + const time = Date.now() - toMilliseconds({minutes: 4}); + const result = moreThan5minAgo(time); + expect(result).toBe(false); + }); }); });