diff --git a/app/utils/channel/index.test.ts b/app/utils/channel/index.test.ts new file mode 100644 index 000000000..87a2cfec8 --- /dev/null +++ b/app/utils/channel/index.test.ts @@ -0,0 +1,246 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createIntl} from 'react-intl'; + +import {Channel, General, Permissions} from '@constants'; +import {hasPermission} from '@utils/role'; + +import {compareNotifyProps, filterChannelsMatchingTerm, generateChannelNameFromDisplayName, getDirectChannelName, isArchived, isDMorGM, isTypeDMorGM, selectDefaultChannelForTeam, sortChannelsByDisplayName, sortChannelsModelByDisplayName, validateDisplayName} from '.'; + +import type ChannelModel from '@typings/database/models/servers/channel'; + +jest.mock('@utils/role', () => ({ + hasPermission: jest.fn(() => true), +})); + +jest.mock('@utils/general', () => ({ + generateId: jest.fn(() => 'generated-id'), +})); + +describe('getDirectChannelName', () => { + it('should return the correct direct channel name', () => { + expect(getDirectChannelName('id1', 'id2')).toBe('id1__id2'); + expect(getDirectChannelName('id2', 'id1')).toBe('id1__id2'); + }); +}); + +describe('isDMorGM', () => { + it('should return true for DM or GM channels', () => { + expect(isDMorGM({type: General.DM_CHANNEL} as Channel)).toBe(true); + expect(isDMorGM({type: General.GM_CHANNEL} as Channel)).toBe(true); + }); + + it('should return false for other channel types', () => { + expect(isDMorGM({type: General.OPEN_CHANNEL} as Channel)).toBe(false); + }); +}); + +describe('isTypeDMorGM', () => { + it('should return true for DM or GM channel types', () => { + expect(isTypeDMorGM(General.DM_CHANNEL)).toBe(true); + expect(isTypeDMorGM(General.GM_CHANNEL)).toBe(true); + }); + + it('should return false for other channel types', () => { + expect(isTypeDMorGM(General.OPEN_CHANNEL)).toBe(false); + expect(isTypeDMorGM(undefined)).toBe(false); + }); +}); + +describe('isArchived', () => { + it('should return true if channel is archived', () => { + expect(isArchived({delete_at: 12345} as Channel)).toBe(true); + expect(isArchived({deleteAt: 12345} as ChannelModel)).toBe(true); + }); + + it('should return false if channel is not archived', () => { + expect(isArchived({delete_at: 0} as Channel)).toBe(false); + expect(isArchived({deleteAt: 0} as ChannelModel)).toBe(false); + }); +}); + +describe('selectDefaultChannelForTeam', () => { + const channels = [ + {id: '1', name: General.DEFAULT_CHANNEL, team_id: 'team1', type: General.OPEN_CHANNEL}, + {id: '2', name: 'random', team_id: 'team1', type: General.OPEN_CHANNEL}, + ] as Channel[]; + + const memberships = [ + {channel_id: '2'}, + ] as ChannelMembership[]; + + const roles = [ + {permissions: [Permissions.JOIN_PUBLIC_CHANNELS]}, + ] as Role[]; + + it('should select the default channel if user is a member', () => { + const result = selectDefaultChannelForTeam(channels, memberships, 'team1', roles); + expect(result).toEqual(channels[0]); + + const channelModels = [ + {id: '1', name: General.DEFAULT_CHANNEL, teamId: 'team1', type: General.OPEN_CHANNEL}, + {id: '2', name: 'random', teamId: 'team1', type: General.OPEN_CHANNEL}, + ] as ChannelModel[]; + const result2 = selectDefaultChannelForTeam(channelModels, memberships, 'team1', roles); + expect(result2).toEqual(channelModels[0]); + }); + + it('should select the first channel in the team if user can join public channels', () => { + const result = selectDefaultChannelForTeam(channels, [], 'team1', roles); + expect(result).toEqual(channels[0]); + }); + + it('should select the first channel in the team if user cannot join public channels', () => { + (hasPermission as jest.Mock).mockReturnValueOnce(false); + const result = selectDefaultChannelForTeam(channels, memberships, 'team1', roles); + expect(result).toEqual(channels[1]); + }); + + it('should select the default channel in the team if user cannot join public channels', () => { + (hasPermission as jest.Mock).mockReturnValueOnce(false); + const memberOf = [ + {channel_id: '1'}, + ] as ChannelMembership[]; + const result = selectDefaultChannelForTeam(channels, memberOf, 'team1', roles); + expect(result).toEqual(channels[0]); + }); + + it('should return undefined if no channels are available', () => { + const result = selectDefaultChannelForTeam([], [], 'team1', roles); + expect(result).toBeUndefined(); + }); +}); + +describe('sortChannelsByDisplayName', () => { + it('should sort channels by display name', () => { + const channels = [ + {name: 'name1', display_name: 'Zeta'}, + {name: 'name2', display_name: 'Alpha'}, + ] as Channel[]; + + const channelsModels = [ + {name: 'name1', displayName: 'Zeta'}, + {name: 'name2', displayName: 'Alpha'}, + ] as ChannelModel[]; + + const result = channels.sort((a, b) => sortChannelsByDisplayName('en', a, b)); + expect(result[0].name).toBe('name2'); + + const result2 = channelsModels.sort((a, b) => sortChannelsByDisplayName('en', a, b)); + expect(result2[0].name).toBe('name2'); + }); + + it('should sort channels by name if display name is not defined', () => { + const channels = [ + {name: 'Zeta'}, + {name: 'Alpha'}, + ] as Channel[]; + + const result = channels.sort((a, b) => sortChannelsByDisplayName('en', a, b)); + expect(result[0].name).toBe('Alpha'); + }); +}); + +describe('sortChannelsModelByDisplayName', () => { + it('should sort channel models by display name', () => { + const channels = [ + {name: 'name1', displayName: 'Zeta'}, + {name: 'name2', displayName: 'Alpha'}, + ] as ChannelModel[]; + + const result = channels.sort((a, b) => sortChannelsModelByDisplayName('en', a, b)); + expect(result[0].name).toBe('name2'); + }); + + it('should sort channel models by name if display name is not defined', () => { + const channels = [ + {name: 'Zeta'}, + {name: 'Alpha'}, + ] as ChannelModel[]; + + const result = channels.sort((a, b) => sortChannelsModelByDisplayName('en', a, b)); + expect(result[0].name).toBe('Alpha'); + }); +}); + +describe('validateDisplayName', () => { + const intl = createIntl({locale: 'en', messages: {}}); + it('should return an error if display name is empty', () => { + const result = validateDisplayName(intl, ''); + expect(result.error).toBe('Channel name is required'); + }); + + it('should return an error if display name is too long', () => { + const result = validateDisplayName(intl, 'a'.repeat(100)); + expect(result.error).toBe('Channel name must be less than 64 characters'); + }); + + it('should return an error if display name is too short', () => { + const minLength = Channel.MIN_CHANNEL_NAME_LENGTH; + Channel.MIN_CHANNEL_NAME_LENGTH = 2; + const result = validateDisplayName(intl, 'a'); + expect(result.error).toBe('Channel name must be 2 or more characters'); + Channel.MIN_CHANNEL_NAME_LENGTH = minLength; + }); + + it('should return no error if display name is valid', () => { + const result = validateDisplayName(intl, 'valid name'); + expect(result.error).toBe(''); + }); +}); + +describe('generateChannelNameFromDisplayName', () => { + it('should generate a channel name from display name', () => { + const result = generateChannelNameFromDisplayName('Valid Name'); + expect(result).toBe('valid-name'); + }); + + it('should generate an ID if display name is empty after cleanup', () => { + const result = generateChannelNameFromDisplayName(''); + expect(result).toBe('generated-id'); + }); +}); + +describe('compareNotifyProps', () => { + it('should return true if notify props are equal', () => { + const propsA: Partial = {desktop: 'default', email: 'default', mark_unread: 'all', push: 'default', ignore_channel_mentions: 'default'}; + const propsB = {...propsA}; + expect(compareNotifyProps(propsA, propsB)).toBe(true); + }); + + it('should return false if notify props are different', () => { + const propsA: Partial = {desktop: 'default', email: 'default', mark_unread: 'all', push: 'default', ignore_channel_mentions: 'default'}; + const propsB: Partial = {...propsA, desktop: 'none'}; + expect(compareNotifyProps(propsA, propsB)).toBe(false); + }); +}); + +describe('filterChannelsMatchingTerm', () => { + const channels = [ + {name: 'channel1', display_name: 'Channel One'}, + {name: 'channel2', display_name: 'Channel Two'}, + {display_name: 'Undefined name'}, + {}, + ] as Channel[]; + + it('should filter channels by name or display name', () => { + let result = filterChannelsMatchingTerm(channels, 'channel1'); + expect(result.length).toBe(1); + expect(result[0].name).toBe('channel1'); + + result = filterChannelsMatchingTerm(channels, 'Channel One'); + expect(result.length).toBe(1); + expect(result[0].display_name).toBe('Channel One'); + + result = filterChannelsMatchingTerm([...channels, undefined] as Channel[], 'Channel One'); + expect(result.length).toBe(1); + expect(result[0].display_name).toBe('Channel One'); + }); + + it('should return an empty array if no channels match the term', () => { + const result = filterChannelsMatchingTerm(channels, 'nonexistent'); + expect(result.length).toBe(0); + }); +}); + diff --git a/app/utils/channel/index.ts b/app/utils/channel/index.ts index d1ed84125..c896e9ad0 100644 --- a/app/utils/channel/index.ts +++ b/app/utils/channel/index.ts @@ -1,15 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Channel, General, Permissions} from '@constants'; -import {t, DEFAULT_LOCALE} from '@i18n'; -import {hasPermission} from '@utils/role'; +import {defineMessages, type IntlShape} from 'react-intl'; -import {generateId} from '../general'; -import {cleanUpUrlable} from '../url'; +import {Channel, General, Permissions} from '@constants'; +import {DEFAULT_LOCALE} from '@i18n'; +import {generateId} from '@utils/general'; +import {hasPermission} from '@utils/role'; +import {cleanUpUrlable} from '@utils/url'; import type ChannelModel from '@typings/database/models/servers/channel'; -import type {IntlShape} from 'react-intl'; export function getDirectChannelName(id: string, otherId: string): string { let handle; @@ -83,20 +83,20 @@ export function sortChannelsModelByDisplayName(locale: string, a: ChannelModel, return a.name.toLowerCase().localeCompare(b.name.toLowerCase(), locale, {numeric: true}); } -const displayNameValidationMessages = { +const displayNameValidationMessages = defineMessages({ display_name_required: { - id: t('mobile.rename_channel.display_name_required'), + id: 'mobile.rename_channel.display_name_required', defaultMessage: 'Channel name is required', }, display_name_maxLength: { - id: t('mobile.rename_channel.display_name_maxLength'), + id: 'mobile.rename_channel.display_name_maxLength', defaultMessage: 'Channel name must be less than {maxLength, number} characters', }, display_name_minLength: { - id: t('mobile.rename_channel.display_name_minLength'), + id: 'mobile.rename_channel.display_name_minLength', defaultMessage: 'Channel name must be {minLength, number} or more characters', }, -}; +}); export const validateDisplayName = (intl: IntlShape, displayName: string): {error: string} => { let errorMessage; @@ -117,6 +117,7 @@ export const validateDisplayName = (intl: IntlShape, displayName: string): {erro default: errorMessage = ''; + break; } return {error: errorMessage}; }; diff --git a/app/utils/deep_link/index.test.ts b/app/utils/deep_link/index.test.ts new file mode 100644 index 000000000..57539ef5f --- /dev/null +++ b/app/utils/deep_link/index.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createIntl} from 'react-intl'; + +import {makeDirectChannel, switchToChannelByName} from '@actions/remote/channel'; +import {showPermalink} from '@actions/remote/permalink'; +import {fetchUsersByUsernames} from '@actions/remote/user'; +import {DeepLink, Launch, Preferences} from '@constants'; +import DatabaseManager from '@database/manager'; +import {t} from '@i18n'; +import WebsocketManager from '@managers/websocket_manager'; +import {getActiveServerUrl} from '@queries/app/servers'; +import {queryUsersByUsername} from '@queries/servers/user'; +import {dismissAllModalsAndPopToRoot} from '@screens/navigation'; +import {logError} from '@utils/log'; +import {addNewServer} from '@utils/server'; + +import {alertErrorWithFallback, errorBadChannel, errorUnkownUser} from '../draft'; + +import {alertInvalidDeepLink, getLaunchPropsFromDeepLink, handleDeepLink} from '.'; + +jest.mock('@actions/remote/user', () => ({ + fetchUsersByUsernames: jest.fn(), +})); + +jest.mock('@actions/remote/permalink', () => ({ + showPermalink: jest.fn(), +})); + +jest.mock('@queries/app/servers', () => ({ + getActiveServerUrl: jest.fn(), +})); + +jest.mock('@queries/servers/user', () => ({ + getCurrentUser: jest.fn(), + queryUsersByUsername: jest.fn(() => ({fetchIds: jest.fn(() => ['user-id'])})), +})); + +jest.mock('@database/manager', () => ({ + searchUrl: jest.fn(), + setActiveServerDatabase: jest.fn(), + getServerDatabaseAndOperator: jest.fn(() => ({database: {}, operator: {}})), +})); + +jest.mock('@managers/websocket_manager', () => ({ + initializeClient: jest.fn(), +})); + +jest.mock('@store/navigation_store', () => ({ + getVisibleScreen: jest.fn(() => 'HOME'), + hasModalsOpened: jest.fn(() => false), + waitUntilScreenHasLoaded: jest.fn(), +})); + +jest.mock('@utils/server', () => ({ + addNewServer: jest.fn(), +})); + +jest.mock('@actions/remote/channel', () => ({ + makeDirectChannel: jest.fn(), + switchToChannelByName: jest.fn(), +})); + +jest.mock('@utils/draft', () => ({ + errorBadChannel: jest.fn(), + errorUnkownUser: jest.fn(), + alertErrorWithFallback: jest.fn(), +})); + +jest.mock('@utils/log', () => ({ + logError: jest.fn(), +})); + +jest.mock('@i18n', () => ({ + DEFAULT_LOCALE: 'en', + getTranslations: jest.fn(() => ({})), + t: jest.fn((id) => id), +})); + +describe('handleDeepLink', () => { + const intl = createIntl({locale: 'en', messages: {}}); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return error for invalid deep link', async () => { + const result = await handleDeepLink('invalid-url'); + expect(result).toEqual({error: true}); + }); + + it('should add new server if not existing', async () => { + (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://currentserver.com'); + (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce(''); + const result = await handleDeepLink('https://newserver.com/team/channels/town-square'); + expect(addNewServer).toHaveBeenCalledWith(Preferences.THEMES.denim, 'newserver.com', undefined, {type: DeepLink.Channel, + data: { + serverUrl: 'newserver.com', + channelName: 'town-square', + teamName: 'team', + }, + url: 'https://newserver.com/team/channels/town-square', + }); + expect(result).toEqual({error: false}); + }); + + it('should handle existing server and switch to home screen', async () => { + (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://currentserver.com'); + (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); + const result = await handleDeepLink('https://existingserver.com/team/channels/town-square'); + expect(dismissAllModalsAndPopToRoot).toHaveBeenCalled(); + expect(DatabaseManager.setActiveServerDatabase).toHaveBeenCalledWith('https://existingserver.com'); + expect(WebsocketManager.initializeClient).toHaveBeenCalledWith('https://existingserver.com'); + expect(result).toEqual({error: false}); + }); + + it('should switch to channel by name for Channel deep link', async () => { + (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); + (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); + const result = await handleDeepLink('https://existingserver.com/team/channels/town-square', intl); + expect(switchToChannelByName).toHaveBeenCalledWith('https://existingserver.com', 'town-square', 'team', errorBadChannel, intl); + expect(result).toEqual({error: false}); + }); + + it('should create direct message for DirectMessage deep link', async () => { + (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); + (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); + (queryUsersByUsername as jest.Mock).mockReturnValueOnce({fetchIds: jest.fn(() => ['user-id'])}); + const result = await handleDeepLink('https://existingserver.com/team/messages/@user-id', intl); + expect(makeDirectChannel).toHaveBeenCalledWith('https://existingserver.com', 'user-id', '', true); + expect(result).toEqual({error: false}); + }); + + it('should fetch user and create direct message if user not found locally', async () => { + (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); + (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); + (queryUsersByUsername as jest.Mock).mockReturnValueOnce({fetchIds: jest.fn(() => [])}); + (fetchUsersByUsernames as jest.Mock).mockResolvedValueOnce({users: [{id: 'user-id'}]}); + const result = await handleDeepLink('https://existingserver.com/team/messages/@user-id', intl); + expect(makeDirectChannel).toHaveBeenCalledWith('https://existingserver.com', 'user-id', '', true); + expect(result).toEqual({error: false}); + }); + + it('should show unknown user error if user not found', async () => { + (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); + (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); + (queryUsersByUsername as jest.Mock).mockReturnValueOnce({fetchIds: jest.fn(() => [])}); + (fetchUsersByUsernames as jest.Mock).mockResolvedValueOnce({users: []}); + const result = await handleDeepLink('https://existingserver.com/team/messages/@user-id', intl); + expect(errorUnkownUser).toHaveBeenCalledWith(intl); + expect(result).toEqual({error: false}); + }); + + it('should switch to group message channel for GroupMessage deep link', async () => { + (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); + (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); + const result = await handleDeepLink('https://existingserver.com/team/messages/7b35c77a645e1906e03a2c330f89203385db102f', intl); + expect(switchToChannelByName).toHaveBeenCalledWith('https://existingserver.com', '7b35c77a645e1906e03a2c330f89203385db102f', 'team', errorBadChannel, intl); + expect(result).toEqual({error: false}); + }); + + it('should show permalink for Permalink deep link', async () => { + (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); + (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); + const postid = '7b35c77a645e1906e03a2c330f'; + const result = await handleDeepLink(`https://existingserver.com/team/pl/${postid}`, intl); + expect(showPermalink).toHaveBeenCalledWith('https://existingserver.com', 'team', postid); + expect(result).toEqual({error: false}); + }); + + it('should log error and return error true on failure', async () => { + (getActiveServerUrl as jest.Mock).mockImplementationOnce(() => { + throw new Error('DB does not exist error'); + }); + const result = await handleDeepLink('https://existingserver.com/team/messages/7b35c77a645e1906e03a2c330f89203385db102f'); + expect(logError).toHaveBeenCalledWith('Failed to open channel from deeplink', expect.any(Error), undefined); + expect(result).toEqual({error: true}); + }); +}); + +describe('getLaunchPropsFromDeepLink', () => { + it('should return launch props with launchError when deep link is invalid', () => { + const result = getLaunchPropsFromDeepLink('invalid-url'); + + expect(result).toEqual({ + launchType: Launch.DeepLink, + coldStart: false, + launchError: true, + }); + }); + + it('should return launch props with extra data when deep link is valid', () => { + const extraData = { + type: DeepLink.Channel, + data: { + channelName: 'town-square', + serverUrl: 'existingserver.com', + teamName: 'team', + }, + url: 'https://existingserver.com/team/channels/town-square', + }; + const result = getLaunchPropsFromDeepLink('https://existingserver.com/team/channels/town-square', true); + + expect(result).toEqual({ + launchType: Launch.DeepLink, + coldStart: true, + extra: extraData, + }); + }); +}); + +describe('alertInvalidDeepLink', () => { + it('should call alertErrorWithFallback with correct arguments', () => { + const intl = createIntl({locale: 'en', messages: {}}); + const message = { + id: 'mobile.deep_link.invalid', + defaultMessage: 'This link you are trying to open is invalid.', + }; + + (t as jest.Mock).mockReturnValue(message.id); + + alertInvalidDeepLink(intl); + + expect(alertErrorWithFallback).toHaveBeenCalledWith(intl, {}, message); + }); +}); diff --git a/app/utils/document/index.test.ts b/app/utils/document/index.test.ts new file mode 100644 index 000000000..6a280be71 --- /dev/null +++ b/app/utils/document/index.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Alert} from 'react-native'; + +import {alertFailedToOpenDocument, alertDownloadDocumentDisabled, alertDownloadFailed} from './'; + +import type {IntlShape} from 'react-intl'; + +jest.mock('react-native', () => ({ + Alert: { + alert: jest.fn(), + }, +})); + +describe('alertUtils', () => { + const intlMock = { + formatMessage: jest.fn(), + } as unknown as IntlShape; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should alert failed to open document with correct messages', () => { + const file = {extension: 'pdf'} as FileInfo; + + intlMock.formatMessage. + + //@ts-expect-error type definition + mockReturnValueOnce('Open Document failed'). + mockReturnValueOnce('An error occurred while opening the document. Please make sure you have a PDF viewer installed and try again.\n'). + mockReturnValueOnce('OK'); + + alertFailedToOpenDocument(file, intlMock); + + expect(Alert.alert).toHaveBeenCalledWith( + 'Open Document failed', + 'An error occurred while opening the document. Please make sure you have a PDF viewer installed and try again.\n', + [{text: 'OK'}], + ); + }); + + it('should alert download document disabled with correct messages', () => { + intlMock.formatMessage. + + //@ts-expect-error type definition + mockReturnValueOnce('Download disabled'). + mockReturnValueOnce('File downloads are disabled on this server. Please contact your System Admin for more details.\n'). + mockReturnValueOnce('OK'); + + alertDownloadDocumentDisabled(intlMock); + + expect(Alert.alert).toHaveBeenCalledWith( + 'Download disabled', + 'File downloads are disabled on this server. Please contact your System Admin for more details.\n', + [{text: 'OK'}], + ); + }); + + it('should alert download failed with correct messages', () => { + intlMock.formatMessage. + + //@ts-expect-error type definition + mockReturnValueOnce('Download failed'). + mockReturnValueOnce('An error occurred while downloading the file. Please check your internet connection and try again.\n'). + mockReturnValueOnce('OK'); + + alertDownloadFailed(intlMock); + + expect(Alert.alert).toHaveBeenCalledWith( + 'Download failed', + 'An error occurred while downloading the file. Please check your internet connection and try again.\n', + [{text: 'OK'}], + ); + }); +}); diff --git a/app/utils/draft/index.test.ts b/app/utils/draft/index.test.ts new file mode 100644 index 000000000..d6b6cd2a5 --- /dev/null +++ b/app/utils/draft/index.test.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createIntl} from 'react-intl'; +import {Alert} from 'react-native'; + +import {General} from '@constants'; + +import { + errorBadChannel, + errorUnkownUser, + permalinkBadTeam, + alertErrorWithFallback, + alertAttachmentFail, + textContainsAtAllAtChannel, + textContainsAtHere, + buildChannelWideMentionMessage, + alertChannelWideMention, + alertSendToGroups, + getStatusFromSlashCommand, + alertSlashCommandFailed, +} from './'; + +jest.mock('react-native', () => { + const RN = jest.requireActual('react-native'); + return { + Platform: RN.Platform, + NativeModules: { + ...RN.NativeModules, + RNUtils: { + getConstants: () => ({ + appGroupIdentifier: 'group.mattermost.rnbeta', + appGroupSharedDirectory: { + sharedDirectory: '', + databasePath: '', + }, + }), + addListener: jest.fn(), + removeListeners: jest.fn(), + isRunningInSplitView: jest.fn().mockReturnValue({isSplit: false, isTablet: false}), + getDeliveredNotifications: jest.fn().mockResolvedValue([]), + removeChannelNotifications: jest.fn().mockImplementation(), + removeThreadNotifications: jest.fn().mockImplementation(), + removeServerNotifications: jest.fn().mockImplementation(), + }, + }, + Alert: { + alert: jest.fn(), + }, + }; +}); + +jest.mock('@i18n', () => ({ + t: (id: string) => id, +})); + +describe('draft utils', () => { + const intl = createIntl({locale: 'en', messages: {}}); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should alert error bad channel', () => { + errorBadChannel(intl); + expect(Alert.alert).toHaveBeenCalledWith('', 'This link belongs to a deleted channel or to a channel to which you do not have access.', undefined); + }); + + it('should alert error unknown user', () => { + errorUnkownUser(intl); + expect(Alert.alert).toHaveBeenCalledWith('', 'We can\'t redirect you to the DM. The user specified is unknown.', undefined); + }); + + it('should alert permalink bad team', () => { + permalinkBadTeam(intl); + expect(Alert.alert).toHaveBeenCalledWith('', 'This link belongs to a deleted team or to a team to which you do not have access.', undefined); + }); + + it('should alert error with fallback', () => { + const error = {message: 'Test Error'}; + const fallback = {id: 'fallback.id', defaultMessage: 'Fallback Message'}; + alertErrorWithFallback(intl, error, fallback); + expect(Alert.alert).toHaveBeenCalledWith('', 'Test Error', undefined); + }); + + it('should alert error with fallback when network request failed', () => { + const error = {message: 'Network request failed'}; + const fallback = {id: 'fallback.id', defaultMessage: 'Fallback Message'}; + + alertErrorWithFallback(intl, error, fallback); + expect(Alert.alert).toHaveBeenCalledWith('', 'Fallback Message', undefined); + }); + + it('should alert attachment fail', () => { + const accept = jest.fn(); + const cancel = jest.fn(); + + alertAttachmentFail(intl, accept, cancel); + expect(Alert.alert).toHaveBeenCalledWith( + 'Attachment failure', + 'Some attachments failed to upload to the server. Are you sure you want to post the message?', + [ + {text: 'No', onPress: cancel}, + {text: 'Yes', onPress: accept}, + ], + ); + }); + + it('should detect @all and @channel in text', () => { + const text = 'Hello @all'; + expect(textContainsAtAllAtChannel(text)).toBe(true); + }); + + it('should detect @here in text', () => { + const text = 'Hello @here'; + expect(textContainsAtHere(text)).toBe(true); + }); + + it('should build channel-wide mention message with timezones', () => { + const membersCount = 10; + const channelTimezoneCount = 3; + + let message = buildChannelWideMentionMessage(intl, membersCount, channelTimezoneCount, true); + expect(message).toBe('By using @here you are about to send notifications up to 9 people in 3 timezones. Are you sure you want to do this?'); + + message = buildChannelWideMentionMessage(intl, membersCount, channelTimezoneCount, false); + expect(message).toBe('By using @all or @channel you are about to send notifications to 9 people in 3 timezones. Are you sure you want to do this?'); + }); + + it('should build channel-wide mention message without timezones', () => { + const membersCount = 10; + const channelTimezoneCount = 0; + + let message = buildChannelWideMentionMessage(intl, membersCount, channelTimezoneCount, true); + expect(message).toBe('By using @here you are about to send notifications to up to 9 people. Are you sure you want to do this?'); + + message = buildChannelWideMentionMessage(intl, membersCount, channelTimezoneCount, false); + expect(message).toBe('By using @all or @channel you are about to send notifications to 9 people. Are you sure you want to do this?'); + }); + + it('should alert channel-wide mention', () => { + const notifyAllMessage = 'Notify all message'; + const accept = jest.fn(); + const cancel = jest.fn(); + + alertChannelWideMention(intl, notifyAllMessage, accept, cancel); + expect(Alert.alert).toHaveBeenCalledWith( + 'Confirm sending notifications to entire channel', + 'Notify all message', + [ + {text: 'Cancel', onPress: cancel}, + {text: 'Confirm', onPress: accept}, + ], + ); + }); + + it('should alert send to groups', () => { + const notifyAllMessage = 'Notify all message'; + const accept = jest.fn(); + const cancel = jest.fn(); + + alertSendToGroups(intl, notifyAllMessage, accept, cancel); + expect(Alert.alert).toHaveBeenCalledWith( + 'Confirm sending notifications to groups', + 'Notify all message', + [ + {text: 'Cancel', onPress: cancel}, + {text: 'Confirm', onPress: accept}, + ], + ); + }); + + it('should get status from slash command', () => { + General.STATUS_COMMANDS = ['status']; + const message = '/status'; + expect(getStatusFromSlashCommand(message)).toBe('status'); + }); + + it('should not get status from non-status slash command', () => { + General.STATUS_COMMANDS = ['status']; + const message = '/nonstatus'; + expect(getStatusFromSlashCommand(message)).toBe(''); + }); + + it('should alert slash command failed', () => { + const error = 'Error message'; + + alertSlashCommandFailed(intl, error); + expect(Alert.alert).toHaveBeenCalledWith( + 'Error Executing Command', + 'Error message', + ); + }); +}); diff --git a/app/utils/emoji/helpers.test.ts b/app/utils/emoji/helpers.test.ts new file mode 100644 index 000000000..5a397f993 --- /dev/null +++ b/app/utils/emoji/helpers.test.ts @@ -0,0 +1,259 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Fuse from 'fuse.js'; + +import { + isEmoticon, + isUnicodeEmoji, + getEmoticonName, + matchEmoticons, + getValidEmojis, + getEmojiName, + isReactionMatch, + isValidNamedEmoji, + hasJumboEmojiOnly, + doesMatchNamedEmoji, + getEmojiFirstAlias, + getEmojiByName, + mapCustomEmojiNames, + compareEmojis, + isCustomEmojiEnabled, + fillEmoji, + getSkin, + getEmojis, + searchEmojis, +} from './helpers'; + +import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; +import type SystemModel from '@typings/database/models/servers/system'; + +jest.mock('@database/models/server/system'); + +describe('helpers.ts tests', () => { + describe('isEmoticon', () => { + it('should return true for valid emoticons', () => { + expect(isEmoticon(':)')).toBe(true); + expect(isEmoticon(';)')).toBe(true); + }); + + it('should return false for invalid emoticons', () => { + expect(isEmoticon(':-*')).toBe(false); + expect(isEmoticon('hello')).toBe(false); + }); + }); + + describe('isUnicodeEmoji', () => { + it('should return true for unicode emoji', () => { + expect(isUnicodeEmoji('😊')).toBe(true); + }); + + it('should return false for non-emoji text', () => { + expect(isUnicodeEmoji('hello')).toBe(false); + }); + }); + + describe('getEmoticonName', () => { + it('should return correct emoticon name', () => { + expect(getEmoticonName(':)')).toBe('slightly_smiling_face'); + }); + + it('should return undefined for invalid emoticon', () => { + expect(getEmoticonName('hello')).toBeUndefined(); + }); + }); + + describe('matchEmoticons', () => { + it('should match named, unicode, and emoticon emojis', () => { + const text = 'Hello :) :smile: 😊'; + const result = matchEmoticons(text); + expect(result).toEqual([':smile:', ' :)', '😊']); + }); + + it('should return empty array for no matches', () => { + expect(matchEmoticons('hello')).toEqual([]); + }); + }); + + describe('getValidEmojis', () => { + it('should return valid emoji names', () => { + const emojis = [':smile:', '😊', ':)']; + const customEmojis = [{name: 'custom_emoji'}] as CustomEmojiModel[]; + const result = getValidEmojis(emojis, customEmojis); + expect(result).toContain('smile'); + }); + + it('should return empty array for no valid emojis', () => { + const emojis = ['hello']; + const customEmojis = [{name: 'custom_emoji'}] as CustomEmojiModel[]; + const result = getValidEmojis(emojis, customEmojis); + expect(result).toEqual([]); + }); + }); + + describe('getEmojiName', () => { + it('should return correct emoji name for named emoji', () => { + expect(getEmojiName(':smile:', [])).toBe('smile'); + }); + + it('should return correct emoji name for unicode emoji', () => { + expect(getEmojiName('😊', [])).toBe('blush'); + }); + + it('should return correct emoji name for emoticon', () => { + expect(getEmojiName(':)', [])).toBe('slightly_smiling_face'); + }); + + it('should return undefined for invalid emoji', () => { + expect(getEmojiName('hello', [])).toBeUndefined(); + }); + }); + + describe('isReactionMatch', () => { + it('should return correct reaction match for valid reaction', () => { + const custom = [{name: 'custom'}] as CustomEmojiModel[]; + expect(isReactionMatch('+:smile:', custom)).toEqual({add: true, emoji: 'smile'}); + expect(isReactionMatch('+:custom:', custom)).toEqual({add: true, emoji: 'custom'}); + expect(isReactionMatch('+:lala:', custom)).toEqual(null); + }); + + it('should return null for invalid reaction', () => { + expect(isReactionMatch(':hello:', [])).toBeNull(); + }); + }); + + describe('isValidNamedEmoji', () => { + it('should return true for valid named emoji', () => { + expect(isValidNamedEmoji('smile', [])).toBe(true); + }); + + it('should return false for invalid named emoji', () => { + expect(isValidNamedEmoji('hello', [])).toBe(false); + }); + }); + + describe('hasJumboEmojiOnly', () => { + it('should return true for jumbo emojis only', () => { + expect(hasJumboEmojiOnly('😊', [])).toBe(true); + expect(hasJumboEmojiOnly('😊 😊', [])).toBe(true); + }); + + it('should return false for non-jumbo emojis', () => { + expect(hasJumboEmojiOnly('hello', [])).toBe(false); + }); + + it('should return false for strings with spaces only', () => { + expect(hasJumboEmojiOnly(' ', [])).toBe(false); + }); + + it('should return true for strings with custom emojis', () => { + const custom = ['custom']; + expect(hasJumboEmojiOnly('😊 :custom: :blush:', custom)).toBe(true); + }); + + it('should return false for strings with more than 8 emojis', () => { + expect(hasJumboEmojiOnly('😊 😊 😊 😊 😊 😊 😊 😊 😊', [])).toBe(false); + }); + }); + + describe('doesMatchNamedEmoji', () => { + it('should return true for valid named emoji', () => { + expect(doesMatchNamedEmoji(':smile:')).toBe(true); + }); + + it('should return false for invalid named emoji', () => { + expect(doesMatchNamedEmoji('hello')).toBe(false); + }); + }); + + describe('getEmojiFirstAlias', () => { + it('should return first alias of emoji', () => { + expect(getEmojiFirstAlias('smile')).toBe('smile'); + expect(getEmojiFirstAlias('lolo')).toBe('lolo'); + }); + }); + + describe('getEmojiByName', () => { + it('should return emoji by name for standard emoji', () => { + expect(getEmojiByName('smile', [])).toEqual(expect.objectContaining({short_names: ['smile'], category: 'smileys-emotion'})); + }); + + it('should return emoji by name for custom emoji', () => { + const customEmojis = [{name: 'custom_emoji'}] as CustomEmojiModel[]; + expect(getEmojiByName('custom_emoji', customEmojis)).toEqual({name: 'custom_emoji'}); + }); + + it('should return undefined for invalid emoji', () => { + expect(getEmojiByName('hello', [])).toBeUndefined(); + }); + }); + + describe('mapCustomEmojiNames', () => { + it('should return names of custom emojis', () => { + const customEmojis = [{name: 'custom_emoji'}] as CustomEmojiModel[]; + expect(mapCustomEmojiNames(customEmojis)).toEqual(['custom_emoji']); + }); + }); + + describe('compareEmojis', () => { + it('should compare emojis correctly based on search term', () => { + expect(compareEmojis('smile', 'grin', 'smi')).toBe(-1); + expect(compareEmojis('grin', 'smile', 'smi')).toBe(1); + expect(compareEmojis('smile', 'grin', '')).toBe(1); + expect(compareEmojis('smile', 'smile', 'smi')).toBe(0); + }); + }); + + describe('isCustomEmojiEnabled', () => { + it('should return true if custom emoji is enabled in config', () => { + expect(isCustomEmojiEnabled({EnableCustomEmoji: 'true'} as any)).toBe(true); + + expect(isCustomEmojiEnabled({value: {EnableCustomEmoji: 'true'}} as SystemModel)).toBe(true); + }); + + it('should return false if custom emoji is disabled in config', () => { + expect(isCustomEmojiEnabled({EnableCustomEmoji: 'false'} as any)).toBe(false); + }); + }); + + describe('fillEmoji', () => { + it('should fill emoji correctly', () => { + expect(fillEmoji('category', 1)).toEqual({name: 'smiley', aliases: ['smiley'], category: 'category'}); + }); + }); + + describe('getSkin', () => { + it('should return default for skin variations', () => { + const emoji = {skin_variations: []}; + expect(getSkin(emoji)).toBe('default'); + }); + + it('should return first skin for skins array', () => { + const emoji = {skins: ['light']}; + expect(getSkin(emoji)).toBe('light'); + }); + + it('should return null for no skin variations', () => { + const emoji = {}; + expect(getSkin(emoji)).toBeNull(); + }); + }); + + describe('getEmojis', () => { + it('should return emojis for specified skin tone', () => { + const custom = [{name: 'hey_custom'}] as CustomEmojiModel[]; + expect(getEmojis('dark', custom)).toEqual(expect.arrayContaining(['grinning', 'smiley', 'smile', 'grin', 'laughing', 'satisfied', 'sweat_smile', 'rolling_on_the_floor_laughing', 'rofl', 'joy', 'hey_custom'])); + }); + }); + + describe('searchEmojis', () => { + it('should return emojis matching search term', () => { + const options = {findAllMatches: true, ignoreLocation: true, includeMatches: true, shouldSort: false, includeScore: true}; + const emojis = getEmojis('light', []); + const fuse = new Fuse(emojis, options); + const result = searchEmojis(fuse, 'smile'); + expect(result).toEqual(['smile', 'smile_cat', 'smiley', 'smiley_cat', 'sweat_smile']); + expect(searchEmojis(fuse, 'cawabunga')).toEqual([]); + }); + }); +}); diff --git a/app/utils/emoji/helpers.ts b/app/utils/emoji/helpers.ts index 8383b4cad..1d8c22eb3 100644 --- a/app/utils/emoji/helpers.ts +++ b/app/utils/emoji/helpers.ts @@ -42,7 +42,7 @@ const RE_REACTION = /^(\+|-):([^:\s]+):\s*$/; const MAX_JUMBO_EMOJIS = 8; -function isEmoticon(text: string) { +export function isEmoticon(text: string) { for (const emoticon of Object.keys(RE_EMOTICON)) { const reEmoticon = RE_EMOTICON[emoticon]; const matchEmoticon = text.match(reEmoticon); @@ -106,9 +106,17 @@ export function getEmojiName(emoji: string, customEmojiNames: string[]) { const matchUnicodeEmoji = emoji.match(RE_UNICODE_EMOJI); if (matchUnicodeEmoji) { - const index = EmojiIndicesByUnicode.get(matchUnicodeEmoji[0]); - if (index != null) { - return fillEmoji('', Emojis[index]).name; + function getEmojiHexValue(code: string) { + let hexValue = ''; + for (const char of code) { + hexValue += char.codePointAt(0)?.toString(16).toLowerCase() + ' '; + } + return hexValue.trim(); + } + const hex = getEmojiHexValue(matchUnicodeEmoji[0]); + const index = EmojiIndicesByUnicode.get(hex); + if (index != null && Emojis[index]) { + return fillEmoji('', index).name; } return undefined; } @@ -303,11 +311,11 @@ export function compareEmojis(emojiA: string | Partial, emojiB: strin } export const isCustomEmojiEnabled = (config: ClientConfig | SystemModel) => { - if (config instanceof SystemModel) { - return config?.value?.EnableCustomEmoji === 'true'; + if ('value' in config) { + return config.value?.EnableCustomEmoji === 'true'; } - return config?.EnableCustomEmoji === 'true'; + return config.EnableCustomEmoji === 'true'; }; export function fillEmoji(category: string, index: number) {