// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {AppCallResponseTypes} from '@constants/apps'; import {convertDialogToAppForm, convertAppFormValuesToDialogSubmission} from './dialog_conversion'; import {InteractiveDialogAdapter} from './interactive_dialog_adapter'; // Mock dependencies jest.mock('@actions/remote/integrations'); jest.mock('./dialog_conversion', () => ({ ...jest.requireActual('./dialog_conversion'), convertDialogToAppForm: jest.fn(), convertAppFormValuesToDialogSubmission: jest.fn(), })); jest.mock('@utils/log'); const mockSubmitInteractiveDialog = require('@actions/remote/integrations').submitInteractiveDialog; const mockConvertDialogToAppForm = convertDialogToAppForm as jest.MockedFunction; const mockConvertAppFormValuesToDialogSubmission = convertAppFormValuesToDialogSubmission as jest.MockedFunction; // Mock intl object const mockIntl = { formatMessage: jest.fn(({defaultMessage}, values) => { if (values && defaultMessage?.includes('{error}')) { return defaultMessage.replace('{error}', values.error); } return defaultMessage; }), }; describe('InteractiveDialogAdapter', () => { const mockConfig: InteractiveDialogConfig = { app_id: 'test-app', dialog: { callback_id: 'test-callback', title: 'Test Dialog', introduction_text: 'Test introduction', elements: [ { name: 'text_field', type: 'text', display_name: 'Text Field', optional: false, default: 'default_value', placeholder: 'Enter text', help_text: 'Help text', min_length: 0, max_length: 100, data_source: '', options: [], }, { name: 'select_field', type: 'select', display_name: 'Select Field', optional: true, options: [ {value: 'option1', text: 'Option 1'}, {value: 'option2', text: 'Option 2'}, ], default: '', placeholder: '', help_text: '', min_length: 0, max_length: 0, data_source: '', }, ], submit_label: 'Submit', state: 'test-state', notify_on_cancel: false, }, url: 'https://test.com/dialog', trigger_id: 'test-trigger-id', }; const mockAppForm: AppForm = { title: 'Test Dialog', header: 'Test introduction', fields: [ { name: 'text_field', type: 'text', is_required: true, label: 'Text Field', description: 'Help text', position: 0, hint: 'Enter text', value: 'default_value', max_length: 100, min_length: 0, }, { name: 'select_field', type: 'static_select', is_required: false, label: 'Select Field', position: 1, options: [ {label: 'Option 1', value: 'option1'}, {label: 'Option 2', value: 'option2'}, ], }, ], submit: { path: '/dialog/submit', expand: {}, }, }; beforeEach(() => { jest.clearAllMocks(); // Set up default mock return values mockConvertDialogToAppForm.mockReturnValue(mockAppForm); // Cache is private and managed internally - no need to clear it manually }); describe('convertToAppForm', () => { it('should convert dialog config to app form', () => { const result = InteractiveDialogAdapter.convertToAppForm(mockConfig); expect(mockConvertDialogToAppForm).toHaveBeenCalledWith(mockConfig); expect(result).toBe(mockAppForm); }); it('should cache conversion results', () => { // Use a fresh config object to avoid any existing cache const freshConfig = {...mockConfig}; // First call const result1 = InteractiveDialogAdapter.convertToAppForm(freshConfig); expect(mockConvertDialogToAppForm).toHaveBeenCalledTimes(1); expect(result1).toBe(mockAppForm); // Second call with same config should use cache const result2 = InteractiveDialogAdapter.convertToAppForm(freshConfig); expect(mockConvertDialogToAppForm).toHaveBeenCalledTimes(1); // Still 1 expect(result2).toBe(mockAppForm); expect(result1).toBe(result2); // Same object reference }); it('should not cache results for different configs', () => { // Use fresh config objects to avoid any existing cache const config1 = {...mockConfig}; const config2 = {...mockConfig, trigger_id: 'different-trigger'}; InteractiveDialogAdapter.convertToAppForm(config1); InteractiveDialogAdapter.convertToAppForm(config2); expect(mockConvertDialogToAppForm).toHaveBeenCalledTimes(2); expect(mockConvertDialogToAppForm).toHaveBeenNthCalledWith(1, config1); expect(mockConvertDialogToAppForm).toHaveBeenNthCalledWith(2, config2); }); }); describe('convertValuesToSubmission', () => { const mockAppFormValues: AppFormValues = { text_field: 'user input', select_field: {label: 'Option 1', value: 'option1'}, }; it('should convert app form values to dialog submission format', () => { const mockConversionResult = { submission: { text_field: 'user input', select_field: 'option1', }, errors: [], }; mockConvertAppFormValuesToDialogSubmission.mockReturnValue(mockConversionResult); const result = InteractiveDialogAdapter.convertValuesToSubmission(mockAppFormValues, mockConfig); expect(mockConvertAppFormValuesToDialogSubmission).toHaveBeenCalledWith( mockAppFormValues, mockConfig.dialog.elements, ); expect(result).toEqual({ url: 'https://test.com/dialog', callback_id: 'test-callback', state: 'test-state', submission: { text_field: 'user input', select_field: 'option1', }, user_id: '', channel_id: '', team_id: '', cancelled: false, }); }); it('should handle conversion errors', () => { const mockConversionResult = { submission: {}, errors: ['Field validation failed'], }; mockConvertAppFormValuesToDialogSubmission.mockReturnValue(mockConversionResult); const result = InteractiveDialogAdapter.convertValuesToSubmission(mockAppFormValues, mockConfig); expect(result.submission).toEqual({}); // Should still return valid DialogSubmission structure even with errors expect(result.callback_id).toBe('test-callback'); }); it('should handle missing url and callback_id gracefully', () => { const configWithMissingFields = { ...mockConfig, url: undefined, dialog: { ...mockConfig.dialog, callback_id: undefined, }, } as any; mockConvertAppFormValuesToDialogSubmission.mockReturnValue({ submission: {}, errors: [], }); const result = InteractiveDialogAdapter.convertValuesToSubmission({}, configWithMissingFields); expect(result.url).toBe(''); expect(result.callback_id).toBe(''); }); it('should collect file_ids from file-type elements', () => { const fileConfig = { ...mockConfig, dialog: { ...mockConfig.dialog, elements: [ ...mockConfig.dialog.elements, { name: 'attachment', type: 'file', display_name: 'Attachment', optional: true, allow_multiple: true, default: '', placeholder: '', help_text: '', min_length: 0, max_length: 0, data_source: '', options: [], }, ], }, } as InteractiveDialogConfig; mockConvertAppFormValuesToDialogSubmission.mockReturnValue({ submission: { text_field: 'user input', attachment: 'file-1,file-2', }, errors: [], }); const result = InteractiveDialogAdapter.convertValuesToSubmission(mockAppFormValues, fileConfig); expect(result.file_ids).toEqual(['file-1', 'file-2']); }); it('should omit file_ids when no file element has a value', () => { mockConvertAppFormValuesToDialogSubmission.mockReturnValue({ submission: {text_field: 'user input'}, errors: [], }); const result = InteractiveDialogAdapter.convertValuesToSubmission(mockAppFormValues, mockConfig); expect(result.file_ids).toBeUndefined(); }); }); describe('createSubmitHandler', () => { const serverUrl = 'https://test.mattermost.com'; const mockAppFormValues: AppFormValues = { text_field: 'test input', }; it('should create submit handler that converts and submits successfully', async () => { const mockConversionResult = { submission: {text_field: 'test input'}, errors: [], }; const mockSubmissionResult = {data: {success: true}}; mockConvertAppFormValuesToDialogSubmission.mockReturnValue(mockConversionResult); mockSubmitInteractiveDialog.mockResolvedValue(mockSubmissionResult); const submitHandler = InteractiveDialogAdapter.createSubmitHandler(mockConfig, serverUrl, mockIntl as any); const result = await submitHandler(mockAppFormValues); expect(mockSubmitInteractiveDialog).toHaveBeenCalledWith(serverUrl, expect.objectContaining({ callback_id: 'test-callback', submission: {text_field: 'test input'}, cancelled: false, })); expect(result).toEqual({ data: { type: AppCallResponseTypes.OK, text: '', }, }); }); it('should handle server-side validation errors', async () => { const mockConversionResult = { submission: {text_field: 'invalid input'}, errors: [], }; const mockSubmissionResult = { data: { error: 'Validation failed', errors: {text_field: 'Field is required'}, }, }; mockConvertAppFormValuesToDialogSubmission.mockReturnValue(mockConversionResult); mockSubmitInteractiveDialog.mockResolvedValue(mockSubmissionResult); const submitHandler = InteractiveDialogAdapter.createSubmitHandler(mockConfig, serverUrl, mockIntl as any); const result = await submitHandler(mockAppFormValues); expect(result).toEqual({ error: { type: AppCallResponseTypes.ERROR, text: 'Validation failed', data: { errors: {text_field: 'Field is required'}, }, }, }); }); it('should handle network errors with appropriate message', async () => { const networkError = new Error('network timeout error'); mockConvertAppFormValuesToDialogSubmission.mockReturnValue({submission: {}, errors: []}); mockSubmitInteractiveDialog.mockRejectedValue(networkError); const submitHandler = InteractiveDialogAdapter.createSubmitHandler(mockConfig, serverUrl, mockIntl as any); const result = await submitHandler(mockAppFormValues); expect(result).toEqual({ error: { type: AppCallResponseTypes.ERROR, text: 'Submission failed due to network error. Please check your connection and try again.', data: { errors: {}, }, }, }); }); it('should handle validation errors with appropriate message', async () => { const validationError = new Error('Conversion validation failed'); mockConvertAppFormValuesToDialogSubmission.mockReturnValue({submission: {}, errors: []}); mockSubmitInteractiveDialog.mockRejectedValue(validationError); const submitHandler = InteractiveDialogAdapter.createSubmitHandler(mockConfig, serverUrl, mockIntl as any); const result = await submitHandler(mockAppFormValues); expect(result).toEqual({ error: { type: AppCallResponseTypes.ERROR, text: 'Submission failed due to form validation. Please check your inputs and try again.', data: { errors: {}, }, }, }); }); it('should handle generic errors with fallback message', async () => { const genericError = new Error('Unexpected error'); mockConvertAppFormValuesToDialogSubmission.mockReturnValue({submission: {}, errors: []}); mockSubmitInteractiveDialog.mockRejectedValue(genericError); const submitHandler = InteractiveDialogAdapter.createSubmitHandler(mockConfig, serverUrl, mockIntl as any); const result = await submitHandler(mockAppFormValues); expect(result).toEqual({ error: { type: AppCallResponseTypes.ERROR, text: 'Submission failed. Please try again.', data: { errors: {}, }, }, }); }); it('should handle non-Error exceptions', async () => { mockConvertAppFormValuesToDialogSubmission.mockReturnValue({submission: {}, errors: []}); mockSubmitInteractiveDialog.mockRejectedValue('String error'); const submitHandler = InteractiveDialogAdapter.createSubmitHandler(mockConfig, serverUrl, mockIntl as any); const result = await submitHandler(mockAppFormValues); expect(result).toEqual({ error: { type: AppCallResponseTypes.ERROR, text: 'Submission failed. Please try again.', data: { errors: {}, }, }, }); }); }); describe('createCancelHandler', () => { const serverUrl = 'https://test.mattermost.com'; it('should handle cancellation when notify_on_cancel is true', async () => { const configWithNotification = { ...mockConfig, dialog: { ...mockConfig.dialog, notify_on_cancel: true, }, }; mockConvertAppFormValuesToDialogSubmission.mockReturnValue({ submission: {}, errors: [], }); mockSubmitInteractiveDialog.mockResolvedValue({data: {success: true}}); const cancelHandler = InteractiveDialogAdapter.createCancelHandler(configWithNotification, serverUrl); await cancelHandler(); expect(mockSubmitInteractiveDialog).toHaveBeenCalledWith(serverUrl, expect.objectContaining({ callback_id: 'test-callback', cancelled: true, })); }); it('should not submit when notify_on_cancel is false', async () => { const cancelHandler = InteractiveDialogAdapter.createCancelHandler(mockConfig, serverUrl); await cancelHandler(); expect(mockSubmitInteractiveDialog).not.toHaveBeenCalled(); }); it('should handle cancellation errors gracefully', async () => { const configWithNotification = { ...mockConfig, dialog: { ...mockConfig.dialog, notify_on_cancel: true, }, }; mockConvertAppFormValuesToDialogSubmission.mockReturnValue({submission: {}, errors: []}); mockSubmitInteractiveDialog.mockRejectedValue(new Error('Network error')); const cancelHandler = InteractiveDialogAdapter.createCancelHandler(configWithNotification, serverUrl); // Should not throw await expect(cancelHandler()).resolves.not.toThrow(); }); }); describe('convertResponseToAppCall', () => { it('should convert successful response', () => { const successResult = {data: {success: true}}; const result = InteractiveDialogAdapter.convertResponseToAppCall(successResult, mockIntl as any); expect(result).toEqual({ data: { type: AppCallResponseTypes.OK, text: '', }, }); }); it('should convert server validation errors', () => { const errorResult = { data: { error: 'Validation failed', errors: {field1: 'Required field'}, }, }; const result = InteractiveDialogAdapter.convertResponseToAppCall(errorResult, mockIntl as any); expect(result).toEqual({ error: { type: AppCallResponseTypes.ERROR, text: 'Validation failed', data: { errors: {field1: 'Required field'}, }, }, }); }); it('should handle errors without message', () => { const errorResult = { data: { errors: {field1: 'Required field'}, }, }; const result = InteractiveDialogAdapter.convertResponseToAppCall(errorResult, mockIntl as any); expect(result).toEqual({ error: { type: AppCallResponseTypes.ERROR, text: 'Submission failed with validation errors', data: { errors: {field1: 'Required field'}, }, }, }); }); it('should handle network/action-level errors', () => { const errorResult = {error: 'Network timeout'}; const result = InteractiveDialogAdapter.convertResponseToAppCall(errorResult, mockIntl as any); expect(result).toEqual({ error: { type: AppCallResponseTypes.ERROR, text: 'Submission failed', data: { errors: {}, }, }, }); }); it('should handle missing data gracefully', () => { const emptyResult = {}; const result = InteractiveDialogAdapter.convertResponseToAppCall(emptyResult, mockIntl as any); expect(result).toEqual({ data: { type: AppCallResponseTypes.OK, text: '', }, }); }); it('should handle null/undefined result gracefully', () => { const result1 = InteractiveDialogAdapter.convertResponseToAppCall(null, mockIntl as any); const result2 = InteractiveDialogAdapter.convertResponseToAppCall(undefined, mockIntl as any); expect(result1).toEqual({ data: { type: AppCallResponseTypes.OK, text: '', }, }); expect(result2).toEqual({ data: { type: AppCallResponseTypes.OK, text: '', }, }); }); }); describe('WeakMap cache behavior', () => { it('should allow garbage collection of config objects', () => { mockConvertDialogToAppForm.mockReturnValue(mockAppForm); // Create config in limited scope let config = { ...mockConfig, dialog: {...mockConfig.dialog, title: 'Temporary Config'}, }; const result = InteractiveDialogAdapter.convertToAppForm(config); expect(result).toBe(mockAppForm); expect(mockConvertDialogToAppForm).toHaveBeenCalledTimes(1); // Remove reference to config (in real scenario, this would allow GC) config = null as any; // Create new config with same structure but different object reference const newConfig = { ...mockConfig, dialog: {...mockConfig.dialog, title: 'Temporary Config'}, }; InteractiveDialogAdapter.convertToAppForm(newConfig); // Should call conversion again since old config object was dereferenced expect(mockConvertDialogToAppForm).toHaveBeenCalledTimes(2); }); }); describe('convertValuesToSubmission flattens collapsible elements', () => { // These tests exercise the adapter's flattening contract. flattenDialogElements // (from ./dialog_utils) is NOT mocked, so it runs for real; we drive the real // value->submission conversion to prove nested leaves survive end-to-end. const realConvert: typeof convertAppFormValuesToDialogSubmission = jest.requireActual('./dialog_conversion').convertAppFormValuesToDialogSubmission; beforeEach(() => { mockConvertAppFormValuesToDialogSubmission.mockImplementation(realConvert); }); const collapsibleConfig: InteractiveDialogConfig = { app_id: 'app', url: 'https://test.com/dialog', trigger_id: 'trigger', dialog: { callback_id: 'cb', title: 'Nested', state: 'st', notify_on_cancel: false, elements: [ {name: 'name', type: 'text', display_name: 'Name', optional: false} as DialogElement, { name: 'contact_section', type: 'collapsible', display_name: 'Contact', collapsible_config: { elements: [ {name: 'email', type: 'text', display_name: 'Email', optional: true} as DialogElement, {name: 'priority', type: 'select', display_name: 'Priority', optional: true, options: [{text: 'High', value: 'high'}]} as DialogElement, {name: 'notify', type: 'bool', display_name: 'Notify', optional: true} as DialogElement, ], }, } as DialogElement, ], }, } as InteractiveDialogConfig; it('passes the flattened leaf elements (not the collapsible container) to the value converter', () => { InteractiveDialogAdapter.convertValuesToSubmission({name: 'Jane'}, collapsibleConfig); const passedElements: DialogElement[] = mockConvertAppFormValuesToDialogSubmission.mock.calls[0][1]; expect(passedElements.map((e) => e.name)).toEqual(['name', 'email', 'priority', 'notify']); expect(passedElements.some((e) => e.type === 'collapsible')).toBe(false); }); it('carries values from nested leaves into the submission payload, converting select/bool correctly', () => { const result = InteractiveDialogAdapter.convertValuesToSubmission({ name: 'Jane', email: 'jane@example.com', priority: {label: 'High', value: 'high'}, notify: true, }, collapsibleConfig); expect(result.submission).toEqual({ name: 'Jane', email: 'jane@example.com', priority: 'high', notify: true, }); expect(result.callback_id).toBe('cb'); }); it('carries deeply nested (depth-2) leaf values into the submission payload', () => { const deepConfig: InteractiveDialogConfig = { ...collapsibleConfig, dialog: { ...collapsibleConfig.dialog, elements: [ { name: 'outer', type: 'collapsible', display_name: 'Outer', collapsible_config: { elements: [ { name: 'inner', type: 'collapsible', display_name: 'Inner', collapsible_config: { elements: [{name: 'deep', type: 'text', display_name: 'Deep', optional: true} as DialogElement], }, } as DialogElement, ], }, } as DialogElement, ], }, } as InteractiveDialogConfig; const result = InteractiveDialogAdapter.convertValuesToSubmission({deep: 'value'}, deepConfig); expect(result.submission).toEqual({deep: 'value'}); }); it('behaves exactly as before for an ordinary (collapsible-free) dialog', () => { const flatConfig: InteractiveDialogConfig = { ...collapsibleConfig, dialog: { ...collapsibleConfig.dialog, elements: [ {name: 'a', type: 'text', display_name: 'A', optional: true} as DialogElement, {name: 'b', type: 'bool', display_name: 'B', optional: true} as DialogElement, ], }, } as InteractiveDialogConfig; const result = InteractiveDialogAdapter.convertValuesToSubmission({a: 'x', b: false}, flatConfig); expect(result.submission).toEqual({a: 'x', b: false}); }); }); });