Fix testing for checklists (#9332)

* Fix testing for checklists

* address review comments

* chevron tests

* improve new item test

* remove rerenders

* address comments

* missing empty line
This commit is contained in:
Guillermo Vayá 2025-12-17 17:38:44 +01:00 committed by GitHub
parent d26c5c9757
commit 31fc648e50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1845 additions and 77 deletions

View file

@ -315,7 +315,7 @@ describe('renameChecklist', () => {
expect(updated!.title).toBe(newTitle);
});
it('should handle empty title string', async () => {
it('should reject empty title string', async () => {
const runId = 'runid';
const checklist = {
...TestHelper.createPlaybookChecklist(runId, 0, 0),
@ -324,16 +324,18 @@ describe('renameChecklist', () => {
};
await operator.handlePlaybookChecklist({checklists: [checklist], prepareRecordsOnly: false});
const originalTitle = checklist.title;
const {data, error} = await renameChecklist(serverUrl, checklist.id, '');
expect(error).toBeUndefined();
expect(data).toBe(true);
expect(error).toBe('Title cannot be empty or whitespace-only');
expect(data).toBeUndefined();
// Verify the title was not changed
const updated = await getPlaybookChecklistById(operator.database, checklist.id);
expect(updated).toBeDefined();
expect(updated!.title).toBe('');
expect(updated!.title).toBe(originalTitle);
});
it('should handle whitespace-only title', async () => {
it('should reject whitespace-only title', async () => {
const runId = 'runid';
const checklist = {
...TestHelper.createPlaybookChecklist(runId, 0, 0),
@ -342,14 +344,16 @@ describe('renameChecklist', () => {
};
await operator.handlePlaybookChecklist({checklists: [checklist], prepareRecordsOnly: false});
const originalTitle = checklist.title;
const whitespaceTitle = ' ';
const {data, error} = await renameChecklist(serverUrl, checklist.id, whitespaceTitle);
expect(error).toBeUndefined();
expect(data).toBe(true);
expect(error).toBe('Title cannot be empty or whitespace-only');
expect(data).toBeUndefined();
// Verify the title was not changed
const updated = await getPlaybookChecklistById(operator.database, checklist.id);
expect(updated).toBeDefined();
expect(updated!.title).toBe(whitespaceTitle);
expect(updated!.title).toBe(originalTitle);
});
it('should handle very long titles', async () => {

View file

@ -98,6 +98,11 @@ export async function renameChecklist(serverUrl: string, checklistId: string, ti
return {error: `Checklist not found: ${checklistId}`};
}
// Validate title is not empty or whitespace-only
if (!title || !title.trim()) {
return {error: 'Title cannot be empty or whitespace-only'};
}
await database.write(async () => {
checklist.update((c) => {
c.title = title;

View file

@ -203,35 +203,39 @@ describe('renamePlaybookRun', () => {
expect(updatedRun.name).toBe(newName);
});
it('should handle empty name string', async () => {
it('should reject empty name string', async () => {
const runs = TestHelper.createPlaybookRuns(1, 0, 0);
await handlePlaybookRuns(serverUrl, runs, false, false);
const playbookRunId = runs[0].id;
const originalName = runs[0].name;
const {data, error} = await renamePlaybookRun(serverUrl, playbookRunId, '');
expect(error).toBeUndefined();
expect(data).toBe(true);
expect(error).toBe('Name cannot be empty or whitespace-only');
expect(data).toBeUndefined();
// Verify the name was not changed
const updatedRun = await database.get<PlaybookRunModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId);
expect(updatedRun.name).toBe('');
expect(updatedRun.name).toBe(originalName);
});
it('should handle whitespace-only name', async () => {
it('should reject whitespace-only name', async () => {
const runs = TestHelper.createPlaybookRuns(1, 0, 0);
await handlePlaybookRuns(serverUrl, runs, false, false);
const playbookRunId = runs[0].id;
const originalName = runs[0].name;
const whitespaceName = ' ';
const {data, error} = await renamePlaybookRun(serverUrl, playbookRunId, whitespaceName);
expect(error).toBeUndefined();
expect(data).toBe(true);
expect(error).toBe('Name cannot be empty or whitespace-only');
expect(data).toBeUndefined();
// Verify the name was not changed
const updatedRun = await database.get<PlaybookRunModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId);
expect(updatedRun.name).toBe(whitespaceName);
expect(updatedRun.name).toBe(originalName);
});
it('should handle very long names', async () => {

View file

@ -49,6 +49,11 @@ export async function renamePlaybookRun(serverUrl: string, playbookRunId: string
return {error: `Playbook run not found: ${playbookRunId}`};
}
// Validate name is not empty or whitespace-only
if (!name || !name.trim()) {
return {error: 'Name cannot be empty or whitespace-only'};
}
await database.write(async () => {
run.update((r) => {
r.name = name;

View file

@ -1002,19 +1002,6 @@ describe('patchPlaybookRun', () => {
await expect(client.patchPlaybookRun(playbookRunId, updates)).rejects.toThrow('Network error');
});
test('should handle empty updates object', async () => {
const playbookRunId = 'run123';
const updates = {};
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}`;
const expectedOptions = {method: 'patch', body: updates};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.patchPlaybookRun(playbookRunId, updates);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
});
describe('renameChecklist', () => {
@ -1055,20 +1042,6 @@ describe('renameChecklist', () => {
await expect(client.renameChecklist(playbookRunId, checklistNumber, newName)).rejects.toThrow('Network error');
});
test('should handle empty name', async () => {
const playbookRunId = 'run123';
const checklistNumber = 1;
const newName = '';
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNumber}/rename`;
const expectedOptions = {method: 'put', body: {title: newName}};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.renameChecklist(playbookRunId, checklistNumber, newName);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
});
describe('addChecklistItem', () => {
@ -1086,20 +1059,6 @@ describe('addChecklistItem', () => {
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should add item with empty title', async () => {
const playbookRunId = 'run123';
const checklistNum = 1;
const title = '';
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNum}/add`;
const expectedOptions = {method: 'post', body: {title}};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.addChecklistItem(playbookRunId, checklistNum, title);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should handle network errors', async () => {
const playbookRunId = 'run123';
const checklistNum = 1;

View file

@ -6,6 +6,7 @@
import {OperationType} from '@constants/database';
import {createTestConnection} from '@database/operator/utils/create_test_connection';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import {PLAYBOOK_RUN_TYPES} from '@playbooks/constants/playbook_run';
import {PlaybookRunModel} from '@playbooks/database/models';
import {transformPlaybookChecklistItemRecord, transformPlaybookChecklistRecord, transformPlaybookRunRecord, transformPlaybookRunAttributeRecord, transformPlaybookRunAttributeValueRecord} from '.';
@ -311,6 +312,123 @@ describe('*** PLAYBOOK_RUN Prepare Records Test ***', () => {
expect(preparedRecord.updateAt).toBe(1620000004000);
expect(preparedRecord.lastSyncAt).toBeGreaterThan(lastSyncAt);
});
it('=> transformPlaybookRunRecord: should set default type to PlaybookType when playbookId is present', async () => {
const database = await createTestConnection({databaseName: 'playbook_run_prepare_records', setActive: true});
const preparedRecord = await transformPlaybookRunRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
id: 'playbook_run_type_test',
playbook_id: 'playbook_1',
create_at: 1620000000000,
},
},
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord.type).toBe(PLAYBOOK_RUN_TYPES.PlaybookType);
});
it('=> transformPlaybookRunRecord: should set default type to ChannelChecklistType when playbookId is absent', async () => {
const database = await createTestConnection({databaseName: 'playbook_run_prepare_records', setActive: true});
const preparedRecord = await transformPlaybookRunRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
id: 'playbook_run_type_test_2',
create_at: 1620000000000,
},
},
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord.type).toBe(PLAYBOOK_RUN_TYPES.ChannelChecklistType);
});
it('=> transformPlaybookRunRecord: should use updateAt fallback chain correctly', async () => {
const database = await createTestConnection({databaseName: 'playbook_run_prepare_records', setActive: true});
// Test: raw.update_at should be used first
const preparedRecord1 = await transformPlaybookRunRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
id: 'playbook_run_update_at_1',
create_at: 1620000000000,
update_at: 1620000001000,
},
},
});
expect(preparedRecord1.updateAt).toBe(1620000001000);
// Test: when update_at is missing, should fallback to create_at
const preparedRecord2 = await transformPlaybookRunRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
id: 'playbook_run_update_at_2',
create_at: 1620000002000,
},
},
});
expect(preparedRecord2.updateAt).toBe(1620000002000);
// Test: UPDATE action should use record.updateAt if raw.update_at is missing
let existingRecord: PlaybookRunModel | undefined;
await database!.write(async () => {
existingRecord = await database!.get<PlaybookRunModel>(PLAYBOOK_RUN).create((record) => {
record._raw.id = 'playbook_run_update_at_3';
record.createAt = 1620000003000;
record.updateAt = 1620000004000;
});
});
const preparedRecord3 = await transformPlaybookRunRecord({
action: OperationType.UPDATE,
database: database!,
value: {
record: existingRecord,
raw: {
id: 'playbook_run_update_at_3',
},
},
});
await database?.write(async () => {
await database?.batch(preparedRecord3);
});
expect(preparedRecord3.updateAt).toBe(1620000004000);
});
it('=> transformPlaybookRunRecord: should handle CREATE with missing raw.id', async () => {
const database = await createTestConnection({databaseName: 'playbook_run_prepare_records', setActive: true});
const preparedRecord = await transformPlaybookRunRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
playbook_id: 'playbook_1',
create_at: 1620000000000,
} as any,
},
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord.id).toBeTruthy();
});
});
describe('*** PLAYBOOK_CHECKLIST Prepare Records Test ***', () => {
it('=> transformPlaybookChecklistRecord: should return a record of type PlaybookChecklist for CREATE action', async () => {
@ -408,7 +526,6 @@ describe('*** PLAYBOOK_CHECKLIST Prepare Records Test ***', () => {
it('=> transformPlaybookChecklistRecord: should keep most of the data if the partial checklist is empty', async () => {
const database = await createTestConnection({databaseName: 'playbook_checklist_prepare_records', setActive: true});
expect(database).toBeTruthy();
// Create an existing record to simulate the UPDATE action
let existingRecord: PlaybookChecklistModel | undefined;
@ -451,6 +568,47 @@ describe('*** PLAYBOOK_CHECKLIST Prepare Records Test ***', () => {
expect(preparedRecord!.updateAt).toBe(1620000004000);
expect(preparedRecord!.lastSyncAt).toBeGreaterThan(lastSyncAt);
});
it('=> transformPlaybookChecklistRecord: should handle CREATE with missing raw.id', async () => {
const database = await createTestConnection({databaseName: 'playbook_checklist_prepare_records', setActive: true});
const preparedRecord = await transformPlaybookChecklistRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
run_id: 'playbook_run_1',
title: 'Test Checklist',
} as any,
},
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord.id).toBeTruthy();
});
it('=> transformPlaybookChecklistRecord: should handle missing runId', async () => {
const database = await createTestConnection({databaseName: 'playbook_checklist_prepare_records', setActive: true});
const preparedRecord = await transformPlaybookChecklistRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
id: 'checklist_undefined_run_id',
title: 'Test Checklist',
} as any,
},
});
expect(preparedRecord).toBeTruthy();
// When run_id is missing and record is undefined, the transformer sets runId to undefined
// WatermelonDB converts undefined to empty string for non-nullable string fields
expect(preparedRecord.runId).toBe('');
});
});
describe('*** PLAYBOOK_CHECKLIST_ITEM Prepare Records Test ***', () => {
@ -775,6 +933,48 @@ describe('*** PLAYBOOK_CHECKLIST_ITEM Prepare Records Test ***', () => {
expect(preparedRecordDefault!.conditionReason).toBe('');
});
it('=> transformPlaybookChecklistItemRecord: should handle CREATE with missing raw.id', async () => {
const database = await createTestConnection({databaseName: 'playbook_checklist_item_prepare_records', setActive: true});
const preparedRecord = await transformPlaybookChecklistItemRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
checklist_id: 'checklist_1',
title: 'Test Item',
} as any,
},
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord.id).toBeTruthy();
});
it('=> transformPlaybookChecklistItemRecord: should handle missing checklistId', async () => {
const database = await createTestConnection({databaseName: 'playbook_checklist_item_prepare_records', setActive: true});
const preparedRecord = await transformPlaybookChecklistItemRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
id: 'checklist_item_undefined_checklist_id',
title: 'Test Item',
} as any,
},
});
expect(preparedRecord).toBeTruthy();
// When checklist_id is missing and record is undefined, the transformer sets checklistId to undefined
// WatermelonDB converts undefined to empty string for non-nullable string fields
expect(preparedRecord.checklistId).toBe('');
});
});
describe('*** PLAYBOOK_RUN_ATTRIBUTE Prepare Records Test ***', () => {
@ -934,6 +1134,62 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE Prepare Records Test ***', () => {
expect(preparedRecord.attrs).toBe('{"placeholder": "Original"}');
expect(preparedRecord.updateAt).toBe(1620000004000);
});
it('=> transformPlaybookRunAttributeRecord: should handle CREATE with missing raw.id', async () => {
const database = await createTestConnection({databaseName: 'playbook_run_attribute_prepare_records', setActive: true});
const preparedRecord = await transformPlaybookRunAttributeRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
group_id: 'group_1',
name: 'Test Attribute',
} as any,
},
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord.id).toBeTruthy();
});
it('=> transformPlaybookRunAttributeRecord: should handle completely empty raw object', async () => {
const database = await createTestConnection({databaseName: 'playbook_run_attribute_prepare_records', setActive: true});
let existingRecord: PlaybookRunAttributeModel | undefined;
await database!.write(async () => {
existingRecord = await database!.get<PlaybookRunAttributeModel>(PLAYBOOK_RUN_ATTRIBUTE).create((record) => {
record._raw.id = 'attribute_empty_raw';
record.groupId = 'group_1';
record.name = 'Existing Attribute';
record.type = 'text';
record.targetId = 'target_1';
record.targetType = 'playbook_run';
record.createAt = 1620000000000;
record.updateAt = 1620000001000;
record.deleteAt = 0;
record.attrs = '{"key": "value"}';
});
});
const preparedRecord = await transformPlaybookRunAttributeRecord({
action: OperationType.UPDATE,
database: database!,
value: {
record: existingRecord,
raw: {} as any,
},
});
await database?.write(async () => {
await database?.batch(preparedRecord);
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord.name).toBe('Existing Attribute');
});
});
describe('*** PLAYBOOK_RUN_ATTRIBUTE_VALUE Prepare Records Test ***', () => {
@ -1059,4 +1315,55 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE_VALUE Prepare Records Test ***', () => {
expect(preparedRecord.runId).toBe('playbook_run_2');
expect(preparedRecord.value).toBe('Existing Value');
});
it('=> transformPlaybookRunAttributeValueRecord: should handle CREATE with missing raw.id', async () => {
const database = await createTestConnection({databaseName: 'playbook_run_attribute_value_prepare_records', setActive: true});
const preparedRecord = await transformPlaybookRunAttributeValueRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
attribute_id: 'attribute_1',
run_id: 'playbook_run_1',
value: 'Test Value',
} as any,
},
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord.id).toBeTruthy();
});
it('=> transformPlaybookRunAttributeValueRecord: should handle completely empty raw object', async () => {
const database = await createTestConnection({databaseName: 'playbook_run_attribute_value_prepare_records', setActive: true});
expect(database).toBeTruthy();
let existingRecord: PlaybookRunAttributeValueModel | undefined;
await database!.write(async () => {
existingRecord = await database!.get<PlaybookRunAttributeValueModel>(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((record) => {
record._raw.id = 'attribute_value_empty_raw';
record.attributeId = 'attribute_1';
record.runId = 'playbook_run_1';
record.value = 'Existing Value';
});
});
const preparedRecord = await transformPlaybookRunAttributeValueRecord({
action: OperationType.UPDATE,
database: database!,
value: {
record: existingRecord,
raw: {} as any,
},
});
await database?.write(async () => {
await database?.batch(preparedRecord);
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord.value).toBe('Existing Value');
});
});

View file

@ -7,7 +7,7 @@ import {goToScreen} from '@screens/navigation';
import TestHelper from '@test/test_helper';
import {changeOpacity} from '@utils/theme';
import {goToPlaybookRuns, goToPlaybookRun, goToParticipantPlaybooks, goToPlaybookRunWithChannelSwitch, goToEditCommand, goToSelectUser, goToSelectDate, goToPostUpdate, goToSelectPlaybook, goToStartARun} from './navigation';
import {goToPlaybookRuns, goToPlaybookRun, goToParticipantPlaybooks, goToPlaybookRunWithChannelSwitch, goToEditCommand, goToSelectUser, goToSelectDate, goToPostUpdate, goToSelectPlaybook, goToStartARun, goToRenameChecklist, goToAddChecklistItem, goToRenamePlaybookRun, goToCreateQuickChecklist} from './navigation';
jest.mock('@screens/navigation', () => ({
goToScreen: jest.fn(),
@ -483,4 +483,113 @@ describe('Playbooks Navigation', () => {
);
});
});
describe('goToRenameChecklist', () => {
it('should navigate to rename checklist screen with correct parameters', async () => {
const currentTitle = 'Checklist Title';
const onSave = jest.fn();
await goToRenameChecklist(mockIntl, Preferences.THEMES.denim, 'Run 1', currentTitle, onSave);
expect(mockIntl.formatMessage).toHaveBeenCalledWith({
id: 'playbooks.checklist.rename.title',
defaultMessage: 'Rename checklist',
});
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOK_RENAME_CHECKLIST,
'Rename checklist',
{
currentTitle,
onSave,
},
{
topBar: {
subtitle: {
text: 'Run 1',
color: changeOpacity(Preferences.THEMES.denim.sidebarHeaderTextColor, 0.72),
},
},
},
);
});
});
describe('goToAddChecklistItem', () => {
it('should navigate to add checklist item screen with correct parameters', async () => {
const onSave = jest.fn();
await goToAddChecklistItem(mockIntl, Preferences.THEMES.denim, 'Run 1', onSave);
expect(mockIntl.formatMessage).toHaveBeenCalledWith({
id: 'playbooks.checklist_item.add.title',
defaultMessage: 'New Task',
});
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOK_ADD_CHECKLIST_ITEM,
'New Task',
{
onSave,
},
{
topBar: {
subtitle: {
text: 'Run 1',
color: changeOpacity(Preferences.THEMES.denim.sidebarHeaderTextColor, 0.72),
},
},
},
);
});
});
describe('goToRenamePlaybookRun', () => {
it('should navigate to rename playbook run screen with correct parameters', async () => {
const currentTitle = 'Playbook Run Title';
const onSave = jest.fn();
await goToRenamePlaybookRun(mockIntl, Preferences.THEMES.denim, currentTitle, onSave);
expect(mockIntl.formatMessage).toHaveBeenCalledWith({
id: 'playbooks.playbook_run.rename.title',
defaultMessage: 'Rename playbook run',
});
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOK_RENAME_RUN,
'Rename playbook run',
{
currentTitle,
onSave,
},
);
});
});
describe('goToCreateQuickChecklist', () => {
it('should navigate to create quick checklist screen with correct parameters', () => {
const channelId = 'channel-id-1';
const channelName = 'channel-name-1';
const currentUserId = 'user-id-1';
const currentTeamId = 'team-id-1';
const serverUrl = 'https://test.server.com';
goToCreateQuickChecklist(mockIntl, channelId, channelName, currentUserId, currentTeamId, serverUrl);
expect(mockIntl.formatMessage).toHaveBeenCalledWith({
id: 'mobile.playbook.create_checklist',
defaultMessage: 'Create Checklist',
});
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOKS_CREATE_QUICK_CHECKLIST,
'Create Checklist',
{
channelId,
channelName,
currentUserId,
currentTeamId,
serverUrl,
},
{},
);
});
});
});

View file

@ -0,0 +1,353 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, fireEvent} from '@testing-library/react-native';
import React from 'react';
import {Keyboard, View} from 'react-native';
import {Preferences} from '@constants';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {buildNavigationButton, popTopScreen, setButtons} from '@screens/navigation';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import {getLastCall, getLastCallForButton} from '@test/mock_helpers';
import AddChecklistItemBottomSheet from './add_checklist_item_bottom_sheet';
jest.mock('@screens/navigation', () => ({
buildNavigationButton: jest.fn(),
popTopScreen: jest.fn(),
setButtons: jest.fn(),
}));
jest.mock('react-native', () => {
const RN = jest.requireActual('react-native');
RN.Keyboard = {
dismiss: jest.fn(),
};
return RN;
});
jest.mock('@hooks/navigation_button_pressed', () => jest.fn());
jest.mock('@hooks/android_back_handler', () => jest.fn());
jest.mock('@managers/security_manager', () => ({
getShieldScreenId: jest.fn((id) => `shield-${id}`),
}));
describe('AddChecklistItemBottomSheet', () => {
const componentId = 'test-component-id' as any;
const mockOnSave = jest.fn();
const mockRightButton = {
id: 'add-checklist-item',
enabled: false,
color: Preferences.THEMES.denim.sidebarHeaderTextColor,
};
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(buildNavigationButton).mockReturnValue(mockRightButton as any);
});
function getBaseProps() {
return {
componentId,
onSave: mockOnSave,
};
}
it('should render correctly with default props', () => {
const props = getBaseProps();
const {getByTestId, getByText} = renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist_item.add.input');
expect(input).toBeTruthy();
expect(input.props.value).toBe('');
expect(input.props.autoFocus).toBe(true);
// Check label is rendered
const label = getByText('Task name');
expect(label).toBeTruthy();
});
it('should set up navigation buttons on mount', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
expect(buildNavigationButton).toHaveBeenCalledWith(
'add-checklist-item',
'playbooks.checklist_item.add.save',
undefined,
'Add',
);
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [mockRightButton],
});
});
it('should register navigation button press handler', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
expect(useNavButtonPressed).toHaveBeenCalledWith(
'add-checklist-item',
componentId,
expect.any(Function),
[expect.any(Function)],
);
});
it('should register Android back handler', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith(
componentId,
expect.any(Function),
);
});
it('should update title when text input changes', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist_item.add.input');
const newTitle = 'New Task Title';
act(() => {
fireEvent.changeText(input, newTitle);
});
expect(input.props.value).toBe(newTitle);
});
it('should enable save button when title has content', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist_item.add.input');
// Initially disabled (empty title)
expect(mockRightButton.enabled).toBe(false);
// Update with non-empty title
act(() => {
fireEvent.changeText(input, 'Task Title');
});
// Button should be enabled now
const updatedButton = {
...mockRightButton,
enabled: true,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [updatedButton],
});
});
it('should disable save button when title is empty', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist_item.add.input');
// Set title
act(() => {
fireEvent.changeText(input, 'Task Title');
});
// Button should be enabled now, covered by previous test, but needed to check otherwise there could be a race condition
const checkButton = {
...mockRightButton,
enabled: true,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [checkButton],
});
// Clear title
act(() => {
fireEvent.changeText(input, '');
});
// Button should be disabled
const updatedButton = {
...mockRightButton,
enabled: false,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [updatedButton],
});
});
it('should disable save button when title is only whitespace', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist_item.add.input');
// Set title to whitespace only
act(() => {
fireEvent.changeText(input, ' ');
});
// Button should be disabled
const updatedButton = {
...mockRightButton,
enabled: false,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [updatedButton],
});
});
it('should call onSave and close when save button is pressed with valid title', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist_item.add.input');
const taskTitle = 'New Task';
// Set title
act(() => {
fireEvent.changeText(input, taskTitle);
});
// Trigger save button press
const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'add-checklist-item');
const saveCallback = lastCall[2];
act(() => {
saveCallback();
});
expect(mockOnSave).toHaveBeenCalledWith(taskTitle);
expect(Keyboard.dismiss).toHaveBeenCalled();
expect(popTopScreen).toHaveBeenCalledWith(componentId);
});
it('should trim title when saving', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist_item.add.input');
const taskTitle = ' Task with spaces ';
// Set title with spaces
act(() => {
fireEvent.changeText(input, taskTitle);
});
// Trigger save button press
const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'add-checklist-item');
const saveCallback = lastCall[2];
act(() => {
saveCallback();
});
expect(mockOnSave).toHaveBeenCalledWith('Task with spaces');
expect(mockOnSave).not.toHaveBeenCalledWith(taskTitle);
});
it('should not call onSave when save button is pressed with empty title', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
// Trigger save button press without setting title
const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'add-checklist-item');
expect(lastCall).toBeDefined();
const saveCallback = lastCall[2];
expect(saveCallback).toBeDefined();
act(() => {
saveCallback();
});
expect(mockOnSave).not.toHaveBeenCalled();
expect(popTopScreen).not.toHaveBeenCalled();
});
it('should not call onSave when save button is pressed with whitespace-only title', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist_item.add.input');
// Set title to whitespace only
act(() => {
fireEvent.changeText(input, ' ');
});
// Trigger save button press
const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'add-checklist-item');
const saveCallback = lastCall[2];
act(() => {
saveCallback();
});
expect(mockOnSave).not.toHaveBeenCalled();
expect(popTopScreen).not.toHaveBeenCalled();
});
it('should close when Android back button is pressed', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
// Trigger back handler
const lastCall = getLastCall(jest.mocked(useAndroidHardwareBackHandler));
const backCallback = lastCall[1];
act(() => {
backCallback();
});
expect(Keyboard.dismiss).toHaveBeenCalled();
expect(popTopScreen).toHaveBeenCalledWith(componentId);
expect(mockOnSave).not.toHaveBeenCalled();
});
it('should apply shield screen ID to container', () => {
const props = getBaseProps();
const renderResult = renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
const getAllByType = renderResult.UNSAFE_getAllByType;
const views = getAllByType(View);
const container = views.find((view: any) => view.props.nativeID === `shield-${componentId}`);
expect(container).toBeTruthy();
expect(container?.props.nativeID).toBe(`shield-${componentId}`);
expect(SecurityManager.getShieldScreenId).toHaveBeenCalledWith(componentId);
});
it('should update navigation button when canSave changes', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist_item.add.input');
// Initially disabled
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [{...mockRightButton, enabled: false}],
});
// Enable by adding text
act(() => {
fireEvent.changeText(input, 'Task');
});
// Should update with enabled button
expect(setButtons).toHaveBeenLastCalledWith(componentId, {
rightButtons: [{...mockRightButton, enabled: true}],
});
});
it('should use correct theme color for button', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
// Verify button is created with theme color
const buttonCalls = jest.mocked(setButtons).mock.calls;
expect(buttonCalls.length).toBeGreaterThan(0);
const lastCall = buttonCalls[buttonCalls.length - 1];
const rightButtons = lastCall[1]?.rightButtons;
expect(rightButtons?.[0]?.color).toBe(Preferences.THEMES.denim.sidebarHeaderTextColor);
});
});

View file

@ -5,14 +5,32 @@ import {act, fireEvent, waitFor, within} from '@testing-library/react-native';
import React, {type ComponentProps} from 'react';
import Button from '@components/button';
import {useServerUrl} from '@context/server';
import {addChecklistItem} from '@playbooks/actions/remote/checklist';
import ProgressBar from '@playbooks/components/progress_bar';
import {goToAddChecklistItem} from '@playbooks/screens/navigation';
import {renderWithIntl} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import {logError} from '@utils/log';
import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
import Checklist from './checklist';
import ChecklistItem from './checklist_item';
const serverUrl = 'test-server-url';
jest.mock('@context/server');
jest.mocked(useServerUrl).mockReturnValue(serverUrl);
jest.mock('@components/compass_icon', () => ({
__esModule: true,
default: jest.fn(),
}));
const CompassIcon = require('@components/compass_icon').default;
jest.mocked(CompassIcon).mockImplementation(
(props: any) => React.createElement('CompassIcon', {testID: 'compass-icon', ...props}) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
);
jest.mock('./checklist_item');
jest.mocked(ChecklistItem).mockImplementation(
(props) => React.createElement('ChecklistItem', {testID: 'checklist-item', ...props}),
@ -36,6 +54,15 @@ jest.mock('@playbooks/screens/navigation', () => ({
goToAddChecklistItem: jest.fn(),
}));
jest.mock('@playbooks/actions/remote/checklist', () => ({
renameChecklist: jest.fn(),
addChecklistItem: jest.fn(),
}));
jest.mock('@utils/snack_bar', () => ({
showPlaybookErrorSnackbar: jest.fn(),
}));
describe('Checklist', () => {
const mockChecklist = TestHelper.fakePlaybookChecklistModel({
id: 'checklist-1',
@ -290,4 +317,116 @@ describe('Checklist', () => {
expect(goToAddChecklistItem).toHaveBeenCalled();
});
it('renders edit icon in header', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntl(<Checklist {...props}/>);
const editButton = getByTestId('edit-checklist-button');
expect(editButton).toBeTruthy();
});
it('toggles expanded state which affects chevron icon', async () => {
const props = getBaseProps();
const {getByText, getByTestId, getAllByTestId} = renderWithIntl(<Checklist {...props}/>);
const header = getByText('Test Checklist');
// Initially expanded - chevron should be down
await waitFor(() => {
expect(getByTestId('checklist-items-container')).toHaveAnimatedStyle({paddingVertical: 16});
});
const chevronIcons = getAllByTestId('compass-icon');
const chevron = chevronIcons.find((icon) => icon.props.name === 'chevron-down' || icon.props.name === 'chevron-right');
expect(chevron).toBeTruthy();
expect(chevron?.props.name).toBe('chevron-down');
// Toggle to collapsed - chevron should change to right
act(() => {
fireEvent.press(header);
});
await waitFor(() => {
expect(getByTestId('checklist-items-container')).toHaveAnimatedStyle({paddingVertical: 0});
});
const chevronIconsCollapsed = getAllByTestId('compass-icon');
const chevronCollapsed = chevronIconsCollapsed.find((icon) => icon.props.name === 'chevron-down' || icon.props.name === 'chevron-right');
expect(chevronCollapsed).toBeTruthy();
expect(chevronCollapsed?.props.name).toBe('chevron-right');
// Toggle back to expanded - chevron should change to down
act(() => {
fireEvent.press(header);
});
await waitFor(() => {
expect(getByTestId('checklist-items-container')).toHaveAnimatedStyle({paddingVertical: 16});
});
const chevronIconsExpanded = getAllByTestId('compass-icon');
const chevronExpanded = chevronIconsExpanded.find((icon) => icon.props.name === 'chevron-down' || icon.props.name === 'chevron-right');
expect(chevronExpanded).toBeTruthy();
expect(chevronExpanded?.props.name).toBe('chevron-down');
});
it('handles add item error correctly', async () => {
const mockError = {message: 'Add item failed'};
jest.mocked(addChecklistItem).mockResolvedValueOnce({error: mockError} as {error: Error});
jest.mocked(goToAddChecklistItem).mockClear();
const props = getBaseProps();
props.isFinished = false;
props.isParticipant = true;
const {getByTestId} = renderWithIntl(<Checklist {...props}/>);
const addButton = getByTestId('add-checklist-item-button');
act(() => {
fireEvent.press(addButton);
});
const onAdd = jest.mocked(goToAddChecklistItem).mock.calls[0][3];
await act(async () => {
await onAdd('New Item');
});
await waitFor(() => {
expect(addChecklistItem).toHaveBeenCalledWith(
serverUrl,
props.playbookRunId,
props.checklistNumber,
'New Item',
);
expect(showPlaybookErrorSnackbar).toHaveBeenCalled();
expect(logError).toHaveBeenCalledWith('error on addChecklistItem', expect.any(String));
});
});
it('handles successful add item without errors', async () => {
jest.mocked(addChecklistItem).mockResolvedValueOnce({data: true} as {data: boolean});
jest.mocked(goToAddChecklistItem).mockImplementation(async (intl, theme, playbookRunName, onAdd) => {
onAdd('New Item');
});
const props = getBaseProps();
props.isFinished = false;
props.isParticipant = true;
const {getByTestId} = renderWithIntl(<Checklist {...props}/>);
const addButton = getByTestId('add-checklist-item-button');
act(() => {
fireEvent.press(addButton);
});
await waitFor(() => {
expect(addChecklistItem).toHaveBeenCalledWith(
serverUrl,
props.playbookRunId,
props.checklistNumber,
'New Item',
);
expect(showPlaybookErrorSnackbar).not.toHaveBeenCalled();
expect(logError).not.toHaveBeenCalled();
});
});
});

View file

@ -214,7 +214,10 @@ const Checklist = ({
</View>
<View style={styles.progressAndEditContainer}>
<Text style={styles.progressText}>{`${completed} / ${totalNumber} done`}</Text>
<TouchableOpacity onPress={handleEditPress}>
<TouchableOpacity
onPress={handleEditPress}
testID='edit-checklist-button'
>
<CompassIcon
name='pencil-outline'
style={styles.editIcon}

View file

@ -0,0 +1,325 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, fireEvent} from '@testing-library/react-native';
import React from 'react';
import {Keyboard} from 'react-native';
import {Preferences} from '@constants';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {buildNavigationButton, popTopScreen, setButtons} from '@screens/navigation';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import RenameChecklistBottomSheet from './rename_checklist_bottom_sheet';
jest.mock('@screens/navigation', () => ({
buildNavigationButton: jest.fn(),
popTopScreen: jest.fn(),
setButtons: jest.fn(),
}));
jest.mock('@hooks/navigation_button_pressed', () => jest.fn());
jest.mock('@hooks/android_back_handler', () => jest.fn());
jest.mock('@managers/security_manager', () => ({
getShieldScreenId: jest.fn((id) => `shield-${id}`),
}));
describe('RenameChecklistBottomSheet', () => {
const componentId = 'test-component-id' as any;
const currentTitle = 'Original Checklist';
const mockOnSave = jest.fn();
const mockRightButton = {
id: 'save-checklist-name',
enabled: false,
color: Preferences.THEMES.denim.sidebarHeaderTextColor,
};
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(buildNavigationButton).mockReturnValue(mockRightButton as any);
jest.mocked(useNavButtonPressed).mockImplementation((buttonId, compId, callback) => {
// Simulate button press when buttonId matches
if (buttonId === 'save-checklist-name' && compId === componentId) {
// Store callback for manual triggering in tests
(useNavButtonPressed as any).lastCallback = callback;
}
});
jest.mocked(useAndroidHardwareBackHandler).mockImplementation((compId, callback) => {
// Store callback for manual triggering in tests
(useAndroidHardwareBackHandler as any).lastCallback = callback;
});
});
function getBaseProps() {
return {
componentId,
currentTitle,
onSave: mockOnSave,
};
}
it('should render correctly with currentTitle', () => {
const props = getBaseProps();
const {getByTestId, getByText} = renderWithIntlAndTheme(<RenameChecklistBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist.rename.input');
expect(input).toBeTruthy();
expect(input.props.value).toBe(currentTitle);
expect(input.props.autoFocus).toBe(true);
// Check label is rendered
const label = getByText('Section name');
expect(label).toBeTruthy();
});
it('should set up navigation buttons on mount', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<RenameChecklistBottomSheet {...props}/>);
expect(buildNavigationButton).toHaveBeenCalledWith(
'save-checklist-name',
'playbooks.checklist.rename.button',
undefined,
'Save',
);
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [mockRightButton],
});
});
it('should register navigation button press handler', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<RenameChecklistBottomSheet {...props}/>);
expect(useNavButtonPressed).toHaveBeenCalledWith(
'save-checklist-name',
componentId,
expect.any(Function),
[expect.any(Function)],
);
});
it('should register Android back handler', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<RenameChecklistBottomSheet {...props}/>);
expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith(
componentId,
expect.any(Function),
);
});
it('should update title when text input changes', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<RenameChecklistBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist.rename.input');
const newTitle = 'New Checklist Title';
act(() => {
fireEvent.changeText(input, newTitle);
});
expect(input.props.value).toBe(newTitle);
});
it('should enable save button when title has content and is different from currentTitle', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<RenameChecklistBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist.rename.input');
// Initially disabled (same as currentTitle)
expect(mockRightButton.enabled).toBe(false);
// Update with different title
act(() => {
fireEvent.changeText(input, 'New Checklist Title');
});
// Button should be enabled now
const updatedButton = {
...mockRightButton,
enabled: true,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [updatedButton],
});
});
it('should disable save button when title is same as currentTitle', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<RenameChecklistBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist.rename.input');
// Change title to something different
act(() => {
fireEvent.changeText(input, 'Different Title');
});
// Button should be enabled
const enabledButton = {
...mockRightButton,
enabled: true,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [enabledButton],
});
// Change back to original title
act(() => {
fireEvent.changeText(input, currentTitle);
});
// Button should be disabled
const disabledButton = {
...mockRightButton,
enabled: false,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [disabledButton],
});
});
it('should disable save button when title is empty', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<RenameChecklistBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist.rename.input');
// Set title to something different
act(() => {
fireEvent.changeText(input, 'Different Title');
});
// Button should be enabled to ensure there are no race conditions passing as good
const checkButton = {
...mockRightButton,
enabled: true,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [checkButton],
});
// Clear title
act(() => {
fireEvent.changeText(input, '');
});
// Button should be disabled
const updatedButton = {
...mockRightButton,
enabled: false,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [updatedButton],
});
});
it('should disable save button when title is only whitespace', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<RenameChecklistBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist.rename.input');
// Set title to whitespace only
act(() => {
fireEvent.changeText(input, ' ');
});
// Button should be disabled
const updatedButton = {
...mockRightButton,
enabled: false,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [updatedButton],
});
});
it('should call onSave and close when save button is pressed with valid title', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<RenameChecklistBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist.rename.input');
const newTitle = 'New Checklist Name';
// Set title
act(() => {
fireEvent.changeText(input, newTitle);
});
// Trigger save button press
const saveCallback = (useNavButtonPressed as any).lastCallback;
act(() => {
saveCallback();
});
expect(mockOnSave).toHaveBeenCalledWith(newTitle);
expect(Keyboard.dismiss).toHaveBeenCalled();
expect(popTopScreen).toHaveBeenCalledWith(componentId);
});
it('should trim title when saving', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<RenameChecklistBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist.rename.input');
const titleWithSpaces = ' New Checklist Name ';
// Set title with spaces
act(() => {
fireEvent.changeText(input, titleWithSpaces);
});
// Trigger save button press
const saveCallback = (useNavButtonPressed as any).lastCallback;
act(() => {
saveCallback();
});
expect(mockOnSave).toHaveBeenCalledWith('New Checklist Name');
expect(mockOnSave).not.toHaveBeenCalledWith(titleWithSpaces);
});
it('should close when Android back button is pressed', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<RenameChecklistBottomSheet {...props}/>);
// Trigger back handler
const backCallback = (useAndroidHardwareBackHandler as any).lastCallback;
act(() => {
backCallback();
});
expect(Keyboard.dismiss).toHaveBeenCalled();
expect(popTopScreen).toHaveBeenCalledWith(componentId);
expect(mockOnSave).not.toHaveBeenCalled();
});
it('should update navigation button when canSave changes', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<RenameChecklistBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist.rename.input');
// Initially disabled (same as currentTitle)
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [{...mockRightButton, enabled: false}],
});
// Enable by changing to different text
act(() => {
fireEvent.changeText(input, 'Different Title');
});
// Should update with enabled button
expect(setButtons).toHaveBeenLastCalledWith(componentId, {
rightButtons: [{...mockRightButton, enabled: true}],
});
});
});

View file

@ -11,6 +11,7 @@ import {useServerUrl} from '@context/server';
import DatabaseManager from '@database/manager';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {finishRun, setOwner} from '@playbooks/actions/remote/runs';
import {PLAYBOOK_RUN_TYPES} from '@playbooks/constants/playbook_run';
import {openUserProfileModal, popTopScreen} from '@screens/navigation';
import {fireEvent, renderWithEverything, waitFor} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
@ -412,4 +413,204 @@ describe('PlaybookRun', () => {
expect(popTopScreen).toHaveBeenCalled();
});
it('does not render StatusUpdateIndicator for ChannelChecklistType', () => {
const props = getBaseProps();
(props.playbookRun as PlaybookRunModel).type = PLAYBOOK_RUN_TYPES.ChannelChecklistType;
const {queryByTestId} = renderWithEverything(<PlaybookRun {...props}/>, {database});
expect(queryByTestId('status-update-indicator')).toBeNull();
});
it('renders StatusUpdateIndicator for PlaybookType', () => {
const props = getBaseProps();
(props.playbookRun as PlaybookRunModel).type = PLAYBOOK_RUN_TYPES.PlaybookType;
const {getByTestId} = renderWithEverything(<PlaybookRun {...props}/>, {database});
expect(getByTestId('status-update-indicator')).toBeTruthy();
});
it('handles openOwnerProfile when owner is undefined', () => {
const props = getBaseProps();
props.owner = undefined;
jest.mocked(openUserProfileModal).mockClear();
const {queryByTestId} = renderWithEverything(<PlaybookRun {...props}/>, {database});
// Should not crash, but owner chip won't be rendered
expect(queryByTestId('user-chip')).toBeNull();
});
it('handles openChangeOwnerModal when owner is undefined', () => {
const props = getBaseProps();
props.owner = undefined;
jest.mocked(goToSelectUser).mockClear();
const {queryByTestId} = renderWithEverything(<PlaybookRun {...props}/>, {database});
// Should not crash, but owner chip won't be rendered
expect(queryByTestId('user-chip')).toBeNull();
expect(goToSelectUser).not.toHaveBeenCalled();
});
it('handles handleSelectOwner when playbookRun is undefined', () => {
// This test verifies the early return in handleSelectOwner
// Since the component shows ErrorState when playbookRun is undefined,
// we can't test the callback directly, but we verify the component handles it gracefully
const props = getBaseProps();
props.playbookRun = undefined;
const {getByTestId} = renderWithEverything(<PlaybookRun {...props}/>, {database});
// Component should render ErrorState when playbookRun is undefined
expect(getByTestId('error-state')).toBeTruthy();
});
it('handles handleFinishRun when playbookRun is undefined', () => {
const props = getBaseProps();
props.participants.push(TestHelper.fakeUserModel({id: props.currentUserId}));
props.playbookRun = undefined;
jest.mocked(Alert.alert).mockClear();
renderWithEverything(<PlaybookRun {...props}/>, {database});
// Should not show alert when playbookRun is undefined
expect(Alert.alert).not.toHaveBeenCalled();
});
it('handles channelId from model type', () => {
const props = getBaseProps();
(props.playbookRun as PlaybookRunModel).channelId = 'channel-id-123';
const {getByTestId} = renderWithEverything(<PlaybookRun {...props}/>, {database});
const checklistList = getByTestId('checklist-list');
expect(checklistList.props.channelId).toBe('channel-id-123');
});
it('handles channel_id from API type', () => {
const props = getBaseProps();
const apiRun = TestHelper.fakePlaybookRun({
id: 'run-1',
name: 'Test Playbook Run',
summary: 'Test summary',
channel_id: 'api-channel-id-456',
});
props.playbookRun = apiRun;
const {getByTestId} = renderWithEverything(<PlaybookRun {...props}/>, {database});
const checklistList = getByTestId('checklist-list');
expect(checklistList.props.channelId).toBe('api-channel-id-456');
});
it('defaults channelId to empty string when missing', () => {
const props = getBaseProps();
const apiRun = TestHelper.fakePlaybookRun({
id: 'run-1',
name: 'Test Playbook Run',
summary: 'Test summary',
});
const runWithoutChannelId = {...apiRun} as Record<string, unknown>;
delete runWithoutChannelId.channel_id;
props.playbookRun = runWithoutChannelId as unknown as PlaybookRunModel;
const {getByTestId} = renderWithEverything(<PlaybookRun {...props}/>, {database});
const checklistList = getByTestId('checklist-list');
expect(checklistList.props.channelId).toBe('');
});
it('handles lastSyncAt from model type', () => {
const props = getBaseProps();
(props.playbookRun as PlaybookRunModel).lastSyncAt = 99999;
const {getByTestId} = renderWithEverything(<PlaybookRun {...props}/>, {database});
const outOfDateHeader = getByTestId('out-of-date-header');
expect(outOfDateHeader.props.lastSyncAt).toBe(99999);
});
it('defaults lastSyncAt to 0 when missing', () => {
const props = getBaseProps();
const apiRun = TestHelper.fakePlaybookRun({
id: 'run-1',
name: 'Test Playbook Run',
summary: 'Test summary',
});
props.playbookRun = apiRun;
const {getByTestId} = renderWithEverything(<PlaybookRun {...props}/>, {database});
const outOfDateHeader = getByTestId('out-of-date-header');
expect(outOfDateHeader.props.lastSyncAt).toBe(0);
});
it('sets isParticipant to true when owner is current user', () => {
const props = getBaseProps();
props.owner = TestHelper.fakeUserModel({id: props.currentUserId});
props.participants = [];
const {getByTestId} = renderWithEverything(<PlaybookRun {...props}/>, {database});
const checklistList = getByTestId('checklist-list');
expect(checklistList.props.isParticipant).toBe(true);
});
it('sets readOnly to true when finished even if user is participant', () => {
const props = getBaseProps();
props.participants.push(TestHelper.fakeUserModel({id: props.currentUserId}));
(props.playbookRun as PlaybookRunModel).currentStatus = 'Finished';
const {queryByText} = renderWithEverything(<PlaybookRun {...props}/>, {database});
// Finish button should not be rendered when readOnly is true
expect(queryByText('Finish')).toBeNull();
});
it('defaults playbookRunType to PlaybookType when type is undefined but playbookId exists', () => {
const props = getBaseProps();
const run = props.playbookRun as PlaybookRunModel;
delete (run as Partial<PlaybookRunModel>).type;
run.playbookId = 'playbook-id-123';
const {getByTestId} = renderWithEverything(<PlaybookRun {...props}/>, {database});
// StatusUpdateIndicator should be rendered for PlaybookType
expect(getByTestId('status-update-indicator')).toBeTruthy();
});
it('defaults playbookRunType to ChannelChecklistType when type is undefined and playbookId is empty', () => {
const props = getBaseProps();
const run = props.playbookRun as PlaybookRunModel;
delete (run as Partial<PlaybookRunModel>).type;
run.playbookId = '';
const {queryByTestId} = renderWithEverything(<PlaybookRun {...props}/>, {database});
// StatusUpdateIndicator should not be rendered for ChannelChecklistType
expect(queryByTestId('status-update-indicator')).toBeNull();
});
it('handles playbookRunType with playbook_id from API type', () => {
const props = getBaseProps();
const apiRun = TestHelper.fakePlaybookRun({
id: 'run-1',
name: 'Test Playbook Run',
summary: 'Test summary',
playbook_id: 'api-playbook-id-789',
});
const runWithoutType = {...apiRun} as Record<string, unknown>;
delete runWithoutType.type;
props.playbookRun = runWithoutType as unknown as PlaybookRunModel;
const {getByTestId} = renderWithEverything(<PlaybookRun {...props}/>, {database});
// StatusUpdateIndicator should be rendered for PlaybookType
expect(getByTestId('status-update-indicator')).toBeTruthy();
});
it('handles playbookRunType with empty playbook_id from API type', () => {
const props = getBaseProps();
const apiRun = TestHelper.fakePlaybookRun({
id: 'run-1',
name: 'Test Playbook Run',
summary: 'Test summary',
playbook_id: '',
});
const runWithoutType = {...apiRun} as Record<string, unknown>;
delete runWithoutType.type;
props.playbookRun = runWithoutType as unknown as PlaybookRunModel;
const {queryByTestId} = renderWithEverything(<PlaybookRun {...props}/>, {database});
// StatusUpdateIndicator should not be rendered for ChannelChecklistType
expect(queryByTestId('status-update-indicator')).toBeNull();
});
});

View file

@ -202,7 +202,16 @@ export default function PlaybookRun({
const isFinished = isRunFinished(playbookRun);
const readOnly = isFinished || !isParticipant;
const playbookRunType = useMemo(() => playbookRun?.type || 'playbook', [playbookRun]);
const playbookRunType = useMemo(() => {
if (!playbookRun) {
return PLAYBOOK_RUN_TYPES.PlaybookType;
}
if (playbookRun.type) {
return playbookRun.type;
}
const playbookId = 'playbookId' in playbookRun ? playbookRun.playbookId : (playbookRun.playbook_id || '');
return playbookId ? PLAYBOOK_RUN_TYPES.PlaybookType : PLAYBOOK_RUN_TYPES.ChannelChecklistType;
}, [playbookRun]);
const containerStyle = useMemo(() => {
return [

View file

@ -0,0 +1,333 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, fireEvent} from '@testing-library/react-native';
import React from 'react';
import {Keyboard} from 'react-native';
import {Preferences} from '@constants';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {buildNavigationButton, popTopScreen, setButtons} from '@screens/navigation';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import RenamePlaybookRunBottomSheet from './rename_playbook_run_bottom_sheet';
jest.mock('@screens/navigation', () => ({
buildNavigationButton: jest.fn(),
popTopScreen: jest.fn(),
setButtons: jest.fn(),
}));
jest.mock('react-native', () => {
const RN = jest.requireActual('react-native');
RN.Keyboard = {
dismiss: jest.fn(),
};
return RN;
});
jest.mock('@hooks/navigation_button_pressed', () => jest.fn());
jest.mock('@hooks/android_back_handler', () => jest.fn());
jest.mock('@managers/security_manager', () => ({
getShieldScreenId: jest.fn((id) => `shield-${id}`),
}));
describe('RenamePlaybookRunBottomSheet', () => {
const componentId = 'test-component-id' as any;
const currentTitle = 'Original Playbook Run';
const mockOnSave = jest.fn();
const mockRightButton = {
id: 'save-playbook-run-name',
enabled: false,
color: Preferences.THEMES.denim.sidebarHeaderTextColor,
};
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(buildNavigationButton).mockReturnValue(mockRightButton as any);
jest.mocked(useNavButtonPressed).mockImplementation((buttonId, compId, callback) => {
// Simulate button press when buttonId matches
if (buttonId === 'save-playbook-run-name' && compId === componentId) {
// Store callback for manual triggering in tests
(useNavButtonPressed as any).lastCallback = callback;
}
});
jest.mocked(useAndroidHardwareBackHandler).mockImplementation((compId, callback) => {
// Store callback for manual triggering in tests
(useAndroidHardwareBackHandler as any).lastCallback = callback;
});
});
function getBaseProps() {
return {
componentId,
currentTitle,
onSave: mockOnSave,
};
}
it('should render correctly with currentTitle', () => {
const props = getBaseProps();
const {getByTestId, getByText} = renderWithIntlAndTheme(<RenamePlaybookRunBottomSheet {...props}/>);
const input = getByTestId('playbooks.playbook_run.rename.input');
expect(input).toBeTruthy();
expect(input.props.value).toBe(currentTitle);
expect(input.props.autoFocus).toBe(true);
// Check label is rendered
const label = getByText('Checklist name');
expect(label).toBeTruthy();
});
it('should set up navigation buttons on mount', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<RenamePlaybookRunBottomSheet {...props}/>);
expect(buildNavigationButton).toHaveBeenCalledWith(
'save-playbook-run-name',
'playbooks.playbook_run.rename.button',
undefined,
'Save',
);
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [mockRightButton],
});
});
it('should register navigation button press handler', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<RenamePlaybookRunBottomSheet {...props}/>);
expect(useNavButtonPressed).toHaveBeenCalledWith(
'save-playbook-run-name',
componentId,
expect.any(Function),
[expect.any(Function)],
);
});
it('should register Android back handler', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<RenamePlaybookRunBottomSheet {...props}/>);
expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith(
componentId,
expect.any(Function),
);
});
it('should update title when text input changes', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<RenamePlaybookRunBottomSheet {...props}/>);
const input = getByTestId('playbooks.playbook_run.rename.input');
const newTitle = 'New Playbook Run Title';
act(() => {
fireEvent.changeText(input, newTitle);
});
expect(input.props.value).toBe(newTitle);
});
it('should enable save button when title has content and is different from currentTitle', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<RenamePlaybookRunBottomSheet {...props}/>);
const input = getByTestId('playbooks.playbook_run.rename.input');
// Initially disabled (same as currentTitle)
expect(mockRightButton.enabled).toBe(false);
// Update with different title
act(() => {
fireEvent.changeText(input, 'New Playbook Run Title');
});
// Button should be enabled now
const updatedButton = {
...mockRightButton,
enabled: true,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [updatedButton],
});
});
it('should disable save button when title is same as currentTitle', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<RenamePlaybookRunBottomSheet {...props}/>);
const input = getByTestId('playbooks.playbook_run.rename.input');
// Change title to something different
act(() => {
fireEvent.changeText(input, 'Different Title');
});
// Button should be enabled
const enabledButton = {
...mockRightButton,
enabled: true,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [enabledButton],
});
// Change back to original title
act(() => {
fireEvent.changeText(input, currentTitle);
});
// Button should be disabled
const disabledButton = {
...mockRightButton,
enabled: false,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [disabledButton],
});
});
it('should disable save button when title is empty', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<RenamePlaybookRunBottomSheet {...props}/>);
const input = getByTestId('playbooks.playbook_run.rename.input');
// Set title to something different
act(() => {
fireEvent.changeText(input, 'Different Title');
});
// Button should be enabled to ensure there are no race conditions passing as good
const checkButton = {
...mockRightButton,
enabled: true,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [checkButton],
});
// Clear title
act(() => {
fireEvent.changeText(input, '');
});
// Button should be disabled
const updatedButton = {
...mockRightButton,
enabled: false,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [updatedButton],
});
});
it('should disable save button when title is only whitespace', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<RenamePlaybookRunBottomSheet {...props}/>);
const input = getByTestId('playbooks.playbook_run.rename.input');
// Set title to whitespace only
act(() => {
fireEvent.changeText(input, ' ');
});
// Button should be disabled
const updatedButton = {
...mockRightButton,
enabled: false,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [updatedButton],
});
});
it('should call onSave and close when save button is pressed with valid title', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<RenamePlaybookRunBottomSheet {...props}/>);
const input = getByTestId('playbooks.playbook_run.rename.input');
const newTitle = 'New Playbook Run Name';
// Set title
act(() => {
fireEvent.changeText(input, newTitle);
});
// Trigger save button press
const saveCallback = (useNavButtonPressed as any).lastCallback;
act(() => {
saveCallback();
});
expect(mockOnSave).toHaveBeenCalledWith(newTitle);
expect(Keyboard.dismiss).toHaveBeenCalled();
expect(popTopScreen).toHaveBeenCalledWith(componentId);
});
it('should trim title when saving', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<RenamePlaybookRunBottomSheet {...props}/>);
const input = getByTestId('playbooks.playbook_run.rename.input');
const titleWithSpaces = ' New Playbook Run Name ';
// Set title with spaces
act(() => {
fireEvent.changeText(input, titleWithSpaces);
});
// Trigger save button press
const saveCallback = (useNavButtonPressed as any).lastCallback;
act(() => {
saveCallback();
});
expect(mockOnSave).toHaveBeenCalledWith('New Playbook Run Name');
expect(mockOnSave).not.toHaveBeenCalledWith(titleWithSpaces);
});
it('should close when Android back button is pressed', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<RenamePlaybookRunBottomSheet {...props}/>);
// Trigger back handler
const backCallback = (useAndroidHardwareBackHandler as any).lastCallback;
act(() => {
backCallback();
});
expect(Keyboard.dismiss).toHaveBeenCalled();
expect(popTopScreen).toHaveBeenCalledWith(componentId);
expect(mockOnSave).not.toHaveBeenCalled();
});
it('should update navigation button when canSave changes', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<RenamePlaybookRunBottomSheet {...props}/>);
const input = getByTestId('playbooks.playbook_run.rename.input');
// Initially disabled (same as currentTitle)
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [{...mockRightButton, enabled: false}],
});
// Enable by changing to different text
act(() => {
fireEvent.changeText(input, 'Different Title');
});
// Should update with enabled button
expect(setButtons).toHaveBeenLastCalledWith(componentId, {
rightButtons: [{...mockRightButton, enabled: true}],
});
});
});

View file

@ -16,6 +16,7 @@ import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {fetchPlaybookRun, fetchPlaybookRunMetadata, postStatusUpdate} from '@playbooks/actions/remote/runs';
import {popTopScreen, setButtons} from '@screens/navigation';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import {getLastCall} from '@test/mock_helpers';
import TestHelper from '@test/test_helper';
import PostUpdate from './post_update';
@ -79,11 +80,6 @@ jest.mock('react-native', () => {
};
});
function getLastCall<T, U extends any[], V>(mock: jest.Mock<T, U, V>): U {
const allCalls = mock.mock.calls;
return allCalls[allCalls.length - 1];
}
describe('PostUpdate', () => {
function getBaseProps(): ComponentProps<typeof PostUpdate> {
return {

View file

@ -15,6 +15,7 @@ import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {createPlaybookRun} from '@playbooks/actions/remote/runs';
import {popTopScreen, setButtons} from '@screens/navigation';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import {getLastCall, getLastCallForButton} from '@test/mock_helpers';
import TestHelper from '@test/test_helper';
import StartARun from './start_a_run';
@ -70,17 +71,6 @@ jest.mock('@utils/snack_bar', () => ({
showPlaybookErrorSnackbar: jest.fn(),
}));
function getLastCall<T, U extends unknown[], V>(mock: jest.Mock<T, U, V>): U {
const allCalls = mock.mock.calls;
return allCalls[allCalls.length - 1];
}
function getLastCallForButton<T, U extends unknown[], V>(mock: jest.Mock<T, U, V>, buttonId: string): U {
const allCalls = mock.mock.calls;
const buttonCalls = allCalls.filter((call) => call[0] === buttonId);
return buttonCalls[buttonCalls.length - 1];
}
describe('StartARun', () => {
function getBaseProps(): ComponentProps<typeof StartARun> {
return {

26
test/mock_helpers.ts Normal file
View file

@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* Get the last call made to a Jest mock function.
* @param mock - The Jest mock function
* @returns The arguments array from the last call
*/
export function getLastCall<T, U extends unknown[], V>(mock: jest.Mock<T, U, V>): U {
const allCalls = mock.mock.calls;
return allCalls[allCalls.length - 1];
}
/**
* Get the last call made to a Jest mock function for a specific button ID.
* Useful for testing navigation button handlers where the first argument is the button ID.
* @param mock - The Jest mock function
* @param buttonId - The button ID to filter by
* @returns The arguments array from the last call matching the button ID
*/
export function getLastCallForButton<T, U extends unknown[], V>(mock: jest.Mock<T, U, V>, buttonId: string): U {
const allCalls = mock.mock.calls;
const buttonCalls = allCalls.filter((call) => call[0] === buttonId);
return buttonCalls[buttonCalls.length - 1];
}