diff --git a/.github/workflows/e2e-detox-template.yml b/.github/workflows/e2e-detox-template.yml index 659f793e20a..9465a2ef046 100644 --- a/.github/workflows/e2e-detox-template.yml +++ b/.github/workflows/e2e-detox-template.yml @@ -42,12 +42,12 @@ on: description: "iPhone simulator name" required: false type: string - default: "iPhone 14" + default: "iPhone 15 Pro" ios_device_os_name: description: "iPhone simulator OS version" required: false type: string - default: "iOS 17.0" + default: "iOS 17.4" low_bandwidth_mode: description: "Enable low bandwidth mode" required: false @@ -66,8 +66,8 @@ env: ADMIN_EMAIL: ${{ secrets.MM_MOBILE_E2E_ADMIN_EMAIL }} ADMIN_USERNAME: ${{ secrets.MM_MOBILE_E2E_ADMIN_USERNAME }} ADMIN_PASSWORD: ${{ secrets.MM_MOBILE_E2E_ADMIN_PASSWORD }} - # BRANCH: ${{ github.head_ref || github.ref_name }} - # COMMIT_HASH: ${{ github.sha }} + BRANCH: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }} + COMMIT_HASH: ${{ github.sha }} DEVICE_NAME: ${{ inputs.ios_device_name }} DEVICE_OS_VERSION: ${{ inputs.ios_device_os_name }} DETOX_AWS_S3_BUCKET: "mattermost-detox-report" @@ -195,7 +195,6 @@ jobs: DETOX_OS_VERSION: ${{ env.DEVICE_OS_VERSION }} LOW_BANDWIDTH_MODE: ${{ inputs.low_bandwidth_mode }} - - name: reset network settings if: ${{ inputs.low_bandwidth_mode || failure() }} run: | diff --git a/README.md b/README.md index 58e94e0d6e9..aebddd09245 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Mattermost Mobile v2 -- **Minimum Server versions:** Current ESR version (9.5.0+) +- **Minimum Server versions:** Current ESR version (9.11.0+) - **Supported iOS versions:** 13.4+ - **Supported Android versions:** 7.0+ diff --git a/android/app/build.gradle b/android/app/build.gradle index 27b27383843..6958bfc8488 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { applicationId "com.mattermost.rnbeta" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 572 - versionName "2.22.0" + versionCode 583 + versionName "2.23.0" testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } @@ -186,6 +186,7 @@ repositories { maven { url 'https://maven.google.com' } + maven { url 'https://jitpack.io' } } dependencies { @@ -203,8 +204,11 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation "com.google.firebase:firebase-messaging:$firebaseVersion" - androidTestImplementation('com.wix:detox:+') + androidTestImplementation 'androidx.test:core:1.6.0' + androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestImplementation 'com.wix:detox:20.26.2' + implementation project(':reactnativenotifications') implementation project(':watermelondb-jsi') @@ -217,6 +221,7 @@ dependencies { configurations.all { resolutionStrategy { + force 'androidx.test:core:1.6.0' eachDependency { DependencyResolveDetails details -> if (details.requested.name == 'play-services-base') { details.useTarget group: details.requested.group, name: details.requested.name, version: '18.2.0' diff --git a/android/build.gradle b/android/build.gradle index e1b16d9d0d1..a7c12891fe7 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -34,7 +34,7 @@ buildscript { allprojects { repositories { maven { - url "$rootDir/../node_modules/detox/Detox-android" + url "$rootDir/../detox/node_modules/detox/Detox-android" } } } diff --git a/android/gradle.properties b/android/gradle.properties index 5ef02eb480c..6e14a186d9e 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -10,7 +10,7 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m -org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m +org.gradle.jvmargs=-Xmx4096m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit diff --git a/app/actions/local/category.test.ts b/app/actions/local/category.test.ts index 21326e71df5..7c785519905 100644 --- a/app/actions/local/category.test.ts +++ b/app/actions/local/category.test.ts @@ -14,6 +14,7 @@ import { } from './category'; import type ServerDataOperator from '@database/operator/server_data_operator'; +import type ChannelModel from '@typings/database/models/servers/channel'; describe('handleConvertedGMCategories', () => { const serverUrl = 'baseHandler.test.com'; @@ -66,7 +67,7 @@ describe('handleConvertedGMCategories', () => { }; await operator.handleCategoryChannels({categoryChannels: [dmCategoryChannel, customCategoryChannel], prepareRecordsOnly: false}); - const {models, error} = await handleConvertedGMCategories(serverUrl, channelId, teamId1, true); + const {models, error} = await handleConvertedGMCategories(serverUrl, channelId, teamId1); expect(error).toBeUndefined(); expect(models).toBeDefined(); expect(models!.length).toBe(3); // two for removing channel for a custom and a DM category, and one for adding it to default channels category @@ -83,7 +84,7 @@ describe('handleConvertedGMCategories', () => { }); it('error - database not prepared', async () => { - const {error} = await handleConvertedGMCategories(serverUrl, channelId, teamId1, true); + const {error} = await handleConvertedGMCategories('', channelId, teamId1, true); expect(error).toBeDefined(); }); }); @@ -124,6 +125,11 @@ describe('category crud', () => { expect(error2).toBeUndefined(); }); + it('deleteCategory - handle database not found', async () => { + const {error} = await deleteCategory('', ''); + expect(error).toBeDefined(); + }); + it('storeCategories', async () => { const defaultCategory: CategoryWithChannels = { id: 'default_category_id', @@ -143,6 +149,11 @@ describe('category crud', () => { expect(models!.length).toBe(3); }); + it('storeCategories - handle database not found', async () => { + const {error} = await storeCategories('', []); + expect(error).toBeDefined(); + }); + it('toggleCollapseCategory', async () => { const defaultCategory: Category = { id: 'default_category_id', @@ -163,6 +174,11 @@ describe('category crud', () => { expect(categoryResult2).toBeDefined(); expect(categoryResult2?.collapsed).toBe(defaultCategory.collapsed); }); + + it('toggleCollapseCategory - handle database not found', async () => { + const {error} = await toggleCollapseCategory('', ''); + expect(error).toBeDefined(); + }); }); describe('addChannelToDefaultCategory', () => { @@ -221,6 +237,11 @@ describe('addChannelToDefaultCategory', () => { expect(dmError).toBeUndefined(); expect(dmModels).toBeDefined(); expect(dmModels!.length).toBe(1); // one for the dm channel + + const {models: channelModels, error: channelModelError} = await addChannelToDefaultCategory(serverUrl, {teamId: teamId1, id: channelId} as ChannelModel); + expect(channelModelError).toBeUndefined(); + expect(channelModels).toBeDefined(); + expect(channelModels!.length).toBe(1); // one for the channel }); it('error - no current user', async () => { @@ -233,7 +254,7 @@ describe('addChannelToDefaultCategory', () => { }); it('error - database not prepared', async () => { - const {error} = await addChannelToDefaultCategory(serverUrl, channel); + const {error} = await addChannelToDefaultCategory('', channel); expect(error).toBeDefined(); }); }); diff --git a/app/actions/local/draft.test.ts b/app/actions/local/draft.test.ts index faae4a98e0d..a876c450148 100644 --- a/app/actions/local/draft.test.ts +++ b/app/actions/local/draft.test.ts @@ -70,7 +70,7 @@ describe('updateDraftFile', () => { it('update draft file', async () => { await operator.handleDraft({drafts: [{...draft, files: [{...fileInfo, localPath: 'path0'}]}], prepareRecordsOnly: false}); - const {draft: draftModel, error} = await updateDraftFile(serverUrl, channelId, '', fileInfo, false); + const {draft: draftModel, error} = await updateDraftFile(serverUrl, channelId, '', fileInfo); expect(error).toBeUndefined(); expect(draftModel).toBeDefined(); expect(draftModel?.files?.length).toBe(1); @@ -101,7 +101,7 @@ describe('removeDraftFile', () => { it('remove draft file', async () => { await operator.handleDraft({drafts: [{...draft, files: [fileInfo]}], prepareRecordsOnly: false}); - const {draft: draftModel, error} = await removeDraftFile(serverUrl, channelId, '', 'clientid', false); + const {draft: draftModel, error} = await removeDraftFile(serverUrl, channelId, '', 'clientid'); expect(error).toBeUndefined(); expect(draftModel).toBeDefined(); }); @@ -137,7 +137,7 @@ describe('updateDraftMessage', () => { it('update draft message', async () => { await operator.handleDraft({drafts: [{...draft, files: [fileInfo]}], prepareRecordsOnly: false}); - const result = await updateDraftMessage(serverUrl, channelId, '', 'newmessage', false) as {draft: DraftModel; error: unknown}; + const result = await updateDraftMessage(serverUrl, channelId, '', 'newmessage') as {draft: DraftModel; error: unknown}; expect(result.error).toBeUndefined(); expect(result.draft).toBeDefined(); expect(result.draft.message).toBe('newmessage'); @@ -178,7 +178,7 @@ describe('addFilesToDraft', () => { it('add draft files', async () => { await operator.handleDraft({drafts: [draft], prepareRecordsOnly: false}); - const result = await addFilesToDraft(serverUrl, channelId, '', [fileInfo], false) as {draft: DraftModel; error: unknown}; + const result = await addFilesToDraft(serverUrl, channelId, '', [fileInfo]) as {draft: DraftModel; error: unknown}; expect(result.error).toBeUndefined(); expect(result.draft).toBeDefined(); expect(result?.draft.files.length).toBe(1); @@ -201,7 +201,15 @@ describe('removeDraft', () => { it('remove draft', async () => { await operator.handleDraft({drafts: [draft], prepareRecordsOnly: false}); - const result = await removeDraft(serverUrl, channelId, ''); + const result = await removeDraft(serverUrl, channelId); + expect(result.error).toBeUndefined(); + expect(result.draft).toBeDefined(); + }); + + it('remove draft with root id', async () => { + await operator.handleDraft({drafts: [{...draft, root_id: 'postid'}], prepareRecordsOnly: false}); + + const result = await removeDraft(serverUrl, channelId, 'postid'); expect(result.error).toBeUndefined(); expect(result.draft).toBeDefined(); }); diff --git a/app/actions/local/file.test.ts b/app/actions/local/file.test.ts index 067d5cd3f9e..a2c0231ada2 100644 --- a/app/actions/local/file.test.ts +++ b/app/actions/local/file.test.ts @@ -6,12 +6,13 @@ import DatabaseManager from '@database/manager'; import { updateLocalFile, updateLocalFilePath, + getLocalFileInfo, } from './file'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type FileModel from '@typings/database/models/servers/file'; -describe('updateLocalFiles', () => { +describe('files', () => { let operator: ServerDataOperator; const serverUrl = 'baseHandler.test.com'; const fileInfo: FileInfo = { @@ -52,4 +53,23 @@ describe('updateLocalFiles', () => { const {error} = await updateLocalFilePath(serverUrl, fileInfo.id as string, 'newpath'); expect(error).toBeUndefined(); }); + + it('updateLocalFilePath - no file', async () => { + const {error} = await updateLocalFilePath(serverUrl, fileInfo.id as string, 'newpath'); + expect(error).toBeUndefined(); + }); + + it('getLocalFileInfo - handle not found database', async () => { + const {error} = await getLocalFileInfo('foo', ''); + expect(error).toBeTruthy(); + }); + + it('getLocalFileInfo', async () => { + await operator.handleFiles({files: [fileInfo], prepareRecordsOnly: false}); + + const {file, error} = await getLocalFileInfo(serverUrl, fileInfo.id as string); + expect(error).toBeUndefined(); + expect(file).toBeDefined(); + expect(file!.id).toBe(fileInfo.id); + }); }); diff --git a/app/actions/local/group.test.ts b/app/actions/local/group.test.ts index 7d13939e7ff..5763a86a32e 100644 --- a/app/actions/local/group.test.ts +++ b/app/actions/local/group.test.ts @@ -66,6 +66,16 @@ describe('searchGroups', () => { expect(models[0].id).toBe(group.id); }); + it('searchGroupsByName', async () => { + const groupModels = await operator.handleGroups({groups: [group], prepareRecordsOnly: false}); + mockedRemoteGroups.fetchGroupsForAutocomplete.mockReturnValueOnce(Promise.resolve(groupModels)); + + const models = await searchGroupsByName(serverUrl, group.name); + expect(models).toBeDefined(); + expect(models.length).toBe(1); + expect(models[0].id).toBe(group.id); + }); + it('searchGroupsByNameInTeam - handle not found database', async () => { const models = await searchGroupsByNameInTeam('foo', 'test', teamId); expect(models).toBeDefined(); @@ -90,6 +100,18 @@ describe('searchGroups', () => { expect(models[0].id).toBe(group.id); }); + it('searchGroupsByNameInTeam', async () => { + const groupModels = await operator.handleGroups({groups: [group], prepareRecordsOnly: false}); + await operator.handleGroupTeamsForTeam({groups: [group], teamId, prepareRecordsOnly: false}); + + mockedRemoteGroups.fetchFilteredTeamGroups.mockReturnValueOnce(Promise.resolve(groupModels)); + + const models = await searchGroupsByNameInTeam(serverUrl, group.name, teamId); + expect(models).toBeDefined(); + expect(models.length).toBe(1); + expect(models[0].id).toBe(group.id); + }); + it('searchGroupsByNameInChannel - handle not found database', async () => { const models = await searchGroupsByNameInChannel('foo', 'test', channelId); expect(models).toBeDefined(); @@ -113,4 +135,16 @@ describe('searchGroups', () => { expect(models.length).toBe(1); expect(models[0].id).toBe(group.id); }); + + it('searchGroupsByNameInChannel', async () => { + const groupModels = await operator.handleGroups({groups: [group], prepareRecordsOnly: false}); + await operator.handleGroupChannelsForChannel({groups: [group], channelId, prepareRecordsOnly: false}); + + mockedRemoteGroups.fetchFilteredChannelGroups.mockReturnValueOnce(Promise.resolve(groupModels)); + + const models = await searchGroupsByNameInChannel(serverUrl, group.name, channelId); + expect(models).toBeDefined(); + expect(models.length).toBe(1); + expect(models[0].id).toBe(group.id); + }); }); diff --git a/app/actions/local/post.test.ts b/app/actions/local/post.test.ts index aa8707abe1e..f7a920fdd2b 100644 --- a/app/actions/local/post.test.ts +++ b/app/actions/local/post.test.ts @@ -36,6 +36,16 @@ jest.mock('@utils/general', () => { }; }); +let mockGetIsCRTEnabled: jest.Mock; +jest.mock('@queries/servers/thread', () => { + const original = jest.requireActual('@queries/servers/thread'); + mockGetIsCRTEnabled = jest.fn(() => true); + return { + ...original, + getIsCRTEnabled: mockGetIsCRTEnabled, + }; +}); + const channelId = 'channelid1'; const user: UserProfile = { id: 'userid', @@ -62,7 +72,7 @@ describe('sendAddToChannelEphemeralPost', () => { it('base case', async () => { const users = await operator.handleUsers({users: [user], prepareRecordsOnly: false}); - const {posts, error} = await sendAddToChannelEphemeralPost(serverUrl, users[0], ['username2'], ['added username2'], channelId, ''); + const {posts, error} = await sendAddToChannelEphemeralPost(serverUrl, users[0], ['username2'], ['added username2'], channelId); expect(error).toBeUndefined(); expect(posts).toBeDefined(); expect(posts?.length).toBe(1); @@ -86,7 +96,7 @@ describe('sendEphemeralPost', () => { it('handle no user', async () => { await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: 'useridcurrent'}], prepareRecordsOnly: false}); - const {post, error} = await sendEphemeralPost(serverUrl, 'newmessage', channelId, ''); + const {post, error} = await sendEphemeralPost(serverUrl, 'newmessage', channelId); expect(error).toBeUndefined(); expect(post).toBeDefined(); expect(post?.user_id).toBe('useridcurrent'); @@ -122,6 +132,12 @@ describe('removePost', () => { expect(rPost).toBeDefined(); }); + it('base case - missing post', async () => { + const {post: rPost, error} = await removePost(serverUrl, post); + expect(error).toBeUndefined(); + expect(rPost).toBeDefined(); + }); + it('base case - system message', async () => { const systemPost = {...TestHelper.fakePost(channelId), id: `${COMBINED_USER_ACTIVITY}id1_id2`, type: Post.POST_TYPES.COMBINED_USER_ACTIVITY as PostType, props: {system_post_ids: ['id1']}}; @@ -189,22 +205,24 @@ describe('storePostsForChannel', () => { expect(error).toBeTruthy(); }); - it('base case', async () => { + it('base case - CRT on', async () => { await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false}); - await operator.handleConfigs({ - configs: [ - {id: 'CollapsedThreads', value: 'default_on'}, - {id: 'FeatureFlagCollapsedThreads', value: 'true'}, - ], - configsToDelete: [], - prepareRecordsOnly: false, - }); const {models, error} = await storePostsForChannel(serverUrl, channelId, [post], [post.id], '', ActionType.POSTS.RECEIVED_IN_CHANNEL, [user], false); expect(error).toBeUndefined(); expect(models).toBeDefined(); expect(models?.length).toBe(5); // Post, PostsInChannel, User, MyChannel, Thread }); + + it('base case - CRT off', async () => { + await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false}); + mockGetIsCRTEnabled.mockImplementationOnce(() => false); + + const {models, error} = await storePostsForChannel(serverUrl, channelId, [post], [post.id], '', ActionType.POSTS.RECEIVED_IN_CHANNEL, [user]); + expect(error).toBeUndefined(); + expect(models).toBeDefined(); + expect(models?.length).toBe(4); // Post, PostsInChannel, User, MyChannel + }); }); describe('getPosts', () => { @@ -267,7 +285,7 @@ describe('addPostAcknowledgement', () => { prepareRecordsOnly: false, }); - const {model, error} = await addPostAcknowledgement(serverUrl, post.id, user.id, 123, false); + const {model, error} = await addPostAcknowledgement(serverUrl, post.id, user.id, 123); expect(error).toBeUndefined(); expect(model).toBeDefined(); }); @@ -296,7 +314,7 @@ describe('removePostAcknowledgement', () => { prepareRecordsOnly: false, }); - const {model, error} = await removePostAcknowledgement(serverUrl, post.id, user.id, false); + const {model, error} = await removePostAcknowledgement(serverUrl, post.id, user.id); expect(error).toBeUndefined(); expect(model).toBeDefined(); }); diff --git a/app/actions/local/post.ts b/app/actions/local/post.ts index 8dffef59988..5a9b159a747 100644 --- a/app/actions/local/post.ts +++ b/app/actions/local/post.ts @@ -160,7 +160,7 @@ export async function markPostAsDeleted(serverUrl: string, post: Post, prepareRe p.message = ''; p.messageSource = ''; p.metadata = null; - p.props = undefined; + p.props = null; }); if (!prepareRecordsOnly) { diff --git a/app/actions/local/systems.test.ts b/app/actions/local/systems.test.ts index be3fd8e6be6..b4cda2eb81f 100644 --- a/app/actions/local/systems.test.ts +++ b/app/actions/local/systems.test.ts @@ -50,6 +50,41 @@ describe('storeConfigAndLicense', () => { expect(models.length).toBe(0); }); + it('handle undefined config - storeConfig', async () => { + const models = await storeConfig(serverUrl, undefined); + expect(models).toBeDefined(); + expect(models.length).toBe(0); + }); + + it('base case - storeConfig', async () => { + await operator.handleConfigs({ + configs: [ + {id: 'DataRetentionEnableMessageDeletion', value: 'true'}, + {id: 'AboutLink', value: 'link'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + const models = await storeConfig(serverUrl, {AboutLink: 'link'} as ClientConfig); + expect(models).toBeDefined(); + expect(models.length).toBe(1); // data retention removed + }); + + it('nothing to update - storeConfig', async () => { + await operator.handleConfigs({ + configs: [ + {id: 'AboutLink', value: 'link'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + const models = await storeConfig(serverUrl, {AboutLink: 'link'} as ClientConfig); + expect(models).toBeDefined(); + expect(models.length).toBe(0); + }); + it('handle not found database', async () => { const models = await storeConfigAndLicense('foo', {} as ClientConfig, {} as ClientLicense); expect(models).toBeDefined(); @@ -76,6 +111,12 @@ describe('dataRetention', () => { expect(models.length).toBe(2); // data retention and granular data retention policies }); + it('empty case - storeDataRetentionPolicies', async () => { + const models = await storeDataRetentionPolicies(serverUrl, {} as DataRetentionPoliciesRequest); + expect(models).toBeDefined(); + expect(models.length).toBe(2); // data retention and granular data retention policies + }); + it('handle not found database - updateLastDataRetentionRun', async () => { const {error} = await updateLastDataRetentionRun('foo', 0) as {error: unknown}; expect(error).toBeDefined(); @@ -87,6 +128,12 @@ describe('dataRetention', () => { expect(models.length).toBe(1); // data retention }); + it('no time provided - updateLastDataRetentionRun', async () => { + const models = await updateLastDataRetentionRun(serverUrl) as SystemModel[]; + expect(models).toBeDefined(); + expect(models.length).toBe(1); // data retention + }); + it('handle not found database - dataRetentionCleanup', async () => { const {error} = await dataRetentionCleanup('foo'); expect(error).toBeDefined(); @@ -134,6 +181,33 @@ describe('dataRetention', () => { expect(error).toBeDefined(); // LokiJSAdapter doesn't support unsafeSqlQuery spy.mockRestore(); }); + + it('already cleaned today - dataRetentionCleanup', async () => { + const channel: Channel = { + id: 'channelid1', + team_id: 'teamid1', + total_msg_count: 0, + } as Channel; + + await operator.handleConfigs({ + configs: [ + {id: 'DataRetentionEnableMessageDeletion', value: 'true'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + await operator.handleSystem({systems: + [ + {id: SYSTEM_IDENTIFIERS.LAST_DATA_RETENTION_RUN, value: Date.now()}, + {id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', DataRetention: 'true'}}, + {id: SYSTEM_IDENTIFIERS.GRANULAR_DATA_RETENTION_POLICIES, value: {team: [{team_id: 'teamid1', post_duration: 100}], channel: [{channel_id: 'channelid1', post_duration: 100}]}}, + ], + prepareRecordsOnly: false}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + + const {error} = await dataRetentionCleanup(serverUrl); + expect(error).toBeUndefined(); + }); }); describe('setLastServerVersionCheck', () => { @@ -146,6 +220,11 @@ describe('setLastServerVersionCheck', () => { const {error} = await setLastServerVersionCheck(serverUrl); expect(error).toBeUndefined(); }); + + it('base case - reset', async () => { + const {error} = await setLastServerVersionCheck(serverUrl, true); + expect(error).toBeUndefined(); + }); }); describe('setGlobalThreadsTab', () => { diff --git a/app/actions/local/thread.test.ts b/app/actions/local/thread.test.ts index cb7b7c22094..b12879cc084 100644 --- a/app/actions/local/thread.test.ts +++ b/app/actions/local/thread.test.ts @@ -38,6 +38,7 @@ jest.mock('@store/navigation_store', () => { return { ...original, waitUntilScreenIsTop: jest.fn(() => Promise.resolve()), + getScreensInStack: jest.fn(() => []), }; }); @@ -99,7 +100,25 @@ describe('switchToGlobalThreads', () => { await operator.handleTeam({teams: [team], prepareRecordsOnly: false}); await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); - const {models, error} = await switchToGlobalThreads(serverUrl, undefined, false); + const {models, error} = await switchToGlobalThreads(serverUrl, undefined); + expect(error).toBeUndefined(); + expect(models).toBeDefined(); + expect(models?.length).toBe(1); // history + }); + + it('base case - provided team id', async () => { + let mockIsTablet: jest.Mock; + jest.mock('@utils/helpers', () => { + const original = jest.requireActual('@utils/helpers'); + mockIsTablet = jest.fn(() => true); + return { + ...original, + isTablet: mockIsTablet, + }; + }); + await operator.handleTeam({teams: [team], prepareRecordsOnly: false}); + + const {models, error} = await switchToGlobalThreads(serverUrl, team.id); expect(error).toBeUndefined(); expect(models).toBeDefined(); expect(models?.length).toBe(1); // history @@ -121,7 +140,7 @@ describe('switchToThread', () => { await operator.handleUsers({users: [user], prepareRecordsOnly: false}); await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); - const {error} = await switchToThread(serverUrl, '', false); + const {error} = await switchToThread(serverUrl, ''); expect((error as Error).message).toBe('Post not found'); }); @@ -136,7 +155,7 @@ describe('switchToThread', () => { prepareRecordsOnly: false, }); - const {error} = await switchToThread(serverUrl, post.id, false); + const {error} = await switchToThread(serverUrl, post.id); expect((error as Error).message).toBe('Channel not found'); }); @@ -157,6 +176,42 @@ describe('switchToThread', () => { const {error} = await switchToThread(serverUrl, post.id, true); expect(error).toBeUndefined(); }); + + it('base case not from notification', async () => { + EphemeralStore.theme = Preferences.THEMES.denim; + await operator.handleUsers({users: [user, user2], prepareRecordsOnly: false}); + await operator.handleTeam({teams: [team], prepareRecordsOnly: false}); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: 'teamid2'}, {id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + const post = {...TestHelper.fakePost(channelId, user2.id), id: 'postid', create_at: 1}; + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post.id], + posts: [post], + prepareRecordsOnly: false, + }); + + const {error} = await switchToThread(serverUrl, post.id, false); + expect(error).toBeUndefined(); + }); + + it('base case for DM', async () => { + EphemeralStore.theme = Preferences.THEMES.denim; + await operator.handleUsers({users: [user, user2], prepareRecordsOnly: false}); + await operator.handleTeam({teams: [team], prepareRecordsOnly: false}); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: 'teamid2'}, {id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); + await operator.handleChannel({channels: [{...channel, team_id: '', type: 'D', display_name: 'user1-user2'}], prepareRecordsOnly: false}); + const post = {...TestHelper.fakePost(channelId, user2.id), id: 'postid', create_at: 1}; + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post.id], + posts: [post], + prepareRecordsOnly: false, + }); + + const {error} = await switchToThread(serverUrl, post.id, true); + expect(error).toBeUndefined(); + }); }); describe('createThreadFromNewPost', () => { @@ -181,7 +236,7 @@ describe('createThreadFromNewPost', () => { await operator.handleUsers({users: [user2], prepareRecordsOnly: false}); const post = {...TestHelper.fakePost(channelId, user2.id), id: 'postid', create_at: 1}; - const {models, error} = await createThreadFromNewPost(serverUrl, post, false); + const {models, error} = await createThreadFromNewPost(serverUrl, post); expect(error).toBeUndefined(); expect(models).toBeDefined(); expect(models?.length).toBe(1); // thread @@ -215,7 +270,7 @@ describe('processReceivedThreads', () => { }, ] as Thread[]; - const {models, error} = await processReceivedThreads(serverUrl, thread, team.id, false); + const {models, error} = await processReceivedThreads(serverUrl, thread, team.id); expect(error).toBeUndefined(); expect(models).toBeDefined(); expect(models?.length).toBe(4); // post, thread, thread participant, thread in team @@ -289,7 +344,7 @@ describe('updateTeamThreadsSync', () => { }); it('base case', async () => { - const {models, error} = await updateTeamThreadsSync(serverUrl, {id: 'id1', earliest: 1, latest: 2}, false); + const {models, error} = await updateTeamThreadsSync(serverUrl, {id: 'id1', earliest: 1, latest: 2}); expect(error).toBeUndefined(); expect(models).toBeDefined(); }); diff --git a/app/actions/remote/category.test.ts b/app/actions/remote/category.test.ts new file mode 100644 index 00000000000..f338c919b52 --- /dev/null +++ b/app/actions/remote/category.test.ts @@ -0,0 +1,239 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {storeCategories} from '@actions/local/category'; +import {CHANNELS_CATEGORY, DMS_CATEGORY, FAVORITES_CATEGORY} from '@constants/categories'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import NetworkManager from '@managers/network_manager'; +import {getFullErrorMessage} from '@utils/errors'; +import {logDebug} from '@utils/log'; + +import {fetchCategories, toggleFavoriteChannel} from './category'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; + +jest.mock('@managers/network_manager'); +jest.mock('@utils/log'); +jest.mock('@utils/errors'); +jest.mock('@actions/local/category'); + +const serverUrl = 'baseHandler.test.com'; +let operator: ServerDataOperator; + +const teamId = 'teamid1'; +const channelId = 'channelid1'; +const channel = {id: channelId, type: 'O'} as Channel; +const mockCategories = [{id: 'category1'}, {id: 'category2'}]; +const error = new Error('Test error'); + +beforeEach(async () => { + jest.clearAllMocks(); + await DatabaseManager.init([serverUrl]); + operator = DatabaseManager.serverDatabases[serverUrl]!.operator; +}); + +describe('fetchCategories', () => { + it('should fetch categories successfully', async () => { + const mockClient = { + getCategories: jest.fn().mockResolvedValue({categories: mockCategories}), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + + const result = await fetchCategories(serverUrl, teamId); + + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.getCategories).toHaveBeenCalledWith('me', teamId); + expect(storeCategories).toHaveBeenCalledWith(serverUrl, mockCategories, false); + expect(result).toEqual({categories: mockCategories}); + }); + + it('should only fetch categories successfully', async () => { + const mockClient = { + getCategories: jest.fn().mockResolvedValue({categories: mockCategories}), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + + const result = await fetchCategories(serverUrl, teamId, false, true); + + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.getCategories).toHaveBeenCalledWith('me', teamId); + expect(result).toEqual({categories: mockCategories}); + }); + + it('should handle error during fetch categories', async () => { + const mockClient = { + getCategories: jest.fn().mockRejectedValue(error), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + (getFullErrorMessage as jest.Mock).mockReturnValue('Full error message'); + + const result = await fetchCategories(serverUrl, teamId); + + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.getCategories).toHaveBeenCalledWith('me', teamId); + expect(logDebug).toHaveBeenCalledWith('error on fetchCategories', 'Full error message'); + expect(result).toEqual({error}); + }); +}); + +describe('toggleFavoriteChannel', () => { + const favCategory: Category = { + id: 'fav_category_id', + team_id: teamId, + type: FAVORITES_CATEGORY, + } as Category; + + const categoryChannels: CategoryChannel = { + id: 'teamid1_channelid1', + category_id: 'fav_category_id', + channel_id: channelId, + sort_order: 1, + }; + + const defaultCategory: Category = { + id: 'default_category_id', + team_id: teamId, + type: CHANNELS_CATEGORY, + } as Category; + + const dmCategory: Category = { + id: 'dm_category_id', + team_id: teamId, + type: DMS_CATEGORY, + } as Category; + + it('should handle no channel found', async () => { + const result = await toggleFavoriteChannel(serverUrl, channelId, true); + + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(result).toEqual({error: 'channel not found'}); + }); + + it('should handle no channel category', async () => { + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + const result = await toggleFavoriteChannel(serverUrl, channelId, true); + + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(result).toEqual({error: 'channel does not belong to a category'}); + }); + + it('should error on no target category', async () => { + await operator.handleCategoryChannels({categoryChannels: [categoryChannels], prepareRecordsOnly: false}); + await operator.handleCategories({categories: [favCategory], prepareRecordsOnly: false}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + const mockClient = { + updateChannelCategories: jest.fn().mockResolvedValue({}), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + + const result = await toggleFavoriteChannel(serverUrl, channelId, true); + + expect(result).toEqual({error: 'target category not found'}); + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + }); + + it('should unfavorite channel successfully', async () => { + await operator.handleCategoryChannels({categoryChannels: [categoryChannels], prepareRecordsOnly: false}); + await operator.handleCategories({categories: [favCategory, defaultCategory], prepareRecordsOnly: false}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + const mockClient = { + updateChannelCategories: jest.fn().mockResolvedValue({}), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + + const result = await toggleFavoriteChannel(serverUrl, channelId, true); + + expect(result).toEqual({data: true}); + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.updateChannelCategories).toHaveBeenCalled(); + }); + + it('should unfavorite DM channel successfully', async () => { + await operator.handleCategoryChannels({categoryChannels: [categoryChannels], prepareRecordsOnly: false}); + await operator.handleCategories({categories: [favCategory, dmCategory], prepareRecordsOnly: false}); + await operator.handleChannel({channels: [{...channel, type: 'D', display_name: 'displayname'}], prepareRecordsOnly: false}); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + const mockClient = { + updateChannelCategories: jest.fn().mockResolvedValue({}), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + + const result = await toggleFavoriteChannel(serverUrl, channelId, true); + + expect(result).toEqual({data: true}); + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.updateChannelCategories).toHaveBeenCalled(); + }); + + it('should favorite channel successfully', async () => { + await operator.handleCategoryChannels({categoryChannels: [{...categoryChannels, category_id: defaultCategory.id}], prepareRecordsOnly: false}); + await operator.handleCategories({categories: [favCategory, defaultCategory], prepareRecordsOnly: false}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + const mockClient = { + updateChannelCategories: jest.fn().mockResolvedValue({}), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + + const result = await toggleFavoriteChannel(serverUrl, channelId, true); + + expect(result).toEqual({data: true}); + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.updateChannelCategories).toHaveBeenCalled(); + }); + + it('should favorite channel successfully with no snack bar', async () => { + await operator.handleCategoryChannels({categoryChannels: [{...categoryChannels, category_id: defaultCategory.id}], prepareRecordsOnly: false}); + await operator.handleCategories({categories: [favCategory, defaultCategory], prepareRecordsOnly: false}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + const mockClient = { + updateChannelCategories: jest.fn().mockResolvedValue({}), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + + const result = await toggleFavoriteChannel(serverUrl, channelId, false); + + expect(result).toEqual({data: true}); + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.updateChannelCategories).toHaveBeenCalled(); + }); + + it('should error on no favorites category', async () => { + await operator.handleCategoryChannels({categoryChannels: [{...categoryChannels, category_id: defaultCategory.id}], prepareRecordsOnly: false}); + await operator.handleCategories({categories: [defaultCategory], prepareRecordsOnly: false}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + const mockClient = { + updateChannelCategories: jest.fn().mockResolvedValue({}), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + + const result = await toggleFavoriteChannel(serverUrl, channelId, true); + + expect(result).toEqual({error: 'No favorites category'}); + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + }); + + it('should handle error during toggle favorite channel', async () => { + await operator.handleCategoryChannels({categoryChannels: [categoryChannels], prepareRecordsOnly: false}); + await operator.handleCategories({categories: [favCategory, defaultCategory], prepareRecordsOnly: false}); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + const mockClient = { + updateChannelCategories: jest.fn().mockRejectedValue(error), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + (getFullErrorMessage as jest.Mock).mockReturnValue('Full error message'); + + const result = await toggleFavoriteChannel(serverUrl, channelId); + + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.updateChannelCategories).toHaveBeenCalled(); + expect(logDebug).toHaveBeenCalledWith('error on toggleFavoriteChannel', 'Full error message'); + expect(result).toEqual({error}); + }); +}); diff --git a/app/actions/remote/command.test.ts b/app/actions/remote/command.test.ts index 005a81a3706..3e565ea5cd2 100644 --- a/app/actions/remote/command.test.ts +++ b/app/actions/remote/command.test.ts @@ -107,6 +107,15 @@ describe('executeCommand', () => { expect(result).toEqual({error: 'invalid_url database not found'}); }); + it('handle client error', async () => { + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(() => { + throw error; + }); + + const result = await executeCommand(serverUrl, intl, message, channelId, rootId); + expect(result).toEqual({error}); + }); + it('handle apps enabled', async () => { jest.spyOn(AppsManager, 'isAppsEnabled').mockResolvedValue(true); const parser = { @@ -164,6 +173,34 @@ describe('executeCommand', () => { expect(result).toEqual({data: {trigger_id: 'trigger_id'}}); }); + it('handle /code command execution with successful response', async () => { + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + + jest.spyOn(AppsManager, 'isAppsEnabled').mockResolvedValue(false); + const mockSetTriggerId = jest.fn(); + jest.spyOn(IntegrationsManager, 'getManager').mockReturnValue({ + setTriggerId: mockSetTriggerId, + } as any); + + const result = await executeCommand(serverUrl, intl, '/code', channelId, rootId); + + expect(mockClient.executeCommand).toHaveBeenCalledWith('/code ', args); + expect(mockSetTriggerId).toHaveBeenCalledWith('trigger_id'); + expect(result).toEqual({data: {trigger_id: 'trigger_id'}}); + }); + + it('handle command execution with no trigger id', async () => { + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + + jest.spyOn(AppsManager, 'isAppsEnabled').mockResolvedValue(false); + mockClient.executeCommand.mockResolvedValueOnce({} as never); + + const result = await executeCommand(serverUrl, intl, message, channelId, rootId); + + expect(mockClient.executeCommand).toHaveBeenCalledWith(message, args); + expect(result).toEqual({data: {}}); + }); + it('handle command execution with error response', async () => { await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); @@ -181,6 +218,16 @@ describe('executeCommand', () => { describe('executeAppCommand', () => { const msg = 'test message'; + it('should handle a undefined creq', async () => { + const parser = { + composeCommandSubmitCall: jest.fn().mockResolvedValue({errorMessage: 'Error occurred'}), + }; + + const result = await executeAppCommand(serverUrl, intl, parser as any, msg, args); + + expect(result).toEqual({error: {message: 'Error occurred'}}); + }); + it('should handle a successful command execution with OK response', async () => { const parser = { composeCommandSubmitCall: jest.fn().mockResolvedValue({creq: {}, errorMessage: null}), @@ -196,6 +243,20 @@ describe('executeAppCommand', () => { expect(result).toEqual({data: {}}); }); + it('should handle OK response with no text', async () => { + const parser = { + composeCommandSubmitCall: jest.fn().mockResolvedValue({creq: {}, errorMessage: null}), + }; + (AppCommandParser as jest.Mock).mockReturnValue(parser); + (doAppSubmit as jest.Mock).mockResolvedValue({data: {type: AppCallResponseTypes.OK}}); + + const result = await executeAppCommand(serverUrl, intl, parser as any, msg, args); + + expect(parser.composeCommandSubmitCall).toHaveBeenCalledWith(msg); + expect(doAppSubmit).toHaveBeenCalledWith(serverUrl, {}, intl); + expect(result).toEqual({data: {}}); + }); + it('should handle an error response', async () => { const parser = { composeCommandSubmitCall: jest.fn().mockResolvedValue({creq: {}, errorMessage: null}), @@ -210,6 +271,20 @@ describe('executeAppCommand', () => { expect(result).toEqual({error: {message: 'Error occurred'}}); }); + it('should handle an error response with no text', async () => { + const parser = { + composeCommandSubmitCall: jest.fn().mockResolvedValue({creq: {}, errorMessage: null}), + }; + (AppCommandParser as jest.Mock).mockReturnValue(parser); + (doAppSubmit as jest.Mock).mockResolvedValue({error: {}}); + + const result = await executeAppCommand(serverUrl, intl, parser as any, msg, args); + + expect(parser.composeCommandSubmitCall).toHaveBeenCalledWith(msg); + expect(doAppSubmit).toHaveBeenCalledWith(serverUrl, {}, intl); + expect(result).toEqual({error: {message: 'Unknown error.'}}); + }); + it('should handle a form response', async () => { const parser = { composeCommandSubmitCall: jest.fn().mockResolvedValue({creq: {context: {}}, errorMessage: null}), @@ -225,6 +300,20 @@ describe('executeAppCommand', () => { expect(result).toEqual({data: {}}); }); + it('should handle a form response with no form', async () => { + const parser = { + composeCommandSubmitCall: jest.fn().mockResolvedValue({creq: {context: {}}, errorMessage: null}), + }; + (AppCommandParser as jest.Mock).mockReturnValue(parser); + (doAppSubmit as jest.Mock).mockResolvedValue({data: {type: AppCallResponseTypes.FORM}}); + + const result = await executeAppCommand(serverUrl, intl, parser as any, msg, args); + + expect(parser.composeCommandSubmitCall).toHaveBeenCalledWith(msg); + expect(doAppSubmit).toHaveBeenCalledWith(serverUrl, {context: {}}, intl); + expect(result).toEqual({data: {}}); + }); + it('should handle a navigate response', async () => { const parser = { composeCommandSubmitCall: jest.fn().mockResolvedValue({creq: {}, errorMessage: null}), @@ -239,6 +328,20 @@ describe('executeAppCommand', () => { expect(result).toEqual({data: {}}); }); + it('should handle a navigate response with no url', async () => { + const parser = { + composeCommandSubmitCall: jest.fn().mockResolvedValue({creq: {}, errorMessage: null}), + }; + (AppCommandParser as jest.Mock).mockReturnValue(parser); + (doAppSubmit as jest.Mock).mockResolvedValue({data: {type: AppCallResponseTypes.NAVIGATE}}); + + const result = await executeAppCommand(serverUrl, intl, parser as any, msg, args); + + expect(parser.composeCommandSubmitCall).toHaveBeenCalledWith(msg); + expect(doAppSubmit).toHaveBeenCalledWith(serverUrl, {}, intl); + expect(result).toEqual({data: {}}); + }); + it('should handle an unknown response type', async () => { const parser = { composeCommandSubmitCall: jest.fn().mockResolvedValue({creq: {}, errorMessage: null}), diff --git a/app/actions/remote/custom_emoji.test.ts b/app/actions/remote/custom_emoji.test.ts new file mode 100644 index 00000000000..69f3f4ad3f6 --- /dev/null +++ b/app/actions/remote/custom_emoji.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {forceLogoutIfNecessary} from '@actions/remote/session'; +import DatabaseManager from '@database/manager'; +import NetworkManager from '@managers/network_manager'; +import {getFullErrorMessage} from '@utils/errors'; +import {logDebug} from '@utils/log'; + +import {fetchCustomEmojis, searchCustomEmojis, fetchCustomEmojiInBatchForTest} from './custom_emoji'; + +jest.mock('@managers/network_manager'); +jest.mock('@utils/log'); +jest.mock('@utils/errors'); +jest.mock('@actions/remote/session'); + +const serverUrl = 'baseHandler.test.com'; + +const emojiId = 'emoji_id'; +const emoji = {id: emojiId, name: 'emoji_name'}; +const mockEmojis = [emoji]; +const error = new Error('Test error'); + +beforeEach(async () => { + jest.clearAllMocks(); + await DatabaseManager.init([serverUrl]); +}); + +describe('fetchCustomEmojis', () => { + it('should fetch custom emojis successfully', async () => { + const mockClient = { + getCustomEmojis: jest.fn().mockResolvedValue(mockEmojis), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + + const result = await fetchCustomEmojis(serverUrl); + + expect(result).toEqual({data: mockEmojis}); + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.getCustomEmojis).toHaveBeenCalled(); + }); + + it('should handle error during fetch custom emojis', async () => { + const mockClient = { + getCustomEmojis: jest.fn().mockRejectedValue(error), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + (getFullErrorMessage as jest.Mock).mockReturnValue('Full error message'); + + const result = await fetchCustomEmojis(serverUrl); + + expect(result).toEqual({error}); + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.getCustomEmojis).toHaveBeenCalled(); + expect(logDebug).toHaveBeenCalledWith('error on fetchCustomEmojis', 'Full error message'); + expect(forceLogoutIfNecessary).toHaveBeenCalled(); + }); +}); + +describe('searchCustomEmojis', () => { + it('should search custom emojis successfully', async () => { + const term = 'emoji'; + const mockClient = { + searchCustomEmoji: jest.fn().mockResolvedValue(mockEmojis), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + + const result = await searchCustomEmojis(serverUrl, term); + + expect(result).toEqual({data: mockEmojis}); + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.searchCustomEmoji).toHaveBeenCalledWith(term); + }); + + it('should handle error during search custom emojis', async () => { + const term = 'emoji'; + const mockClient = { + searchCustomEmoji: jest.fn().mockRejectedValue(error), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + (getFullErrorMessage as jest.Mock).mockReturnValue('Full error message'); + + const result = await searchCustomEmojis(serverUrl, term); + + expect(result).toEqual({error}); + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.searchCustomEmoji).toHaveBeenCalledWith(term); + expect(logDebug).toHaveBeenCalledWith('error on searchCustomEmojis', 'Full error message'); + expect(forceLogoutIfNecessary).toHaveBeenCalled(); + }); +}); + +describe('fetchEmojisByName', () => { + it('should fetch emojis by name successfully', async () => { + const mockClient = { + getCustomEmojiByName: jest.fn().mockResolvedValue(emoji), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + + await fetchCustomEmojiInBatchForTest(serverUrl, emoji.name); + + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.getCustomEmojiByName).toHaveBeenCalledWith(emoji.name); + }); + + it('should handle no emojis', async () => { + const mockClient = { + getCustomEmojiByName: jest.fn().mockRejectedValue('error message'), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + + await fetchCustomEmojiInBatchForTest(serverUrl, emoji.name); + + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.getCustomEmojiByName).toHaveBeenCalledWith(emoji.name); + }); + + it('should handle error during fetch emojis by name', async () => { + (NetworkManager.getClient as jest.Mock).mockRejectedValue('error message'); + await fetchCustomEmojiInBatchForTest(serverUrl, emoji.name); + + expect(logDebug).toHaveBeenCalled(); + }); +}); diff --git a/app/actions/remote/custom_emoji.ts b/app/actions/remote/custom_emoji.ts index 3a309420043..4b5188311f6 100644 --- a/app/actions/remote/custom_emoji.ts +++ b/app/actions/remote/custom_emoji.ts @@ -53,7 +53,8 @@ export const searchCustomEmojis = async (serverUrl: string, term: string) => { }; const names = new Set(); -const debouncedFetchEmojiByNames = debounce(async (serverUrl: string) => { + +export const fetchEmojisByName = async (serverUrl: string) => { try { const client = NetworkManager.getClient(serverUrl); const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -77,7 +78,9 @@ const debouncedFetchEmojiByNames = debounce(async (serverUrl: string) => { logDebug('error on debouncedFetchEmojiByNames', getFullErrorMessage(error)); return {error}; } -}, 200, false, () => { +}; + +const debouncedFetchEmojiByNames = debounce(fetchEmojisByName, 200, false, () => { names.clear(); }); @@ -85,3 +88,8 @@ export const fetchCustomEmojiInBatch = (serverUrl: string, emojiName: string) => names.add(emojiName); return debouncedFetchEmojiByNames.apply(null, [serverUrl]); }; + +export const fetchCustomEmojiInBatchForTest = (serverUrl: string, emojiName: string) => { + names.add(emojiName); + return fetchEmojisByName(serverUrl); +}; diff --git a/app/actions/remote/entry/common.ts b/app/actions/remote/entry/common.ts index c9c1ab2142d..8a68e7e6be7 100644 --- a/app/actions/remote/entry/common.ts +++ b/app/actions/remote/entry/common.ts @@ -396,6 +396,11 @@ async function restDeferredAppEntryActions( const sortedTeamIds = new Set(teamsOrder?.value.split(',')); const membershipSet = new Set(teamData.memberships.map((m) => m.team_id)); const teamMap = new Map(teamData.teams.map((t) => [t.id, t])); + if (initialTeamId) { + sortedTeamIds.delete(initialTeamId); + membershipSet.delete(initialTeamId); + teamMap.delete(initialTeamId); + } let myTeams: Team[]; if (sortedTeamIds.size) { @@ -411,7 +416,10 @@ async function restDeferredAppEntryActions( myTeams = teamData.teams. sort((a, b) => a.display_name.toLocaleLowerCase().localeCompare(b.display_name.toLocaleLowerCase())); } - fetchTeamsChannelsThreadsAndUnreadPosts(serverUrl, since, myTeams, isCRTEnabled); + + if (myTeams.length) { + fetchTeamsChannelsThreadsAndUnreadPosts(serverUrl, since, myTeams, isCRTEnabled); + } } }); diff --git a/app/actions/remote/post.test.ts b/app/actions/remote/post.test.ts index 428381c3fe0..998d4765dee 100644 --- a/app/actions/remote/post.test.ts +++ b/app/actions/remote/post.test.ts @@ -3,7 +3,7 @@ /* eslint-disable max-lines */ -import {ActionType, Post} from '@constants'; +import {ActionType, Post, ServerErrors} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import PostModel from '@database/models/server/post'; @@ -121,6 +121,16 @@ jest.mock('@queries/servers/thread', () => { }; }); +let mockAddRecentReaction: jest.Mock; +jest.mock('@actions/local/reactions', () => { + const original = jest.requireActual('@actions/local/reactions'); + mockAddRecentReaction = jest.fn(() => [{user_id: 'userid1', emoji_name: 'smile'}]); + return { + ...original, + addRecentReaction: mockAddRecentReaction, + }; +}); + beforeAll(() => { // eslint-disable-next-line // @ts-ignore @@ -143,6 +153,28 @@ describe('create, update & delete posts', () => { expect(result.error).toBeTruthy(); }); + it('createPost - handle client error', async () => { + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); + + const result = await createPost(serverUrl, post1); + expect(result).toBeDefined(); + expect(result.error).toBeTruthy(); + }); + + it('createPost - handle existing failed post', async () => { + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post1.id], + posts: [{...post1, props: {failed: false}}], + prepareRecordsOnly: false, + }); + + const result = await createPost(serverUrl, {...post1, pending_post_id: post1.id}); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.data).toBe(false); + }); + it('createPost - fail create', async () => { mockClient.createPost.mockImplementationOnce(jest.fn(throwFunc)); await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); @@ -153,6 +185,45 @@ describe('create, update & delete posts', () => { expect(result.data).toBeTruthy(); }); + it('createPost - fail on deleted root post server error', async () => { + mockClient.createPost.mockImplementationOnce(jest.fn(() => { + // eslint-disable-next-line no-throw-literal + throw {message: 'error', server_error_id: ServerErrors.DELETED_ROOT_POST_ERROR}; + })); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + + const result = await createPost(serverUrl, post1); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.data).toBeTruthy(); + }); + + it('createPost - fail on town square read only server error', async () => { + mockClient.createPost.mockImplementationOnce(jest.fn(() => { + // eslint-disable-next-line no-throw-literal + throw {message: 'error', server_error_id: ServerErrors.TOWN_SQUARE_READ_ONLY_ERROR}; + })); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + + const result = await createPost(serverUrl, post1); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.data).toBeTruthy(); + }); + + it('createPost - fail on plugin dismissed post server error', async () => { + mockClient.createPost.mockImplementationOnce(jest.fn(() => { + // eslint-disable-next-line no-throw-literal + throw {message: 'error', server_error_id: ServerErrors.PLUGIN_DISMISSED_POST_ERROR}; + })); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + + const result = await createPost(serverUrl, post1); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.data).toBeTruthy(); + }); + it('createPost - root', async () => { await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); @@ -162,6 +233,16 @@ describe('create, update & delete posts', () => { expect(result.data).toBeTruthy(); }); + it('createPost - without reactions', async () => { + mockAddRecentReaction.mockImplementationOnce(() => []); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + + const result = await createPost(serverUrl, post1); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.data).toBeTruthy(); + }); + it('createPost - reply', async () => { await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); @@ -177,6 +258,14 @@ describe('create, update & delete posts', () => { expect(result.error).toBeTruthy(); }); + it('retryFailedPost - handle client error', async () => { + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); + + const result = await retryFailedPost(serverUrl, mockPostModel({id: post1.id, prepareUpdate: jest.fn(), toApi: async () => post1})); + expect(result).toBeDefined(); + expect(result.error).toBeTruthy(); + }); + it('retryFailedPost - base case', async () => { await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); @@ -192,6 +281,42 @@ describe('create, update & delete posts', () => { expect(result.error).toBeTruthy(); }); + it('retryFailedPost - fail on deleted root post server error', async () => { + mockClient.createPost.mockImplementationOnce(jest.fn(() => { + // eslint-disable-next-line no-throw-literal + throw {message: 'error', server_error_id: ServerErrors.DELETED_ROOT_POST_ERROR}; + })); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + + const result = await retryFailedPost(serverUrl, mockPostModel({id: post1.id, prepareUpdate: jest.fn(), toApi: async () => post1})); + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + }); + + it('retryFailedPost - fail on town square read only server error', async () => { + mockClient.createPost.mockImplementationOnce(jest.fn(() => { + // eslint-disable-next-line no-throw-literal + throw {message: 'error', server_error_id: ServerErrors.TOWN_SQUARE_READ_ONLY_ERROR}; + })); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + + const result = await retryFailedPost(serverUrl, mockPostModel({id: post1.id, prepareUpdate: jest.fn(), toApi: async () => post1})); + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + }); + + it('retryFailedPost - fail on plugin dismissed post server error', async () => { + mockClient.createPost.mockImplementationOnce(jest.fn(() => { + // eslint-disable-next-line no-throw-literal + throw {message: 'error', server_error_id: ServerErrors.PLUGIN_DISMISSED_POST_ERROR}; + })); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + + const result = await retryFailedPost(serverUrl, mockPostModel({id: post1.id, prepareUpdate: jest.fn(), toApi: async () => post1})); + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + }); + it('togglePinPost - handle database not found', async () => { const result = await togglePinPost('foo', ''); expect(result).toBeDefined(); @@ -382,6 +507,65 @@ describe('get posts', () => { expect(result.posts?.length).toBe(2); }); + it('fetchPostsForChannel - base case with since', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + await operator.handleMyChannel({channels: [{ + id: channelId, + team_id: teamId, + total_msg_count: 0, + creator_id: user1.id, + } as Channel], + myChannels: [{ + id: 'id', + channel_id: channelId, + user_id: user1.id, + msg_count: 0, + } as ChannelMembership], + prepareRecordsOnly: false}); + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post1.id], + posts: [post1], + prepareRecordsOnly: false, + }); + + const result = await fetchPostsForChannel(serverUrl, channelId, true); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.posts).toBeTruthy(); + expect(result.posts?.length).toBe(2); + }); + + it('fetchPostsForChannel - no posts with since', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + await operator.handleMyChannel({channels: [{ + id: channelId, + team_id: teamId, + total_msg_count: 0, + creator_id: user1.id, + } as Channel], + myChannels: [{ + id: 'id', + channel_id: channelId, + user_id: user1.id, + msg_count: 0, + } as ChannelMembership], + prepareRecordsOnly: false}); + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post1.id], + posts: [post1], + prepareRecordsOnly: false, + }); + + mockClient.getPostsSince.mockImplementationOnce(jest.fn(() => ({posts: {}, order: []}))); + const result = await fetchPostsForChannel(serverUrl, channelId, true); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.posts).toBeTruthy(); + expect(result.posts?.length).toBe(0); + }); + it('fetchPostsForChannel - request error', async () => { mockClient.getPosts.mockImplementationOnce(jest.fn(throwFunc)); @@ -419,8 +603,30 @@ describe('get posts', () => { expect(result.posts?.length).toBe(2); }); + it('fetchPosts - no CRT', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + mockGetIsCRTEnabled.mockImplementationOnce(() => false); + + const result = await fetchPosts(serverUrl, channelId); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.posts).toBeTruthy(); + expect(result.posts?.length).toBe(2); + }); + + it('fetchPosts - no authors needed', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + mockClient.getProfilesByIds.mockImplementationOnce(jest.fn(() => [])); + + const result = await fetchPosts(serverUrl, channelId); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.posts).toBeTruthy(); + expect(result.posts?.length).toBe(2); + }); + it('fetchPostsBefore - handle database not found', async () => { - const result = await fetchPostsBefore('foo', '', '') as {error: unknown}; + const result = await fetchPostsBefore('foo', '', '', 50, true) as {error: unknown}; expect(result).toBeDefined(); expect(result.error).toBeTruthy(); }); @@ -438,6 +644,48 @@ describe('get posts', () => { expect(result.posts?.length).toBe(2); }); + it('fetchPostsBefore - no CRT', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + mockGetIsCRTEnabled.mockImplementationOnce(() => false); + + const result = await fetchPostsBefore(serverUrl, channelId, post1.id) as { + posts: Post[]; + order: string[]; + previousPostId: string | undefined; + }; + expect(result).toBeDefined(); + expect(result.posts).toBeTruthy(); + expect(result.posts?.length).toBe(2); + }); + + it('fetchPostsBefore - no authors needed', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + mockClient.getProfilesByIds.mockImplementationOnce(jest.fn(() => [])); + + const result = await fetchPostsBefore(serverUrl, channelId, post1.id) as { + posts: Post[]; + order: string[]; + previousPostId: string | undefined; + }; + expect(result).toBeDefined(); + expect(result.posts).toBeTruthy(); + expect(result.posts?.length).toBe(2); + }); + + it('fetchPostsBefore - no posts', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + mockClient.getPostsBefore.mockImplementationOnce(jest.fn(() => ({posts: {}, order: []}))); + + const result = await fetchPostsBefore(serverUrl, channelId, post1.id) as { + posts: Post[]; + order: string[]; + previousPostId: string | undefined; + }; + expect(result).toBeDefined(); + expect(result.posts).toBeTruthy(); + expect(result.posts?.length).toBe(0); + }); + it('fetchPostsSince - handle database not found', async () => { const result = await fetchPostsSince('foo', '', 0); expect(result).toBeDefined(); @@ -500,6 +748,40 @@ describe('get posts', () => { expect(result.posts?.[1].id).toBe(reply1.id); }); + it('fetchPostThread - no CRT', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + mockGetIsCRTEnabled.mockImplementationOnce(() => false); + + const result = await fetchPostThread(serverUrl, post1.id); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.posts).toBeTruthy(); + expect(result.posts?.length).toBe(2); + expect(result.posts?.[0].id).toBe(post1.id); + expect(result.posts?.[1].id).toBe(reply1.id); + }); + + it('fetchPostThread - no authors needed', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + mockClient.getProfilesByIds.mockImplementationOnce(jest.fn(() => [])); + + const result = await fetchPostThread(serverUrl, post1.id); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.posts).toBeTruthy(); + }); + + it('fetchPostThread - no posts', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + mockClient.getPostThread.mockImplementationOnce(jest.fn(() => ({posts: {}, order: []}))); + + const result = await fetchPostThread(serverUrl, post1.id); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.posts).toBeTruthy(); + expect(result.posts?.length).toBe(0); + }); + it('fetchPostsAround - handle database not found', async () => { const result = await fetchPostsAround('foo', '', ''); expect(result).toBeDefined(); @@ -516,6 +798,28 @@ describe('get posts', () => { expect(result.posts?.length).toBe(2); }); + it('fetchPostsAround - no CRT', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + mockGetIsCRTEnabled.mockImplementationOnce(() => false); + + const result = await fetchPostsAround(serverUrl, channelId, post2.id, 100, true); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.posts).toBeTruthy(); + expect(result.posts?.length).toBe(2); + }); + + it('fetchPostsAround - no authors needed', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + mockClient.getProfilesByIds.mockImplementationOnce(jest.fn(() => [])); + + const result = await fetchPostsAround(serverUrl, channelId, post2.id, 100, true); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.posts).toBeTruthy(); + expect(result.posts?.length).toBe(2); + }); + it('fetchMissingChannelsFromPosts - handle database not found', async () => { const result = await fetchMissingChannelsFromPosts('foo', []); expect(result).toBeDefined(); @@ -548,6 +852,38 @@ describe('get posts', () => { expect(result.post?.id).toBe(post2.id); }); + it('fetchPostById - no CRT', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + mockGetIsCRTEnabled.mockImplementationOnce(() => false); + + const result = await fetchPostById(serverUrl, post2.id); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.post).toBeDefined(); + expect(result.post?.id).toBe(post2.id); + }); + + it('fetchPostById - no authors needed', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + mockClient.getProfilesByIds.mockImplementationOnce(jest.fn(() => [])); + + const result = await fetchPostById(serverUrl, post2.id); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.post).toBeDefined(); + expect(result.post?.id).toBe(post2.id); + }); + + it('fetchPostById - fetch only', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + + const result = await fetchPostById(serverUrl, post2.id, true); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.post).toBeDefined(); + expect(result.post?.id).toBe(post2.id); + }); + it('fetchSavedPosts - handle database not found', async () => { const result = await fetchSavedPosts('foo'); expect(result).toBeDefined(); diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index e358a094cb8..fd3193178d6 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -80,7 +80,7 @@ export async function createPost(serverUrl: string, post: Partial, files: const pendingPostId = post.pending_post_id || `${currentUserId}:${timestamp}`; const existing = await getPostById(database, pendingPostId); - if (existing && !existing.props.failed) { + if (existing && !existing.props?.failed) { return {data: false}; } @@ -240,7 +240,7 @@ export const retryFailedPost = async (serverUrl: string, post: PostModel) => { // timestamps will remain the same as the initial attempt for createAt // but updateAt will be use for the optimistic post UI post.prepareUpdate((p) => { - p.props = newPost.props; + p.props = newPost.props || null; p.updateAt = timestamp; }); await operator.batchRecords([post], 'retryFailedPost - first update'); diff --git a/app/actions/remote/search.ts b/app/actions/remote/search.ts index 7f99bb6e945..2a5783eacda 100644 --- a/app/actions/remote/search.ts +++ b/app/actions/remote/search.ts @@ -161,7 +161,7 @@ export const searchFiles = async (serverUrl: string, teamId: string, params: Fil }, {}); files.forEach((f) => { if (f.post_id) { - f.postProps = idToPost[f.post_id]?.props; + f.postProps = idToPost[f.post_id]?.props || {}; } }); return {files, channels}; diff --git a/app/actions/remote/session.test.ts b/app/actions/remote/session.test.ts index ae92fb0eb58..09051d2b8c6 100644 --- a/app/actions/remote/session.test.ts +++ b/app/actions/remote/session.test.ts @@ -3,6 +3,8 @@ /* eslint-disable max-lines */ +import {Platform} from 'react-native'; + import {GLOBAL_IDENTIFIERS, SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; @@ -12,6 +14,7 @@ import { forceLogoutIfNecessary, fetchSessions, login, + logout, cancelSessionNotification, scheduleSessionNotification, sendPasswordResetEmail, @@ -109,6 +112,13 @@ describe('sessions', () => { expect(result.error).toBeDefined(); }); + it('addPushProxyVerificationStateFromLogin - no verification', async () => { + mockGetPushProxyVerificationState.mockImplementationOnce(() => ''); + const result = await addPushProxyVerificationStateFromLogin(serverUrl); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + }); + it('addPushProxyVerificationStateFromLogin - base case', async () => { const result = await addPushProxyVerificationStateFromLogin(serverUrl); expect(result).toBeDefined(); @@ -146,6 +156,13 @@ describe('sessions', () => { expect(result).toBeUndefined(); }); + it('fetchSessions - handle client error', async () => { + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); + + const result = await fetchSessions(serverUrl, user1.id); + expect(result).toBeUndefined(); + }); + it('fetchSessions - base case', async () => { const result = await fetchSessions(serverUrl, user1.id); expect(result).toBeDefined(); @@ -168,6 +185,21 @@ describe('sessions', () => { expect(result.failed).toBe(true); }); + it('login - handle throw after login request', async () => { + jest.spyOn(DatabaseManager, 'setActiveServerDatabase').mockImplementationOnce(throwFunc); + + const result = await login(serverUrl, {config: {DiagnosticId: 'diagnosticid'}} as LoginArgs); + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.failed).toBe(false); + }); + + it('logout - base case', async () => { + const result = await logout(serverUrl, true, true, true); + expect(result).toBeDefined(); + expect(result.data).toBeDefined(); + }); + it('cancelSessionNotification - handle not found database', async () => { const result = await cancelSessionNotification('foo'); expect(result).toBeDefined(); @@ -192,6 +224,12 @@ describe('sessions', () => { expect(result.error).toBeUndefined(); }); + it('cancelSessionNotification - no expired session', async () => { + const result = await cancelSessionNotification(serverUrl); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + }); + it('scheduleSessionNotification - handle not found database', async () => { const result = await scheduleSessionNotification('foo'); expect(result).toBeDefined(); @@ -216,6 +254,20 @@ describe('sessions', () => { expect(result.error).toBeUndefined(); }); + it('scheduleSessionNotification - no session', async () => { + mockClient.getSessions.mockImplementationOnce(() => []); + const result = await scheduleSessionNotification(serverUrl); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + }); + + it('scheduleSessionNotification - null sessions', async () => { + mockClient.getSessions.mockImplementationOnce(() => null as any); + const result = await scheduleSessionNotification(serverUrl); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + }); + it('sendPasswordResetEmail - handle error', async () => { mockClient.sendPasswordResetEmail.mockImplementationOnce(jest.fn(throwFunc)); const result = await sendPasswordResetEmail('foo', ''); @@ -245,6 +297,15 @@ describe('sessions', () => { expect(result.failed).toBe(false); }); + it('ssoLogin - handle throw after login request', async () => { + jest.spyOn(DatabaseManager, 'setActiveServerDatabase').mockImplementationOnce(throwFunc); + + const result = await ssoLogin(serverUrl, 'servername', 'diagnosticid', 'authtoken', 'csrftoken'); + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.failed).toBe(false); + }); + it('findSession - handle not found database', async () => { const result = await findSession('foo', []); expect(result).toBeUndefined(); @@ -277,8 +338,35 @@ describe('sessions', () => { expect(session).toBeDefined(); }); + it('findSession - non-match device token', async () => { + await DatabaseManager.appDatabase?.operator.handleGlobal({ + globals: [{id: GLOBAL_IDENTIFIERS.DEVICE_TOKEN, value: 'diffdeviceid'}], + prepareRecordsOnly: false, + }); + + const session = await findSession(serverUrl, [session1]); + expect(session).toBeDefined(); + }); + it('findSession - by csrf', async () => { const session = await findSession(serverUrl, [session1]); expect(session).toBeDefined(); }); + + it('findSession - no csrf token', async () => { + mockGetCSRFFromCookie.mockResolvedValueOnce(''); + const session = await findSession(serverUrl, [session1]); + expect(session).toBeUndefined(); + }); + + it('findSession - by os', async () => { + const session = await findSession(serverUrl, [{...session1, props: {os: Platform.OS, csrf: 'diffcsrfid'}}]); + expect(session).toBeDefined(); + }); + + it('findSession - handle error', async () => { + jest.spyOn(DatabaseManager, 'getServerDatabaseAndOperator').mockImplementationOnce(throwFunc); + const result = await findSession(serverUrl, []); + expect(result).toBeUndefined(); + }); }); diff --git a/app/actions/remote/session.ts b/app/actions/remote/session.ts index 60c8845ce94..fbe42bfb29e 100644 --- a/app/actions/remote/session.ts +++ b/app/actions/remote/session.ts @@ -149,6 +149,8 @@ export const logout = async (serverUrl: string, skipServerLogout = false, remove if (!skipEvents) { DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {serverUrl, removeServer}); } + + return {data: true}; }; export const cancelSessionNotification = async (serverUrl: string) => { diff --git a/app/actions/remote/thread.ts b/app/actions/remote/thread.ts index a6b6534c187..0549c6ae1de 100644 --- a/app/actions/remote/thread.ts +++ b/app/actions/remote/thread.ts @@ -14,7 +14,6 @@ import {getConfigValue, getCurrentChannelId, getCurrentTeamId} from '@queries/se import {getIsCRTEnabled, getThreadById, getTeamThreadsSyncData} from '@queries/servers/thread'; import {getCurrentUser} from '@queries/servers/user'; import {getFullErrorMessage} from '@utils/errors'; -import {isMinimumServerVersion} from '@utils/helpers'; import {logDebug, logError} from '@utils/log'; import {showThreadFollowingSnackbar} from '@utils/snack_bar'; import {getThreadsListEdges} from '@utils/thread'; @@ -286,15 +285,10 @@ export const syncThreadsIfNeeded = async (serverUrl: string, isCRTEnabled: boole return {models: []}; } - const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const promises = []; const models: Model[][] = []; - // this is to keep backwards compatibility with servers that send the - // threads for DM / GM regardless for every team - const version = await getConfigValue(database, 'Version'); - const hasThreadExclusions = isMinimumServerVersion(version, 10, 2, 0); - if (teams?.length) { for (const team of teams) { promises.push(syncTeamThreads(serverUrl, team.id, true, true)); @@ -312,7 +306,7 @@ export const syncThreadsIfNeeded = async (serverUrl: string, isCRTEnabled: boole const flat = models.flat(); if (!fetchOnly && flat.length) { - const uniqueArray = hasThreadExclusions ? flat : removeDuplicatesModels(flat); + const uniqueArray = removeDuplicatesModels(flat); await operator.batchRecords(uniqueArray, 'syncThreadsIfNeeded'); } diff --git a/app/components/animated_number/index.test.tsx b/app/components/animated_number/index.test.tsx new file mode 100644 index 00000000000..472d978a87e --- /dev/null +++ b/app/components/animated_number/index.test.tsx @@ -0,0 +1,133 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent, render, waitFor, screen} from '@testing-library/react-native'; +import React from 'react'; +import {Animated} from 'react-native'; + +import AnimatedNumber from '.'; + +const NUMBER_HEIGHT = 10; + +describe('AnimatedNumber', () => { + // running on jest, since Animated is a native module, Animated.timing.start needs to be mocked in order to update to the final Animated.Value. + // Ex: 1 => 2, the Animated.Value should be -20 (from -10) after the animation is done + jest.spyOn(Animated, 'timing').mockImplementation((a, b) => ({ + + // @ts-expect-error mock implementation for testing + start: jest.fn().mockImplementation(() => a.setValue(b.toValue)), + }) as unknown as Animated.CompositeAnimation); + + it('should render the non-animated number', () => { + render(); + + const text = screen.getByTestId('no-animation-number'); + expect(text.children).toContainEqual('123'); + }); + + it('should removed the non-animation number after getting the correct height', () => { + render(); + + const text = screen.getByTestId('no-animation-number'); + + fireEvent(text, 'onLayout', {nativeEvent: {layout: {height: NUMBER_HEIGHT}}}); + + const removedText = screen.queryByTestId('no-animation-number'); + + expect(removedText).toBeNull(); + }); + + it('should switch to the animated number view', async () => { + render(); + + const text = screen.getByTestId('no-animation-number'); + + fireEvent(text, 'onLayout', {nativeEvent: {layout: {height: NUMBER_HEIGHT}}}); + + const animatedView = screen.getByTestId('animation-number-main'); + expect(animatedView).toBeTruthy(); + }); + + describe.each([1, 23, 579, -123, 6789, 23456])('should show the correct number of animated views based on the digits', (animateToNumber: number) => { + const numberOfDigits = animateToNumber.toString().length; + it(`should display ${numberOfDigits} view(s) for ${animateToNumber}`, async () => { + render(); + + const text = screen.getByTestId('no-animation-number'); + + fireEvent(text, 'onLayout', {nativeEvent: {layout: {height: NUMBER_HEIGHT}}}); + + await waitFor(() => { + const animatedView = screen.getByTestId('animation-number-main'); + expect(animatedView.children).toHaveLength(numberOfDigits); + }); + }); + }); + + describe.each([123, 9982, 12345, 901876, -157])('should show the correct number', (animateToNumber: number) => { + const absAnimatedNumberString = String(Math.abs(animateToNumber)); + const numberOfDigits = absAnimatedNumberString.length; + it(`should display the number ${animateToNumber}`, async () => { + render(); + + const text = screen.getByTestId('no-animation-number'); + + fireEvent(text, 'onLayout', {nativeEvent: {layout: {height: NUMBER_HEIGHT}}}); + + const checkEachDigit = absAnimatedNumberString.split('').map(async (number, index) => { + const useIndex = numberOfDigits - 1 - index; + + // every digit will have a row of 10 numbers, so the translateY should be the height of the number * the number * -1 (since the animation is going up) + const transformedView = screen.getByTestId(`animated-number-view-${useIndex}`); + const {translateY} = transformedView.props.style.transform[0]; + + expect(Math.abs(translateY / NUMBER_HEIGHT)).toEqual(Number(number)); + }); + + await Promise.all(checkEachDigit); + }); + }); + + describe.each([146, 144, 1, 1000000, -145])('should rerender the correct number that it animates to', (animateToNumber: number) => { + it(`should display the number ${animateToNumber}`, async () => { + const startingNumber = 145; + render(); + + const text = screen.getByTestId('no-animation-number'); + + fireEvent(text, 'onLayout', {nativeEvent: {layout: {height: NUMBER_HEIGHT}}}); + + screen.rerender(); + + const animateToNumberString = String(Math.abs(animateToNumber)); + const checkEachDigit = animateToNumberString.split('').map(async (number, index) => { + const useIndex = animateToNumberString.length - 1 - index; + + const transformedView = screen.getByTestId(`animated-number-view-${useIndex}`); + const {translateY} = transformedView.props.style.transform[0]; + + expect(Math.abs((translateY) / NUMBER_HEIGHT)).toEqual(Number(number)); + }); + + await Promise.all(checkEachDigit); + }); + }); + + it('KNOWN UI BUG: should show that there will be an issue if the text height changes, due to the non-animated number view has been removed', async () => { + // the number text will get cut-off if the user changes the text size on their mobile devices + render(); + + const text = screen.getByTestId('no-animation-number'); + + fireEvent(text, 'onLayout', {nativeEvent: {layout: {height: NUMBER_HEIGHT}}}); + + try { + fireEvent(text, 'onLayout', {nativeEvent: {layout: {height: NUMBER_HEIGHT + NUMBER_HEIGHT}}}); + } catch (e) { + expect(e).toEqual(new Error('Unable to find node on an unmounted component.')); + } + + const animatedView = screen.getByTestId('animation-number-main'); + expect(animatedView).toBeTruthy(); + }); +}); diff --git a/app/components/animated_number/index.tsx b/app/components/animated_number/index.tsx index 3b8ee973f3e..f8a61328f70 100644 --- a/app/components/animated_number/index.tsx +++ b/app/components/animated_number/index.tsx @@ -94,43 +94,54 @@ const AnimatedNumber = ({ return ( <> {numberHeight !== 0 && ( - + {animateToNumber < 0 && ( {'-'} )} - {Array.from(animateToNumberString, Number).map((_, index) => ( - - { + const useIndex = animateToNumberString.length - 1 - index; + return ( + - {NUMBERS.map((number, i) => ( - - - {number} - - - ))} - - - ))} + + {NUMBERS.map((number, i) => ( + + + {number} + + + ))} + + + ); + })} )} {numberHeight === 0 && {animateToNumberString} diff --git a/app/components/announcement_banner/announcement_banner.tsx b/app/components/announcement_banner/announcement_banner.tsx index 70fbd87bcf2..242a803a6ab 100644 --- a/app/components/announcement_banner/announcement_banner.tsx +++ b/app/components/announcement_banner/announcement_banner.tsx @@ -9,7 +9,6 @@ import { View, } from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; -import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {dismissAnnouncement} from '@actions/local/systems'; import CompassIcon from '@components/compass_icon'; @@ -85,7 +84,6 @@ const AnnouncementBanner = ({ const intl = useIntl(); const serverUrl = useServerUrl(); const height = useSharedValue(0); - const {bottom} = useSafeAreaInsets(); const theme = useTheme(); const [visible, setVisible] = useState(false); const style = getStyle(theme); @@ -107,7 +105,6 @@ const AnnouncementBanner = ({ const snapPoint = bottomSheetSnapPoint( 1, SNAP_POINT_WITHOUT_DISMISS + (allowDismissal ? DISMISS_BUTTON_HEIGHT : 0), - bottom, ); bottomSheet({ @@ -117,7 +114,7 @@ const AnnouncementBanner = ({ snapPoints: [1, snapPoint], theme, }); - }, [theme.sidebarHeaderTextColor, intl.locale, renderContent, allowDismissal, bottom]); + }, [theme.sidebarHeaderTextColor, intl.locale, renderContent, allowDismissal]); const handleDismiss = useCallback(() => { dismissAnnouncement(serverUrl, bannerText); diff --git a/app/components/autocomplete_selector/index.tsx b/app/components/autocomplete_selector/index.tsx index 64f0842bb0b..9412a343670 100644 --- a/app/components/autocomplete_selector/index.tsx +++ b/app/components/autocomplete_selector/index.tsx @@ -19,6 +19,7 @@ import {getUserById, observeTeammateNameDisplay} from '@queries/servers/user'; import {goToScreen} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {secureGetFromRecord} from '@utils/types'; import {displayUsername} from '@utils/user'; import type {WithDatabaseArgs} from '@typings/database/database'; @@ -93,7 +94,7 @@ async function getItemName(serverUrl: string, selected: string, teammateNameDisp return ''; } - const database = DatabaseManager.serverDatabases[serverUrl]?.database; + const database = secureGetFromRecord(DatabaseManager.serverDatabases, serverUrl)?.database; switch (dataSource) { case ViewConstants.DATA_SOURCE_USERS: { diff --git a/app/components/channel_bookmarks/add_bookmark.tsx b/app/components/channel_bookmarks/add_bookmark.tsx index 2a71f4a6895..32bc929579c 100644 --- a/app/components/channel_bookmarks/add_bookmark.tsx +++ b/app/components/channel_bookmarks/add_bookmark.tsx @@ -3,14 +3,13 @@ import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; -import {Alert, View, type Insets} from 'react-native'; -import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {Alert, Platform, View, type Insets} from 'react-native'; import Button from '@components/button'; import {ITEM_HEIGHT} from '@components/option_item'; import {Screens} from '@constants'; import {useTheme} from '@context/theme'; -import {TITLE_HEIGHT} from '@screens/bottom_sheet'; +import {BOTTOM_SHEET_ANDROID_OFFSET, TITLE_HEIGHT} from '@screens/bottom_sheet'; import {bottomSheet, showModal} from '@screens/navigation'; import {bottomSheetSnapPoint} from '@utils/helpers'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -81,7 +80,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ const AddBookmark = ({bookmarksCount, channelId, currentUserId, canUploadFiles, showLarge}: Props) => { const theme = useTheme(); const {formatMessage} = useIntl(); - const {bottom} = useSafeAreaInsets(); const styles = getStyleSheet(theme); const onPress = useCallback(() => { @@ -126,14 +124,19 @@ const AddBookmark = ({bookmarksCount, channelId, currentUserId, canUploadFiles, /> ); + let height = bottomSheetSnapPoint(1, (2 * ITEM_HEIGHT)) + TITLE_HEIGHT; + if (Platform.OS === 'android') { + height += BOTTOM_SHEET_ANDROID_OFFSET; + } + bottomSheet({ title: formatMessage({id: 'channel_info.add_bookmark', defaultMessage: 'Add a bookmark'}), renderContent, - snapPoints: [1, bottomSheetSnapPoint(1, (2 * ITEM_HEIGHT), bottom) + TITLE_HEIGHT], + snapPoints: [1, height], theme, closeButtonId: 'close-channel-quick-actions', }); - }, [bottom, bookmarksCount, canUploadFiles, currentUserId, channelId, theme]); + }, [bookmarksCount, canUploadFiles, formatMessage, theme, channelId, currentUserId]); const button = (