diff --git a/app/actions/websocket/category.test.ts b/app/actions/websocket/category.test.ts new file mode 100644 index 000000000..88c191fcc --- /dev/null +++ b/app/actions/websocket/category.test.ts @@ -0,0 +1,296 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {deleteCategory, storeCategories} from '@actions/local/category'; +import {fetchCategories} from '@actions/remote/category'; +import DatabaseManager from '@database/manager'; +import {queryCategoriesById} from '@queries/servers/categories'; + +import {handleCategoryCreatedEvent, handleCategoryUpdatedEvent, handleCategoryDeletedEvent, handleCategoryOrderUpdatedEvent, type WebsocketCategoriesMessage} from './category'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; + +jest.mock('@actions/local/category'); +jest.mock('@actions/remote/category'); +jest.mock('@database/manager'); +jest.mock('@queries/servers/categories'); + +describe('WebSocket Category Actions', () => { + const serverUrl = 'baseHandler.test.com'; + const teamId = 'team-id'; + const categoryId = 'category-id'; + + let operator: ServerDataOperator; + let batchRecords: jest.SpyInstance; + + beforeEach(async () => { + jest.clearAllMocks(); + + await DatabaseManager.init([serverUrl]); + operator = DatabaseManager.serverDatabases[serverUrl]!.operator; + batchRecords = jest.spyOn(operator, 'batchRecords').mockResolvedValue(); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + jest.restoreAllMocks(); + }); + + describe('handleCategoryCreatedEvent', () => { + it('should handle valid category creation', async () => { + const mockCategory = { + id: categoryId, + team_id: teamId, + display_name: 'Test Category', + }; + + const msg = { + data: { + category: JSON.stringify(mockCategory), + }, + broadcast: { + team_id: teamId, + }, + } as WebsocketCategoriesMessage; + + await handleCategoryCreatedEvent(serverUrl, msg); + + expect(storeCategories).toHaveBeenCalledWith(serverUrl, [mockCategory]); + }); + + it('should handle invalid JSON in category data', async () => { + const msg = { + data: { + category: 'invalid-json', + }, + broadcast: { + team_id: teamId, + }, + } as WebsocketCategoriesMessage; + + await handleCategoryCreatedEvent(serverUrl, msg); + + expect(fetchCategories).toHaveBeenCalledWith(serverUrl, teamId); + }); + + it('should handle invalid JSON in category data - no team id', async () => { + const msg = { + data: { + category: 'invalid-json', + }, + broadcast: {}, + } as WebsocketCategoriesMessage; + + await handleCategoryCreatedEvent(serverUrl, msg); + + expect(fetchCategories).not.toHaveBeenCalled(); + }); + + it('should handle missing category data', async () => { + const msg = { + data: {}, + broadcast: { + team_id: teamId, + }, + } as WebsocketCategoriesMessage; + + await handleCategoryCreatedEvent(serverUrl, msg); + + expect(storeCategories).not.toHaveBeenCalled(); + }); + }); + + describe('handleCategoryUpdatedEvent', () => { + it('should handle valid category updates', async () => { + const mockCategories = [{ + id: categoryId, + team_id: teamId, + display_name: 'Updated Category', + }]; + + const msg = { + data: { + updatedCategories: JSON.stringify(mockCategories), + }, + broadcast: { + team_id: teamId, + }, + } as WebsocketCategoriesMessage; + + await handleCategoryUpdatedEvent(serverUrl, msg); + + expect(storeCategories).toHaveBeenCalledWith(serverUrl, mockCategories); + }); + + it('should handle invalid JSON in updated categories', async () => { + const msg = { + data: { + updatedCategories: 'invalid-json', + }, + broadcast: { + team_id: teamId, + }, + } as WebsocketCategoriesMessage; + + await handleCategoryUpdatedEvent(serverUrl, msg); + + expect(fetchCategories).toHaveBeenCalledWith(serverUrl, teamId, true); + }); + + it('should handle invalid JSON in updated categories - no team id', async () => { + const msg = { + data: { + updatedCategories: 'invalid-json', + }, + broadcast: {}, + } as WebsocketCategoriesMessage; + + await handleCategoryUpdatedEvent(serverUrl, msg); + + expect(fetchCategories).not.toHaveBeenCalled(); + }); + + it('should handle missing updated categories data', async () => { + const msg = { + data: {}, + broadcast: { + team_id: teamId, + }, + } as WebsocketCategoriesMessage; + + await handleCategoryUpdatedEvent(serverUrl, msg); + + expect(storeCategories).not.toHaveBeenCalled(); + }); + }); + + describe('handleCategoryDeletedEvent', () => { + it('should handle category deletion', async () => { + const msg = { + data: { + category_id: categoryId, + }, + broadcast: { + team_id: teamId, + }, + } as WebsocketCategoriesMessage; + + await handleCategoryDeletedEvent(serverUrl, msg); + + expect(deleteCategory).toHaveBeenCalledWith(serverUrl, categoryId); + expect(fetchCategories).toHaveBeenCalledWith(serverUrl, teamId); + }); + + it('should handle missing category_id', async () => { + const msg = { + data: {}, + broadcast: { + team_id: teamId, + }, + } as WebsocketCategoriesMessage; + + await handleCategoryDeletedEvent(serverUrl, msg); + + expect(deleteCategory).not.toHaveBeenCalled(); + expect(fetchCategories).toHaveBeenCalledWith(serverUrl, teamId); + }); + }); + + describe('handleCategoryOrderUpdatedEvent', () => { + it('should handle empty order array', async () => { + const msg = { + data: { + order: [], + team_id: teamId, + }, + broadcast: { + team_id: teamId, + }, + } as unknown as WebsocketCategoriesMessage; + + await handleCategoryOrderUpdatedEvent(serverUrl, msg); + expect(batchRecords).not.toHaveBeenCalled(); + }); + + it('should update category order', async () => { + const mockCategories = [ + {id: 'cat1', prepareUpdate: jest.fn()}, + {id: 'cat2', prepareUpdate: jest.fn()}, + ]; + + jest.mocked(queryCategoriesById).mockReturnValue({ + fetch: jest.fn().mockResolvedValue(mockCategories), + } as any); + + const msg = { + data: { + order: ['cat1', 'cat2'], + team_id: teamId, + }, + broadcast: { + team_id: teamId, + }, + } as WebsocketCategoriesMessage; + + await handleCategoryOrderUpdatedEvent(serverUrl, msg); + + expect(batchRecords).toHaveBeenCalled(); + mockCategories.forEach((cat) => { + expect(cat.prepareUpdate).toHaveBeenCalled(); + }); + }); + + it('should handle missing order data', async () => { + const msg = { + data: { + team_id: teamId, + }, + broadcast: { + team_id: teamId, + }, + } as WebsocketCategoriesMessage; + + await handleCategoryOrderUpdatedEvent(serverUrl, msg); + + expect(batchRecords).not.toHaveBeenCalled(); + }); + + it('should handle database error', async () => { + jest.mocked(queryCategoriesById).mockImplementation(() => { + throw new Error('Database error'); + }); + + const msg = { + data: { + order: ['cat1', 'cat2'], + team_id: teamId, + }, + broadcast: { + team_id: teamId, + }, + } as WebsocketCategoriesMessage; + + await handleCategoryOrderUpdatedEvent(serverUrl, msg); + + expect(fetchCategories).toHaveBeenCalledWith(serverUrl, teamId); + }); + + it('should handle database error - no team id', async () => { + jest.mocked(queryCategoriesById).mockImplementation(() => { + throw new Error('Database error'); + }); + + const msg = { + data: { + order: ['cat1', 'cat2'], + team_id: teamId, + }, + broadcast: {}, + } as WebsocketCategoriesMessage; + + await handleCategoryOrderUpdatedEvent(serverUrl, msg); + + expect(fetchCategories).not.toHaveBeenCalledWith(); + }); + }); +}); diff --git a/app/actions/websocket/category.ts b/app/actions/websocket/category.ts index 56fa25139..bffe8517b 100644 --- a/app/actions/websocket/category.ts +++ b/app/actions/websocket/category.ts @@ -7,7 +7,7 @@ import DatabaseManager from '@database/manager'; import {queryCategoriesById} from '@queries/servers/categories'; import {logError} from '@utils/log'; -type WebsocketCategoriesMessage = { +export type WebsocketCategoriesMessage = { broadcast: { team_id: string; }; diff --git a/app/actions/websocket/integrations.test.ts b/app/actions/websocket/integrations.test.ts new file mode 100644 index 000000000..dedf55e5a --- /dev/null +++ b/app/actions/websocket/integrations.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import IntegrationsManager from '@managers/integrations_manager'; +import {getActiveServerUrl} from '@queries/app/servers'; + +import {handleOpenDialogEvent} from './integrations'; + +jest.mock('@managers/integrations_manager'); +jest.mock('@queries/app/servers'); + +describe('WebSocket Integrations Actions', () => { + const serverUrl = 'baseHandler.test.com'; + + beforeEach(() => { + jest.clearAllMocks(); + const mockManager = { + setDialog: jest.fn(), + }; + jest.spyOn(IntegrationsManager, 'getManager').mockReturnValue(mockManager as any); + }); + + describe('handleOpenDialogEvent', () => { + const mockDialog = { + app_id: 'app1', + trigger_id: 'trigger1', + url: 'http://test.com', + dialog: { + callback_id: 'callback1', + title: 'Test Dialog', + introduction_text: 'Test Intro', + elements: [], + submit_label: 'Submit', + notify_on_cancel: false, + state: 'some-state', + }, + } as InteractiveDialogConfig; + + it('should handle missing dialog data', async () => { + const msg = { + data: {}, + } as WebSocketMessage; + + await handleOpenDialogEvent(serverUrl, msg); + + expect(IntegrationsManager.getManager).not.toHaveBeenCalled(); + }); + + it('should handle invalid JSON dialog data', async () => { + const msg = { + data: { + dialog: '{invalid json', + }, + } as WebSocketMessage; + + await handleOpenDialogEvent(serverUrl, msg); + + expect(IntegrationsManager.getManager).not.toHaveBeenCalled(); + }); + + it('should not set dialog when server url does not match active server', async () => { + jest.mocked(getActiveServerUrl).mockResolvedValue('different-server'); + + const msg = { + data: { + dialog: JSON.stringify(mockDialog), + }, + } as WebSocketMessage; + + await handleOpenDialogEvent(serverUrl, msg); + + expect(IntegrationsManager.getManager).not.toHaveBeenCalled(); + }); + + it('should set dialog when server url matches active server', async () => { + jest.mocked(getActiveServerUrl).mockResolvedValue(serverUrl); + + const msg = { + data: { + dialog: JSON.stringify(mockDialog), + }, + } as WebSocketMessage; + + await handleOpenDialogEvent(serverUrl, msg); + + expect(IntegrationsManager.getManager).toHaveBeenCalledWith(serverUrl); + expect(IntegrationsManager.getManager(serverUrl).setDialog).toHaveBeenCalledWith(mockDialog); + }); + }); +}); diff --git a/app/actions/websocket/preferences.test.ts b/app/actions/websocket/preferences.test.ts new file mode 100644 index 000000000..f45d1d081 --- /dev/null +++ b/app/actions/websocket/preferences.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {updateDmGmDisplayName} from '@actions/local/channel'; +import {fetchPostById} from '@actions/remote/post'; +import {handleCRTToggled} from '@actions/remote/preference'; +import {Preferences} from '@constants'; +import DatabaseManager from '@database/manager'; +import {getPostById} from '@queries/servers/post'; +import {deletePreferences, differsFromLocalNameFormat, getHasCRTChanged} from '@queries/servers/preference'; +import EphemeralStore from '@store/ephemeral_store'; + +import {handlePreferenceChangedEvent, handlePreferencesChangedEvent, handlePreferencesDeletedEvent} from './preferences'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; + +jest.mock('@actions/local/channel'); +jest.mock('@actions/remote/post'); +jest.mock('@actions/remote/preference'); +jest.mock('@database/manager'); +jest.mock('@queries/servers/post'); +jest.mock('@queries/servers/preference'); +jest.mock('@store/ephemeral_store'); + +describe('WebSocket Preferences Actions', () => { + const serverUrl = 'baseHandler.test.com'; + let operator: ServerDataOperator; + + beforeEach(async () => { + jest.clearAllMocks(); + + await DatabaseManager.init([serverUrl]); + operator = DatabaseManager.serverDatabases[serverUrl]!.operator; + jest.spyOn(operator, 'handlePreferences').mockResolvedValue([]); + + jest.mocked(EphemeralStore.isEnablingCRT).mockReturnValue(false); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + describe('handlePreferenceChangedEvent', () => { + const mockPreference = { + category: Preferences.CATEGORIES.SAVED_POST, + name: 'post1', + user_id: 'user1', + value: 'true', + }; + + it('should handle enabling CRT', async () => { + jest.mocked(EphemeralStore.isEnablingCRT).mockReturnValue(true); + + const msg = { + data: { + preference: JSON.stringify(mockPreference), + }, + } as WebSocketMessage; + + await handlePreferenceChangedEvent(serverUrl, msg); + + expect(operator.handlePreferences).not.toHaveBeenCalled(); + }); + + it('should handle preference change with saved post', async () => { + const msg = { + data: { + preference: JSON.stringify(mockPreference), + }, + } as WebSocketMessage; + + jest.mocked(differsFromLocalNameFormat).mockResolvedValue(false); + jest.mocked(getHasCRTChanged).mockResolvedValue(false); + jest.mocked(getPostById).mockResolvedValue(undefined); + + await handlePreferenceChangedEvent(serverUrl, msg); + + expect(operator.handlePreferences).toHaveBeenCalledWith({ + prepareRecordsOnly: false, + preferences: [mockPreference], + }); + expect(fetchPostById).toHaveBeenCalledWith(serverUrl, 'post1', false); + }); + + it('should handle name format changes', async () => { + const msg = { + data: { + preference: JSON.stringify(mockPreference), + }, + } as WebSocketMessage; + + jest.mocked(differsFromLocalNameFormat).mockResolvedValue(true); + jest.mocked(getHasCRTChanged).mockResolvedValue(false); + + await handlePreferenceChangedEvent(serverUrl, msg); + + expect(updateDmGmDisplayName).toHaveBeenCalledWith(serverUrl); + }); + + it('should handle CRT changes', async () => { + const msg = { + data: { + preference: JSON.stringify(mockPreference), + }, + } as WebSocketMessage; + + jest.mocked(differsFromLocalNameFormat).mockResolvedValue(false); + jest.mocked(getHasCRTChanged).mockResolvedValue(true); + + await handlePreferenceChangedEvent(serverUrl, msg); + + expect(handleCRTToggled).toHaveBeenCalledWith(serverUrl); + }); + }); + + describe('handlePreferencesChangedEvent', () => { + const mockPreferences = [{ + category: Preferences.CATEGORIES.SAVED_POST, + name: 'post1', + user_id: 'user1', + value: 'true', + }]; + + it('should handle enabling CRT', async () => { + jest.mocked(EphemeralStore.isEnablingCRT).mockReturnValue(true); + + const msg = { + data: { + preferences: JSON.stringify(mockPreferences), + }, + } as WebSocketMessage; + + await handlePreferencesChangedEvent(serverUrl, msg); + + expect(operator.handlePreferences).not.toHaveBeenCalled(); + }); + + it('should handle multiple preferences change', async () => { + const msg = { + data: { + preferences: JSON.stringify(mockPreferences), + }, + } as WebSocketMessage; + + jest.mocked(differsFromLocalNameFormat).mockResolvedValue(false); + jest.mocked(getHasCRTChanged).mockResolvedValue(false); + jest.mocked(getPostById).mockResolvedValue(undefined); + + await handlePreferencesChangedEvent(serverUrl, msg); + + expect(operator.handlePreferences).toHaveBeenCalledWith({ + prepareRecordsOnly: false, + preferences: mockPreferences, + }); + expect(fetchPostById).toHaveBeenCalledWith(serverUrl, 'post1', false); + }); + + it('should handle name format changes in bulk', async () => { + const msg = { + data: { + preferences: JSON.stringify(mockPreferences), + }, + } as WebSocketMessage; + + jest.mocked(differsFromLocalNameFormat).mockResolvedValue(true); + jest.mocked(getHasCRTChanged).mockResolvedValue(false); + + await handlePreferencesChangedEvent(serverUrl, msg); + + expect(updateDmGmDisplayName).toHaveBeenCalledWith(serverUrl); + }); + }); + + describe('handlePreferencesDeletedEvent', () => { + const mockPreferences = [{ + category: Preferences.CATEGORIES.SAVED_POST, + name: 'post1', + user_id: 'user1', + value: 'true', + }]; + + it('should delete preferences', async () => { + const msg = { + data: { + preferences: JSON.stringify(mockPreferences), + }, + } as WebSocketMessage; + + await handlePreferencesDeletedEvent(serverUrl, msg); + + expect(deletePreferences).toHaveBeenCalled(); + }); + + it('should handle invalid preferences data', async () => { + const msg = { + data: { + preferences: 'invalid-json', + }, + } as WebSocketMessage; + + await handlePreferencesDeletedEvent(serverUrl, msg); + + expect(deletePreferences).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/actions/websocket/reactions.test.ts b/app/actions/websocket/reactions.test.ts new file mode 100644 index 000000000..2992eefa3 --- /dev/null +++ b/app/actions/websocket/reactions.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/manager'; +import {queryReaction} from '@queries/servers/reaction'; + +import {handleAddCustomEmoji, handleReactionAddedToPostEvent, handleReactionRemovedFromPostEvent} from './reactions'; + +jest.mock('@database/manager'); +jest.mock('@queries/servers/reaction'); + +describe('WebSocket Reactions Actions', () => { + const serverUrl = 'baseHandler.test.com'; + const postId = 'post-id'; + const userId = 'user-id'; + const emojiName = 'smile'; + + let operator: any; + let database: any; + + beforeEach(async () => { + jest.clearAllMocks(); + + operator = { + handleCustomEmojis: jest.fn(), + handleReactions: jest.fn(), + }; + + database = { + write: jest.fn((callback) => callback()), + }; + + DatabaseManager.getServerDatabaseAndOperator = jest.fn().mockReturnValue({ + database, + operator, + }); + + await DatabaseManager.init([serverUrl]); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + describe('handleAddCustomEmoji', () => { + it('should handle custom emoji addition', async () => { + const emoji = { + id: 'emoji-id', + name: 'custom-emoji', + creator_id: userId, + }; + + const msg = { + data: { + emoji: JSON.stringify(emoji), + }, + } as WebSocketMessage; + + await handleAddCustomEmoji(serverUrl, msg); + + expect(operator.handleCustomEmojis).toHaveBeenCalledWith({ + prepareRecordsOnly: false, + emojis: [emoji], + }); + }); + + it('should handle invalid emoji data gracefully', async () => { + const msg = { + data: { + emoji: 'invalid-json', + }, + } as WebSocketMessage; + + await handleAddCustomEmoji(serverUrl, msg); + + expect(operator.handleCustomEmojis).not.toHaveBeenCalled(); + }); + }); + + describe('handleReactionAddedToPostEvent', () => { + it('should handle reaction addition', async () => { + const reaction = { + user_id: userId, + post_id: postId, + emoji_name: emojiName, + create_at: 123, + }; + + const msg = { + data: { + reaction: JSON.stringify(reaction), + }, + } as WebSocketMessage; + + await handleReactionAddedToPostEvent(serverUrl, msg); + + expect(operator.handleReactions).toHaveBeenCalledWith({ + prepareRecordsOnly: false, + skipSync: true, + postsReactions: [{ + post_id: postId, + reactions: [reaction], + }], + }); + }); + + it('should handle invalid reaction data gracefully', async () => { + const msg = { + data: { + reaction: 'invalid-json', + }, + } as WebSocketMessage; + + await handleReactionAddedToPostEvent(serverUrl, msg); + + expect(operator.handleReactions).not.toHaveBeenCalled(); + }); + }); + + describe('handleReactionRemovedFromPostEvent', () => { + it('should handle reaction removal', async () => { + const reaction = { + user_id: userId, + post_id: postId, + emoji_name: emojiName, + }; + + const mockReactionModel = { + destroyPermanently: jest.fn(), + }; + + jest.mocked(queryReaction).mockReturnValue({ + fetch: jest.fn().mockResolvedValue([mockReactionModel]), + } as any); + + const msg = { + data: { + reaction: JSON.stringify(reaction), + }, + } as WebSocketMessage; + + await handleReactionRemovedFromPostEvent(serverUrl, msg); + + expect(queryReaction).toHaveBeenCalledWith(database, emojiName, postId, userId); + expect(mockReactionModel.destroyPermanently).toHaveBeenCalled(); + expect(database.write).toHaveBeenCalled(); + }); + + it('should handle non-existent reaction gracefully', async () => { + jest.mocked(queryReaction).mockReturnValue({ + fetch: jest.fn().mockResolvedValue([]), + } as any); + + const msg = { + data: { + reaction: JSON.stringify({ + user_id: userId, + post_id: postId, + emoji_name: emojiName, + }), + }, + } as WebSocketMessage; + + await handleReactionRemovedFromPostEvent(serverUrl, msg); + + expect(database.write).not.toHaveBeenCalled(); + }); + + it('should handle invalid reaction data gracefully', async () => { + const msg = { + data: { + reaction: 'invalid-json', + }, + } as WebSocketMessage; + + await handleReactionRemovedFromPostEvent(serverUrl, msg); + + expect(queryReaction).not.toHaveBeenCalled(); + expect(database.write).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/actions/websocket/roles.test.ts b/app/actions/websocket/roles.test.ts new file mode 100644 index 000000000..1bc45248a --- /dev/null +++ b/app/actions/websocket/roles.test.ts @@ -0,0 +1,309 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fetchRolesIfNeeded} from '@actions/remote/role'; +import DatabaseManager from '@database/manager'; +import {getRoleById} from '@queries/servers/role'; +import {getCurrentUserId} from '@queries/servers/system'; +import {getCurrentUser} from '@queries/servers/user'; + +import {handleRoleUpdatedEvent, handleUserRoleUpdatedEvent, handleTeamMemberRoleUpdatedEvent} from './roles'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type RoleModel from '@typings/database/models/servers/role'; +import type UserModel from '@typings/database/models/servers/user'; + +jest.mock('@actions/remote/role'); +jest.mock('@database/manager'); +jest.mock('@queries/servers/role'); +jest.mock('@queries/servers/system'); +jest.mock('@queries/servers/user'); + +describe('WebSocket Roles Actions', () => { + const serverUrl = 'baseHandler.test.com'; + const currentUserId = 'current-user-id'; + const roleId = 'role-id'; + const teamId = 'team-id'; + + let operator: ServerDataOperator; + let batchRecords: jest.SpyInstance; + let handleRole: jest.SpyInstance; + let handleMyTeam: jest.SpyInstance; + let handleTeamMemberships: jest.SpyInstance; + + beforeEach(async () => { + jest.clearAllMocks(); + + await DatabaseManager.init([serverUrl]); + operator = DatabaseManager.serverDatabases[serverUrl]!.operator; + + batchRecords = jest.spyOn(operator, 'batchRecords').mockResolvedValue(); + handleRole = jest.spyOn(operator, 'handleRole').mockResolvedValue([{id: 'role1'} as RoleModel]); + handleMyTeam = jest.spyOn(operator, 'handleMyTeam').mockResolvedValue([]); + handleTeamMemberships = jest.spyOn(operator, 'handleTeamMemberships').mockResolvedValue([]); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + jest.restoreAllMocks(); + }); + + describe('handleRoleUpdatedEvent', () => { + it('should handle missing operator', async () => { + DatabaseManager.serverDatabases = {}; + const msg = { + data: { + role: JSON.stringify({id: roleId}), + }, + } as WebSocketMessage; + await handleRoleUpdatedEvent(serverUrl, msg); + expect(handleRole).not.toHaveBeenCalled(); + }); + + it('should handle missing role in database', async () => { + jest.mocked(getRoleById).mockResolvedValue(undefined); + const msg = { + data: { + role: JSON.stringify({id: roleId}), + }, + } as WebSocketMessage; + await handleRoleUpdatedEvent(serverUrl, msg); + expect(handleRole).not.toHaveBeenCalled(); + }); + + it('should update existing role', async () => { + const mockRole = { + id: roleId, + name: 'test_role', + permissions: ['permission1'], + }; + jest.mocked(getRoleById).mockResolvedValue({id: roleId} as RoleModel); + const msg = { + data: { + role: JSON.stringify(mockRole), + }, + } as WebSocketMessage; + + await handleRoleUpdatedEvent(serverUrl, msg); + + expect(handleRole).toHaveBeenCalledWith({ + roles: [mockRole], + prepareRecordsOnly: false, + }); + }); + + it('should handle invalid JSON in role data', async () => { + jest.mocked(getRoleById).mockResolvedValue({id: roleId} as RoleModel); + const msg = { + data: { + role: 'invalid json', + }, + } as WebSocketMessage; + + await handleRoleUpdatedEvent(serverUrl, msg); + expect(handleRole).not.toHaveBeenCalled(); + }); + }); + + describe('handleUserRoleUpdatedEvent', () => { + it('should handle missing operator', async () => { + DatabaseManager.serverDatabases = {}; + const msg = { + data: { + user_id: currentUserId, + roles: 'role1 role2', + }, + } as WebSocketMessage; + await handleUserRoleUpdatedEvent(serverUrl, msg); + expect(handleRole).not.toHaveBeenCalled(); + }); + + it('should handle different user', async () => { + jest.mocked(getCurrentUserId).mockResolvedValue('different-user'); + const msg = { + data: { + user_id: currentUserId, + roles: 'role1 role2', + }, + } as WebSocketMessage; + await handleUserRoleUpdatedEvent(serverUrl, msg); + expect(handleRole).not.toHaveBeenCalled(); + }); + + it('should update roles and user', async () => { + jest.mocked(getCurrentUserId).mockResolvedValue(currentUserId); + jest.mocked(getCurrentUser).mockResolvedValue({ + prepareUpdate: jest.fn(), + } as unknown as UserModel); + jest.mocked(fetchRolesIfNeeded).mockResolvedValue({ + roles: [{id: 'role1'} as Role], + }); + + const msg = { + data: { + user_id: currentUserId, + roles: 'role1 role2', + }, + } as WebSocketMessage; + + await handleUserRoleUpdatedEvent(serverUrl, msg); + + expect(handleRole).toHaveBeenCalled(); + expect(batchRecords).toHaveBeenCalled(); + }); + + it('should handle missing current user', async () => { + jest.mocked(getCurrentUserId).mockResolvedValue(currentUserId); + jest.mocked(getCurrentUser).mockResolvedValue(undefined); + jest.mocked(fetchRolesIfNeeded).mockResolvedValue({ + roles: [{id: 'role1'} as Role], + }); + + const msg = { + data: { + user_id: currentUserId, + roles: 'role1', + }, + } as WebSocketMessage; + + await handleUserRoleUpdatedEvent(serverUrl, msg); + + expect(handleRole).toHaveBeenCalled(); + expect(batchRecords).toHaveBeenCalledWith(expect.arrayContaining([]), 'handleUserRoleUpdatedEvent'); + }); + + it('should handle no new roles from fetchRolesIfNeeded', async () => { + jest.mocked(getCurrentUserId).mockResolvedValue(currentUserId); + jest.mocked(getCurrentUser).mockResolvedValue({ + prepareUpdate: jest.fn(), + } as unknown as UserModel); + jest.mocked(fetchRolesIfNeeded).mockResolvedValue({ + roles: [], + }); + + const msg = { + data: { + user_id: currentUserId, + roles: 'role1', + }, + } as WebSocketMessage; + + await handleUserRoleUpdatedEvent(serverUrl, msg); + + expect(handleRole).not.toHaveBeenCalled(); + expect(batchRecords).toHaveBeenCalled(); + }); + }); + + describe('handleTeamMemberRoleUpdatedEvent', () => { + it('should handle missing operator', async () => { + DatabaseManager.serverDatabases = {}; + const msg = { + data: { + member: JSON.stringify({ + user_id: currentUserId, + team_id: teamId, + roles: 'role1', + delete_at: 0, + }), + }, + } as WebSocketMessage; + await handleTeamMemberRoleUpdatedEvent(serverUrl, msg); + expect(handleRole).not.toHaveBeenCalled(); + }); + + it('should handle deleted member', async () => { + const msg = { + data: { + member: JSON.stringify({ + user_id: currentUserId, + team_id: teamId, + roles: 'role1', + delete_at: 1234, + }), + }, + } as WebSocketMessage; + await handleTeamMemberRoleUpdatedEvent(serverUrl, msg); + expect(handleRole).not.toHaveBeenCalled(); + }); + + it('should handle different user', async () => { + jest.mocked(getCurrentUserId).mockResolvedValue('different-user'); + const msg = { + data: { + member: JSON.stringify({ + user_id: currentUserId, + team_id: teamId, + roles: 'role1', + delete_at: 0, + }), + }, + } as WebSocketMessage; + await handleTeamMemberRoleUpdatedEvent(serverUrl, msg); + expect(handleRole).not.toHaveBeenCalled(); + }); + + it('should update roles, myTeam and teamMembership', async () => { + jest.mocked(getCurrentUserId).mockResolvedValue(currentUserId); + jest.mocked(fetchRolesIfNeeded).mockResolvedValue({ + roles: [{id: 'role1'} as Role], + }); + + const msg = { + data: { + member: JSON.stringify({ + user_id: currentUserId, + team_id: teamId, + roles: 'role1', + delete_at: 0, + }), + }, + } as WebSocketMessage; + + await handleTeamMemberRoleUpdatedEvent(serverUrl, msg); + + expect(handleRole).toHaveBeenCalled(); + expect(handleMyTeam).toHaveBeenCalled(); + expect(handleTeamMemberships).toHaveBeenCalled(); + expect(batchRecords).toHaveBeenCalled(); + }); + + it('should handle invalid JSON in member data', async () => { + const msg = { + data: { + member: 'invalid json', + }, + } as WebSocketMessage; + + await handleTeamMemberRoleUpdatedEvent(serverUrl, msg); + expect(handleRole).not.toHaveBeenCalled(); + expect(handleMyTeam).not.toHaveBeenCalled(); + expect(handleTeamMemberships).not.toHaveBeenCalled(); + }); + + it('should handle no new roles from fetchRolesIfNeeded', async () => { + jest.mocked(getCurrentUserId).mockResolvedValue(currentUserId); + jest.mocked(fetchRolesIfNeeded).mockResolvedValue({ + roles: [], + }); + + const msg = { + data: { + member: JSON.stringify({ + user_id: currentUserId, + team_id: teamId, + roles: 'role1', + delete_at: 0, + }), + }, + } as WebSocketMessage; + + await handleTeamMemberRoleUpdatedEvent(serverUrl, msg); + + expect(handleRole).not.toHaveBeenCalled(); + expect(handleMyTeam).toHaveBeenCalled(); + expect(handleTeamMemberships).toHaveBeenCalled(); + expect(batchRecords).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/actions/websocket/system.test.ts b/app/actions/websocket/system.test.ts new file mode 100644 index 000000000..888c95cd2 --- /dev/null +++ b/app/actions/websocket/system.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {updateDmGmDisplayName} from '@actions/local/channel'; +import {storeConfig} from '@actions/local/systems'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import {getConfig, getLicense} from '@queries/servers/system'; + +import {handleLicenseChangedEvent, handleConfigChangedEvent} from './system'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; + +jest.mock('@actions/local/channel'); +jest.mock('@actions/local/systems'); +jest.mock('@database/manager'); +jest.mock('@queries/servers/system'); + +describe('WebSocket System Actions', () => { + const serverUrl = 'baseHandler.test.com'; + + let operator: ServerDataOperator; + let handleSystem: jest.SpyInstance; + + beforeEach(async () => { + jest.clearAllMocks(); + + await DatabaseManager.init([serverUrl]); + operator = DatabaseManager.serverDatabases[serverUrl]!.operator; + handleSystem = jest.spyOn(operator, 'handleSystem'); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + jest.restoreAllMocks(); + }); + + describe('handleLicenseChangedEvent', () => { + it('should handle missing operator', async () => { + DatabaseManager.serverDatabases = {}; + const msg = { + data: { + license: { + LockTeammateNameDisplay: true, + }, + }, + } as WebSocketMessage; + await handleLicenseChangedEvent(serverUrl, msg); + expect(handleSystem).not.toHaveBeenCalled(); + }); + + it('should handle license update with no display name change', async () => { + const mockLicense = { + LockTeammateNameDisplay: false, + }; + + jest.mocked(getLicense).mockResolvedValue({ + LockTeammateNameDisplay: 'false', + } as ClientLicense); + + const msg = { + data: { + license: mockLicense, + }, + } as WebSocketMessage; + + await handleLicenseChangedEvent(serverUrl, msg); + + expect(handleSystem).toHaveBeenCalledWith({ + systems: [{ + id: SYSTEM_IDENTIFIERS.LICENSE, + value: JSON.stringify(mockLicense), + }], + prepareRecordsOnly: false, + }); + expect(updateDmGmDisplayName).not.toHaveBeenCalled(); + }); + + it('should handle license update with display name change', async () => { + const mockLicense = { + LockTeammateNameDisplay: true, + }; + + jest.mocked(getLicense).mockResolvedValue({ + LockTeammateNameDisplay: 'false', + } as ClientLicense); + + const msg = { + data: { + license: mockLicense, + }, + } as WebSocketMessage; + + await handleLicenseChangedEvent(serverUrl, msg); + + expect(handleSystem).toHaveBeenCalled(); + expect(updateDmGmDisplayName).toHaveBeenCalledWith(serverUrl); + }); + }); + + describe('handleConfigChangedEvent', () => { + it('should handle missing operator', async () => { + DatabaseManager.serverDatabases = {}; + const msg = { + data: { + config: { + LockTeammateNameDisplay: true, + }, + }, + } as WebSocketMessage; + await handleConfigChangedEvent(serverUrl, msg); + expect(storeConfig).not.toHaveBeenCalled(); + }); + + it('should handle config update with no display name change', async () => { + const mockConfig = { + LockTeammateNameDisplay: false, + }; + + jest.mocked(getConfig).mockResolvedValue({ + LockTeammateNameDisplay: 'false', + } as ClientConfig); + + const msg = { + data: { + config: mockConfig, + }, + } as WebSocketMessage; + + await handleConfigChangedEvent(serverUrl, msg); + + expect(storeConfig).toHaveBeenCalledWith(serverUrl, mockConfig); + expect(updateDmGmDisplayName).not.toHaveBeenCalled(); + }); + + it('should handle config update with display name change', async () => { + const mockConfig = { + LockTeammateNameDisplay: true, + }; + + jest.mocked(getConfig).mockResolvedValue({ + LockTeammateNameDisplay: 'false', + } as ClientConfig); + + const msg = { + data: { + config: mockConfig, + }, + } as WebSocketMessage; + + await handleConfigChangedEvent(serverUrl, msg); + + expect(storeConfig).toHaveBeenCalledWith(serverUrl, mockConfig); + expect(updateDmGmDisplayName).toHaveBeenCalledWith(serverUrl); + }); + }); +}); diff --git a/app/actions/websocket/threads.test.ts b/app/actions/websocket/threads.test.ts new file mode 100644 index 000000000..76dcc4272 --- /dev/null +++ b/app/actions/websocket/threads.test.ts @@ -0,0 +1,221 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {markTeamThreadsAsRead, processReceivedThreads, updateThread} from '@actions/local/thread'; +import DatabaseManager from '@database/manager'; +import {getCurrentTeamId} from '@queries/servers/system'; +import EphemeralStore from '@store/ephemeral_store'; + +import {handleThreadUpdatedEvent, handleThreadReadChangedEvent, handleThreadFollowChangedEvent} from './threads'; + +jest.mock('@actions/local/thread'); +jest.mock('@database/manager'); +jest.mock('@queries/servers/system'); +jest.mock('@store/ephemeral_store'); + +describe('WebSocket Threads Actions', () => { + const serverUrl = 'baseHandler.test.com'; + const teamId = 'team-id'; + const threadId = 'thread-id'; + + beforeEach(async () => { + jest.clearAllMocks(); + + await DatabaseManager.init([serverUrl]); + DatabaseManager.getServerDatabaseAndOperator = jest.fn().mockReturnValue({ + database: {}, + operator: {}, + }); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + describe('handleThreadUpdatedEvent', () => { + it('should process received thread', async () => { + const mockThread = { + id: threadId, + reply_count: 3, + }; + + const msg = { + data: { + thread: JSON.stringify(mockThread), + }, + broadcast: { + team_id: teamId, + }, + } as WebSocketMessage; + + jest.mocked(getCurrentTeamId).mockResolvedValue(teamId); + jest.mocked(processReceivedThreads); + + await handleThreadUpdatedEvent(serverUrl, msg); + + expect(processReceivedThreads).toHaveBeenCalledWith( + serverUrl, + [{...mockThread, is_following: true}], + teamId, + ); + }); + + it('should handle missing team_id', async () => { + const mockThread = { + id: threadId, + reply_count: 3, + }; + + const msg = { + data: { + thread: JSON.stringify(mockThread), + }, + broadcast: {}, + } as WebSocketMessage; + + jest.mocked(getCurrentTeamId).mockResolvedValue(teamId); + + await handleThreadUpdatedEvent(serverUrl, msg); + + expect(processReceivedThreads).toHaveBeenCalledWith( + serverUrl, + [{...mockThread, is_following: true}], + teamId, + ); + }); + + it('should handle error gracefully', async () => { + const msg = { + data: { + thread: 'invalid-json', + }, + broadcast: {}, + } as WebSocketMessage; + + await handleThreadUpdatedEvent(serverUrl, msg); + + expect(processReceivedThreads).not.toHaveBeenCalled(); + }); + }); + + describe('handleThreadReadChangedEvent', () => { + it('should update thread when thread_id is present', async () => { + const timestamp = 1234567890; + const msg = { + data: { + thread_id: threadId, + timestamp, + unread_mentions: 2, + unread_replies: 5, + }, + broadcast: { + team_id: teamId, + }, + } as WebSocketMessage; + + jest.mocked(EphemeralStore.getCurrentThreadId).mockReturnValue('different-thread'); + + await handleThreadReadChangedEvent(serverUrl, msg); + + expect(updateThread).toHaveBeenCalledWith(serverUrl, threadId, { + unread_mentions: 2, + unread_replies: 5, + last_viewed_at: timestamp, + viewed_at: timestamp, + }); + }); + + it('should not update viewed_at when thread is currently visible', async () => { + const timestamp = 1234567890; + const msg = { + data: { + thread_id: threadId, + timestamp, + unread_mentions: 2, + unread_replies: 5, + }, + broadcast: { + team_id: teamId, + }, + } as WebSocketMessage; + + jest.mocked(EphemeralStore.getCurrentThreadId).mockReturnValue(threadId); + + await handleThreadReadChangedEvent(serverUrl, msg); + + expect(updateThread).toHaveBeenCalledWith(serverUrl, threadId, { + unread_mentions: 2, + unread_replies: 5, + last_viewed_at: timestamp, + }); + }); + + it('should mark team threads as read when thread_id is missing', async () => { + const msg = { + data: { + timestamp: 1234567890, + }, + broadcast: { + team_id: teamId, + }, + } as WebSocketMessage; + + await handleThreadReadChangedEvent(serverUrl, msg); + + expect(markTeamThreadsAsRead).toHaveBeenCalledWith(serverUrl, teamId); + }); + + it('should handle error gracefully', async () => { + jest.mocked(updateThread).mockRejectedValue(new Error('test error')); + + const msg = { + data: { + thread_id: threadId, + timestamp: 1234567890, + }, + broadcast: { + team_id: teamId, + }, + } as WebSocketMessage; + + await handleThreadReadChangedEvent(serverUrl, msg); + + expect(updateThread).toHaveBeenCalled(); + }); + }); + + describe('handleThreadFollowChangedEvent', () => { + it('should update thread following state', async () => { + const msg = { + data: { + thread_id: threadId, + state: true, + reply_count: 5, + }, + } as WebSocketMessage; + + await handleThreadFollowChangedEvent(serverUrl, msg); + + expect(updateThread).toHaveBeenCalledWith(serverUrl, threadId, { + is_following: true, + reply_count: 5, + }); + }); + + it('should handle error gracefully', async () => { + jest.mocked(updateThread).mockRejectedValue(new Error('test error')); + + const msg = { + data: { + thread_id: threadId, + state: false, + reply_count: 3, + }, + } as WebSocketMessage; + + await handleThreadFollowChangedEvent(serverUrl, msg); + + expect(updateThread).toHaveBeenCalled(); + }); + }); +});