added utils unit tests (#8040)

This commit is contained in:
Elias Nahum
2024-07-05 07:17:09 +08:00
committed by GitHub
parent 92bdb2847b
commit a7224479d5
26 changed files with 2607 additions and 56 deletions

View File

@@ -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",
},

766
app/utils/apps.test.ts Normal file
View File

@@ -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,
});
});
});

View File

@@ -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) {

View File

@@ -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 | StyleObject[]>): 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'});
});
});

View File

@@ -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, {

10
app/utils/groups.test.ts Normal file
View File

@@ -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');
});
});

View File

@@ -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);
});
});
});

View File

@@ -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<any>): 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> 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<T>(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[]) {

View File

@@ -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');
});
});

View File

@@ -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);
});
});

79
app/utils/log.test.ts Normal file
View File

@@ -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',
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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<string> = {
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);
});
});

63
app/utils/mix.test.ts Normal file
View File

@@ -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);
});
});

View File

@@ -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});
});
});
});

View File

@@ -16,10 +16,11 @@ export function getDistanceBW2Points(point1: Record<string, any>, point2: Record
*/
export function getNearestPoint(pivotPoint: {height: number; width: number}, points: never[], xAttr = 'x', yAttr = 'y') {
let nearestPoint: Record<string, any> = {};
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;
}

View File

@@ -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();
});
});
});

80
app/utils/reviews.test.ts Normal file
View File

@@ -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);
});
});

View File

@@ -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);
});
});

265
app/utils/sentry.test.ts Normal file
View File

@@ -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();
});
});

29
app/utils/strings.test.ts Normal file
View File

@@ -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);
});
});

View File

@@ -2,5 +2,5 @@
// See LICENSE.txt for license information.
export function nonBreakingString(s: string) {
return s.replace(' ', '\xa0');
return s.replace(/ /g, '\xa0');
}

View File

@@ -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);
});
});

View File

@@ -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');
}
}
});
});
});

View File

@@ -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: {

View File

@@ -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(),