diff --git a/app/components/threads_button/__snapshots__/threads_button.test.tsx.snap b/app/components/threads_button/__snapshots__/threads_button.test.tsx.snap index 1c1a7389b..857a7e3da 100644 --- a/app/components/threads_button/__snapshots__/threads_button.test.tsx.snap +++ b/app/components/threads_button/__snapshots__/threads_button.test.tsx.snap @@ -44,8 +44,8 @@ exports[`Thread item in the channel list Threads Component should match snapshot "flexDirection": "row", }, false, - undefined, - undefined, + false, + false, { "minHeight": 40, }, @@ -82,7 +82,7 @@ exports[`Thread item in the channel list Threads Component should match snapshot "color": "rgba(255,255,255,0.72)", }, false, - undefined, + false, undefined, ] } @@ -137,8 +137,8 @@ exports[`Thread item in the channel list Threads Component should match snapshot "flexDirection": "row", }, false, - undefined, - undefined, + false, + false, { "minHeight": 40, }, @@ -177,7 +177,7 @@ exports[`Thread item in the channel list Threads Component should match snapshot "color": "rgba(255,255,255,0.72)", }, false, - undefined, + false, { "color": "#3f4350", }, diff --git a/app/utils/apps.test.ts b/app/utils/apps.test.ts new file mode 100644 index 000000000..767e5a015 --- /dev/null +++ b/app/utils/apps.test.ts @@ -0,0 +1,766 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable max-lines */ + +import {AppBindingLocations, AppCallResponseTypes, AppFieldTypes} from '@constants/apps'; + +import { + cleanBinding, + validateBindings, + cleanForm, + makeCallErrorResponse, + filterEmptyOptions, + createCallContext, + createCallRequest, +} from './apps'; + +describe('cleanBinding', () => { + it('should return the binding unchanged if it is null', () => { + expect(cleanBinding(null as any, AppBindingLocations.COMMAND)).toBeNull(); + }); + + it('should handle default values for app_id, label, and location', () => { + const binding: AppBinding = { + app_id: 'test_app', + location: 'location', + label: 'label', + bindings: [ + { + app_id: '', + location: '', + label: '', + form: { + submit: { + path: 'submit_path', + }, + }, + }, + ], + }; + + const result = cleanBinding(binding, AppBindingLocations.COMMAND); + expect(result.bindings![0].app_id).toBe('test_app'); + expect(result.bindings![0].label).toBeTruthy(); + expect(result.bindings![0].location).toMatch(/location\/.+/); + }); + + it('should remove bindings without app_id', () => { + const binding: AppBinding = { + app_id: 'test_app', + location: 'location', + label: 'label', + bindings: [ + { + app_id: '', + location: 'sub_location1', + label: 'sub_label1', + }, + { + app_id: 'sub_app', + location: 'sub_location2', + label: 'sub_label2', + form: { + submit: { + path: 'submit_path', + }, + }, + }, + ], + }; + + const result = cleanBinding(binding, AppBindingLocations.COMMAND); + expect(result.bindings).toHaveLength(1); + expect(result.bindings![0].app_id).toBe('sub_app'); + }); + + it('should remove bindings with empty or whitespace labels', () => { + const binding: AppBinding = { + app_id: 'test_app', + location: 'location', + label: 'label', + bindings: [ + { + app_id: 'sub_app1', + location: 'sub_location1', + label: '', + }, + { + app_id: 'sub_app2', + location: 'sub_location2', + label: ' ', + form: { + submit: { + path: 'submit_path', + }, + }, + }, + { + app_id: 'sub_app3', + location: 'sub_location3', + label: 'valid_label', + form: { + submit: { + path: 'submit_path', + }, + }, + }, + ], + }; + + const result = cleanBinding(binding, AppBindingLocations.COMMAND); + expect(result.bindings).toHaveLength(1); + expect(result.bindings![0].label).toBe('valid_label'); + }); + + it('should remove bindings with duplicate labels in COMMAND location', () => { + const binding: AppBinding = { + app_id: 'test_app', + location: 'location', + label: 'label', + bindings: [ + { + app_id: 'sub_app1', + location: 'sub_location1', + label: 'sub_label', + form: { + submit: { + path: 'submit_path', + }, + }, + }, + { + app_id: 'sub_app2', + location: 'sub_location2', + label: 'sub_label', + form: { + submit: { + path: 'submit_path', + }, + }, + }, + ], + }; + + const result = cleanBinding(binding, AppBindingLocations.COMMAND); + expect(result.bindings).toHaveLength(1); + expect(result.bindings![0].app_id).toBe('sub_app1'); + }); + + it('should keep unique labels in IN_POST location', () => { + const binding: AppBinding = { + app_id: 'test_app', + location: 'location', + label: 'label', + bindings: [ + { + app_id: 'sub_app1', + location: 'sub_location1', + label: 'sub_label1', + form: { + submit: { + path: 'submit_path', + }, + }, + }, + { + app_id: 'sub_app2', + location: 'sub_location2', + label: 'sub_label2', + form: { + submit: { + path: 'submit_path', + }, + }, + }, + ], + }; + + const result = cleanBinding(binding, AppBindingLocations.IN_POST); + expect(result.bindings).toHaveLength(2); + }); + + it('should remove invalid sub-bindings without form or submit', () => { + const binding: AppBinding = { + app_id: 'test_app', + location: 'location', + label: 'label', + bindings: [ + { + app_id: 'sub_app1', + location: 'sub_location1', + label: 'sub_label1', + }, + { + app_id: 'sub_app2', + location: 'sub_location2', + label: 'sub_label2', + form: { + submit: { + path: 'submit_path', + }, + }, + }, + { + app_id: 'sub_app3', + location: 'sub_location3', + label: 'sub_label3', + submit: { + path: 'submit_path', + }, + }, + ], + }; + + const result = cleanBinding(binding, AppBindingLocations.COMMAND); + expect(result.bindings).toHaveLength(2); + expect(result.bindings![0].label).toBe('sub_label2'); + expect(result.bindings![1].label).toBe('sub_label3'); + }); + + it('should handle forms correctly', () => { + const binding: AppBinding = { + app_id: 'test_app', + location: 'location', + label: 'label', + bindings: [ + { + app_id: 'sub_app', + location: 'sub_location', + label: 'sub_label', + form: { + submit: { + path: 'submit_path', + }, + }, + }, + ], + }; + + const result = cleanBinding(binding, AppBindingLocations.COMMAND); + expect(result.bindings).toHaveLength(1); + expect(result.bindings![0].form).toBeTruthy(); + }); + + it('should handle submit calls correctly', () => { + const binding: AppBinding = { + app_id: 'test_app', + location: 'location', + label: 'label', + bindings: [ + { + app_id: 'sub_app', + location: 'sub_location', + label: 'sub_label', + submit: { + path: 'submit_path', + }, + }, + ], + }; + + const result = cleanBinding(binding, AppBindingLocations.COMMAND); + expect(result.bindings).toHaveLength(1); + expect(result.bindings![0].submit).toBeTruthy(); + }); + + it('should recursively clean sub-bindings', () => { + const binding: AppBinding = { + app_id: 'test_app', + location: 'location', + label: 'label', + bindings: [ + { + app_id: 'sub_app', + location: 'sub_location', + label: 'sub_label', + bindings: [ + { + app_id: '', + location: 'sub_sub_location1', + label: '', + }, + { + app_id: 'sub_sub_app', + location: 'sub_sub_location2', + label: 'sub_sub_label', + form: { + submit: { + path: 'submit_path', + }, + }, + }, + ], + }, + ], + }; + + const result = cleanBinding(binding, AppBindingLocations.COMMAND); + expect(result.bindings).toHaveLength(1); + expect(result.bindings![0].bindings).toHaveLength(1); + expect(result.bindings![0].bindings![0].label).toBe('sub_sub_label'); + }); + + it('should handle bindings without bindings, form, or submit', () => { + const binding: AppBinding = { + app_id: 'test_app', + location: 'location', + label: 'label', + bindings: [ + { + app_id: 'sub_app', + location: 'sub_location', + label: 'sub_label', + }, + ], + }; + + const result = cleanBinding(binding, AppBindingLocations.COMMAND); + expect(result.bindings).toHaveLength(0); + }); + + it('should handle multiple levels of nested bindings correctly', () => { + const binding: AppBinding = { + app_id: 'test_app', + location: 'location', + label: 'label', + bindings: [ + { + app_id: 'sub_app1', + location: 'sub_location1', + label: 'sub_label1', + bindings: [ + { + app_id: '', + location: 'sub_sub_location1', + label: '', + }, + { + app_id: 'sub_sub_app', + location: 'sub_sub_location2', + label: 'sub_sub_label', + form: { + submit: { + path: 'submit_path', + }, + }, + }, + ], + }, + { + app_id: 'sub_app2', + location: 'sub_location2', + label: 'sub_label2', + form: { + submit: { + path: 'submit_path', + }, + }, + }, + ], + }; + + const result = cleanBinding(binding, AppBindingLocations.COMMAND); + expect(result.bindings).toHaveLength(2); + expect(result.bindings![0].bindings).toHaveLength(1); + expect(result.bindings![0].bindings![0].label).toBe('sub_sub_label'); + expect(result.bindings![1].label).toBe('sub_label2'); + }); +}); + +describe('validateBindings', () => { + it('should return an empty array if no bindings are provided', () => { + expect(validateBindings()).toEqual([]); + }); + + it('should filter and clean bindings by their locations', () => { + const bindings: AppBinding[] = [ + {app_id: '1', location: AppBindingLocations.CHANNEL_HEADER_ICON, label: 'channel_header', bindings: []}, + {app_id: '2', location: AppBindingLocations.POST_MENU_ITEM, label: 'post_menu', bindings: []}, + {app_id: '3', location: AppBindingLocations.COMMAND, label: 'command', bindings: []}, + {app_id: '4', location: 'other', label: 'other', bindings: []}, + ]; + + const result = validateBindings(bindings); + expect(result).toEqual([]); + }); + + it('should return only bindings that have sub-bindings after cleaning', () => { + const bindings: AppBinding[] = [ + {app_id: '1', location: AppBindingLocations.CHANNEL_HEADER_ICON, label: 'channel_header', bindings: []}, + {app_id: '2', + location: AppBindingLocations.POST_MENU_ITEM, + label: 'post_menu', + bindings: [{ + app_id: '2.1', + label: 'sub_binding', + location: '', + form: { + submit: { + path: 'path_2', + }, + }, + }]}, + {app_id: '3', + location: AppBindingLocations.COMMAND, + label: 'command', + bindings: [{ + app_id: '3.1', + label: 'sub_binding', + location: '', + form: { + submit: { + path: 'path_3', + }, + }, + }]}, + ]; + + const result = validateBindings(bindings); + expect(result).toHaveLength(2); + expect(result[0].app_id).toBe('2'); + expect(result[1].app_id).toBe('3'); + }); + + it('should filter out bindings that do not have sub-bindings after cleaning', () => { + const bindings: AppBinding[] = [ + {app_id: '1', + location: AppBindingLocations.CHANNEL_HEADER_ICON, + label: 'channel_header', + bindings: [{ + app_id: '1.1', + label: 'sub_binding', + location: '', + }]}, + {app_id: '2', location: AppBindingLocations.POST_MENU_ITEM, label: 'post_menu', bindings: []}, + {app_id: '3', location: AppBindingLocations.COMMAND, label: 'command', bindings: []}, + ]; + + const result = validateBindings(bindings); + expect(result).toEqual([]); + }); + + it('should handle bindings with various sub-bindings and forms correctly', () => { + const bindings: AppBinding[] = [ + { + app_id: '1', + location: AppBindingLocations.CHANNEL_HEADER_ICON, + label: 'channel_header', + bindings: [{ + app_id: '1.1', + label: 'sub_binding1', + location: '', + form: { + submit: { + path: 'path_1', + }, + }, + }]}, + { + app_id: '2', + location: AppBindingLocations.POST_MENU_ITEM, + label: 'post_menu', + bindings: [{ + app_id: '2.1', + label: 'sub_binding2', + location: '', + form: { + submit: { + path: 'path_2', + }, + }, + }]}, + {app_id: '3', location: AppBindingLocations.COMMAND, label: 'command', bindings: []}, + ]; + + const result = validateBindings(bindings); + expect(result).toHaveLength(2); + expect(result[0].app_id).toBe('2'); + expect(result[1].app_id).toBe('1'); + }); +}); + +describe('cleanForm', () => { + it('should return immediately if form is undefined', () => { + expect(() => cleanForm(undefined)).not.toThrow(); + }); + + it('should remove fields without names', () => { + const form: AppForm = { + fields: [ + {name: '', type: 'text'} as AppField, + {name: 'valid_name', type: 'text'} as AppField, + ], + }; + + cleanForm(form); + expect(form.fields).toHaveLength(1); + expect(form.fields![0].name).toBe('valid_name'); + }); + + it('should remove fields with names containing spaces or tabs', () => { + const form: AppForm = { + fields: [ + {name: 'invalid name', type: 'text'} as AppField, + {name: 'invalid\tname', type: 'text'} as AppField, + {name: 'valid_name', type: 'text'} as AppField, + ], + }; + + cleanForm(form); + expect(form.fields).toHaveLength(1); + expect(form.fields![0].name).toBe('valid_name'); + }); + + it('should remove fields with duplicate or invalid labels', () => { + const form: AppForm = { + fields: [ + {name: 'name1', type: 'text', label: 'label1'} as AppField, + {name: 'name2', type: 'text', label: 'label1'} as AppField, // Duplicate label + {name: 'name3', type: 'text', label: 'invalid label'} as AppField, + {name: 'name4', type: 'text'} as AppField, // No label, should use name + {name: 'name5', type: 'text', label: 'label5'} as AppField, + ], + }; + + cleanForm(form); + expect(form.fields).toHaveLength(3); + expect(form.fields![0].name).toBe('name1'); + expect(form.fields![1].name).toBe('name4'); + expect(form.fields![2].name).toBe('name5'); + }); + + it('should handle STATIC_SELECT fields and remove invalid options', () => { + const form: AppForm = { + fields: [ + { + name: 'select_field', + type: AppFieldTypes.STATIC_SELECT, + options: [ + {label: 'option1', value: 'value1'}, + {label: '', value: 'value2'}, + {label: 'option1', value: 'value3'}, + {label: 'option4', value: ''}, + ], + } as AppField, + ], + }; + + cleanForm(form); + expect(form.fields).toHaveLength(1); + expect(form.fields![0].options).toHaveLength(3); + }); + + it('should retain valid fields and options', () => { + const form: AppForm = { + fields: [ + {name: 'valid_name', type: 'text'} as AppField, + { + name: 'select_field', + type: AppFieldTypes.STATIC_SELECT, + options: [ + {label: 'option1', value: 'value1'}, + {label: 'option2', value: 'value2'}, + ], + } as AppField, + {name: 'dynamic_field_with_lookup', type: AppFieldTypes.DYNAMIC_SELECT, lookup: {path: 'lookup_path'}} as AppField, + ], + }; + + cleanForm(form); + expect(form.fields).toHaveLength(3); + }); +}); + +describe('makeCallErrorResponse', () => { + it('should create an error response object', () => { + const errorMessage = 'An error occurred'; + const errorResponse = makeCallErrorResponse(errorMessage); + + expect(errorResponse.type).toBe(AppCallResponseTypes.ERROR); + expect(errorResponse.text).toBe(errorMessage); + }); +}); + +describe('filterEmptyOptions', () => { + it('should filter out empty options', () => { + const options: AppSelectOption[] = [ + {label: 'Option 1', value: 'value1'}, + {label: 'Option 2', value: ' '}, + {label: 'Option 3', value: ''}, + {label: 'Option 4', value: 'value2'}, + ]; + + const filteredOptions = options.filter(filterEmptyOptions); + + // Check that empty options have been filtered out + expect(filteredOptions.length).toBe(2); + expect(filteredOptions.map((option) => option.label)).toEqual(['Option 1', 'Option 4']); + }); +}); + +describe('createCallContext', () => { + it('should create context with all parameters provided', () => { + const context = createCallContext('appID123', 'location123', 'channelID123', 'teamID123', 'postID123', 'rootID123'); + + expect(context).toEqual({ + app_id: 'appID123', + location: 'location123', + channel_id: 'channelID123', + team_id: 'teamID123', + post_id: 'postID123', + root_id: 'rootID123', + }); + }); + + it('should create context with only appID provided', () => { + const context = createCallContext('appID123'); + + expect(context).toEqual({ + app_id: 'appID123', + location: undefined, + channel_id: undefined, + team_id: undefined, + post_id: undefined, + root_id: undefined, + }); + }); + + it('should create context with some parameters provided', () => { + const context = createCallContext('appID123', 'location123', 'channelID123'); + + expect(context).toEqual({ + app_id: 'appID123', + location: 'location123', + channel_id: 'channelID123', + team_id: undefined, + post_id: undefined, + root_id: undefined, + }); + }); +}); + +describe('createCallRequest', () => { + const mockAppCall: AppCall = { + path: '/mock/path', + expand: { + app: 'all', + }, + }; + + const mockAppContext: AppContext = { + app_id: 'appID123', + location: 'location123', + channel_id: 'channelID123', + team_id: 'teamID123', + post_id: 'postID123', + root_id: 'rootID123', + }; + + const mockDefaultExpand: AppExpand = { + user: 'all', + post: 'none', + }; + + const mockValues: AppCallValues = { + key1: 'value1', + key2: 'value2', + }; + + it('should create call request with all parameters provided', () => { + const rawCommand = '/mock command'; + const request = createCallRequest(mockAppCall, mockAppContext, mockDefaultExpand, mockValues, rawCommand); + + expect(request).toEqual({ + ...mockAppCall, + context: mockAppContext, + values: mockValues, + expand: { + ...mockDefaultExpand, + ...mockAppCall.expand, + }, + raw_command: rawCommand, + }); + }); + + it('should create call request with required parameters only', () => { + const request = createCallRequest(mockAppCall, mockAppContext); + + expect(request).toEqual({ + ...mockAppCall, + context: mockAppContext, + expand: { + ...mockAppCall.expand, + }, + values: undefined, + raw_command: undefined, + }); + }); + + it('should create call request with default expand only', () => { + const request = createCallRequest(mockAppCall, mockAppContext, mockDefaultExpand); + + expect(request).toEqual({ + ...mockAppCall, + context: mockAppContext, + expand: { + ...mockDefaultExpand, + ...mockAppCall.expand, + }, + values: undefined, + raw_command: undefined, + }); + }); + + it('should create call request with values only', () => { + const request = createCallRequest(mockAppCall, mockAppContext, {}, mockValues); + + expect(request).toEqual({ + ...mockAppCall, + context: mockAppContext, + expand: { + ...mockAppCall.expand, + }, + values: mockValues, + raw_command: undefined, + }); + }); + + it('should create call request with raw command only', () => { + const rawCommand = '/mock command'; + const request = createCallRequest(mockAppCall, mockAppContext, {}, undefined, rawCommand); + + expect(request).toEqual({ + ...mockAppCall, + context: mockAppContext, + expand: { + ...mockAppCall.expand, + }, + values: undefined, + raw_command: rawCommand, + }); + }); + + it('should create call request with overridden expand values', () => { + const callWithExpandOverride: AppCall = { + ...mockAppCall, + expand: { + app: 'none', + user: 'summary', + }, + }; + const request = createCallRequest(callWithExpandOverride, mockAppContext, mockDefaultExpand); + + expect(request).toEqual({ + ...callWithExpandOverride, + context: mockAppContext, + expand: { + ...mockDefaultExpand, + ...callWithExpandOverride.expand, + }, + values: undefined, + raw_command: undefined, + }); + }); +}); + diff --git a/app/utils/apps.ts b/app/utils/apps.ts index cbdb26148..2605efd7c 100644 --- a/app/utils/apps.ts +++ b/app/utils/apps.ts @@ -1,5 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. + import {AppBindingLocations, AppCallResponseTypes, AppFieldTypes} from '@constants/apps'; import {generateId} from './general'; @@ -22,7 +23,7 @@ function cleanBindingRec(binding: AppBinding, topLocation: string, depth: number } if (!b.label) { - b.label = b.location || ''; + b.label = binding.label || b.location || ''; } if (!b.location) { diff --git a/app/utils/font_family.test.ts b/app/utils/font_family.test.ts new file mode 100644 index 000000000..f113737f1 --- /dev/null +++ b/app/utils/font_family.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {cloneElement} from 'react'; +import {Text, StyleSheet} from 'react-native'; + +import setFontFamily from './font_family'; + +// Type for style object +type StyleObject = { [key: string]: any }; + +// Custom function to flatten styles +const flattenStyles = (styles: StyleObject | Array): StyleObject|undefined => { + if (styles === null || typeof styles !== 'object') { + return undefined; + } + + if (!Array.isArray(styles)) { + return styles; + } + + return styles.reduce((acc, style) => { + if (!style) { + return acc; + } // Skip if style is null or undefined + + if (Array.isArray(style)) { + // Merge arrays of styles + return style.reduce((prev, curr) => ({ + ...prev, + ...flattenStyles(curr), + }), acc); + } else if (typeof style === 'object') { + // Merge objects of styles + return {...acc, ...style}; + } + + // Skip if style is not an object or array + return acc; + }, {}); +}; + +// Mock the necessary parts of react-native +jest.mock('react-native', () => ({ + Text: { + render: jest.fn(), + }, + StyleSheet: { + create: jest.fn((styles) => styles), + }, +})); + +// Mock cloneElement +jest.mock('react', () => ({ + ...jest.requireActual('react'), + cloneElement: jest.fn((element, props) => { + // Merge the existing style with the new style + const mergedStyle = flattenStyles([props.style, ...(element.props.style ? [element.props.style] : [])]); + + // Return a new object with merged styles + return { + ...element, + props: { + ...element.props, + style: mergedStyle, + }, + }; + }), +})); + +describe('setFontFamily', () => { + let originalTextRender: any; + + beforeEach(() => { + // Capture the original Text.render before modification + // @ts-expect-error renderer is not exposed to TS definition + originalTextRender = Text.render; + }); + + // Restore the original implementations after each test + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('overrides Text.render and applies custom styles', () => { + // Call the function to set the font family + setFontFamily(); + + // Check if the StyleSheet.create was called with the correct styles + expect(StyleSheet.create).toHaveBeenCalledWith({ + defaultText: { + fontFamily: 'OpenSans', + fontSize: 16, + }, + }); + + // Check if Text.render was overridden + // @ts-expect-error renderer is not exposed to TS definition + expect(Text.render).not.toBe(originalTextRender); + + // Create a mock origin render output + const mockOriginRenderOutput = { + props: { + style: [{color: 'red'}], + }, + }; + + // Set the old render function to return the mock output + (originalTextRender as jest.Mock).mockReturnValue(mockOriginRenderOutput); + + // Call the new render function + // @ts-expect-error renderer is not exposed to TS definition + const newRenderOutput = Text.render(); + + // Check if cloneElement was called with the correct arguments + expect(cloneElement).toHaveBeenCalledWith(mockOriginRenderOutput, { + style: [ + { + fontFamily: 'OpenSans', + fontSize: 16, + }, + [{color: 'red'}], + ], + }); + + // Verify the new render output has the expected styles + expect(newRenderOutput.props.style).toEqual({fontFamily: 'OpenSans', fontSize: 16, color: 'red'}); + }); +}); diff --git a/app/utils/font_family.ts b/app/utils/font_family.ts index 5da2abb6f..c9a1c028d 100644 --- a/app/utils/font_family.ts +++ b/app/utils/font_family.ts @@ -18,11 +18,11 @@ const setFontFamily = () => { fontSize: 16, }, }); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore + + // @ts-expect-error renderer is not exposed to TS definition const oldRender = Text.render; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore + + // @ts-expect-error renderer is not exposed to TS definition Text.render = function render(...args) { const origin = oldRender.call(this, ...args); return cloneElement(origin, { diff --git a/app/utils/groups.test.ts b/app/utils/groups.test.ts new file mode 100644 index 000000000..6b7f6958c --- /dev/null +++ b/app/utils/groups.test.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {generateGroupAssociationId} from './groups'; + +describe('groups utility', () => { + test('generateGroupAssociationId', () => { + expect(generateGroupAssociationId('groupId', 'otherId')).toEqual('groupId-otherId'); + }); +}); diff --git a/app/utils/helpers.test.ts b/app/utils/helpers.test.ts index f328ea0a7..fbea00872 100644 --- a/app/utils/helpers.test.ts +++ b/app/utils/helpers.test.ts @@ -1,48 +1,240 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import moment from 'moment-timezone'; +import {NativeModules, Platform} from 'react-native'; -import {areBothStringArraysEqual} from '@utils/helpers'; +import {STATUS_BAR_HEIGHT} from '@constants/view'; -describe('areBothStringArraysEqual', () => { - test('Should return false when length of arrays are not equal', () => { - const array1 = ['test1', 'test2']; - const array2 = ['test1']; +import { + isMinimumServerVersion, + buildQueryString, + isEmail, + identity, + safeParseJSON, + getCurrentMomentForTimezone, + getUtcOffsetForTimeZone, + toTitleCase, + getRoundedTime, + isTablet, + pluckUnique, + bottomSheetSnapPoint, + hasTrailingSpaces, + isMainActivity, + areBothStringArraysEqual, +} from './helpers'; - expect(areBothStringArraysEqual(array1, array2)).toEqual(false); +jest.mock('@mattermost/rnshare', () => ({ + default: { + getCurrentActivityName: jest.fn(). + mockReturnValueOnce('MainActivity'). + mockReturnValue('SomeOtherActivity'), + }, +})); + +describe('Helpers', () => { + afterAll(() => { + Platform.OS = 'ios'; }); - test('Should return false when arrays are not equal', () => { - const array1 = ['test1', 'test2']; - const array2 = ['test1', 'test2', 'test3']; + describe('isMinimumServerVersion', () => { + test('should return true if server version meets minimum requirements', () => { + expect(isMinimumServerVersion('4.6.0', 4, 6, 0)).toBe(true); + expect(isMinimumServerVersion('4.6.0', 4, 5, 0)).toBe(true); + expect(isMinimumServerVersion('4.6.1', 4, 6, 0)).toBe(true); + expect(isMinimumServerVersion('4')).toBe(true); + expect(isMinimumServerVersion('4.6', 4, 6)).toBe(true); + }); - expect(areBothStringArraysEqual(array1, array2)).toEqual(false); + test('should return false if server version does not meet minimum requirements', () => { + expect(isMinimumServerVersion('4.5.0', 4, 6, 0)).toBe(false); + expect(isMinimumServerVersion('4.6.0', 4, 6, 1)).toBe(false); + }); + + test('currentVersion is not set or not a string', () => { + expect(isMinimumServerVersion()).toBe(false); + + // @ts-expect-error argument should be a string + expect(isMinimumServerVersion(0)).toBe(false); + }); }); - test('Should return false when either array is empty', () => { - const array1 = ['test1', 'test2']; - const array2: string[] = []; + describe('buildQueryString', () => { + test('should build query string from object', () => { + const parameters = {key1: 'value1', key2: 'value2'}; + expect(buildQueryString(parameters)).toBe('?key1=value1&key2=value2'); + expect(buildQueryString({...parameters, key3: null})).toBe('?key1=value1&key2=value2'); + expect(buildQueryString({key0: null, ...parameters, key3: null})).toBe('?key1=value1&key2=value2'); + expect(buildQueryString({key1: 'value1', key0: null, key3: null, key2: 'value2'})).toBe('?key1=value1&key2=value2'); + }); - expect(areBothStringArraysEqual(array1, array2)).toEqual(false); + test('should handle empty object', () => { + expect(buildQueryString({})).toBe(''); + }); }); - test('Should return true when arrays are equal', () => { - const array1 = ['test1', 'test2']; - const array2 = ['test1', 'test2']; + describe('isEmail', () => { + test('should validate correct emails', () => { + expect(isEmail('test@example.com')).toBe(true); + expect(isEmail('another@test.com')).toBe(true); + }); - expect(areBothStringArraysEqual(array1, array2)).toEqual(true); + test('should invalidate incorrect emails', () => { + expect(isEmail('invalid')).toBe(false); + expect(isEmail('test@')).toBe(false); + expect(isEmail('test@localhost')).toBe(true); + expect(isEmail('test@example.com')).toBe(true); + }); }); - test('Should return true when arrays are equal but in different order', () => { - const array1 = ['test1', 'test2']; - const array2 = ['test2', 'test1']; - - expect(areBothStringArraysEqual(array1, array2)).toEqual(true); + describe('identity', () => { + test('should return the same argument', () => { + expect(identity('test')).toBe('test'); + expect(identity(123)).toBe(123); + const obj = {}; + expect(identity(obj)).toBe(obj); + }); }); - test('Should return true when both arrays are empty', () => { - const array1: string[] = []; - const array2: string[] = []; + describe('safeParseJSON', () => { + test('should parse valid JSON string', () => { + const jsonString = '{"key": "value"}'; + expect(safeParseJSON(jsonString)).toEqual({key: 'value'}); + }); - expect(areBothStringArraysEqual(array1, array2)).toEqual(false); + test('should handle invalid JSON', () => { + expect(safeParseJSON('invalid-json')).toBe('invalid-json'); + }); + + test('should handle non-string input', () => { + expect(safeParseJSON({key: 'value'})).toEqual({key: 'value'}); + }); + }); + + describe('getCurrentMomentForTimezone', () => { + test('should return current moment in specified timezone', () => { + const timezone = 'America/New_York'; + const result = getCurrentMomentForTimezone(timezone); + expect(result.isValid()).toBe(true); + }); + + test('should return current moment if no timezone specified', () => { + const result = getCurrentMomentForTimezone(null); + expect(result.isValid()).toBe(true); + }); + }); + + describe('toTitleCase', () => { + test('should convert string to title case', () => { + expect(toTitleCase('hello world')).toBe('Hello World'); + }); + }); + + describe('getRoundedTime', () => { + test('should round time to nearest interval', () => { + const time = moment('2024-06-01T12:34:00Z'); + const result = getRoundedTime(time); + expect(result.minute() % 30).toBe(0); // Assuming CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES is 15 + + const time2 = moment('2024-06-01T12:00:00Z'); + const result2 = getRoundedTime(time2); + expect(result2.minute() % 30).toBe(0); + }); + }); + + describe('isTablet', () => { + test('should identify tablet correctly', () => { + console.log('split mock', NativeModules.RNUtils.isRunningInSplitView()); + expect(isTablet()).toBe(false); + + const prevSplitViewModule = NativeModules.RNUtils.isRunningInSplitView; + NativeModules.RNUtils.isRunningInSplitView = jest.fn().mockReturnValue({isSplit: false, isTablet: true}); + console.log('split mock', NativeModules.RNUtils.isRunningInSplitView()); + expect(isTablet()).toBe(true); + NativeModules.RNUtils.isRunningInSplitView = jest.fn().mockReturnValue({isSplit: true, isTablet: true}); + expect(isTablet()).toBe(false); + NativeModules.RNUtils.isRunningInSplitView = prevSplitViewModule; // Restore original value + }); + }); + + // Add tests for other functions similarly + + describe('areBothStringArraysEqual', () => { + test('should compare two string arrays', () => { + const arr1 = ['apple', 'banana', 'cherry']; + const arr2 = ['banana', 'cherry', 'apple']; + expect(areBothStringArraysEqual(arr1, arr2)).toBe(true); + }); + + test('should return false for unequal arrays or empty', () => { + const arr1 = ['apple', 'banana', 'cherry']; + const arr2 = ['banana', 'cherry', 'orange']; + expect(areBothStringArraysEqual(arr1, arr2)).toBe(false); + expect(areBothStringArraysEqual(arr1, [...arr1, ...arr2])).toBe(false); + expect(areBothStringArraysEqual([], [])).toBe(false); + }); + }); + + describe('getUtcOffsetForTimeZone', () => { + test('should return UTC offset for timezone', () => { + const timezone = 'America/New_York'; + const result = getUtcOffsetForTimeZone(timezone); + expect(result).toBe(moment.tz(timezone).utcOffset()); + }); + }); + describe('pluckUnique', () => { + test('should pluck unique values based on key', () => { + const array = [ + {id: 1, name: 'John'}, + {id: 2, name: 'Jane'}, + {id: 1, name: 'John'}, + ]; + const result = pluckUnique('id')(array); + expect(result).toEqual([1, 2]); + }); + }); + + describe('bottomSheetSnapPoint', () => { + test('should calculate bottom sheet snap point', () => { + const itemsCount = 5; + const itemHeight = 50; + const bottomInset = 20; + const result = bottomSheetSnapPoint(itemsCount, itemHeight, bottomInset); + const expected = (itemsCount * itemHeight) + bottomInset + STATUS_BAR_HEIGHT; + expect(result).toBe(expected); + }); + }); + + describe('hasTrailingSpaces', () => { + test('should detect trailing spaces', () => { + const term = 'Hello '; + const result = hasTrailingSpaces(term); + expect(result).toBe(true); + }); + + test('should not detect trailing spaces', () => { + const term = 'Hello'; + const result = hasTrailingSpaces(term); + expect(result).toBe(false); + }); + }); + + describe('isMainActivity', () => { + test('should return true on iOS', () => { + Platform.OS = 'ios'; + const result = isMainActivity(); + expect(result).toBe(true); + }); + + test('should return true if current activity is MainActivity on Android', () => { + Platform.OS = 'android'; + const result = isMainActivity(); + expect(result).toBe(true); + }); + + test('should return false if current activity is not MainActivity on Android', () => { + Platform.OS = 'android'; + const result = isMainActivity(); + expect(result).toBe(false); + }); }); }); diff --git a/app/utils/helpers.ts b/app/utils/helpers.ts index e6b66c0cf..67a2ac238 100644 --- a/app/utils/helpers.ts +++ b/app/utils/helpers.ts @@ -8,12 +8,6 @@ import {Platform} from 'react-native'; import {CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES} from '@constants/custom_status'; import {STATUS_BAR_HEIGHT} from '@constants/view'; -const {isRunningInSplitView} = RNUtils; -const MattermostShare = Platform.select({ - default: null, - android: require('@mattermost/rnshare').default, -}); - // isMinimumServerVersion will return true if currentVersion is equal to higher or than // the provided minimum version. A non-equal major version will ignore minor and dot // versions, and a non-equal minor version will ignore dot version. @@ -76,6 +70,10 @@ export function buildQueryString(parameters: Dictionary): string { } } + if (query.endsWith('&')) { + return query.slice(0, -1); + } + return query; } @@ -86,7 +84,10 @@ export function isEmail(email: string): boolean { // - followed by a single @ symbol // - followed by at least one character that is not a space, comma, or @ symbol // this prevents outlook.style@domain.com addresses and multiple comma-separated addresses from being accepted - return (/^[^ ,@]+@[^ ,@]+$/).test(email); + const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const regexWithoutTLDN = /^[^\s@]+@[^\s@]+$/; + + return regex.test(email) || regexWithoutTLDN.test(email); } export function identity(arg: T): T { @@ -133,7 +134,7 @@ export function getRoundedTime(value: Moment) { } export function isTablet() { - const result: SplitViewResult = isRunningInSplitView(); + const result: SplitViewResult = RNUtils.isRunningInSplitView(); return result.isTablet && !result.isSplit; } @@ -155,10 +156,12 @@ export function hasTrailingSpaces(term: string) { * @returns boolean */ export function isMainActivity() { - return Platform.select({ - default: true, - android: MattermostShare?.getCurrentActivityName() === 'MainActivity', - }); + if (Platform.OS === 'android') { + const MattermostShare = require('@mattermost/rnshare').default; + return MattermostShare?.getCurrentActivityName() === 'MainActivity'; + } + + return true; } export function areBothStringArraysEqual(a: string[], b: string[]) { diff --git a/app/utils/integrations.test.ts b/app/utils/integrations.test.ts new file mode 100644 index 000000000..8ea148750 --- /dev/null +++ b/app/utils/integrations.test.ts @@ -0,0 +1,309 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {checkDialogElementForError, checkIfErrorsMatchElements, selectKeyboardType} from './integrations'; + +describe('checkDialogElementForError', () => { + test('returns required error for empty required field', () => { + const elem: DialogElement = { + name: 'field1', + type: 'text', + optional: false, + display_name: '', + subtype: 'number', + default: '', + placeholder: '', + help_text: '', + min_length: 0, + max_length: 0, + data_source: '', + options: [], + }; + const result = checkDialogElementForError(elem, ''); + expect(result).toEqual({ + id: 'interactive_dialog.error.required', + defaultMessage: 'This field is required.', + }); + }); + + test('returns too short error for text shorter than min_length', () => { + const elem: DialogElement = { + name: 'field1', + type: 'text', + min_length: 5, + display_name: '', + subtype: 'number', + default: '', + placeholder: '', + help_text: '', + optional: false, + max_length: 0, + data_source: '', + options: [], + }; + const result = checkDialogElementForError(elem, '123'); + expect(result).toEqual({ + id: 'interactive_dialog.error.too_short', + defaultMessage: 'Minimum input length is {minLength}.', + values: {minLength: 5}, + }); + }); + + test('returns bad email error for invalid email', () => { + const elem: DialogElement = { + name: 'field1', + type: 'text', + subtype: 'email', + display_name: '', + default: '', + placeholder: '', + help_text: '', + optional: false, + min_length: 0, + max_length: 0, + data_source: '', + options: [], + }; + const result = checkDialogElementForError(elem, 'invalidemail'); + expect(result).toEqual({ + id: 'interactive_dialog.error.bad_email', + defaultMessage: 'Must be a valid email address.', + }); + }); + + test('returns bad number error for invalid number', () => { + const elem: DialogElement = { + name: 'field1', + type: 'text', + subtype: 'number', + display_name: '', + default: '', + placeholder: '', + help_text: '', + optional: false, + min_length: 0, + max_length: 0, + data_source: '', + options: [], + }; + const result = checkDialogElementForError(elem, 'notanumber'); + expect(result).toEqual({ + id: 'interactive_dialog.error.bad_number', + defaultMessage: 'Must be a number.', + }); + }); + + test('returns bad URL error for invalid URL', () => { + const elem: DialogElement = { + name: 'field1', + type: 'text', + subtype: 'url', + display_name: '', + default: '', + placeholder: '', + help_text: '', + optional: false, + min_length: 0, + max_length: 0, + data_source: '', + options: [], + }; + const result = checkDialogElementForError(elem, 'invalidurl'); + expect(result).toEqual({ + id: 'interactive_dialog.error.bad_url', + defaultMessage: 'URL must include http:// or https://.', + }); + }); + + test('returns invalid option error for invalid radio option', () => { + const elem: DialogElement = { + name: 'field1', + type: 'radio', + options: [{ + value: 'option1', + text: '', + }, { + value: 'option2', + text: '', + }], + display_name: '', + subtype: 'number', + default: '', + placeholder: '', + help_text: '', + optional: false, + min_length: 0, + max_length: 0, + data_source: '', + }; + const result = checkDialogElementForError(elem, 'invalidoption'); + expect(result).toEqual({ + id: 'interactive_dialog.error.invalid_option', + defaultMessage: 'Must be a valid option', + }); + }); + + test('returns null for valid inputs', () => { + const elemText: DialogElement = { + name: 'field1', + type: 'text', + min_length: 3, + display_name: '', + subtype: 'password', + default: '', + placeholder: '', + help_text: '', + optional: false, + max_length: 0, + data_source: '', + options: [], + }; + const elemEmail: DialogElement = { + name: 'field2', + type: 'text', + subtype: 'email', + display_name: '', + default: '', + placeholder: '', + help_text: '', + optional: false, + min_length: 0, + max_length: 0, + data_source: '', + options: [], + }; + const elemNumber: DialogElement = { + name: 'field3', + type: 'text', + subtype: 'number', + display_name: '', + default: '', + placeholder: '', + help_text: '', + optional: false, + min_length: 0, + max_length: 0, + data_source: '', + options: [], + }; + const elemURL: DialogElement = { + name: 'field4', + type: 'text', + subtype: 'url', + display_name: '', + default: '', + placeholder: '', + help_text: '', + optional: false, + min_length: 0, + max_length: 0, + data_source: '', + options: [], + }; + const elemRadio: DialogElement = { + name: 'field5', + type: 'radio', + options: [{ + value: 'option1', + text: '', + }, { + value: 'option2', + text: '', + }], + display_name: '', + subtype: 'number', + default: '', + placeholder: '', + help_text: '', + optional: false, + min_length: 0, + max_length: 0, + data_source: '', + }; + + expect(checkDialogElementForError(elemText, 'valid')).toBeNull(); + expect(checkDialogElementForError(elemEmail, 'email@example.com')).toBeNull(); + expect(checkDialogElementForError(elemNumber, '123')).toBeNull(); + expect(checkDialogElementForError(elemURL, 'http://example.com')).toBeNull(); + expect(checkDialogElementForError(elemRadio, 'option1')).toBeNull(); + }); +}); + +describe('checkIfErrorsMatchElements', () => { + test('returns false if no dialog elements', () => { + const errors = {field1: 'error'}; + expect(checkIfErrorsMatchElements(errors)).toBe(false); + }); + + test('returns false if no dialog errprs', () => { + expect(checkIfErrorsMatchElements()).toBe(false); + }); + + test('returns true if errors match elements', () => { + const elements: DialogElement[] = [{ + name: 'field1', + type: 'text', + display_name: '', + subtype: 'number', + default: '', + placeholder: '', + help_text: '', + optional: false, + min_length: 0, + max_length: 0, + data_source: '', + options: [], + }]; + const errors = {field1: 'error'}; + expect(checkIfErrorsMatchElements(errors, elements)).toBe(true); + }); + + test('returns false if errors do not match elements', () => { + const elements: DialogElement[] = [{ + name: 'field1', + type: 'text', + display_name: '', + subtype: 'number', + default: '', + placeholder: '', + help_text: '', + optional: false, + min_length: 0, + max_length: 0, + data_source: '', + options: [], + }]; + const errors = {field2: 'error'}; + expect(checkIfErrorsMatchElements(errors, elements)).toBe(false); + }); + + test('returns false if errors and elements are empty', () => { + expect(checkIfErrorsMatchElements({}, [])).toBe(false); + }); +}); + +describe('selectKeyboardType', () => { + test('returns email-address for email subtype', () => { + expect(selectKeyboardType('email')).toBe('email-address'); + }); + + test('returns numeric for number subtype', () => { + expect(selectKeyboardType('number')).toBe('numeric'); + }); + + test('returns phone-pad for tel subtype', () => { + expect(selectKeyboardType('tel')).toBe('phone-pad'); + }); + + test('returns url for url subtype', () => { + expect(selectKeyboardType('url')).toBe('url'); + }); + + test('returns default for undefined subtype', () => { + expect(selectKeyboardType()).toBe('default'); + }); + + test('returns default for unrecognized subtype', () => { + expect(selectKeyboardType('unrecognized')).toBe('default'); + }); +}); diff --git a/app/utils/key_mirror.test.ts b/app/utils/key_mirror.test.ts new file mode 100644 index 000000000..323049fdf --- /dev/null +++ b/app/utils/key_mirror.test.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import keyMirror from './key_mirror'; + +describe('keyMirror', () => { + test('creates an object with keys mirrored as values', () => { + const input = {key1: null, key2: null, key3: null}; + const expectedOutput = {key1: 'key1', key2: 'key2', key3: 'key3'}; + expect(keyMirror(input)).toEqual(expectedOutput); + }); + + test('throws an error if argument is not an object', () => { + // @ts-expect-error null will complain by TS + expect(() => keyMirror(null)).toThrow('keyMirror(...): Argument must be an object.'); + expect(() => keyMirror([])).toThrow('keyMirror(...): Argument must be an object.'); + expect(() => keyMirror('string')).toThrow('keyMirror(...): Argument must be an object.'); + expect(() => keyMirror(42)).toThrow('keyMirror(...): Argument must be an object.'); + expect(() => keyMirror(true)).toThrow('keyMirror(...): Argument must be an object.'); + }); + + test('ignores properties from the prototype chain', () => { + const input = Object.create({inheritedKey: null}); + input.ownKey = null; + const expectedOutput = {ownKey: 'ownKey'}; + expect(keyMirror(input)).toEqual(expectedOutput); + }); +}); diff --git a/app/utils/log.test.ts b/app/utils/log.test.ts new file mode 100644 index 000000000..252c520db --- /dev/null +++ b/app/utils/log.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import keyMirror from '@utils/key_mirror'; + +import {logError, logWarning, logInfo, logDebug} from './log'; + +// Mock Sentry +jest.mock('@sentry/react-native', () => ({ + addBreadcrumb: jest.fn(), +})); + +// Mock console methods +const originalConsole = global.console; + +// @ts-expect-error global not in TS def +global.console = { + error: jest.fn(), + warn: jest.fn(), + log: jest.fn(), + debug: jest.fn(), +}; + +describe('Logging functions', () => { + const Sentry = require('@sentry/react-native'); + const SentryLevels = keyMirror({debug: null, info: null, warning: null, error: null}); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + global.console = originalConsole; + }); + + test('logError logs error and adds breadcrumb', () => { + const args = ['Error message']; + logError(...args); + expect(console.error).toHaveBeenCalledWith(...args); + expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({ + level: SentryLevels.error, + message: args.join(','), + type: 'console-log', + }); + }); + + test('logWarning logs warning and adds breadcrumb', () => { + const args = ['Warning message']; + logWarning(...args); + expect(console.warn).toHaveBeenCalledWith(...args); + expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({ + level: SentryLevels.warning, + message: args.join(','), + type: 'console-log', + }); + }); + + test('logInfo logs info and adds breadcrumb', () => { + const args = ['Info message']; + logInfo(...args); + expect(console.log).toHaveBeenCalledWith(...args); + expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({ + level: SentryLevels.info, + message: args.join(','), + type: 'console-log', + }); + }); + + test('logDebug logs debug and adds breadcrumb', () => { + const args = ['Debug message']; + logDebug(...args); + expect(console.debug).toHaveBeenCalledWith(...args); + expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({ + level: SentryLevels.debug, + message: args.join(','), + type: 'console-log', + }); + }); +}); diff --git a/app/utils/mattermost_managed.test.ts b/app/utils/mattermost_managed.test.ts new file mode 100644 index 000000000..0a6c41a0c --- /dev/null +++ b/app/utils/mattermost_managed.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import RNUtils from '@mattermost/rnutils'; +import {Platform} from 'react-native'; + +import { + getIOSAppGroupDetails, + deleteIOSDatabase, + renameIOSDatabase, + deleteEntitiesFile, +} from './mattermost_managed'; + +jest.mock('@mattermost/rnutils', () => ({ + getConstants: jest.fn(), + deleteDatabaseDirectory: jest.fn(), + renameDatabase: jest.fn(), + deleteEntitiesFile: jest.fn(), +})); + +describe('iOS Utilities', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getIOSAppGroupDetails', () => { + test('retrieves iOS AppGroup details correctly', () => { + const constantsMock = { + appGroupIdentifier: 'group.com.example.app', + appGroupSharedDirectory: {sharedDirectory: '/shared', databasePath: '/shared/database'}, + }; + + //@ts-expect-error mockReturnValue + RNUtils.getConstants.mockReturnValue(constantsMock); + + const details = getIOSAppGroupDetails(); + + expect(details).toEqual({ + appGroupIdentifier: 'group.com.example.app', + appGroupSharedDirectory: '/shared', + appGroupDatabase: '/shared/database', + }); + expect(RNUtils.getConstants).toHaveBeenCalled(); + }); + }); + + describe('deleteIOSDatabase', () => { + test('deletes iOS database with given parameters', async () => { + //@ts-expect-error mockReturnValue + const deleteMock = RNUtils.deleteDatabaseDirectory.mockResolvedValue(true); + + const result = await deleteIOSDatabase({databaseName: 'test.db', shouldRemoveDirectory: true}); + + expect(deleteMock).toHaveBeenCalledWith('test.db', true); + expect(result).toBe(true); + }); + + test('deletes iOS database with default parameters', async () => { + //@ts-expect-error mockReturnValue + const deleteMock = RNUtils.deleteDatabaseDirectory.mockResolvedValue(true); + + const result = await deleteIOSDatabase({}); + + expect(deleteMock).toHaveBeenCalledWith('', false); + expect(result).toBe(true); + }); + }); + + describe('renameIOSDatabase', () => { + test('renames iOS database with given parameters', () => { + //@ts-expect-error mockReturnValue + const renameMock = RNUtils.renameDatabase.mockResolvedValue(true); + + const result = renameIOSDatabase('old.db', 'new.db'); + + expect(renameMock).toHaveBeenCalledWith('old.db', 'new.db'); + expect(result).resolves.toBe(true); + }); + }); + + describe('deleteEntitiesFile', () => { + test('deletes entities file on iOS', () => { + Platform.OS = 'ios'; + + //@ts-expect-error mockReturnValue + const deleteMock = RNUtils.deleteEntitiesFile.mockResolvedValue(true); + + const result = deleteEntitiesFile(); + + expect(deleteMock).toHaveBeenCalled(); + expect(result).resolves.toBe(true); + }); + + test('resolves true on non-iOS platforms', () => { + Platform.OS = 'android'; + + const result = deleteEntitiesFile(); + + expect(result).resolves.toBe(true); + }); + }); +}); diff --git a/app/utils/message_attachment_colors.test.ts b/app/utils/message_attachment_colors.test.ts new file mode 100644 index 000000000..3ee07e984 --- /dev/null +++ b/app/utils/message_attachment_colors.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Preferences} from '@constants'; + +import {getStatusColors} from './message_attachment_colors'; + +describe('getStatusColors', () => { + const mockTheme = Preferences.THEMES.denim; + + test('returns correct status colors based on theme', () => { + const expectedColors: Dictionary = { + good: '#00c100', + warning: '#dede01', + danger: '#d24b4e', + default: '#3f4350', + primary: '#1c58d9', + success: '#3db887', + }; + + const statusColors = getStatusColors(mockTheme); + expect(statusColors).toEqual(expectedColors); + }); + + test('returns the correct danger color from the theme', () => { + const statusColors = getStatusColors(mockTheme); + expect(statusColors.danger).toBe(mockTheme.errorTextColor); + }); + + test('returns the correct default color from the theme', () => { + const statusColors = getStatusColors(mockTheme); + expect(statusColors.default).toBe(mockTheme.centerChannelColor); + }); + + test('returns the correct primary color from the theme', () => { + const statusColors = getStatusColors(mockTheme); + expect(statusColors.primary).toBe(mockTheme.buttonBg); + }); + + test('returns the correct success color from the theme', () => { + const statusColors = getStatusColors(mockTheme); + expect(statusColors.success).toBe(mockTheme.onlineIndicator); + }); +}); diff --git a/app/utils/mix.test.ts b/app/utils/mix.test.ts new file mode 100644 index 000000000..ad635a9c5 --- /dev/null +++ b/app/utils/mix.test.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import mix from './mix'; + +describe('MixinBuilder', () => { + class BaseClass { + baseMethod() { + return 'base method'; + } + } + + const MixinA = (superclass: any) => class extends superclass { + mixinAMethod() { + return 'mixin A method'; + } + }; + + const MixinB = (superclass: any) => class extends superclass { + mixinBMethod() { + return 'mixin B method'; + } + }; + + test('applies mixins correctly', () => { + const MixedClass = mix(BaseClass).with(MixinA, MixinB); + const instance = new MixedClass(); + + expect(instance.baseMethod()).toBe('base method'); + + // @ts-expect-error mixin method not defined + expect(instance.mixinAMethod()).toBe('mixin A method'); + + // @ts-expect-error mixin method not defined + expect(instance.mixinBMethod()).toBe('mixin B method'); + }); + + test('applies a single mixin correctly', () => { + const MixedClass = mix(BaseClass).with(MixinA); + const instance = new MixedClass(); + + expect(instance.baseMethod()).toBe('base method'); + + // @ts-expect-error mixin method not defined + expect(instance.mixinAMethod()).toBe('mixin A method'); + + // @ts-expect-error mixin method not defined + expect(() => instance.mixinBMethod()).toThrow(TypeError); + }); + + test('returns the superclass if no mixins are provided', () => { + const MixedClass = mix(BaseClass).with(); + const instance = new MixedClass(); + + expect(instance.baseMethod()).toBe('base method'); + + // @ts-expect-error mixin method not defined + expect(() => instance.mixinAMethod()).toThrow(TypeError); + + // @ts-expect-error mixin method not defined + expect(() => instance.mixinBMethod()).toThrow(TypeError); + }); +}); diff --git a/app/utils/opengraph.test.ts b/app/utils/opengraph.test.ts new file mode 100644 index 000000000..6816dd9cb --- /dev/null +++ b/app/utils/opengraph.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {getDistanceBW2Points, getNearestPoint} from './opengraph'; + +describe('Utility Functions', () => { + describe('getDistanceBW2Points', () => { + test('calculates the distance correctly', () => { + const point1 = {x: 1, y: 1}; + const point2 = {x: 4, y: 5}; + const distance = getDistanceBW2Points(point1, point2); + expect(distance).toBeCloseTo(5); + }); + + test('calculates the distance correctly with custom attributes', () => { + const point1 = {a: 1, b: 1}; + const point2 = {a: 4, b: 5}; + const distance = getDistanceBW2Points(point1, point2, 'a', 'b'); + expect(distance).toBeCloseTo(5); + }); + }); + + describe('getNearestPoint', () => { + test('finds the nearest point correctly', () => { + const pivotPoint = {height: 0, width: 0}; + const points = [ + {x: 1, y: 1}, + {x: 2, y: 2}, + {x: -1, y: -1}, + ] as never[]; + const nearestPoint = getNearestPoint(pivotPoint, points); + expect(nearestPoint).toEqual({x: 1, y: 1}); + }); + + test('returns an empty object if points array is empty', () => { + const pivotPoint = {height: 0, width: 0}; + const points: never[] = []; + const nearestPoint = getNearestPoint(pivotPoint, points); + expect(nearestPoint).toEqual({}); + }); + + test('finds the nearest point with custom attributes', () => { + const pivotPoint = {height: 0, width: 0}; + const points = [ + {a: 1, b: 1}, + {a: 2, b: 2}, + {a: -1, b: -1}, + ] as never[]; + const nearestPoint = getNearestPoint(pivotPoint, points, 'a', 'b'); + expect(nearestPoint).toEqual({a: 1, b: 1}); + }); + + test('updates nearest point based on distance comparison', () => { + const pivotPoint = {height: 0, width: 0}; + const points = [ + {x: 5, y: 5}, + {x: 2, y: 2}, + {x: 3, y: 3}, + ] as never[]; + const nearestPoint = getNearestPoint(pivotPoint, points); + expect(nearestPoint).toEqual({x: 2, y: 2}); + }); + }); +}); diff --git a/app/utils/opengraph.ts b/app/utils/opengraph.ts index 97f8a7bbd..f91e3946d 100644 --- a/app/utils/opengraph.ts +++ b/app/utils/opengraph.ts @@ -16,10 +16,11 @@ export function getDistanceBW2Points(point1: Record, point2: Record */ export function getNearestPoint(pivotPoint: {height: number; width: number}, points: never[], xAttr = 'x', yAttr = 'y') { let nearestPoint: Record = {}; + const pivot = {[xAttr]: pivotPoint.width, [yAttr]: pivotPoint.height}; for (const point of points) { if (typeof nearestPoint[xAttr] === 'undefined' || typeof nearestPoint[yAttr] === 'undefined') { nearestPoint = point; - } else if (getDistanceBW2Points(point, pivotPoint, xAttr, yAttr) < getDistanceBW2Points(nearestPoint, pivotPoint, xAttr, yAttr)) { + } else if (getDistanceBW2Points(point, pivot, xAttr, yAttr) < getDistanceBW2Points(nearestPoint, pivot, xAttr, yAttr)) { // Check for bestImage nearestPoint = point; } diff --git a/app/utils/push_proxy.test.ts b/app/utils/push_proxy.test.ts new file mode 100644 index 000000000..664166b1c --- /dev/null +++ b/app/utils/push_proxy.test.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Alert} from 'react-native'; + +import { + storePushDisabledInServerAcknowledged, +} from '@actions/app/global'; +import { + PUSH_PROXY_RESPONSE_NOT_AVAILABLE, + PUSH_PROXY_RESPONSE_UNKNOWN, + PUSH_PROXY_STATUS_NOT_AVAILABLE, + PUSH_PROXY_STATUS_UNKNOWN, + PUSH_PROXY_STATUS_VERIFIED, +} from '@constants/push_proxy'; +import EphemeralStore from '@store/ephemeral_store'; + +import { + pushDisabledInServerAck, + canReceiveNotifications, + alertPushProxyError, + alertPushProxyUnknown, +} from './push_proxy'; +import {urlSafeBase64Encode} from './security'; + +import type {IntlShape} from 'react-intl'; + +jest.mock('react-native', () => ({ + Alert: { + alert: jest.fn(), + }, +})); + +jest.mock('@actions/app/global', () => ({ + storePushDisabledInServerAcknowledged: jest.fn(), +})); + +jest.mock('@queries/app/global', () => ({ + getPushDisabledInServerAcknowledged: jest.fn(), +})); + +jest.mock('@store/ephemeral_store', () => ({ + setPushProxyVerificationState: jest.fn(), +})); + +jest.mock('./security', () => ({ + urlSafeBase64Encode: jest.fn((url: string) => `encoded-${url}`), +})); + +// Mock pushDisabledInServerAck as it's not being mocked by default +jest.mock('./push_proxy', () => ({ + ...jest.requireActual('./push_proxy'), + pushDisabledInServerAck: jest.fn(), +})); + +describe('Notification utilities', () => { + const intl: IntlShape = { + formatMessage: ({defaultMessage}: { defaultMessage: string }) => defaultMessage, + } as IntlShape; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('canReceiveNotifications', () => { + test('handles PUSH_PROXY_RESPONSE_NOT_AVAILABLE', async () => { + const serverUrl = 'https://example.com'; + (pushDisabledInServerAck as jest.Mock).mockResolvedValue(false); + + await canReceiveNotifications(serverUrl, PUSH_PROXY_RESPONSE_NOT_AVAILABLE, intl); + + expect(EphemeralStore.setPushProxyVerificationState).toHaveBeenCalledWith(serverUrl, PUSH_PROXY_STATUS_NOT_AVAILABLE); + expect(Alert.alert).toHaveBeenCalled(); + }); + + test('handles PUSH_PROXY_RESPONSE_UNKNOWN', async () => { + const serverUrl = 'https://example.com'; + (pushDisabledInServerAck as jest.Mock).mockResolvedValue(false); + + await canReceiveNotifications(serverUrl, PUSH_PROXY_RESPONSE_UNKNOWN, intl); + + expect(EphemeralStore.setPushProxyVerificationState).toHaveBeenCalledWith(serverUrl, PUSH_PROXY_STATUS_UNKNOWN); + expect(Alert.alert).toHaveBeenCalled(); + }); + + test('handles default case', async () => { + const serverUrl = 'https://example.com'; + (pushDisabledInServerAck as jest.Mock).mockResolvedValue(false); + + await canReceiveNotifications(serverUrl, 'some_other_response', intl); + + expect(EphemeralStore.setPushProxyVerificationState).toHaveBeenCalledWith(serverUrl, PUSH_PROXY_STATUS_VERIFIED); + expect(Alert.alert).not.toHaveBeenCalled(); + }); + }); + + describe('alertPushProxyError', () => { + test('displays alert with correct messages', () => { + const serverUrl = 'https://example.com'; + const alert = jest.spyOn(Alert, 'alert'); + + alertPushProxyError(intl, serverUrl); + + expect(Alert.alert).toHaveBeenCalledWith( + 'Notifications cannot be received from this server', + 'Due to the configuration of this server, notifications cannot be received in the mobile app. Contact your system admin for more information.', + [{ + text: 'Okay', + onPress: expect.any(Function), + }], + ); + + alert?.mock.calls?.[0]?.[2]?.[0]?.onPress?.(); + expect(storePushDisabledInServerAcknowledged).toHaveBeenCalled(); + }); + }); + + describe('alertPushProxyUnknown', () => { + test('displays alert with correct messages', () => { + alertPushProxyUnknown(intl); + + expect(Alert.alert).toHaveBeenCalledWith( + 'Notifications could not be received from this server', + 'This server was unable to receive push notifications for an unknown reason. This will be attempted again next time you connect.', + [{ + text: 'Okay', + }], + ); + }); + }); + + describe('handleAlertResponse', () => { + const handleAlertResponse = async (buttonIndex: number, serverUrl?: string) => { + if (buttonIndex === 0 && serverUrl) { + await storePushDisabledInServerAcknowledged(urlSafeBase64Encode(serverUrl)); + } + }; + + test('stores acknowledgment when buttonIndex is 0 and serverUrl is provided', async () => { + const serverUrl = 'https://example.com'; + await handleAlertResponse(0, serverUrl); + expect(storePushDisabledInServerAcknowledged).toHaveBeenCalledWith('encoded-https://example.com'); + }); + + test('does not store acknowledgment when buttonIndex is not 0', async () => { + const serverUrl = 'https://example.com'; + await handleAlertResponse(1, serverUrl); + expect(storePushDisabledInServerAcknowledged).not.toHaveBeenCalled(); + }); + + test('does not store acknowledgment when serverUrl is not provided', async () => { + await handleAlertResponse(0); + expect(storePushDisabledInServerAcknowledged).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/utils/reviews.test.ts b/app/utils/reviews.test.ts new file mode 100644 index 000000000..adbcdf632 --- /dev/null +++ b/app/utils/reviews.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Import necessary dependencies +import {isAvailableAsync} from 'expo-store-review'; + +import * as actions from '@actions/app/global'; +import LocalConfig from '@assets/config.json'; +import {Launch} from '@constants'; +import {getDontAskForReview, getLastAskedForReview} from '@queries/app/global'; +import {areAllServersSupported} from '@queries/app/servers'; +import {showReviewOverlay} from '@screens/navigation'; + +import {tryRunAppReview} from './reviews'; + +// Mocks +jest.mock('expo-store-review', () => ({ + isAvailableAsync: jest.fn(() => Promise.resolve(true)), +})); +jest.mock('@queries/app/servers', () => ({ + areAllServersSupported: jest.fn(() => Promise.resolve(true)), +})); +jest.mock('@queries/app/global', () => ({ + getDontAskForReview: jest.fn(() => Promise.resolve(false)), + getFirstLaunch: jest.fn().mockResolvedValueOnce(0).mockResolvedValue(Date.now()), + getLastAskedForReview: jest.fn().mockResolvedValueOnce(0).mockResolvedValue(Date.now() - (91 * 24 * 60 * 60 * 1000)), +})); +jest.mock('@actions/app/global', () => ({ + storeFirstLaunch: jest.fn(), +})); +jest.mock('@screens/navigation', () => ({ + showReviewOverlay: jest.fn(), +})); + +describe('tryRunAppReview function', () => { + afterEach(() => { + jest.clearAllMocks(); // Clear all mock calls after each test + }); + + it('should do nothing if LocalConfig.ShowReview is false', async () => { + LocalConfig.ShowReview = false; + await tryRunAppReview(Launch.Normal, true); + expect(isAvailableAsync).not.toHaveBeenCalled(); + }); + + it('should do nothing if coldStart is false', async () => { + LocalConfig.ShowReview = true; + await tryRunAppReview(Launch.Normal, false); + expect(isAvailableAsync).not.toHaveBeenCalled(); + }); + + it('should do nothing if launchType is not Launch.Normal', async () => { + LocalConfig.ShowReview = true; + await tryRunAppReview('SomeOtherType', true); + expect(isAvailableAsync).not.toHaveBeenCalled(); + }); + + it('should show review overlay if conditions are met', async () => { + LocalConfig.ShowReview = true; + const storeFirstLaunch = jest.spyOn(actions, 'storeFirstLaunch'); + await tryRunAppReview(Launch.Normal, true); + expect(isAvailableAsync).toHaveBeenCalled(); + expect(areAllServersSupported).toHaveBeenCalled(); + expect(getDontAskForReview).toHaveBeenCalled(); + expect(getLastAskedForReview).toHaveBeenCalled(); + expect(storeFirstLaunch).toHaveBeenCalled(); + }); + + it('should show review overlay if last review was done more than TIME_TO_NEXT_REVIEW ago', async () => { + const storeFirstLaunch = jest.spyOn(actions, 'storeFirstLaunch'); + LocalConfig.ShowReview = true; + await tryRunAppReview(Launch.Normal, true); + expect(isAvailableAsync).toHaveBeenCalled(); + expect(areAllServersSupported).toHaveBeenCalled(); + expect(getDontAskForReview).toHaveBeenCalled(); + expect(getLastAskedForReview).toHaveBeenCalled(); + expect(storeFirstLaunch).not.toHaveBeenCalled(); + expect(showReviewOverlay).toHaveBeenCalledWith(true); + }); +}); diff --git a/app/utils/security.test.ts b/app/utils/security.test.ts new file mode 100644 index 000000000..4653f247e --- /dev/null +++ b/app/utils/security.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import CookieManager from '@react-native-cookies/cookies'; + +import {getCSRFFromCookie, urlSafeBase64Encode} from './security'; + +// Mock CookieManager +jest.mock('@react-native-cookies/cookies', () => ({ + get: jest.fn(), +})); + +describe('getCSRFFromCookie function', () => { + afterEach(() => { + jest.clearAllMocks(); // Clear all mock calls after each test + }); + + it('should return MMCSRF value from cookies', async () => { + const url = 'https://example.com'; + const mockCookies = { + MMCSRF: {value: 'mock_CSRF_token'}, + }; + + (CookieManager.get as jest.Mock).mockResolvedValue(mockCookies); + + const result = await getCSRFFromCookie(url); + + expect(CookieManager.get).toHaveBeenCalledWith(url, false); + expect(result).toEqual('mock_CSRF_token'); + }); + + it('should return undefined if MMCSRF value is not found', async () => { + const url = 'https://example.com'; + const mockCookies = {}; + + (CookieManager.get as jest.Mock).mockResolvedValue(mockCookies); + + const result = await getCSRFFromCookie(url); + + expect(CookieManager.get).toHaveBeenCalledWith(url, false); + expect(result).toBeUndefined(); + }); +}); + +describe('urlSafeBase64Encode function', () => { + it('should encode a string to URL-safe Base64', () => { + const input = 'Hello, World!'; + const expectedOutput = 'SGVsbG8sIFdvcmxkIQ=='; + + const result = urlSafeBase64Encode(input); + + expect(result).toEqual(expectedOutput); + }); + + it('should handle special characters in the input string', () => { + const input = 'a+b/c=d'; + const expectedOutput = 'YStiL2M9ZA=='; + + const result = urlSafeBase64Encode(input); + + expect(result).toEqual(expectedOutput); + }); + + it('should handle empty input', () => { + const input = ''; + const expectedOutput = ''; + + const result = urlSafeBase64Encode(input); + + expect(result).toEqual(expectedOutput); + }); +}); diff --git a/app/utils/sentry.test.ts b/app/utils/sentry.test.ts new file mode 100644 index 000000000..acd5f305d --- /dev/null +++ b/app/utils/sentry.test.ts @@ -0,0 +1,265 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Import necessary dependencies and functions +import * as Sentry from '@sentry/react-native'; // Importing Sentry as module mock +import {Platform} from 'react-native'; + +import Config from '@assets/config.json'; +import ClientError from '@client/rest/error'; +import DatabaseManager from '@database/manager'; +import {getConfig} from '@queries/servers/system'; +import {getCurrentUser} from '@queries/servers/user'; + +import * as log from './log'; +import {initializeSentry, captureException, captureJSException, addSentryContext} from './sentry'; + +// Mocks +jest.mock('@sentry/react-native', () => ({ + init: jest.fn(), + captureException: jest.fn(), + addBreadcrumb: jest.fn(), + setContext: jest.fn(), + ReactNativeTracing: jest.fn(), + ReactNativeNavigationInstrumentation: jest.fn(), +})); + +jest.mock('@assets/config.json', () => ({ + SentryEnabled: true, + SentryDsnAndroid: 'YOUR_ANDROID_DSN_HERE', + SentryDsnIos: 'YOUR_IOS_DSN_HERE', + SentryOptions: { + severityLevelFilter: ['error', 'warning'], + }, +})); + +jest.mock('./log', () => ({ + logWarning: jest.fn(), + logError: jest.fn(), +})); + +jest.mock('@database/manager', () => ({ + getServerDatabaseAndOperator: jest.fn().mockReturnValue({ + database: {} as any, // Mocking database object + }), +})); + +jest.mock('@queries/servers/system', () => ({ + getConfig: jest.fn().mockResolvedValue({ + BuildDate: '2024-06-24', + BuildEnterpriseReady: true, + BuildHash: 'HASH', + BuildHashEnterprise: 'HASH_ENTERPRISE', + BuildNumber: '1234', + }), +})); + +jest.mock('@queries/servers/user', () => ({ + getCurrentUser: jest.fn().mockResolvedValue({ + id: 'userId', + locale: 'en-US', + roles: ['user'], + }), +})); + +jest.mock('@utils/errors', () => ({ + getFullErrorMessage: jest.fn().mockReturnValue('Full error message'), +})); + +describe('initializeSentry function', () => { + afterEach(() => { + jest.clearAllMocks(); // Clear all mock calls after each test + }); + + it('should not initialize Sentry if SentryEnabled is false', () => { + Config.SentryEnabled = false; + + initializeSentry(); + + expect(Sentry.init).not.toHaveBeenCalled(); + }); + + it('should log a warning if DSN is missing', () => { + Config.SentryEnabled = true; + Config.SentryDsnAndroid = ''; + Config.SentryDsnIos = ''; + + initializeSentry(); + + expect(log.logWarning).toHaveBeenCalledWith('Sentry is enabled, but not configured on this platform'); + }); + + it('should initialize Sentry correctly', () => { + Config.SentryEnabled = true; + Config.SentryDsnAndroid = 'YOUR_ANDROID_DSN_HERE'; + Config.SentryDsnIos = 'YOUR_IOS_DSN_HERE'; + + initializeSentry(); + + expect(Sentry.init).toHaveBeenCalled(); + Platform.OS = 'ios'; + expect(Sentry.init).toHaveBeenCalledWith({ + dsn: 'YOUR_IOS_DSN_HERE', + sendDefaultPii: false, + environment: 'beta', // Assuming isBetaApp() returns true in this test + tracesSampleRate: 1.0, + sampleRate: 1.0, + attachStacktrace: true, // Adjust based on your actual logic + enableCaptureFailedRequests: false, + integrations: [ + expect.any(Sentry.ReactNativeTracing), + ], + beforeSend: expect.any(Function), + }); + + // @ts-expect-error mock not in definition + const beforeSendFn = Sentry.init.mock.calls[0][0].beforeSend; + const event = {level: 'error'}; + const result = beforeSendFn(event as any); // Simulate a call to beforeSend function + + expect(result).toEqual(event); + }); +}); + +describe('captureException function', () => { + afterEach(() => { + jest.clearAllMocks(); // Clear all mock calls after each test + }); + + it('should not capture exception if Sentry is disabled', () => { + Config.SentryEnabled = false; + + captureException(new Error('Test error')); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('should log a warning if error is missing', () => { + Config.SentryEnabled = true; + + captureException(undefined); + + expect(log.logWarning).toHaveBeenCalledWith('captureException called with missing arguments', undefined); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('should capture exception correctly', () => { + captureException(new Error('Test error')); + + expect(Sentry.captureException).toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Test error')); + }); +}); + +describe('captureJSException function', () => { + afterEach(() => { + jest.clearAllMocks(); // Clear all mock calls after each test + }); + + it('should not capture exception if Sentry is disabled', () => { + Config.SentryEnabled = false; + + captureJSException(new Error('Test error'), true); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('should log a warning if error is missing', () => { + Config.SentryEnabled = true; + + captureJSException(undefined, true); + + expect(log.logWarning).toHaveBeenCalledWith('captureJSException called with missing arguments', undefined); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('should capture ClientError as breadcrumb', () => { + const errorData: ClientErrorProps = { + url: 'https://example.com', + status_code: 400, + message: 'some error from server', + server_error_id: 'server_error_id', + }; + const clientError = new ClientError('Client error', errorData); + + captureJSException(clientError, true); + + expect(Sentry.addBreadcrumb).toHaveBeenCalled(); + expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({ + category: 'uncaught-app-error', + data: { + isFatal: 'true', + server_error_id: 'server_error_id', + status_code: 400, + }, + level: 'warning', + message: 'Full error message', + }); + }); + + it('should capture other exceptions', () => { + const error = new Error('Test error'); + + captureJSException(error, true); + + expect(Sentry.captureException).toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalledWith(error); + }); +}); + +describe('addSentryContext function', () => { + afterEach(() => { + jest.clearAllMocks(); // Clear all mock calls after each test + }); + + it('should not add context if Sentry is disabled', async () => { + Config.SentryEnabled = false; + + await addSentryContext('https://example.com'); + + expect(Sentry.setContext).not.toHaveBeenCalled(); + }); + + it('should add user, build, and server context', async () => { + Config.SentryEnabled = true; + + await addSentryContext('https://example.com'); + + expect(DatabaseManager.getServerDatabaseAndOperator).toHaveBeenCalledWith('https://example.com'); + expect(getCurrentUser).toHaveBeenCalled(); + expect(getConfig).toHaveBeenCalled(); + expect(Sentry.setContext).toHaveBeenCalledWith('User-Information', { + userID: 'userId', + email: '', + username: '', + locale: 'en-US', + roles: ['user'], + }); + expect(Sentry.setContext).toHaveBeenCalledWith('App-Build Information', { + serverBuildHash: 'HASH', + serverBuildNumber: '1234', + }); + expect(Sentry.setContext).toHaveBeenCalledWith('Server-Information', { + config: { + BuildDate: '2024-06-24', + BuildEnterpriseReady: true, + BuildHash: 'HASH', + BuildHashEnterprise: 'HASH_ENTERPRISE', + BuildNumber: '1234', + }, + currentChannel: {}, + currentTeam: {}, + }); + }); + + it('should log an error if an exception occurs', async () => { + (DatabaseManager.getServerDatabaseAndOperator as jest.Mock).mockImplementation(() => { + throw new Error('Database error'); + }); + + await addSentryContext('https://example.com'); + + expect(log.logError).toHaveBeenCalledWith('addSentryContext for serverUrl https://example.com', expect.any(Error)); + expect(Sentry.setContext).not.toHaveBeenCalled(); + }); +}); diff --git a/app/utils/strings.test.ts b/app/utils/strings.test.ts new file mode 100644 index 000000000..ddf094350 --- /dev/null +++ b/app/utils/strings.test.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {nonBreakingString} from './strings'; + +describe('nonBreakingString function', () => { + it('should replace space with non-breaking space', () => { + // Test case 1: Replace single space with non-breaking space + const input1 = 'Hello World'; + const expected1 = 'Hello\xa0World'; + expect(nonBreakingString(input1)).toEqual(expected1); + + // Test case 2: Replace multiple spaces with non-breaking spaces + const input2 = 'This is a test string'; + const expected2 = 'This\xa0is\xa0a\xa0test\xa0string'; + expect(nonBreakingString(input2)).toEqual(expected2); + + // Test case 3: No space to replace + const input3 = 'NoSpace'; + const expected3 = 'NoSpace'; + expect(nonBreakingString(input3)).toEqual(expected3); + }); + + it('should handle empty string', () => { + const input = ''; + const expected = ''; + expect(nonBreakingString(input)).toEqual(expected); + }); +}); diff --git a/app/utils/strings.ts b/app/utils/strings.ts index 610ba3771..5b25a7737 100644 --- a/app/utils/strings.ts +++ b/app/utils/strings.ts @@ -2,5 +2,5 @@ // See LICENSE.txt for license information. export function nonBreakingString(s: string) { - return s.replace(' ', '\xa0'); + return s.replace(/ /g, '\xa0'); } diff --git a/app/utils/timezone.test.ts b/app/utils/timezone.test.ts new file mode 100644 index 000000000..befa0d433 --- /dev/null +++ b/app/utils/timezone.test.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {getTimeZone} from 'react-native-localize'; + +import {getDeviceTimezone} from './timezone'; + +// Mocking react-native-localize getTimeZone function +jest.mock('react-native-localize', () => ({ + getTimeZone: jest.fn(), +})); + +describe('getDeviceTimezone function', () => { + afterEach(() => { + jest.clearAllMocks(); // Clear all mock calls after each test + }); + + it('should return the device timezone', () => { + // Mock getTimeZone to return a specific timezone + const mockTimeZone = 'America/New_York'; + (getTimeZone as jest.Mock).mockReturnValue(mockTimeZone); + + // Call the function + const result = getDeviceTimezone(); + + // Expect getTimeZone to have been called once + expect(getTimeZone).toHaveBeenCalledTimes(1); + + // Expect the result to be the mocked timezone + expect(result).toEqual(mockTimeZone); + }); +}); diff --git a/app/utils/typography.test.ts b/app/utils/typography.test.ts new file mode 100644 index 000000000..818da762a --- /dev/null +++ b/app/utils/typography.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {typography, type FontSizes, type FontStyles, type FontTypes} from './typography'; + +describe('Typography', () => { + const testCases: Array<[FontTypes, FontSizes, FontStyles | undefined, string]> = [ + ['Heading', 1200, 'SemiBold', 'Metropolis-SemiBold'], + ['Heading', 1000, 'Regular', 'Metropolis'], + ['Heading', 900, 'Regular', 'Metropolis'], + ['Heading', 800, 'Light', 'Metropolis-Light'], + ['Heading', 700, 'Regular', 'Metropolis'], + ['Heading', 600, 'Regular', 'Metropolis'], + ['Heading', 600, undefined, 'Metropolis-SemiBold'], + ['Body', 500, 'Regular', 'OpenSans'], + ['Body', 400, 'Regular', 'OpenSans'], + ['Body', 300, 'Light', 'OpenSans-Light'], + ['Body', 200, 'SemiBold', 'OpenSans-SemiBold'], + ['Body', 100, 'Light', 'OpenSans-Light'], + ['Body', 75, 'Regular', 'OpenSans'], + ['Body', 50, 'Regular', 'OpenSans'], + ['Body', 25, 'Light', 'OpenSans-Light'], + ['Body', 25, undefined, 'OpenSans'], + ]; + + testCases.forEach(([type, size, style, expectedFontFamily]) => { + it(`returns correct typography for type: ${type}, size: ${size}, style: ${style}`, () => { + const result = typography(type, size, style); + expect(result).toBeDefined(); + expect(result.fontFamily).toBe(expectedFontFamily); + + switch (size) { + case 1200: + expect(result.fontSize).toBe(66); + expect(result.lineHeight).toBe(48); + expect(result.letterSpacing).toBe(-0.02); + break; + case 1000: + expect(result.fontSize).toBe(40); + expect(result.lineHeight).toBe(48); + expect(result.letterSpacing).toBe(-0.02); + break; + case 900: + expect(result.fontSize).toBe(36); + expect(result.lineHeight).toBe(44); + expect(result.letterSpacing).toBe(-0.02); + break; + case 800: + expect(result.fontSize).toBe(32); + expect(result.lineHeight).toBe(40); + expect(result.letterSpacing).toBe(-0.01); + break; + case 700: + expect(result.fontSize).toBe(28); + expect(result.lineHeight).toBe(36); + expect(result.letterSpacing).toBeUndefined(); + break; + case 600: + expect(result.fontSize).toBe(25); + expect(result.lineHeight).toBe(30); + expect(result.letterSpacing).toBeUndefined(); + break; + case 500: + expect(result.fontSize).toBe(22); + expect(result.lineHeight).toBe(28); + expect(result.letterSpacing).toBeUndefined(); + break; + case 400: + expect(result.fontSize).toBe(20); + expect(result.lineHeight).toBe(28); + expect(result.letterSpacing).toBeUndefined(); + break; + case 300: + expect(result.fontSize).toBe(18); + expect(result.lineHeight).toBe(24); + expect(result.letterSpacing).toBeUndefined(); + break; + case 200: + expect(result.fontSize).toBe(16); + expect(result.lineHeight).toBe(24); + expect(result.letterSpacing).toBeUndefined(); + break; + case 100: + expect(result.fontSize).toBe(14); + expect(result.lineHeight).toBe(20); + expect(result.letterSpacing).toBeUndefined(); + break; + case 75: + expect(result.fontSize).toBe(12); + expect(result.lineHeight).toBe(16); + expect(result.letterSpacing).toBeUndefined(); + break; + case 50: + expect(result.fontSize).toBe(11); + expect(result.lineHeight).toBe(16); + expect(result.letterSpacing).toBeUndefined(); + break; + case 25: + expect(result.fontSize).toBe(10); + expect(result.lineHeight).toBe(16); + expect(result.letterSpacing).toBeUndefined(); + break; + default: + throw new Error(`Unexpected font size: ${size}`); + } + + switch (style) { + case 'SemiBold': + expect(result.fontWeight).toBe('600'); + break; + case 'Regular': + expect(result.fontWeight).toBe('400'); + break; + case 'Light': + expect(result.fontWeight).toBe('300'); + break; + default: + if (type === 'Heading') { + expect(result.fontWeight).toBe('600'); + } else { + expect(result.fontWeight).toBe('400'); + } + } + }); + }); +}); diff --git a/app/utils/typography.ts b/app/utils/typography.ts index 3e3dc036f..0de41c9bb 100644 --- a/app/utils/typography.ts +++ b/app/utils/typography.ts @@ -4,9 +4,9 @@ import {StyleSheet, type TextStyle} from 'react-native'; // type FontFamilies = 'OpenSans' | 'Metropolis'; -type FontTypes = 'Heading' | 'Body'; -type FontStyles = 'SemiBold' | 'Regular' | 'Light'; -type FontSizes = 25 | 50 | 75 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 | 1200; +export type FontTypes = 'Heading' | 'Body'; +export type FontStyles = 'SemiBold' | 'Regular' | 'Light'; +export type FontSizes = 25 | 50 | 75 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 | 1200; const fontFamily = StyleSheet.create({ OpenSans: { diff --git a/test/setup.ts b/test/setup.ts index e75275161..55f86f577 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -144,7 +144,7 @@ jest.doMock('react-native', () => { }), addListener: jest.fn(), removeListeners: jest.fn(), - isRunningInSplitView: jest.fn().mockReturnValue(() => ({isSplit: false, isTablet: false})), + isRunningInSplitView: jest.fn().mockReturnValue({isSplit: false, isTablet: false}), getDeliveredNotifications: jest.fn().mockResolvedValue([]), removeChannelNotifications: jest.fn().mockImplementation(),