From 31fc648e5061abb6984e9bb2197b460ae48073fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillermo=20Vay=C3=A1?= Date: Wed, 17 Dec 2025 17:38:44 +0100 Subject: [PATCH] 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 --- .../playbooks/actions/local/checklist.test.ts | 20 +- .../playbooks/actions/local/checklist.ts | 5 + .../playbooks/actions/local/run.test.ts | 20 +- app/products/playbooks/actions/local/run.ts | 5 + app/products/playbooks/client/rest.test.ts | 41 -- .../operators/transformers/index.test.ts | 309 ++++++++++++++- .../playbooks/screens/navigation.test.ts | 111 +++++- .../add_checklist_item_bottom_sheet.test.tsx | 353 ++++++++++++++++++ .../playbook_run/checklist/checklist.test.tsx | 139 +++++++ .../playbook_run/checklist/checklist.tsx | 5 +- .../rename_checklist_bottom_sheet.test.tsx | 325 ++++++++++++++++ .../playbook_run/playbook_run.test.tsx | 201 ++++++++++ .../screens/playbook_run/playbook_run.tsx | 11 +- .../rename_playbook_run_bottom_sheet.test.tsx | 333 +++++++++++++++++ .../screens/post_update/post_update.test.tsx | 6 +- .../screens/start_a_run/start_a_run.test.tsx | 12 +- test/mock_helpers.ts | 26 ++ 17 files changed, 1845 insertions(+), 77 deletions(-) create mode 100644 app/products/playbooks/screens/playbook_run/checklist/add_checklist_item_bottom_sheet.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/checklist/rename_checklist_bottom_sheet.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet.test.tsx create mode 100644 test/mock_helpers.ts diff --git a/app/products/playbooks/actions/local/checklist.test.ts b/app/products/playbooks/actions/local/checklist.test.ts index bfed0634f..7007bd12a 100644 --- a/app/products/playbooks/actions/local/checklist.test.ts +++ b/app/products/playbooks/actions/local/checklist.test.ts @@ -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 () => { diff --git a/app/products/playbooks/actions/local/checklist.ts b/app/products/playbooks/actions/local/checklist.ts index 052ab1455..8f3f54e44 100644 --- a/app/products/playbooks/actions/local/checklist.ts +++ b/app/products/playbooks/actions/local/checklist.ts @@ -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; diff --git a/app/products/playbooks/actions/local/run.test.ts b/app/products/playbooks/actions/local/run.test.ts index cf44289d5..bb63cd089 100644 --- a/app/products/playbooks/actions/local/run.test.ts +++ b/app/products/playbooks/actions/local/run.test.ts @@ -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(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(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId); - expect(updatedRun.name).toBe(whitespaceName); + expect(updatedRun.name).toBe(originalName); }); it('should handle very long names', async () => { diff --git a/app/products/playbooks/actions/local/run.ts b/app/products/playbooks/actions/local/run.ts index c0ca803cd..302b58e66 100644 --- a/app/products/playbooks/actions/local/run.ts +++ b/app/products/playbooks/actions/local/run.ts @@ -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; diff --git a/app/products/playbooks/client/rest.test.ts b/app/products/playbooks/client/rest.test.ts index 6c0a87a02..c254d349d 100644 --- a/app/products/playbooks/client/rest.test.ts +++ b/app/products/playbooks/client/rest.test.ts @@ -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; diff --git a/app/products/playbooks/database/operators/transformers/index.test.ts b/app/products/playbooks/database/operators/transformers/index.test.ts index 36bf8ab6b..01bb62340 100644 --- a/app/products/playbooks/database/operators/transformers/index.test.ts +++ b/app/products/playbooks/database/operators/transformers/index.test.ts @@ -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(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(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(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'); + }); }); diff --git a/app/products/playbooks/screens/navigation.test.ts b/app/products/playbooks/screens/navigation.test.ts index 9719fdf4f..d91aff641 100644 --- a/app/products/playbooks/screens/navigation.test.ts +++ b/app/products/playbooks/screens/navigation.test.ts @@ -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, + }, + {}, + ); + }); + }); }); diff --git a/app/products/playbooks/screens/playbook_run/checklist/add_checklist_item_bottom_sheet.test.tsx b/app/products/playbooks/screens/playbook_run/checklist/add_checklist_item_bottom_sheet.test.tsx new file mode 100644 index 000000000..4794ebf41 --- /dev/null +++ b/app/products/playbooks/screens/playbook_run/checklist/add_checklist_item_bottom_sheet.test.tsx @@ -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(); + + 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(); + + 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(); + + expect(useNavButtonPressed).toHaveBeenCalledWith( + 'add-checklist-item', + componentId, + expect.any(Function), + [expect.any(Function)], + ); + }); + + it('should register Android back handler', () => { + const props = getBaseProps(); + renderWithIntlAndTheme(); + + expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith( + componentId, + expect.any(Function), + ); + }); + + it('should update title when text input changes', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithIntlAndTheme(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + 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(); + + // 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(); + 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(); + + 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(); + + // 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); + }); +}); + diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist.test.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist.test.tsx index e5d509741..5a5c82aad 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/checklist.test.tsx +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); + }); }); diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist.tsx index c4ffef71a..331c45347 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/checklist.tsx +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist.tsx @@ -214,7 +214,10 @@ const Checklist = ({ {`${completed} / ${totalNumber} done`} - + ({ + 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(); + + 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(); + + 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(); + + expect(useNavButtonPressed).toHaveBeenCalledWith( + 'save-checklist-name', + componentId, + expect.any(Function), + [expect.any(Function)], + ); + }); + + it('should register Android back handler', () => { + const props = getBaseProps(); + renderWithIntlAndTheme(); + + expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith( + componentId, + expect.any(Function), + ); + }); + + it('should update title when text input changes', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithIntlAndTheme(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + 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}], + }); + }); +}); + diff --git a/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx b/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx index 998493a60..d4973d79a 100644 --- a/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx +++ b/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx @@ -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(, {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(, {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(, {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(, {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(, {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(, {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(, {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(, {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; + delete runWithoutChannelId.channel_id; + props.playbookRun = runWithoutChannelId as unknown as PlaybookRunModel; + const {getByTestId} = renderWithEverything(, {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(, {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(, {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(, {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(, {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).type; + run.playbookId = 'playbook-id-123'; + const {getByTestId} = renderWithEverything(, {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).type; + run.playbookId = ''; + const {queryByTestId} = renderWithEverything(, {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; + delete runWithoutType.type; + props.playbookRun = runWithoutType as unknown as PlaybookRunModel; + const {getByTestId} = renderWithEverything(, {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; + delete runWithoutType.type; + props.playbookRun = runWithoutType as unknown as PlaybookRunModel; + const {queryByTestId} = renderWithEverything(, {database}); + + // StatusUpdateIndicator should not be rendered for ChannelChecklistType + expect(queryByTestId('status-update-indicator')).toBeNull(); + }); + }); diff --git a/app/products/playbooks/screens/playbook_run/playbook_run.tsx b/app/products/playbooks/screens/playbook_run/playbook_run.tsx index 992fd387e..c45ce9064 100644 --- a/app/products/playbooks/screens/playbook_run/playbook_run.tsx +++ b/app/products/playbooks/screens/playbook_run/playbook_run.tsx @@ -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 [ diff --git a/app/products/playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet.test.tsx b/app/products/playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet.test.tsx new file mode 100644 index 000000000..b45161d87 --- /dev/null +++ b/app/products/playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet.test.tsx @@ -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(); + + 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(); + + 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(); + + expect(useNavButtonPressed).toHaveBeenCalledWith( + 'save-playbook-run-name', + componentId, + expect.any(Function), + [expect.any(Function)], + ); + }); + + it('should register Android back handler', () => { + const props = getBaseProps(); + renderWithIntlAndTheme(); + + expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith( + componentId, + expect.any(Function), + ); + }); + + it('should update title when text input changes', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithIntlAndTheme(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + 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}], + }); + }); +}); + diff --git a/app/products/playbooks/screens/post_update/post_update.test.tsx b/app/products/playbooks/screens/post_update/post_update.test.tsx index cde22629f..7975f1143 100644 --- a/app/products/playbooks/screens/post_update/post_update.test.tsx +++ b/app/products/playbooks/screens/post_update/post_update.test.tsx @@ -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(mock: jest.Mock): U { - const allCalls = mock.mock.calls; - return allCalls[allCalls.length - 1]; -} - describe('PostUpdate', () => { function getBaseProps(): ComponentProps { return { diff --git a/app/products/playbooks/screens/start_a_run/start_a_run.test.tsx b/app/products/playbooks/screens/start_a_run/start_a_run.test.tsx index 8bbf1c2a2..dcb6b0480 100644 --- a/app/products/playbooks/screens/start_a_run/start_a_run.test.tsx +++ b/app/products/playbooks/screens/start_a_run/start_a_run.test.tsx @@ -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(mock: jest.Mock): U { - const allCalls = mock.mock.calls; - return allCalls[allCalls.length - 1]; -} - -function getLastCallForButton(mock: jest.Mock, 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 { return { diff --git a/test/mock_helpers.ts b/test/mock_helpers.ts new file mode 100644 index 000000000..e0c62f4ed --- /dev/null +++ b/test/mock_helpers.ts @@ -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(mock: jest.Mock): 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(mock: jest.Mock, buttonId: string): U { + const allCalls = mock.mock.calls; + const buttonCalls = allCalls.filter((call) => call[0] === buttonId); + return buttonCalls[buttonCalls.length - 1]; +} +