diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/tests | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mail/static/tests')
| -rw-r--r-- | addons/mail/static/tests/activity_tests.js | 562 | ||||
| -rw-r--r-- | addons/mail/static/tests/chatter_tests.js | 568 | ||||
| -rw-r--r-- | addons/mail/static/tests/document_viewer_tests.js | 232 | ||||
| -rw-r--r-- | addons/mail/static/tests/helpers/mock_models.js | 258 | ||||
| -rw-r--r-- | addons/mail/static/tests/helpers/mock_server.js | 1809 | ||||
| -rw-r--r-- | addons/mail/static/tests/mail_utils_tests.js | 111 | ||||
| -rw-r--r-- | addons/mail/static/tests/many2one_avatar_user_tests.js | 123 | ||||
| -rw-r--r-- | addons/mail/static/tests/systray/systray_activity_menu_tests.js | 276 | ||||
| -rw-r--r-- | addons/mail/static/tests/tools/debug_manager_tests.js | 64 | ||||
| -rw-r--r-- | addons/mail/static/tests/tours/mail_full_composer_test_tour.js | 89 |
10 files changed, 4092 insertions, 0 deletions
diff --git a/addons/mail/static/tests/activity_tests.js b/addons/mail/static/tests/activity_tests.js new file mode 100644 index 00000000..384815c2 --- /dev/null +++ b/addons/mail/static/tests/activity_tests.js @@ -0,0 +1,562 @@ +odoo.define('mail.activity_view_tests', function (require) { +'use strict'; + +var ActivityView = require('mail.ActivityView'); +var testUtils = require('web.test_utils'); +const ActivityRenderer = require('mail.ActivityRenderer'); +const domUtils = require('web.dom'); + +var createActionManager = testUtils.createActionManager; + +var createView = testUtils.createView; + +QUnit.module('mail', {}, function () { +QUnit.module('activity view', { + beforeEach: function () { + this.data = { + task: { + fields: { + id: {string: 'ID', type: 'integer'}, + foo: {string: "Foo", type: "char"}, + activity_ids: { + string: 'Activities', + type: 'one2many', + relation: 'mail.activity', + relation_field: 'res_id', + }, + }, + records: [ + {id: 13, foo: 'Meeting Room Furnitures', activity_ids: [1]}, + {id: 30, foo: 'Office planning', activity_ids: [2, 3]}, + ], + }, + partner: { + fields: { + display_name: { string: "Displayed name", type: "char" }, + }, + records: [{ + id: 2, + display_name: "first partner", + }] + }, + 'mail.activity': { + fields: { + res_id: { string: 'Related document id', type: 'integer' }, + activity_type_id: { string: "Activity type", type: "many2one", relation: "mail.activity.type" }, + display_name: { string: "Display name", type: "char" }, + date_deadline: { string: "Due Date", type: "date" }, + can_write: { string: "Can write", type: "boolean" }, + state: { + string: 'State', + type: 'selection', + selection: [['overdue', 'Overdue'], ['today', 'Today'], ['planned', 'Planned']], + }, + mail_template_ids: { string: "Mail templates", type: "many2many", relation: "mail.template" }, + user_id: { string: "Assigned to", type: "many2one", relation: 'partner' }, + }, + records:[ + { + id: 1, + res_id: 13, + display_name: "An activity", + date_deadline: moment().add(3, "days").format("YYYY-MM-DD"), // now + can_write: true, + + state: "planned", + activity_type_id: 1, + mail_template_ids: [8, 9], + user_id:2, + },{ + id: 2, + res_id: 30, + display_name: "An activity", + date_deadline: moment().format("YYYY-MM-DD"), // now + can_write: true, + state: "today", + activity_type_id: 1, + mail_template_ids: [8, 9], + user_id:2, + },{ + id: 3, + res_id: 30, + display_name: "An activity", + date_deadline: moment().subtract(2, "days").format("YYYY-MM-DD"), // now + can_write: true, + state: "overdue", + activity_type_id: 2, + mail_template_ids: [], + user_id:2, + } + ], + }, + 'mail.template': { + fields: { + name: { string: "Name", type: "char" }, + }, + records: [ + { id: 8, name: "Template1" }, + { id: 9, name: "Template2" }, + ], + }, + 'mail.activity.type': { + fields: { + mail_template_ids: { string: "Mail templates", type: "many2many", relation: "mail.template" }, + name: { string: "Name", type: "char" }, + }, + records: [ + { id: 1, name: "Email", mail_template_ids: [8, 9]}, + { id: 2, name: "Call" }, + { id: 3, name: "Call for Demo" }, + { id: 4, name: "To Do" }, + ], + }, + }; + } +}); + +var activityDateFormat = function (date) { + return date.toLocaleDateString(moment().locale(), { day: 'numeric', month: 'short' }); +}; + +QUnit.test('activity view: simple activity rendering', async function (assert) { + assert.expect(14); + var activity = await createView({ + View: ActivityView, + model: 'task', + data: this.data, + arch: '<activity string="Task">' + + '<templates>' + + '<div t-name="activity-box">' + + '<field name="foo"/>' + + '</div>' + + '</templates>' + + '</activity>', + intercepts: { + do_action: function (event) { + assert.deepEqual(event.data.action, { + context: { + default_res_id: 30, + default_res_model: "task", + default_activity_type_id: 3, + }, + res_id: false, + res_model: "mail.activity", + target: "new", + type: "ir.actions.act_window", + view_mode: "form", + view_type: "form", + views: [[false, "form"]] + }, + "should do a do_action with correct parameters"); + event.data.options.on_close(); + }, + }, + }); + + assert.containsOnce(activity, 'table', + 'should have a table'); + var $th1 = activity.$('table thead tr:first th:nth-child(2)'); + assert.containsOnce($th1, 'span:first:contains(Email)', 'should contain "Email" in header of first column'); + assert.containsOnce($th1, '.o_kanban_counter', 'should contain a progressbar in header of first column'); + assert.hasAttrValue($th1.find('.o_kanban_counter_progress .progress-bar:first'), 'data-original-title', '1 Planned', + 'the counter progressbars should be correctly displayed'); + assert.hasAttrValue($th1.find('.o_kanban_counter_progress .progress-bar:nth-child(2)'), 'data-original-title', '1 Today', + 'the counter progressbars should be correctly displayed'); + var $th2 = activity.$('table thead tr:first th:nth-child(3)'); + assert.containsOnce($th2, 'span:first:contains(Call)', 'should contain "Call" in header of second column'); + assert.hasAttrValue($th2.find('.o_kanban_counter_progress .progress-bar:nth-child(3)'), 'data-original-title', '1 Overdue', + 'the counter progressbars should be correctly displayed'); + assert.containsNone(activity, 'table thead tr:first th:nth-child(4) .o_kanban_counter', + 'should not contain a progressbar in header of 3rd column'); + assert.ok(activity.$('table tbody tr:first td:first:contains(Office planning)').length, + 'should contain "Office planning" in first colum of first row'); + assert.ok(activity.$('table tbody tr:nth-child(2) td:first:contains(Meeting Room Furnitures)').length, + 'should contain "Meeting Room Furnitures" in first colum of second row'); + + var today = activityDateFormat(new Date()); + + assert.ok(activity.$('table tbody tr:first td:nth-child(2).today .o_closest_deadline:contains(' + today + ')').length, + 'should contain an activity for today in second cell of first line ' + today); + var td = 'table tbody tr:nth-child(1) td.o_activity_empty_cell'; + assert.containsN(activity, td, 2, 'should contain an empty cell as no activity scheduled yet.'); + + // schedule an activity (this triggers a do_action) + await testUtils.fields.editAndTrigger(activity.$(td + ':first'), null, ['mouseenter', 'click']); + assert.containsOnce(activity, 'table tfoot tr .o_record_selector', + 'should contain search more selector to choose the record to schedule an activity for it'); + + activity.destroy(); +}); + +QUnit.test('activity view: no content rendering', async function (assert) { + assert.expect(2); + + // reset incompatible setup + this.data['mail.activity'].records = []; + this.data.task.records.forEach(function (task) { + task.activity_ids = false; + }); + this.data['mail.activity.type'].records = []; + + var activity = await createView({ + View: ActivityView, + model: 'task', + data: this.data, + arch: '<activity string="Task">' + + '<templates>' + + '<div t-name="activity-box">' + + '<field name="foo"/>' + + '</div>' + + '</templates>' + + '</activity>', + }); + + assert.containsOnce(activity, '.o_view_nocontent', + "should display the no content helper"); + assert.strictEqual(activity.$('.o_view_nocontent .o_view_nocontent_empty_folder').text().trim(), + "No data to display", + "should display the no content helper text"); + + activity.destroy(); +}); + +QUnit.test('activity view: batch send mail on activity', async function (assert) { + assert.expect(6); + var activity = await createView({ + View: ActivityView, + model: 'task', + data: this.data, + arch: '<activity string="Task">' + + '<templates>' + + '<div t-name="activity-box">' + + '<field name="foo"/>' + + '</div>' + + '</templates>' + + '</activity>', + mockRPC: function(route, args) { + if (args.method === 'activity_send_mail'){ + assert.step(JSON.stringify(args.args)); + return Promise.resolve(); + } + return this._super.apply(this, arguments); + }, + }); + assert.notOk(activity.$('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show').length, + 'dropdown shouldn\'t be displayed'); + + testUtils.dom.click(activity.$('table thead tr:first th:nth-child(2) span:nth-child(2) i.fa-ellipsis-v')); + assert.ok(activity.$('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show').length, + 'dropdown should have appeared'); + + testUtils.dom.click(activity.$('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show .o_send_mail_template:contains(Template2)')); + assert.notOk(activity.$('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show').length, + 'dropdown shouldn\'t be displayed'); + + testUtils.dom.click(activity.$('table thead tr:first th:nth-child(2) span:nth-child(2) i.fa-ellipsis-v')); + testUtils.dom.click(activity.$('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show .o_send_mail_template:contains(Template1)')); + assert.verifySteps([ + '[[13,30],9]', // send mail template 9 on tasks 13 and 30 + '[[13,30],8]', // send mail template 8 on tasks 13 and 30 + ]); + + activity.destroy(); +}); + +QUnit.test('activity view: activity widget', async function (assert) { + assert.expect(16); + + const params = { + View: ActivityView, + model: 'task', + data: this.data, + arch: '<activity string="Task">' + + '<templates>' + + '<div t-name="activity-box">' + + '<field name="foo"/>' + + '</div>' + + '</templates>'+ + '</activity>', + mockRPC: function(route, args) { + if (args.method === 'activity_send_mail'){ + assert.deepEqual([[30],8],args.args, "Should send template 8 on record 30"); + assert.step('activity_send_mail'); + return Promise.resolve(); + } + if (args.method === 'action_feedback_schedule_next'){ + assert.deepEqual([[3]],args.args, "Should execute action_feedback_schedule_next on activity 3 only "); + assert.equal(args.kwargs.feedback, "feedback2"); + assert.step('action_feedback_schedule_next'); + return Promise.resolve({serverGeneratedAction: true}); + } + return this._super.apply(this, arguments); + }, + intercepts: { + do_action: function (ev) { + var action = ev.data.action; + if (action.serverGeneratedAction) { + assert.step('serverGeneratedAction'); + } else if (action.res_model === 'mail.compose.message') { + assert.deepEqual({ + default_model: "task", + default_res_id: 30, + default_template_id: 8, + default_use_template: true, + force_email: true + }, action.context); + assert.step("do_action_compose"); + } else if (action.res_model === 'mail.activity') { + assert.deepEqual({ + "default_res_id": 30, + "default_res_model": "task" + }, action.context); + assert.step("do_action_activity"); + } else { + assert.step("Unexpected action"); + } + }, + }, + }; + + var activity = await createView(params); + var today = activity.$('table tbody tr:first td:nth-child(2).today'); + var dropdown = today.find('.dropdown-menu.o_activity'); + + await testUtils.dom.click(today.find('.o_closest_deadline')); + assert.hasClass(dropdown,'show', "dropdown should be displayed"); + assert.ok(dropdown.find('.o_activity_color_today:contains(Today)').length, "Title should be today"); + assert.ok(dropdown.find('.o_activity_title_entry[data-activity-id="2"]:first div:contains(template8)').length, + "template8 should be available"); + assert.ok(dropdown.find('.o_activity_title_entry[data-activity-id="2"]:eq(1) div:contains(template9)').length, + "template9 should be available"); + + await testUtils.dom.click(dropdown.find('.o_activity_title_entry[data-activity-id="2"]:first .o_activity_template_preview')); + await testUtils.dom.click(dropdown.find('.o_activity_title_entry[data-activity-id="2"]:first .o_activity_template_send')); + var overdue = activity.$('table tbody tr:first td:nth-child(3).overdue'); + await testUtils.dom.click(overdue.find('.o_closest_deadline')); + dropdown = overdue.find('.dropdown-menu.o_activity'); + assert.notOk(dropdown.find('.o_activity_title div div div:first span').length, + "No template should be available"); + + await testUtils.dom.click(dropdown.find('.o_schedule_activity')); + await testUtils.dom.click(overdue.find('.o_closest_deadline')); + await testUtils.dom.click(dropdown.find('.o_mark_as_done')); + dropdown.find('#activity_feedback').val("feedback2"); + + await testUtils.dom.click(dropdown.find('.o_activity_popover_done_next')); + assert.verifySteps([ + "do_action_compose", + "activity_send_mail", + "do_action_activity", + "action_feedback_schedule_next", + "serverGeneratedAction" + ]); + + activity.destroy(); +}); +QUnit.test('activity view: no group_by_menu and no comparison_menu', async function (assert) { + assert.expect(4); + + var actionManager = await createActionManager({ + actions: [{ + id: 1, + name: 'Task Action', + res_model: 'task', + type: 'ir.actions.act_window', + views: [[false, 'activity']], + }], + archs: { + 'task,false,activity': '<activity string="Task">' + + '<templates>' + + '<div t-name="activity-box">' + + '<field name="foo"/>' + + '</div>' + + '</templates>' + + '</activity>', + 'task,false,search': '<search></search>', + }, + data: this.data, + session: { + user_context: {lang: 'zz_ZZ'}, + }, + mockRPC: function(route, args) { + if (args.method === 'get_activity_data') { + assert.deepEqual(args.kwargs.context, {lang: 'zz_ZZ'}, + 'The context should have been passed'); + } + return this._super.apply(this, arguments); + }, + }); + + await actionManager.doAction(1); + + assert.containsN(actionManager, '.o_search_options .o_dropdown button:visible', 2, + "only two elements should be available in view search"); + assert.isVisible(actionManager.$('.o_search_options .o_dropdown.o_filter_menu > button'), + "filter should be available in view search"); + assert.isVisible(actionManager.$('.o_search_options .o_dropdown.o_favorite_menu > button'), + "favorites should be available in view search"); + actionManager.destroy(); +}); + +QUnit.test('activity view: search more to schedule an activity for a record of a respecting model', async function (assert) { + assert.expect(5); + _.extend(this.data.task.fields, { + name: { string: "Name", type: "char" }, + }); + this.data.task.records[2] = { id: 31, name: "Task 3" }; + var activity = await createView({ + View: ActivityView, + model: 'task', + data: this.data, + arch: '<activity string="Task">' + + '<templates>' + + '<div t-name="activity-box">' + + '<field name="foo"/>' + + '</div>' + + '</templates>' + + '</activity>', + archs: { + "task,false,list": '<tree string="Task"><field name="name"/></tree>', + "task,false,search": '<search></search>', + }, + mockRPC: function(route, args) { + if (args.method === 'name_search') { + args.kwargs.name = "Task"; + } + return this._super.apply(this, arguments); + }, + intercepts: { + do_action: function (ev) { + assert.step('doAction'); + var expectedAction = { + context: { + default_res_id: { id: 31, display_name: undefined }, + default_res_model: "task", + }, + name: "Schedule Activity", + res_id: false, + res_model: "mail.activity", + target: "new", + type: "ir.actions.act_window", + view_mode: "form", + views: [[false, "form"]], + }; + assert.deepEqual(ev.data.action, expectedAction, + "should execute an action with correct params"); + ev.data.options.on_close(); + }, + }, + }); + + assert.containsOnce(activity, 'table tfoot tr .o_record_selector', + 'should contain search more selector to choose the record to schedule an activity for it'); + await testUtils.dom.click(activity.$('table tfoot tr .o_record_selector')); + // search create dialog + var $modal = $('.modal-lg'); + assert.strictEqual($modal.find('.o_data_row').length, 3, "all tasks should be available to select"); + // select a record to schedule an activity for it (this triggers a do_action) + testUtils.dom.click($modal.find('.o_data_row:last')); + assert.verifySteps(['doAction']); + + activity.destroy(); +}); + +QUnit.test('Activity view: discard an activity creation dialog', async function (assert) { + assert.expect(2); + + var actionManager = await createActionManager({ + actions: [{ + id: 1, + name: 'Task Action', + res_model: 'task', + type: 'ir.actions.act_window', + views: [[false, 'activity']], + }], + archs: { + 'task,false,activity': ` + <activity string="Task"> + <templates> + <div t-name="activity-box"> + <field name="foo"/> + </div> + </templates> + </activity>`, + 'task,false,search': '<search></search>', + 'mail.activity,false,form': ` + <form> + <field name="display_name"/> + <footer> + <button string="Discard" class="btn-secondary" special="cancel"/> + </footer> + </form>` + }, + data: this.data, + intercepts: { + do_action(ev) { + actionManager.doAction(ev.data.action, ev.data.options); + } + }, + async mockRPC(route, args) { + if (args.method === 'check_access_rights') { + return true; + } + return this._super(...arguments); + }, + }); + await actionManager.doAction(1); + + await testUtils.dom.click(actionManager.$('.o_activity_view .o_data_row .o_activity_empty_cell')[0]); + assert.containsOnce( + $, + '.modal.o_technical_modal.show', + "Activity Modal should be opened"); + + await testUtils.dom.click($('.modal.o_technical_modal.show button[special="cancel"]')); + assert.containsNone( + $, + '.modal.o_technical_modal.show', + "Activity Modal should be closed"); + + actionManager.destroy(); +}); + +QUnit.test("Activity view: on_destroy_callback doesn't crash", async function (assert) { + assert.expect(3); + + const params = { + View: ActivityView, + model: 'task', + data: this.data, + arch: '<activity string="Task">' + + '<templates>' + + '<div t-name="activity-box">' + + '<field name="foo"/>' + + '</div>' + + '</templates>'+ + '</activity>', + }; + + ActivityRenderer.patch('test_mounted_unmounted', T => + class extends T { + mounted() { + assert.step('mounted'); + } + willUnmount() { + assert.step('willUnmount'); + } + }); + + const activity = await createView(params); + domUtils.detach([{widget: activity}]); + + assert.verifySteps([ + 'mounted', + 'willUnmount' + ]); + + ActivityRenderer.unpatch('test_mounted_unmounted'); + activity.destroy(); +}); + +}); +}); diff --git a/addons/mail/static/tests/chatter_tests.js b/addons/mail/static/tests/chatter_tests.js new file mode 100644 index 00000000..006b9ff5 --- /dev/null +++ b/addons/mail/static/tests/chatter_tests.js @@ -0,0 +1,568 @@ +odoo.define('mail.chatter_tests', function (require) { +"use strict"; + +const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js'); + +var FormView = require('web.FormView'); +var ListView = require('web.ListView'); +var testUtils = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('Chatter', { + beforeEach: function () { + beforeEach(this); + + this.data['res.partner'].records.push({ id: 11, im_status: 'online' }); + this.data['mail.activity.type'].records.push( + { id: 1, name: "Type 1" }, + { id: 2, name: "Type 2" }, + { id: 3, name: "Type 3", category: 'upload_file' }, + { id: 4, name: "Exception", decoration_type: "warning", icon: "fa-warning" } + ); + this.data['ir.attachment'].records.push( + { + id: 1, + mimetype: 'image/png', + name: 'filename.jpg', + res_id: 7, + res_model: 'res.users', + type: 'url', + }, + { + id: 2, + mimetype: "application/x-msdos-program", + name: "file2.txt", + res_id: 7, + res_model: 'res.users', + type: 'binary', + }, + { + id: 3, + mimetype: "application/x-msdos-program", + name: "file3.txt", + res_id: 5, + res_model: 'res.users', + type: 'binary', + }, + ); + Object.assign(this.data['res.users'].fields, { + activity_exception_decoration: { + string: 'Decoration', + type: 'selection', + selection: [['warning', 'Alert'], ['danger', 'Error']], + }, + activity_exception_icon: { + string: 'icon', + type: 'char', + }, + activity_ids: { + string: 'Activities', + type: 'one2many', + relation: 'mail.activity', + relation_field: 'res_id', + }, + activity_state: { + string: 'State', + type: 'selection', + selection: [['overdue', 'Overdue'], ['today', 'Today'], ['planned', 'Planned']], + }, + activity_summary: { + string: "Next Activity Summary", + type: 'char', + }, + activity_type_icon: { + string: "Activity Type Icon", + type: 'char', + }, + activity_type_id: { + string: "Activity type", + type: "many2one", + relation: "mail.activity.type", + }, + foo: { string: "Foo", type: "char", default: "My little Foo Value" }, + message_attachment_count: { + string: 'Attachment count', + type: 'integer', + }, + message_follower_ids: { + string: "Followers", + type: "one2many", + relation: 'mail.followers', + relation_field: "res_id", + }, + message_ids: { + string: "messages", + type: "one2many", + relation: 'mail.message', + relation_field: "res_id", + }, + }); + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('list activity widget with no activity', async function (assert) { + assert.expect(5); + + const { widget: list } = await start({ + hasView: true, + View: ListView, + model: 'res.users', + data: this.data, + arch: '<list><field name="activity_ids" widget="list_activity"/></list>', + mockRPC: function (route) { + assert.step(route); + return this._super(...arguments); + }, + session: { uid: 2 }, + }); + + assert.containsOnce(list, '.o_mail_activity .o_activity_color_default'); + assert.strictEqual(list.$('.o_activity_summary').text(), ''); + + assert.verifySteps([ + '/web/dataset/search_read', + '/mail/init_messaging', + ]); + + list.destroy(); +}); + +QUnit.test('list activity widget with activities', async function (assert) { + assert.expect(7); + + const currentUser = this.data['res.users'].records.find(user => + user.id === this.data.currentUserId + ); + Object.assign(currentUser, { + activity_ids: [1, 4], + activity_state: 'today', + activity_summary: 'Call with Al', + activity_type_id: 3, + activity_type_icon: 'fa-phone', + }); + + this.data['res.users'].records.push({ + id: 44, + activity_ids: [2], + activity_state: 'planned', + activity_summary: false, + activity_type_id: 2, + }); + + const { widget: list } = await start({ + hasView: true, + View: ListView, + model: 'res.users', + data: this.data, + arch: '<list><field name="activity_ids" widget="list_activity"/></list>', + mockRPC: function (route) { + assert.step(route); + return this._super(...arguments); + }, + }); + + const $firstRow = list.$('.o_data_row:first'); + assert.containsOnce($firstRow, '.o_mail_activity .o_activity_color_today.fa-phone'); + assert.strictEqual($firstRow.find('.o_activity_summary').text(), 'Call with Al'); + + const $secondRow = list.$('.o_data_row:nth(1)'); + assert.containsOnce($secondRow, '.o_mail_activity .o_activity_color_planned.fa-clock-o'); + assert.strictEqual($secondRow.find('.o_activity_summary').text(), 'Type 2'); + + assert.verifySteps([ + '/web/dataset/search_read', + '/mail/init_messaging', + ]); + + list.destroy(); +}); + +QUnit.test('list activity widget with exception', async function (assert) { + assert.expect(5); + + const currentUser = this.data['res.users'].records.find(user => + user.id === this.data.currentUserId + ); + Object.assign(currentUser, { + activity_ids: [1], + activity_state: 'today', + activity_summary: 'Call with Al', + activity_type_id: 3, + activity_exception_decoration: 'warning', + activity_exception_icon: 'fa-warning', + }); + + const { widget: list } = await start({ + hasView: true, + View: ListView, + model: 'res.users', + data: this.data, + arch: '<list><field name="activity_ids" widget="list_activity"/></list>', + mockRPC: function (route) { + assert.step(route); + return this._super(...arguments); + }, + }); + + assert.containsOnce(list, '.o_activity_color_today.text-warning.fa-warning'); + assert.strictEqual(list.$('.o_activity_summary').text(), 'Warning'); + + assert.verifySteps([ + '/web/dataset/search_read', + '/mail/init_messaging', + ]); + + list.destroy(); +}); + +QUnit.test('list activity widget: open dropdown', async function (assert) { + assert.expect(10); + + const currentUser = this.data['res.users'].records.find(user => + user.id === this.data.currentUserId + ); + Object.assign(currentUser, { + activity_ids: [1, 4], + activity_state: 'today', + activity_summary: 'Call with Al', + activity_type_id: 3, + }); + this.data['mail.activity'].records.push( + { + id: 1, + display_name: "Call with Al", + date_deadline: moment().format("YYYY-MM-DD"), // now + can_write: true, + state: "today", + user_id: this.data.currentUserId, + create_uid: this.data.currentUserId, + activity_type_id: 3, + }, + { + id: 4, + display_name: "Meet FP", + date_deadline: moment().add(1, 'day').format("YYYY-MM-DD"), // tomorrow + can_write: true, + state: "planned", + user_id: this.data.currentUserId, + create_uid: this.data.currentUserId, + activity_type_id: 1, + } + ); + + const { env, widget: list } = await start({ + hasView: true, + View: ListView, + model: 'res.users', + data: this.data, + arch: ` + <list> + <field name="foo"/> + <field name="activity_ids" widget="list_activity"/> + </list>`, + mockRPC: function (route, args) { + assert.step(args.method || route); + if (args.method === 'action_feedback') { + const currentUser = this.data['res.users'].records.find(user => + user.id === env.messaging.currentUser.id + ); + Object.assign(currentUser, { + activity_ids: [4], + activity_state: 'planned', + activity_summary: 'Meet FP', + activity_type_id: 1, + }); + return Promise.resolve(); + } + return this._super(route, args); + }, + intercepts: { + switch_view: () => assert.step('switch_view'), + }, + }); + + assert.strictEqual(list.$('.o_activity_summary').text(), 'Call with Al'); + + // click on the first record to open it, to ensure that the 'switch_view' + // assertion is relevant (it won't be opened as there is no action manager, + // but we'll log the 'switch_view' event) + await testUtils.dom.click(list.$('.o_data_cell:first')); + + // from this point, no 'switch_view' event should be triggered, as we + // interact with the activity widget + assert.step('open dropdown'); + await testUtils.dom.click(list.$('.o_activity_btn span')); // open the popover + await testUtils.dom.click(list.$('.o_mark_as_done:first')); // mark the first activity as done + await testUtils.dom.click(list.$('.o_activity_popover_done')); // confirm + + assert.strictEqual(list.$('.o_activity_summary').text(), 'Meet FP'); + + assert.verifySteps([ + '/web/dataset/search_read', + '/mail/init_messaging', + 'switch_view', + 'open dropdown', + 'activity_format', + 'action_feedback', + 'read', + ]); + + list.destroy(); +}); + +QUnit.test('list activity exception widget with activity', async function (assert) { + assert.expect(3); + + const currentUser = this.data['res.users'].records.find(user => + user.id === this.data.currentUserId + ); + currentUser.activity_ids = [1]; + this.data['res.users'].records.push({ + id: 13, + message_attachment_count: 3, + display_name: "second partner", + foo: "Tommy", + message_follower_ids: [], + message_ids: [], + activity_ids: [2], + activity_exception_decoration: 'warning', + activity_exception_icon: 'fa-warning', + }); + this.data['mail.activity'].records.push( + { + id: 1, + display_name: "An activity", + date_deadline: moment().format("YYYY-MM-DD"), // now + can_write: true, + state: "today", + user_id: 2, + create_uid: 2, + activity_type_id: 1, + }, + { + id: 2, + display_name: "An exception activity", + date_deadline: moment().format("YYYY-MM-DD"), // now + can_write: true, + state: "today", + user_id: 2, + create_uid: 2, + activity_type_id: 4, + } + ); + + const { widget: list } = await start({ + hasView: true, + View: ListView, + model: 'res.users', + data: this.data, + arch: '<tree>' + + '<field name="foo"/>' + + '<field name="activity_exception_decoration" widget="activity_exception"/> ' + + '</tree>', + }); + + assert.containsN(list, '.o_data_row', 2, "should have two records"); + assert.doesNotHaveClass(list.$('.o_data_row:eq(0) .o_activity_exception_cell div'), 'fa-warning', + "there is no any exception activity on record"); + assert.hasClass(list.$('.o_data_row:eq(1) .o_activity_exception_cell div'), 'fa-warning', + "there is an exception on a record"); + + list.destroy(); +}); + +QUnit.module('FieldMany2ManyTagsEmail', { + beforeEach() { + beforeEach(this); + + Object.assign(this.data['res.users'].fields, { + timmy: { string: "pokemon", type: "many2many", relation: 'partner_type' }, + }); + this.data['res.users'].records.push({ + id: 11, + display_name: "first record", + timmy: [], + }); + Object.assign(this.data, { + partner_type: { + fields: { + name: { string: "Partner Type", type: "char" }, + email: { string: "Email", type: "char" }, + }, + records: [], + }, + }); + this.data['partner_type'].records.push( + { id: 12, display_name: "gold", email: 'coucou@petite.perruche' }, + { id: 14, display_name: "silver", email: '' } + ); + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('fieldmany2many tags email', function (assert) { + assert.expect(13); + var done = assert.async(); + + const user11 = this.data['res.users'].records.find(user => user.id === 11); + user11.timmy = [12, 14]; + + // the modals need to be closed before the form view rendering + start({ + hasView: true, + View: FormView, + model: 'res.users', + data: this.data, + res_id: 11, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="display_name"/>' + + '<field name="timmy" widget="many2many_tags_email"/>' + + '</sheet>' + + '</form>', + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.method === 'read' && args.model === 'partner_type') { + assert.step(JSON.stringify(args.args[0])); + assert.deepEqual(args.args[1], ['display_name', 'email'], "should read the email"); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner_type,false,form': '<form string="Types"><field name="display_name"/><field name="email"/></form>', + }, + }).then(async function ({ widget: form }) { + // should read it 3 times (1 with the form view, one with the form dialog and one after save) + assert.verifySteps(['[12,14]', '[14]', '[14]']); + await testUtils.nextTick(); + assert.containsN(form, '.o_field_many2manytags[name="timmy"] .badge.o_tag_color_0', 2, + "two tags should be present"); + var firstTag = form.$('.o_field_many2manytags[name="timmy"] .badge.o_tag_color_0').first(); + assert.strictEqual(firstTag.find('.o_badge_text').text(), "gold", + "tag should only show display_name"); + assert.hasAttrValue(firstTag.find('.o_badge_text'), 'title', "coucou@petite.perruche", + "tag should show email address on mouse hover"); + form.destroy(); + done(); + }); + testUtils.nextTick().then(function () { + assert.strictEqual($('.modal-body.o_act_window').length, 1, + "there should be one modal opened to edit the empty email"); + assert.strictEqual($('.modal-body.o_act_window input[name="display_name"]').val(), "silver", + "the opened modal should be a form view dialog with the partner_type 14"); + assert.strictEqual($('.modal-body.o_act_window input[name="email"]').length, 1, + "there should be an email field in the modal"); + + // set the email and save the modal (will render the form view) + testUtils.fields.editInput($('.modal-body.o_act_window input[name="email"]'), 'coucou@petite.perruche'); + testUtils.dom.click($('.modal-footer .btn-primary')); + }); + +}); + +QUnit.test('fieldmany2many tags email (edition)', async function (assert) { + assert.expect(15); + + const user11 = this.data['res.users'].records.find(user => user.id === 11); + user11.timmy = [12]; + + var { widget: form } = await start({ + hasView: true, + View: FormView, + model: 'res.users', + data: this.data, + res_id: 11, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="display_name"/>' + + '<field name="timmy" widget="many2many_tags_email"/>' + + '</sheet>' + + '</form>', + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.method === 'read' && args.model === 'partner_type') { + assert.step(JSON.stringify(args.args[0])); + assert.deepEqual(args.args[1], ['display_name', 'email'], "should read the email"); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner_type,false,form': '<form string="Types"><field name="display_name"/><field name="email"/></form>', + }, + }); + + assert.verifySteps(['[12]']); + assert.containsOnce(form, '.o_field_many2manytags[name="timmy"] .badge.o_tag_color_0', + "should contain one tag"); + + // add an other existing tag + await testUtils.fields.many2one.clickOpenDropdown('timmy'); + await testUtils.fields.many2one.clickHighlightedItem('timmy'); + + assert.strictEqual($('.modal-body.o_act_window').length, 1, + "there should be one modal opened to edit the empty email"); + assert.strictEqual($('.modal-body.o_act_window input[name="display_name"]').val(), "silver", + "the opened modal in edit mode should be a form view dialog with the partner_type 14"); + assert.strictEqual($('.modal-body.o_act_window input[name="email"]').length, 1, + "there should be an email field in the modal"); + + // set the email and save the modal (will rerender the form view) + await testUtils.fields.editInput($('.modal-body.o_act_window input[name="email"]'), 'coucou@petite.perruche'); + await testUtils.dom.click($('.modal-footer .btn-primary')); + + assert.containsN(form, '.o_field_many2manytags[name="timmy"] .badge.o_tag_color_0', 2, + "should contain the second tag"); + // should have read [14] three times: when opening the dropdown, when opening the modal, and + // after the save + assert.verifySteps(['[14]', '[14]', '[14]']); + + form.destroy(); +}); + +QUnit.test('many2many_tags_email widget can load more than 40 records', async function (assert) { + assert.expect(3); + + const user11 = this.data['res.users'].records.find(user => user.id === 11); + this.data['res.users'].fields.partner_ids = { string: "Partner", type: "many2many", relation: 'res.users' }; + user11.partner_ids = []; + for (let i = 100; i < 200; i++) { + this.data['res.users'].records.push({ id: i, display_name: `partner${i}` }); + user11.partner_ids.push(i); + } + + const { widget: form } = await start({ + hasView: true, + View: FormView, + model: 'res.users', + data: this.data, + arch: '<form><field name="partner_ids" widget="many2many_tags"/></form>', + res_id: 11, + }); + + assert.strictEqual(form.$('.o_field_widget[name="partner_ids"] .badge').length, 100); + + await testUtils.form.clickEdit(form); + + assert.hasClass(form.$('.o_form_view'), 'o_form_editable'); + + // add a record to the relation + await testUtils.fields.many2one.clickOpenDropdown('partner_ids'); + await testUtils.fields.many2one.clickHighlightedItem('partner_ids'); + + assert.strictEqual(form.$('.o_field_widget[name="partner_ids"] .badge').length, 101); + + form.destroy(); +}); + +}); + +}); diff --git a/addons/mail/static/tests/document_viewer_tests.js b/addons/mail/static/tests/document_viewer_tests.js new file mode 100644 index 00000000..534e0fa4 --- /dev/null +++ b/addons/mail/static/tests/document_viewer_tests.js @@ -0,0 +1,232 @@ +odoo.define('mail.document_viewer_tests', function (require) { +"use strict"; + +var DocumentViewer = require('mail.DocumentViewer'); + +var testUtils = require('web.test_utils'); +var Widget = require('web.Widget'); + +/** + * @param {Object} params + * @param {Object[]} params.attachments + * @param {int} params.attachmentID + * @param {function} [params.mockRPC] + * @param {boolean} [params.debug] + * @returns {DocumentViewer} + */ +var createViewer = async function (params) { + var parent = new Widget(); + var viewer = new DocumentViewer(parent, params.attachments, params.attachmentID); + + var mockRPC = function (route) { + if (route === '/web/static/lib/pdfjs/web/viewer.html?file=/web/content/1?model%3Dir.attachment%26filename%3DfilePdf.pdf') { + return Promise.resolve(); + } + if (route === 'https://www.youtube.com/embed/FYqW0Gdwbzk') { + return Promise.resolve(); + } + if (route === '/web/content/4?model=ir.attachment') { + return Promise.resolve(); + } + if (route === '/web/image/6?unique=56789abc&model=ir.attachment') { + return Promise.resolve(); + } + }; + await testUtils.mock.addMockEnvironment(parent, { + mockRPC: function () { + if (params.mockRPC) { + var _super = this._super; + this._super = mockRPC; + var def = params.mockRPC.apply(this, arguments); + this._super = _super; + return def; + } else { + return mockRPC.apply(this, arguments); + } + }, + intercepts: params.intercepts || {}, + }); + var $target = $("#qunit-fixture"); + if (params.debug) { + $target = $('body'); + $target.addClass('debug'); + } + + // actually destroy the parent when the viewer is destroyed + viewer.destroy = function () { + delete viewer.destroy; + parent.destroy(); + }; + return viewer.appendTo($target).then(function() { + return viewer; + }); +}; + +QUnit.module('mail', {}, function () { +QUnit.module('document_viewer_tests.js', { + beforeEach: function () { + this.attachments = [ + {id: 1, name: 'filePdf.pdf', type: 'binary', mimetype: 'application/pdf', datas:'R0lGOP////ywAADs='}, + {id: 2, name: 'urlYoutube', type: 'url', mimetype: '', url: 'https://youtu.be/FYqW0Gdwbzk'}, + {id: 3, name: 'urlRandom', type: 'url', mimetype: '', url: 'https://www.google.com'}, + {id: 4, name: 'text.html', type: 'binary', mimetype: 'text/html', datas:'testee'}, + {id: 5, name: 'video.mp4', type: 'binary', mimetype: 'video/mp4', datas:'R0lDOP////ywAADs='}, + {id: 6, name: 'image.jpg', type: 'binary', mimetype: 'image/jpeg', checksum: '123456789abc', datas:'R0lVOP////ywAADs='}, + ]; + }, +}, function () { + + QUnit.test('basic rendering', async function (assert) { + assert.expect(7); + + var viewer = await createViewer({ + attachmentID: 1, + attachments: this.attachments, + }); + + assert.containsOnce(viewer, '.o_viewer_content', + "there should be a preview"); + assert.containsOnce(viewer, '.o_close_btn', + "there should be a close button"); + assert.containsOnce(viewer, '.o_viewer-header', + "there should be a header"); + assert.containsOnce(viewer, '.o_image_caption', + "there should be an image caption"); + assert.containsOnce(viewer, '.o_viewer_zoomer', + "there should be a zoomer"); + assert.containsOnce(viewer, '.fa-chevron-right', + "there should be a right nav icon"); + assert.containsOnce(viewer, '.fa-chevron-left', + "there should be a left nav icon"); + + viewer.destroy(); + }); + + QUnit.test('Document Viewer Youtube', async function (assert) { + assert.expect(3); + + var youtubeURL = 'https://www.youtube.com/embed/FYqW0Gdwbzk'; + var viewer = await createViewer({ + attachmentID: 2, + attachments: this.attachments, + mockRPC: function (route) { + if (route === youtubeURL) { + assert.ok(true, "should have called youtube URL"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(viewer.$(".o_image_caption:contains('urlYoutube')").length, 1, + "the viewer should be on the right attachment"); + assert.containsOnce(viewer, '.o_viewer_text[data-src="' + youtubeURL + '"]', + "there should be a video player"); + + viewer.destroy(); + }); + + QUnit.test('Document Viewer html/(txt)', async function (assert) { + assert.expect(2); + + var viewer = await createViewer({ + attachmentID: 4, + attachments: this.attachments, + }); + + assert.strictEqual(viewer.$(".o_image_caption:contains('text.html')").length, 1, + "the viewer be on the right attachment"); + assert.containsOnce(viewer, 'iframe[data-src="/web/content/4?model=ir.attachment"]', + "there should be an iframe with the right src"); + + viewer.destroy(); + }); + + QUnit.test('Document Viewer mp4', async function (assert) { + assert.expect(2); + + var viewer = await createViewer({ + attachmentID: 5, + attachments: this.attachments, + }); + + assert.strictEqual(viewer.$(".o_image_caption:contains('video.mp4')").length, 1, + "the viewer be on the right attachment"); + assert.containsOnce(viewer, '.o_viewer_video', + "there should be a video player"); + + viewer.destroy(); + }); + + QUnit.test('Document Viewer jpg', async function (assert) { + assert.expect(2); + + var viewer = await createViewer({ + attachmentID: 6, + attachments: this.attachments, + }); + + assert.strictEqual(viewer.$(".o_image_caption:contains('image.jpg')").length, 1, + "the viewer be on the right attachment"); + assert.containsOnce(viewer, 'img[data-src="/web/image/6?unique=56789abc&model=ir.attachment"]', + "there should be a video player"); + + viewer.destroy(); + }); + + QUnit.test('is closable by button', async function (assert) { + assert.expect(3); + + var viewer = await createViewer({ + attachmentID: 6, + attachments: this.attachments, + }); + + assert.containsOnce(viewer, '.o_viewer_content', + "should have a document viewer"); + assert.containsOnce(viewer, '.o_close_btn', + "should have a close button"); + + await testUtils.dom.click(viewer.$('.o_close_btn')); + + assert.ok(viewer.isDestroyed(), 'viewer should be destroyed'); + }); + + QUnit.test('is closable by clicking on the wrapper', async function (assert) { + assert.expect(3); + + var viewer = await createViewer({ + attachmentID: 6, + attachments: this.attachments, + }); + + assert.containsOnce(viewer, '.o_viewer_content', + "should have a document viewer"); + assert.containsOnce(viewer, '.o_viewer_img_wrapper', + "should have a wrapper"); + + await testUtils.dom.click(viewer.$('.o_viewer_img_wrapper')); + + assert.ok(viewer.isDestroyed(), 'viewer should be destroyed'); + }); + + QUnit.test('fileType and integrity test', async function (assert) { + assert.expect(3); + + var viewer = await createViewer({ + attachmentID: 2, + attachments: this.attachments, + }); + + assert.strictEqual(this.attachments[1].type, 'url', + "the type should be url"); + assert.strictEqual(this.attachments[1].fileType, 'youtu', + "there should be a fileType 'youtu'"); + assert.strictEqual(this.attachments[1].youtube, 'FYqW0Gdwbzk', + "there should be a youtube token"); + + viewer.destroy(); + }); +}); +}); + +}); diff --git a/addons/mail/static/tests/helpers/mock_models.js b/addons/mail/static/tests/helpers/mock_models.js new file mode 100644 index 00000000..873b5b0b --- /dev/null +++ b/addons/mail/static/tests/helpers/mock_models.js @@ -0,0 +1,258 @@ +odoo.define('mail/static/tests/helpers/mock_models.js', function (require) { +'use strict'; + +const patchMixin = require('web.patchMixin'); + +/** + * Allows to generate mocked models that will be used by the mocked server. + * This is defined as a class to allow patches by dependent modules and a new + * data object is generated every time to ensure any test can modify it without + * impacting other tests. + */ +class MockModels { + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns a new data set of mocked models. + * + * @static + * @returns {Object} + */ + static generateData() { + return { + 'ir.attachment': { + fields: { + create_date: { type: 'date' }, + create_uid: { string: "Created By", type: "many2one", relation: 'res.users' }, + datas: { string: "File Content (base64)", type: 'binary' }, + mimetype: { string: "mimetype", type: 'char' }, + name: { string: "attachment name", type: 'char', required: true }, + res_id: { string: "res id", type: 'integer' }, + res_model: { type: 'char', string: "res model" }, + type: { type: 'selection', selection: [['url', "URL"], ['binary', "BINARY"]] }, + url: { string: 'url', type: 'char' }, + }, + records: [], + }, + 'mail.activity': { + fields: { + activity_category: { string: "Category", type: 'selection', selection: [['default', 'Other'], ['upload_file', 'Upload File']] }, + activity_type_id: { string: "Activity type", type: "many2one", relation: "mail.activity.type" }, + can_write: { string: "Can write", type: "boolean" }, + create_uid: { string: "Created By", type: "many2one", relation: 'res.users' }, + display_name: { string: "Display name", type: "char" }, + date_deadline: { string: "Due Date", type: "date", default() { return moment().format('YYYY-MM-DD'); } }, + icon: { type: 'char' }, + note: { string: "Note", type: "html" }, + res_id: { type: 'integer' }, + res_model: { type: 'char' }, + state: { string: 'State', type: 'selection', selection: [['overdue', 'Overdue'], ['today', 'Today'], ['planned', 'Planned']] }, + user_id: { string: "Assigned to", type: "many2one", relation: 'res.users' }, + }, + records: [], + }, + 'mail.activity.type': { + fields: { + category: { string: 'Category', type: 'selection', selection: [['default', 'Other'], ['upload_file', 'Upload File']] }, + decoration_type: { string: "Decoration Type", type: "selection", selection: [['warning', 'Alert'], ['danger', 'Error']] }, + icon: { string: 'icon', type: "char" }, + name: { string: "Name", type: "char" }, + }, + records: [ + { icon: 'fa-envelope', id: 1, name: "Email" }, + ], + }, + 'mail.channel': { + fields: { + channel_type: { string: "Channel Type", type: "selection", default: 'channel' }, + // Equivalent to members but required due to some RPC giving this field in domain. + channel_partner_ids: { string: "Channel Partner Ids", type: 'many2many', relation: 'res.partner' }, + // In python this belongs to mail.channel.partner. Here for simplicity. + custom_channel_name: { string: "Custom channel name", type: 'char' }, + fetched_message_id: { string: "Last Fetched", type: 'many2one', relation: 'mail.message' }, + group_based_subscription: { string: "Group based subscription", type: "boolean", default: false }, + id: { string: "Id", type: 'integer' }, + // In python this belongs to mail.channel.partner. Here for simplicity. + is_minimized: { string: "isMinimized", type: "boolean", default: false }, + // In python it is moderator_ids. Here for simplicity. + is_moderator: { string: "Is current partner moderator?", type: "boolean", default: false }, + // In python this belongs to mail.channel.partner. Here for simplicity. + is_pinned: { string: "isPinned", type: "boolean", default: true }, + // In python: email_send. + mass_mailing: { string: "Send messages by email", type: "boolean", default: false }, + members: { string: "Members", type: 'many2many', relation: 'res.partner', default() { return [this.currentPartnerId]; } }, + message_unread_counter: { string: "# unread messages", type: 'integer' }, + moderation: { string: "Moderation", type: 'boolean', default: false }, + name: { string: "Name", type: "char", required: true }, + public: { string: "Public", type: "boolean", default: 'groups' }, + seen_message_id: { string: "Last Seen", type: 'many2one', relation: 'mail.message' }, + // In python this belongs to mail.channel.partner. Here for simplicity. + state: { string: "FoldState", type: "char", default: 'open' }, + // naive and non RFC-compliant UUID, good enough for the + // string comparison that are done with it during tests + uuid: { string: "UUID", type: "char", required: true, default() { return _.uniqueId('mail.channel_uuid-'); } }, + }, + records: [], + }, + // Fake model to simulate "hardcoded" commands from python + 'mail.channel_command': { + fields: { + channel_types: { type: 'binary' }, // array is expected + help: { type: 'char' }, + name: { type: 'char' }, + }, + records: [], + }, + 'mail.followers': { + fields: { + channel_id: { type: 'integer' }, + email: { type: 'char' }, + id: { type: 'integer' }, + is_active: { type: 'boolean' }, + is_editable: { type: 'boolean' }, + name: { type: 'char' }, + partner_id: { type: 'integer' }, + res_id: { type: 'many2one_reference' }, + res_model: { type: 'char' }, + subtype_ids: { type: 'many2many', relation: 'mail.message.subtype' } + }, + records: [], + }, + 'mail.message': { + fields: { + attachment_ids: { string: "Attachments", type: 'many2many', relation: 'ir.attachment', default: [] }, + author_id: { string: "Author", type: 'many2one', relation: 'res.partner', default() { return this.currentPartnerId; } }, + body: { string: "Contents", type: 'html', default: "<p></p>" }, + channel_ids: { string: "Channels", type: 'many2many', relation: 'mail.channel' }, + date: { string: "Date", type: 'datetime' }, + email_from: { string: "From", type: 'char' }, + history_partner_ids: { string: "Partners with History", type: 'many2many', relation: 'res.partner' }, + id: { string: "Id", type: 'integer' }, + is_discussion: { string: "Discussion", type: 'boolean' }, + is_note: { string: "Note", type: 'boolean' }, + is_notification: { string: "Notification", type: 'boolean' }, + message_type: { string: "Type", type: 'selection', default: 'email' }, + model: { string: "Related Document model", type: 'char' }, + needaction: { string: "Need Action", type: 'boolean' }, + needaction_partner_ids: { string: "Partners with Need Action", type: 'many2many', relation: 'res.partner' }, + moderation_status: { string: "Moderation status", type: 'selection', selection: [['pending_moderation', "Pending Moderation"], ['accepted', "Accepted"], ['rejected', "Rejected"]], default: false }, + notification_ids: { string: "Notifications", type: 'one2many', relation: 'mail.notification' }, + partner_ids: { string: "Recipients", type: 'many2many', relation: 'res.partner' }, + record_name: { string: "Name", type: 'char' }, + res_id: { string: "Related Document ID", type: 'integer' }, + // In python, result of a formatter. Here for simplicity. + res_model_name: { string: "Res Model Name", type: 'char' }, + starred_partner_ids: { string: "Favorited By", type: 'many2many', relation: 'res.partner' }, + subject: { string: "Subject", type: 'char' }, + subtype_id: { string: "Subtype id", type: 'many2one', relation: 'mail.message.subtype' }, + tracking_value_ids: { relation: 'mail.tracking.value', string: "Tracking values", type: 'one2many' }, + }, + records: [], + }, + 'mail.message.subtype': { + fields: { + default: { type: 'boolean', default: true }, + description: { type: 'text' }, + hidden: { type: 'boolean' }, + internal: { type: 'boolean' }, + name: { type: 'char' }, + parent_id: { type: 'many2one', relation: 'mail.message.subtype' }, + relation_field: { type: 'char' }, + res_model: { type: 'char' }, + sequence: { type: 'integer', default: 1 }, + // not a field in Python but xml id of data + subtype_xmlid: { type: 'char' }, + }, + records: [ + { name: "Discussions", sequence: 0, subtype_xmlid: 'mail.mt_comment' }, + { default: false, internal: true, name: "Note", sequence: 100, subtype_xmlid: 'mail.mt_note' }, + { default: false, internal: true, name: "Activities", sequence: 90, subtype_xmlid: 'mail.mt_activities' }, + ], + }, + 'mail.notification': { + fields: { + failure_type: { string: "Failure Type", type: 'selection', selection: [["SMTP", "Connection failed (outgoing mail server problem)"], ["RECIPIENT", "Invalid email address"], ["BOUNCE", "Email address rejected by destination"], ["UNKNOWN", "Unknown error"]] }, + is_read: { string: "Is Read", type: 'boolean', default: false }, + mail_message_id: { string: "Message", type: 'many2one', relation: 'mail.message' }, + notification_status: { string: "Notification Status", type: 'selection', selection: [['ready', 'Ready to Send'], ['sent', 'Sent'], ['bounce', 'Bounced'], ['exception', 'Exception'], ['canceled', 'Canceled']], default: 'ready' }, + notification_type: { string: "Notification Type", type: 'selection', selection: [['email', 'Handle by Emails'], ['inbox', 'Handle in Odoo']], default: 'email' }, + res_partner_id: { string: "Needaction Recipient", type: 'many2one', relation: 'res.partner' }, + }, + records: [], + }, + 'mail.shortcode': { + fields: { + source: { type: 'char' }, + substitution: { type: 'char' }, + }, + records: [], + }, + 'mail.tracking.value': { + fields: { + changed_field: { string: 'Changed field', type: 'char' }, + field_type: { string: 'Field type', type: 'char' }, + new_value: { string: 'New value', type: 'char' }, + old_value: { string: 'Old value', type: 'char' }, + }, + records: [], + }, + 'res.country': { + fields: { + code: { string: "Code", type: 'char' }, + name: { string: "Name", type: 'char' }, + }, + records: [], + }, + 'res.partner': { + fields: { + active: { string: "Active", type: 'boolean', default: true }, + activity_ids: { string: "Activities", type: 'one2many', relation: 'mail.activity' }, + contact_address_complete: { string: "Address", type: 'char' }, + country_id: { string: "Country", type: 'many2one', relation: 'res.country' }, + description: { string: 'description', type: 'text' }, + display_name: { string: "Displayed name", type: "char" }, + email: { type: 'char' }, + image_128: { string: "Image 128", type: 'image' }, + im_status: { string: "IM Status", type: 'char' }, + message_follower_ids: { relation: 'mail.followers', string: "Followers", type: "one2many" }, + message_attachment_count: { string: 'Attachment count', type: 'integer' }, + message_ids: { string: "Messages", type: 'one2many', relation: 'mail.message' }, + name: { string: "Name", type: 'char' }, + partner_latitude: { string: "Latitude", type: 'float' }, + partner_longitude: { string: "Longitude", type: 'float' }, + }, + records: [], + }, + 'res.users': { + fields: { + active: { string: "Active", type: 'boolean', default: true }, + display_name: { string: "Display name", type: "char" }, + im_status: { string: "IM Status", type: 'char' }, + name: { string: "Name", type: 'char' }, + partner_id: { string: "Related partners", type: 'many2one', relation: 'res.partner' }, + }, + records: [], + }, + 'res.fake': { + fields: { + activity_ids: { string: "Activities", type: 'one2many', relation: 'mail.activity' }, + email_cc: { type: 'char' }, + partner_ids: { + string: "Related partners", + type: 'many2one', + relation: 'res.partner' + }, + }, + records: [], + }, + }; + } + +} + +return patchMixin(MockModels); + +}); diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js new file mode 100644 index 00000000..3574a57c --- /dev/null +++ b/addons/mail/static/tests/helpers/mock_server.js @@ -0,0 +1,1809 @@ +odoo.define('mail.MockServer', function (require) { +"use strict"; + +const { nextAnimationFrame } = require('mail/static/src/utils/test_utils.js'); + +const MockServer = require('web.MockServer'); + +MockServer.include({ + /** + * Param 'data' may have keys for the different magic partners/users. + * + * Note: we must delete these keys, so that this is not + * handled as a model definition. + * + * @override + * @param {Object} [data.currentPartnerId] + * @param {Object} [data.currentUserId] + * @param {Object} [data.partnerRootId] + * @param {Object} [data.publicPartnerId] + * @param {Object} [data.publicUserId] + * @param {Widget} [options.widget] mocked widget (use to call services) + */ + init(data, options) { + if (data && data.currentPartnerId) { + this.currentPartnerId = data.currentPartnerId; + delete data.currentPartnerId; + } + if (data && data.currentUserId) { + this.currentUserId = data.currentUserId; + delete data.currentUserId; + } + if (data && data.partnerRootId) { + this.partnerRootId = data.partnerRootId; + delete data.partnerRootId; + } + if (data && data.publicPartnerId) { + this.publicPartnerId = data.publicPartnerId; + delete data.publicPartnerId; + } + if (data && data.publicUserId) { + this.publicUserId = data.publicUserId; + delete data.publicUserId; + } + this._widget = options.widget; + + this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _performFetch(resource, init) { + if (resource === '/web/binary/upload_attachment') { + const formData = init.body; + const model = formData.get('model'); + const id = parseInt(formData.get('id')); + const ufiles = formData.getAll('ufile'); + const callback = formData.get('callback'); + + const attachmentIds = []; + for (const ufile of ufiles) { + const attachmentId = this._mockCreate('ir.attachment', { + // datas, + mimetype: ufile.type, + name: ufile.name, + res_id: id, + res_model: model, + }); + attachmentIds.push(attachmentId); + } + const attachments = this._getRecords('ir.attachment', [['id', 'in', attachmentIds]]); + const formattedAttachments = attachments.map(attachment => { + return { + 'filename': attachment.name, + 'id': attachment.id, + 'mimetype': attachment.mimetype, + 'size': attachment.file_size + }; + }); + return { + text() { + return ` + <script language="javascript" type="text/javascript"> + var win = window.top.window; + win.jQuery(win).trigger('${callback}', ${JSON.stringify(formattedAttachments)}); + </script> + `; + }, + }; + } + return this._super(...arguments); + }, + /** + * @override + */ + async _performRpc(route, args) { + // routes + if (route === '/mail/chat_post') { + const uuid = args.uuid; + const message_content = args.message_content; + const context = args.context; + return this._mockRouteMailChatPost(uuid, message_content, context); + } + if (route === '/mail/get_suggested_recipients') { + const model = args.model; + const res_ids = args.res_ids; + return this._mockRouteMailGetSuggestedRecipient(model, res_ids); + } + if (route === '/mail/init_messaging') { + return this._mockRouteMailInitMessaging(); + } + if (route === '/mail/read_followers') { + return this._mockRouteMailReadFollowers(args); + } + if (route === '/mail/read_subscription_data') { + const follower_id = args.follower_id; + return this._mockRouteMailReadSubscriptionData(follower_id); + } + // mail.activity methods + if (args.model === 'mail.activity' && args.method === 'activity_format') { + let res = this._mockRead(args.model, args.args, args.kwargs); + res = res.map(function (record) { + if (record.mail_template_ids) { + record.mail_template_ids = record.mail_template_ids.map(function (template_id) { + return { id: template_id, name: "template" + template_id }; + }); + } + return record; + }); + return res; + } + if (args.model === 'mail.activity' && args.method === 'get_activity_data') { + const res_model = args.args[0] || args.kwargs.res_model; + const domain = args.args[1] || args.kwargs.domain; + return this._mockMailActivityGetActivityData(res_model, domain); + } + // mail.channel methods + if (args.model === 'mail.channel' && args.method === 'channel_fetched') { + const ids = args.args[0]; + return this._mockMailChannelChannelFetched(ids); + } + if (args.model === 'mail.channel' && args.method === 'channel_fetch_listeners') { + return []; + } + if (args.model === 'mail.channel' && args.method === 'channel_fetch_preview') { + const ids = args.args[0]; + return this._mockMailChannelChannelFetchPreview(ids); + } + if (args.model === 'mail.channel' && args.method === 'channel_fold') { + const uuid = args.args[0] || args.kwargs.uuid; + const state = args.args[1] || args.kwargs.state; + return this._mockMailChannelChannelFold(uuid, state); + } + if (args.model === 'mail.channel' && args.method === 'channel_get') { + const partners_to = args.args[0] || args.kwargs.partners_to; + const pin = args.args[1] !== undefined + ? args.args[1] + : args.kwargs.pin !== undefined + ? args.kwargs.pin + : undefined; + return this._mockMailChannelChannelGet(partners_to, pin); + } + if (args.model === 'mail.channel' && args.method === 'channel_info') { + const ids = args.args[0]; + return this._mockMailChannelChannelInfo(ids); + } + if (args.model === 'mail.channel' && args.method === 'channel_join_and_get_info') { + const ids = args.args[0]; + return this._mockMailChannelChannelJoinAndGetInfo(ids); + } + if (args.model === 'mail.channel' && args.method === 'channel_minimize') { + return; + } + if (args.model === 'mail.channel' && args.method === 'channel_seen') { + const channel_ids = args.args[0]; + const last_message_id = args.args[1] || args.kwargs.last_message_id; + return this._mockMailChannelChannelSeen(channel_ids, last_message_id); + } + if (args.model === 'mail.channel' && args.method === 'channel_set_custom_name') { + const channel_id = args.args[0] || args.kwargs.channel_id; + const name = args.args[1] || args.kwargs.name; + return this._mockMailChannelChannelSetCustomName(channel_id, name); + } + if (args.model === 'mail.channel' && args.method === 'execute_command') { + return this._mockMailChannelExecuteCommand(args); + } + if (args.model === 'mail.channel' && args.method === 'message_post') { + const id = args.args[0]; + const kwargs = args.kwargs; + const context = kwargs.context; + delete kwargs.context; + return this._mockMailChannelMessagePost(id, kwargs, context); + } + if (args.model === 'mail.channel' && args.method === 'notify_typing') { + const ids = args.args[0]; + const is_typing = args.args[1] || args.kwargs.is_typing; + const context = args.kwargs.context; + return this._mockMailChannelNotifyTyping(ids, is_typing, context); + } + // mail.message methods + if (args.model === 'mail.message' && args.method === 'mark_all_as_read') { + const domain = args.args[0] || args.kwargs.domain; + return this._mockMailMessageMarkAllAsRead(domain); + } + if (args.model === 'mail.message' && args.method === 'message_fetch') { + // TODO FIXME delay RPC until next potential render as a workaround + // to issue https://github.com/odoo/owl/pull/724 + await nextAnimationFrame(); + const domain = args.args[0] || args.kwargs.domain; + const limit = args.args[1] || args.kwargs.limit; + const moderated_channel_ids = args.args[2] || args.kwargs.moderated_channel_ids; + return this._mockMailMessageMessageFetch(domain, limit, moderated_channel_ids); + } + if (args.model === 'mail.message' && args.method === 'message_format') { + const ids = args.args[0]; + return this._mockMailMessageMessageFormat(ids); + } + if (args.model === 'mail.message' && args.method === 'moderate') { + return this._mockMailMessageModerate(args); + } + if (args.model === 'mail.message' && args.method === 'set_message_done') { + const ids = args.args[0]; + return this._mockMailMessageSetMessageDone(ids); + } + if (args.model === 'mail.message' && args.method === 'toggle_message_starred') { + const ids = args.args[0]; + return this._mockMailMessageToggleMessageStarred(ids); + } + if (args.model === 'mail.message' && args.method === 'unstar_all') { + return this._mockMailMessageUnstarAll(); + } + // res.partner methods + if (args.method === 'get_mention_suggestions') { + if (args.model === 'mail.channel') { + return this._mockMailChannelGetMentionSuggestions(args); + } + if (args.model === 'res.partner') { + return this._mockResPartnerGetMentionSuggestions(args); + } + } + if (args.model === 'res.partner' && args.method === 'im_search') { + const name = args.args[0] || args.kwargs.search; + const limit = args.args[1] || args.kwargs.limit; + return this._mockResPartnerImSearch(name, limit); + } + // mail.thread methods (can work on any model) + if (args.method === 'message_subscribe') { + const ids = args.args[0]; + const partner_ids = args.args[1] || args.kwargs.partner_ids; + const channel_ids = args.args[2] || args.kwargs.channel_ids; + const subtype_ids = args.args[3] || args.kwargs.subtype_ids; + return this._mockMailThreadMessageSubscribe(args.model, ids, partner_ids, channel_ids, subtype_ids); + } + if (args.method === 'message_unsubscribe') { + const ids = args.args[0]; + const partner_ids = args.args[1] || args.kwargs.partner_ids; + const channel_ids = args.args[2] || args.kwargs.channel_ids; + return this._mockMailThreadMessageUnsubscribe(args.model, ids, partner_ids, channel_ids); + } + if (args.method === 'message_post') { + const id = args.args[0]; + const kwargs = args.kwargs; + const context = kwargs.context; + delete kwargs.context; + return this._mockMailThreadMessagePost(args.model, [id], kwargs, context); + } + return this._super(route, args); + }, + + //-------------------------------------------------------------------------- + // Private Mocked Routes + //-------------------------------------------------------------------------- + + /** + * Simulates the `/mail/chat_post` route. + * + * @private + * @param {string} uuid + * @param {string} message_content + * @param {Object} [context={}] + * @returns {Object} one key for list of followers and one for subtypes + */ + async _mockRouteMailChatPost(uuid, message_content, context = {}) { + const mailChannel = this._getRecords('mail.channel', [['uuid', '=', uuid]])[0]; + if (!mailChannel) { + return false; + } + + let user_id; + // find the author from the user session + if ('mockedUserId' in context) { + // can be falsy to simulate not being logged in + user_id = context.mockedUserId; + } else { + user_id = this.currentUserId; + } + let author_id; + let email_from; + if (user_id) { + const author = this._getRecords('res.users', [['id', '=', user_id]])[0]; + author_id = author.partner_id; + email_from = `${author.display_name} <${author.email}>`; + } else { + author_id = false; + // simpler fallback than catchall_formatted + email_from = mailChannel.anonymous_name || "catchall@example.com"; + } + // supposedly should convert plain text to html + const body = message_content; + // ideally should be posted with mail_create_nosubscribe=True + return this._mockMailChannelMessagePost( + mailChannel.id, + { + author_id, + email_from, + body, + message_type: 'comment', + subtype_xmlid: 'mail.mt_comment', + }, + context + ); + }, + /** + * Simulates `/mail/get_suggested_recipients` route. + * + * @private + * @returns {string} model + * @returns {integer[]} res_ids + * @returns {Object} + */ + _mockRouteMailGetSuggestedRecipient(model, res_ids) { + if (model === 'res.fake') { + return this._mockResFake_MessageGetSuggestedRecipients(model, res_ids); + } + return this._mockMailThread_MessageGetSuggestedRecipients(model, res_ids); + }, + /** + * Simulates the `/mail/init_messaging` route. + * + * @private + * @returns {Object} + */ + _mockRouteMailInitMessaging() { + const channels = this._getRecords('mail.channel', [ + ['channel_type', '=', 'channel'], + ['members', 'in', this.currentPartnerId], + ['public', 'in', ['public', 'groups']], + ]); + const channelInfos = this._mockMailChannelChannelInfo(channels.map(channel => channel.id)); + + const directMessages = this._getRecords('mail.channel', [ + ['channel_type', '=', 'chat'], + ['is_pinned', '=', true], + ['members', 'in', this.currentPartnerId], + ]); + const directMessageInfos = this._mockMailChannelChannelInfo(directMessages.map(channel => channel.id)); + + const privateGroups = this._getRecords('mail.channel', [ + ['channel_type', '=', 'channel'], + ['members', 'in', this.currentPartnerId], + ['public', '=', 'private'], + ]); + const privateGroupInfos = this._mockMailChannelChannelInfo(privateGroups.map(channel => channel.id)); + + const moderation_channel_ids = this._getRecords('mail.channel', [['is_moderator', '=', true]]).map(channel => channel.id); + const moderation_counter = this._getRecords('mail.message', [ + ['model', '=', 'mail.channel'], + ['res_id', 'in', moderation_channel_ids], + ['moderation_status', '=', 'pending_moderation'], + ]).length; + + const partnerRoot = this._getRecords( + 'res.partner', + [['id', '=', this.partnerRootId]], + { active_test: false } + )[0]; + const partnerRootFormat = this._mockResPartnerMailPartnerFormat(partnerRoot.id); + + const publicPartner = this._getRecords( + 'res.partner', + [['id', '=', this.publicPartnerId]], + { active_test: false } + )[0]; + const publicPartnerFormat = this._mockResPartnerMailPartnerFormat(publicPartner.id); + + const currentPartner = this._getRecords('res.partner', [['id', '=', this.currentPartnerId]])[0]; + const currentPartnerFormat = this._mockResPartnerMailPartnerFormat(currentPartner.id); + + const needaction_inbox_counter = this._mockResPartnerGetNeedactionCount(); + + const mailFailures = this._mockMailMessageMessageFetchFailed(); + + const shortcodes = this._getRecords('mail.shortcode', []); + + const commands = this._getRecords('mail.channel_command', []); + + const starredCounter = this._getRecords('mail.message', [ + ['starred_partner_ids', 'in', this.currentPartnerId], + ]).length; + + return { + channel_slots: { + channel_channel: channelInfos, + channel_direct_message: directMessageInfos, + channel_private_group: privateGroupInfos, + }, + commands, + current_partner: currentPartnerFormat, + current_user_id: this.currentUserId, + mail_failures: mailFailures, + mention_partner_suggestions: [], + menu_id: false, + moderation_channel_ids, + moderation_counter, + needaction_inbox_counter, + partner_root: partnerRootFormat, + public_partner: publicPartnerFormat, + shortcodes, + starred_counter: starredCounter, + }; + }, + /** + * Simulates the `/mail/read_followers` route. + * + * @private + * @param {integer[]} follower_ids + * @returns {Object} one key for list of followers and one for subtypes + */ + async _mockRouteMailReadFollowers(args) { + const res_id = args.res_id; // id of record to read the followers + const res_model = args.res_model; // model of record to read the followers + const followers = this._getRecords('mail.followers', [['res_id', '=', res_id], ['res_model', '=', res_model]]); + const currentPartnerFollower = followers.find(follower => follower.id === this.currentPartnerId); + const subtypes = currentPartnerFollower + ? this._mockRouteMailReadSubscriptionData(currentPartnerFollower.id) + : false; + return { followers, subtypes }; + }, + /** + * Simulates the `/mail/read_subscription_data` route. + * + * @private + * @param {integer} follower_id + * @returns {Object[]} list of followed subtypes + */ + async _mockRouteMailReadSubscriptionData(follower_id) { + const follower = this._getRecords('mail.followers', [['id', '=', follower_id]])[0]; + const subtypes = this._getRecords('mail.message.subtype', [ + '&', + ['hidden', '=', false], + '|', + ['res_model', '=', follower.res_model], + ['res_model', '=', false], + ]); + const subtypes_list = subtypes.map(subtype => { + const parent = this._getRecords('mail.message.subtype', [ + ['id', '=', subtype.parent_id], + ])[0]; + return { + 'default': subtype.default, + 'followed': follower.subtype_ids.includes(subtype.id), + 'id': subtype.id, + 'internal': subtype.internal, + 'name': subtype.name, + 'parent_model': parent ? parent.res_model : false, + 'res_model': subtype.res_model, + 'sequence': subtype.sequence, + }; + }); + // NOTE: server is also doing a sort here, not reproduced for simplicity + return subtypes_list; + }, + + //-------------------------------------------------------------------------- + // Private Mocked Methods + //-------------------------------------------------------------------------- + + /** + * Simulates `get_activity_data` on `mail.activity`. + * + * @private + * @param {string} res_model + * @param {string} domain + * @returns {Object} + */ + _mockMailActivityGetActivityData(res_model, domain) { + const self = this; + const records = this._getRecords(res_model, domain); + + const activityTypes = this._getRecords('mail.activity.type', []); + const activityIds = _.pluck(records, 'activity_ids').flat(); + + const groupedActivities = {}; + const resIdToDeadline = {}; + const groups = self._mockReadGroup('mail.activity', { + domain: [['id', 'in', activityIds]], + fields: ['res_id', 'activity_type_id', 'ids:array_agg(id)', 'date_deadline:min(date_deadline)'], + groupby: ['res_id', 'activity_type_id'], + lazy: false, + }); + groups.forEach(function (group) { + // mockReadGroup doesn't correctly return all asked fields + const activites = self._getRecords('mail.activity', group.__domain); + group.activity_type_id = group.activity_type_id[0]; + let minDate; + activites.forEach(function (activity) { + if (!minDate || moment(activity.date_deadline) < moment(minDate)) { + minDate = activity.date_deadline; + } + }); + group.date_deadline = minDate; + resIdToDeadline[group.res_id] = minDate; + let state; + if (group.date_deadline === moment().format("YYYY-MM-DD")) { + state = 'today'; + } else if (moment(group.date_deadline) > moment()) { + state = 'planned'; + } else { + state = 'overdue'; + } + if (!groupedActivities[group.res_id]) { + groupedActivities[group.res_id] = {}; + } + groupedActivities[group.res_id][group.activity_type_id] = { + count: group.__count, + state: state, + o_closest_deadline: group.date_deadline, + ids: _.pluck(activites, 'id'), + }; + }); + + return { + activity_types: activityTypes.map(function (type) { + let mailTemplates = []; + if (type.mail_template_ids) { + mailTemplates = type.mail_template_ids.map(function (id) { + const template = _.findWhere(self.data['mail.template'].records, { id: id }); + return { + id: id, + name: template.name, + }; + }); + } + return [type.id, type.display_name, mailTemplates]; + }), + activity_res_ids: _.sortBy(_.pluck(records, 'id'), function (id) { + return moment(resIdToDeadline[id]); + }), + grouped_activities: groupedActivities, + }; + }, + /** + * Simulates `_broadcast` on `mail.channel`. + * + * @private + * @param {integer} id + * @param {integer[]} partner_ids + * @returns {Object} + */ + _mockMailChannel_broadcast(ids, partner_ids) { + const notifications = this._mockMailChannel_channelChannelNotifications(ids, partner_ids); + this._widget.call('bus_service', 'trigger', 'notification', notifications); + }, + /** + * Simulates `_channel_channel_notifications` on `mail.channel`. + * + * @private + * @param {integer} id + * @param {integer[]} partner_ids + * @returns {Object} + */ + _mockMailChannel_channelChannelNotifications(ids, partner_ids) { + const notifications = []; + for (const partner_id of partner_ids) { + const user = this._getRecords('res.users', [['partner_id', 'in', partner_id]])[0]; + if (!user) { + continue; + } + // Note: `channel_info` on the server is supposed to be called with + // the proper user context, but this is not done here for simplicity + // of not having `channel.partner`. + const channelInfos = this._mockMailChannelChannelInfo(ids); + for (const channelInfo of channelInfos) { + notifications.push([[false, 'res.partner', partner_id], channelInfo]); + } + } + return notifications; + }, + /** + * Simulates `channel_fetched` on `mail.channel`. + * + * @private + * @param {integer[]} ids + * @param {string} extra_info + */ + _mockMailChannelChannelFetched(ids) { + const channels = this._getRecords('mail.channel', [['id', 'in', ids]]); + for (const channel of channels) { + const channelMessages = this._getRecords('mail.message', [['channel_ids', 'in', channel.id]]); + const lastMessage = channelMessages.reduce((lastMessage, message) => { + if (message.id > lastMessage.id) { + return message; + } + return lastMessage; + }, channelMessages[0]); + if (!lastMessage) { + continue; + } + this._mockWrite('mail.channel', [ + [channel.id], + { fetched_message_id: lastMessage.id }, + ]); + const notification = [ + ["dbName", 'mail.channel', channel.id], + { + id: `${channel.id}/${this.currentPartnerId}`, // simulate channel.partner id + info: 'channel_fetched', + last_message_id: lastMessage.id, + partner_id: this.currentPartnerId, + }, + ]; + this._widget.call('bus_service', 'trigger', 'notification', [notification]); + } + }, + /** + * Simulates `channel_fetch_preview` on `mail.channel`. + * + * @private + * @param {integer[]} ids + * @returns {Object[]} list of channels previews + */ + _mockMailChannelChannelFetchPreview(ids) { + const channels = this._getRecords('mail.channel', [['id', 'in', ids]]); + return channels.map(channel => { + const channelMessages = this._getRecords('mail.message', [['channel_ids', 'in', channel.id]]); + const lastMessage = channelMessages.reduce((lastMessage, message) => { + if (message.id > lastMessage.id) { + return message; + } + return lastMessage; + }, channelMessages[0]); + return { + id: channel.id, + last_message: lastMessage ? this._mockMailMessageMessageFormat([lastMessage.id])[0] : false, + }; + }); + }, + /** + * Simulates the 'channel_fold' route on `mail.channel`. + * In particular sends a notification on the bus. + * + * @private + * @param {string} uuid + * @param {state} [state] + */ + _mockMailChannelChannelFold(uuid, state) { + const channel = this._getRecords('mail.channel', [['uuid', '=', uuid]])[0]; + this._mockWrite('mail.channel', [ + [channel.id], + { + is_minimized: state !== 'closed', + state, + } + ]); + const notifConfirmFold = [ + ["dbName", 'res.partner', this.currentPartnerId], + this._mockMailChannelChannelInfo([channel.id])[0] + ]; + this._widget.call('bus_service', 'trigger', 'notification', [notifConfirmFold]); + }, + /** + * Simulates 'channel_get' on 'mail.channel'. + * + * @private + * @param {integer[]} [partners_to=[]] + * @param {boolean} [pin=true] + * @returns {Object} + */ + _mockMailChannelChannelGet(partners_to = [], pin = true) { + if (partners_to.length === 0) { + return false; + } + if (!partners_to.includes(this.currentPartnerId)) { + partners_to.push(this.currentPartnerId); + } + const partners = this._getRecords('res.partner', [['id', 'in', partners_to]]); + + // NOTE: this mock is not complete, which is done for simplicity. + // Indeed if a chat already exists for the given partners, the server + // is supposed to return this existing chat. But the mock is currently + // always creating a new chat, because no test is relying on receiving + // an existing chat. + const id = this._mockCreate('mail.channel', { + channel_type: 'chat', + mass_mailing: false, + is_minimized: true, + is_pinned: true, + members: [[6, 0, partners_to]], + name: partners.map(partner => partner.name).join(", "), + public: 'private', + state: 'open', + }); + return this._mockMailChannelChannelInfo([id])[0]; + }, + /** + * Simulates `channel_info` on `mail.channel`. + * + * @private + * @param {integer[]} ids + * @param {string} [extra_info] + * @returns {Object[]} + */ + _mockMailChannelChannelInfo(ids, extra_info) { + const channels = this._getRecords('mail.channel', [['id', 'in', ids]]); + const all_partners = [...new Set(channels.reduce((all_partners, channel) => { + return [...all_partners, ...channel.members]; + }, []))]; + const direct_partners = [...new Set(channels.reduce((all_partners, channel) => { + if (channel.channel_type === 'chat') { + return [...all_partners, ...channel.members]; + } + return all_partners; + }, []))]; + const partnerInfos = this._mockMailChannelPartnerInfo(all_partners, direct_partners); + return channels.map(channel => { + const members = channel.members.map(partnerId => partnerInfos[partnerId]); + const messages = this._getRecords('mail.message', [ + ['channel_ids', 'in', [channel.id]], + ]); + const lastMessageId = messages.reduce((lastMessageId, message) => { + if (!lastMessageId || message.id > lastMessageId) { + return message.id; + } + return lastMessageId; + }, undefined); + const messageNeedactionCounter = this._getRecords('mail.notification', [ + ['res_partner_id', '=', this.currentPartnerId], + ['is_read', '=', false], + ['mail_message_id', 'in', messages.map(message => message.id)], + ]).length; + const res = Object.assign({}, channel, { + info: extra_info, + last_message_id: lastMessageId, + members, + message_needaction_counter: messageNeedactionCounter, + }); + if (channel.channel_type === 'channel') { + delete res.members; + } + return res; + }); + }, + /** + * Simulates `channel_join_and_get_info` on `mail.channel`. + * + * @private + * @param {integer[]} ids + * @returns {Object[]} + */ + _mockMailChannelChannelJoinAndGetInfo(ids) { + const id = ids[0]; // ensure one + const channel = this._getRecords('mail.channel', [['id', '=', id]])[0]; + // channel.partner not handled here for simplicity + if (!channel.is_pinned) { + this._mockWrite('mail.channel', [ + [channel.id], + { is_pinned: true }, + ]); + const body = `<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="${channel.id}">#${channel.name}</a></div>`; + const message_type = "notification"; + const subtype_xmlid = "mail.mt_comment"; + this._mockMailChannelMessagePost( + 'mail.channel', + [channel.id], + { body, message_type, subtype_xmlid }, + ); + } + // moderation_guidelines not handled here for simplicity + const channelInfo = this._mockMailChannelChannelInfo([channel.id], 'join')[0]; + const notification = [[false, 'res.partner', this.currentPartnerId], channelInfo]; + this._widget.call('bus_service', 'trigger', 'notification', [notification]); + return channelInfo; + }, + /** + * Simulates the `channel_seen` method of `mail.channel`. + * + * @private + * @param integer[] ids + * @param {integer} last_message_id + */ + async _mockMailChannelChannelSeen(ids, last_message_id) { + // Update record + const channel_id = ids[0]; + if (!channel_id) { + throw new Error('Should only be one channel in channel_seen mock params'); + } + const channel = this._getRecords('mail.channel', [['id', '=', channel_id]])[0]; + const messagesBeforeGivenLastMessage = this._getRecords('mail.message', [ + ['channel_ids', 'in', [channel.id]], + ['id', '<=', last_message_id], + ]); + if (!messagesBeforeGivenLastMessage || messagesBeforeGivenLastMessage.length === 0) { + return; + } + if (!channel) { + return; + } + if (channel.seen_message_id && channel.seen_message_id >= last_message_id) { + return; + } + this._mockMailChannel_SetLastSeenMessage([channel.id], last_message_id); + + // Send notification + const payload = { + channel_id, + info: 'channel_seen', + last_message_id, + partner_id: this.currentPartnerId, + }; + let notification; + if (channel.channel_type === 'chat') { + notification = [[false, 'mail.channel', channel_id], payload]; + } else { + notification = [[false, 'res.partner', this.currentPartnerId], payload]; + } + this._widget.call('bus_service', 'trigger', 'notification', [notification]); + }, + /** + * Simulates `channel_set_custom_name` on `mail.channel`. + * + * @private + * @param {integer} channel_id + * @returns {string} [name] + */ + _mockMailChannelChannelSetCustomName(channel_id, name) { + this._mockWrite('mail.channel', [ + [channel_id], + { custom_channel_name: name }, + ]); + }, + /** + * Simulates `execute_command` on `mail.channel`. + * In particular sends a notification on the bus. + * + * @private + */ + _mockMailChannelExecuteCommand(args) { + const ids = args.args[0]; + const commandName = args.kwargs.command || args.args[1]; + const channels = this._getRecords('mail.channel', [['id', 'in', ids]]); + if (commandName === 'leave') { + for (const channel of channels) { + this._mockWrite('mail.channel', [ + [channel.id], + { is_pinned: false }, + ]); + const notifConfirmUnpin = [ + ["dbName", 'res.partner', this.currentPartnerId], + Object.assign({}, channel, { info: 'unsubscribe' }) + ]; + this._widget.call('bus_service', 'trigger', 'notification', [notifConfirmUnpin]); + } + return; + } else if (commandName === 'who') { + for (const channel of channels) { + const members = channel.members.map(memberId => this._getRecords('res.partner', [['id', '=', memberId]])[0].name); + let message = "You are alone in this channel."; + if (members.length > 0) { + message = `Users in this channel: ${members.join(', ')} and you`; + } + const notification = [ + ["dbName", 'res.partner', this.currentPartnerId], + { + 'body': `<span class="o_mail_notification">${message}</span>`, + 'channel_ids': [channel.id], + 'info': 'transient_message', + } + ]; + this._widget.call('bus_service', 'trigger', 'notification', [notification]); + } + return; + } + throw new Error(`mail/mock_server: the route execute_command doesn't implement the command "${commandName}"`); + }, + /** + * Simulates `get_mention_suggestions` on `mail.channel`. + * + * @private + * @returns {Array[]} + */ + _mockMailChannelGetMentionSuggestions(args) { + const search = args.kwargs.search || ''; + const limit = args.kwargs.limit || 8; + + /** + * Returns the given list of channels after filtering it according to + * the logic of the Python method `get_mention_suggestions` for the + * given search term. The result is truncated to the given limit and + * formatted as expected by the original method. + * + * @param {Object[]} channels + * @param {string} search + * @param {integer} limit + * @returns {Object[]} + */ + const mentionSuggestionsFilter = function (channels, search, limit) { + const matchingChannels = channels + .filter(channel => { + // no search term is considered as return all + if (!search) { + return true; + } + // otherwise name or email must match search term + if (channel.name && channel.name.includes(search)) { + return true; + } + return false; + }).map(channel => { + // expected format + return { + id: channel.id, + name: channel.name, + public: channel.public, + }; + }); + // reduce results to max limit + matchingChannels.length = Math.min(matchingChannels.length, limit); + return matchingChannels; + }; + + const mentionSuggestions = mentionSuggestionsFilter(this.data['mail.channel'].records, search, limit); + + return mentionSuggestions; + }, + /** + * Simulates `message_post` on `mail.channel`. + * + * For simplicity this mock handles a simple case in regard to moderation: + * - messages from JS are assumed to be always sent by the current partner, + * - moderation white list and black list are not checked. + * + * @private + * @param {integer} id + * @param {Object} kwargs + * @param {Object} [context] + * @returns {integer|false} + */ + _mockMailChannelMessagePost(id, kwargs, context) { + const message_type = kwargs.message_type || 'notification'; + const channel = this._getRecords('mail.channel', [['id', '=', id]])[0]; + if (channel.channel_type !== 'channel' && !channel.is_pinned) { + // channel.partner not handled here for simplicity + this._mockWrite('mail.channel', [ + [channel.id], + { is_pinned: true }, + ]); + } + let moderation_status = 'accepted'; + if (channel.moderation && ['email', 'comment'].includes(message_type)) { + if (!channel.is_moderator) { + moderation_status = 'pending_moderation'; + } + } + let channel_ids = []; + if (moderation_status === 'accepted') { + channel_ids = [[4, channel.id]]; + } + const messageId = this._mockMailThreadMessagePost( + 'mail.channel', + [id], + Object.assign(kwargs, { + channel_ids, + message_type, + moderation_status, + }), + context, + ); + if (kwargs.author_id === this.currentPartnerId) { + this._mockMailChannel_SetLastSeenMessage([channel.id], messageId); + } else { + this._mockWrite('mail.channel', [ + [channel.id], + { message_unread_counter: (channel.message_unread_counter || 0) + 1 }, + ]); + } + return messageId; + }, + /** + * Simulates `notify_typing` on `mail.channel`. + * + * @private + * @param {integer[]} ids + * @param {boolean} is_typing + * @param {Object} [context={}] + */ + _mockMailChannelNotifyTyping(ids, is_typing, context = {}) { + const channels = this._getRecords('mail.channel', [['id', 'in', ids]]); + let partner_id; + if ('mockedPartnerId' in context) { + partner_id = context.mockedPartnerId; + } else { + partner_id = this.currentPartnerId; + } + const partner = this._getRecords('res.partner', [['id', '=', partner_id]]); + const data = { + 'info': 'typing_status', + 'is_typing': is_typing, + 'partner_id': partner_id, + 'partner_name': partner.name, + }; + const notifications = []; + for (const channel of channels) { + notifications.push([[false, 'mail.channel', channel.id], data]); + notifications.push([channel.uuid, data]); // notify livechat users + } + this._widget.call('bus_service', 'trigger', 'notification', notifications); + }, + /** + * Simulates `partner_info` on `mail.channel`. + * + * @private + * @param {integer[]} all_partners + * @param {integer[]} direct_partners + * @returns {Object[]} + */ + _mockMailChannelPartnerInfo(all_partners, direct_partners) { + const partners = this._getRecords( + 'res.partner', + [['id', 'in', all_partners]], + { active_test: false }, + ); + const partnerInfos = {}; + for (const partner of partners) { + const partnerInfo = { + email: partner.email, + id: partner.id, + name: partner.name, + }; + if (direct_partners.includes(partner.id)) { + partnerInfo.im_status = partner.im_status; + } + partnerInfos[partner.id] = partnerInfo; + } + return partnerInfos; + }, + /** + * Simulates the `_set_last_seen_message` method of `mail.channel`. + * + * @private + * @param {integer[]} ids + * @param {integer} message_id + */ + _mockMailChannel_SetLastSeenMessage(ids, message_id) { + this._mockWrite('mail.channel', [ids, { + fetched_message_id: message_id, + seen_message_id: message_id, + }]); + }, + /** + * Simulates `mark_all_as_read` on `mail.message`. + * + * @private + * @param {Array[]} [domain] + * @returns {integer[]} + */ + _mockMailMessageMarkAllAsRead(domain) { + const notifDomain = [ + ['res_partner_id', '=', this.currentPartnerId], + ['is_read', '=', false], + ]; + if (domain) { + const messages = this._getRecords('mail.message', domain); + const ids = messages.map(messages => messages.id); + this._mockMailMessageSetMessageDone(ids); + return ids; + } + const notifications = this._getRecords('mail.notification', notifDomain); + this._mockWrite('mail.notification', [ + notifications.map(notification => notification.id), + { is_read: true }, + ]); + const messageIds = []; + for (const notification of notifications) { + if (!messageIds.includes(notification.mail_message_id)) { + messageIds.push(notification.mail_message_id); + } + } + const messages = this._getRecords('mail.message', [['id', 'in', messageIds]]); + // simulate compute that should be done based on notifications + for (const message of messages) { + this._mockWrite('mail.message', [ + [message.id], + { + needaction: false, + needaction_partner_ids: message.needaction_partner_ids.filter( + partnerId => partnerId !== this.currentPartnerId + ), + }, + ]); + } + const notificationData = { type: 'mark_as_read', message_ids: messageIds, needaction_inbox_counter: this._mockResPartnerGetNeedactionCount() }; + const notification = [[false, 'res.partner', this.currentPartnerId], notificationData]; + this._widget.call('bus_service', 'trigger', 'notification', [notification]); + return messageIds; + }, + /** + * Simulates `message_fetch` on `mail.message`. + * + * @private + * @param {Array[]} domain + * @param {string} [limit=20] + * @param {Object} [moderated_channel_ids] + * @returns {Object[]} + */ + _mockMailMessageMessageFetch(domain, limit = 20, moderated_channel_ids) { + let messages = this._getRecords('mail.message', domain); + if (moderated_channel_ids) { + const mod_messages = this._getRecords('mail.message', [ + ['model', '=', 'mail.channel'], + ['res_id', 'in', moderated_channel_ids], + '|', + ['author_id', '=', this.currentPartnerId], + ['moderation_status', '=', 'pending_moderation'], + ]); + messages = [...new Set([...messages, ...mod_messages])]; + } + // sorted from highest ID to lowest ID (i.e. from youngest to oldest) + messages.sort(function (m1, m2) { + return m1.id < m2.id ? 1 : -1; + }); + // pick at most 'limit' messages + messages.length = Math.min(messages.length, limit); + return this._mockMailMessageMessageFormat(messages.map(message => message.id)); + }, + /** + * Simulates `message_fetch_failed` on `mail.message`. + * + * @private + * @returns {Object[]} + */ + _mockMailMessageMessageFetchFailed() { + const messages = this._getRecords('mail.message', [ + ['author_id', '=', this.currentPartnerId], + ['res_id', '!=', 0], + ['model', '!=', false], + ['message_type', '!=', 'user_notification'], + ]).filter(message => { + // Purpose is to simulate the following domain on mail.message: + // ['notification_ids.notification_status', 'in', ['bounce', 'exception']], + // But it's not supported by _getRecords domain to follow a relation. + const notifications = this._getRecords('mail.notification', [ + ['mail_message_id', '=', message.id], + ['notification_status', 'in', ['bounce', 'exception']], + ]); + return notifications.length > 0; + }); + return this._mockMailMessage_MessageNotificationFormat(messages.map(message => message.id)); + }, + /** + * Simulates `message_format` on `mail.message`. + * + * @private + * @returns {integer[]} ids + * @returns {Object[]} + */ + _mockMailMessageMessageFormat(ids) { + const messages = this._getRecords('mail.message', [['id', 'in', ids]]); + // sorted from highest ID to lowest ID (i.e. from most to least recent) + messages.sort(function (m1, m2) { + return m1.id < m2.id ? 1 : -1; + }); + return messages.map(message => { + const thread = message.model && this._getRecords(message.model, [ + ['id', '=', message.res_id], + ])[0]; + let formattedAuthor; + if (message.author_id) { + const author = this._getRecords( + 'res.partner', + [['id', '=', message.author_id]], + { active_test: false } + )[0]; + formattedAuthor = [author.id, author.display_name]; + } else { + formattedAuthor = [0, message.email_from]; + } + const attachments = this._getRecords('ir.attachment', [ + ['id', 'in', message.attachment_ids], + ]); + const formattedAttachments = attachments.map(attachment => { + return Object.assign({ + 'checksum': attachment.checksum, + 'id': attachment.id, + 'filename': attachment.name, + 'name': attachment.name, + 'mimetype': attachment.mimetype, + 'is_main': thread && thread.message_main_attachment_id === attachment.id, + 'res_id': attachment.res_id, + 'res_model': attachment.res_model, + }); + }); + const allNotifications = this._getRecords('mail.notification', [ + ['mail_message_id', '=', message.id], + ]); + const historyPartnerIds = allNotifications + .filter(notification => notification.is_read) + .map(notification => notification.res_partner_id); + const needactionPartnerIds = allNotifications + .filter(notification => !notification.is_read) + .map(notification => notification.res_partner_id); + let notifications = this._mockMailNotification_FilteredForWebClient( + allNotifications.map(notification => notification.id) + ); + notifications = this._mockMailNotification_NotificationFormat( + notifications.map(notification => notification.id) + ); + const trackingValueIds = this._getRecords('mail.tracking.value', [ + ['id', 'in', message.tracking_value_ids], + ]); + const response = Object.assign({}, message, { + attachment_ids: formattedAttachments, + author_id: formattedAuthor, + history_partner_ids: historyPartnerIds, + needaction_partner_ids: needactionPartnerIds, + notifications, + tracking_value_ids: trackingValueIds, + }); + if (message.subtype_id) { + const subtype = this._getRecords('mail.message.subtype', [ + ['id', '=', message.subtype_id], + ])[0]; + response.subtype_description = subtype.description; + } + return response; + }); + }, + /** + * Simulates `moderate` on `mail.message`. + * + * @private + */ + _mockMailMessageModerate(args) { + const messageIDs = args.args[0]; + const decision = args.args[1]; + const model = this.data['mail.message']; + if (decision === 'reject' || decision === 'discard') { + model.records = _.reject(model.records, function (rec) { + return _.contains(messageIDs, rec.id); + }); + // simulate notification back (deletion of rejected/discarded + // message in channel) + const dbName = undefined; // useless for tests + const notifData = { + message_ids: messageIDs, + type: "deletion", + }; + const metaData = [dbName, 'res.partner', this.currentPartnerId]; + const notification = [metaData, notifData]; + this._widget.call('bus_service', 'trigger', 'notification', [notification]); + } else if (decision === 'accept') { + // simulate notification back (new accepted message in channel) + const messages = this._getRecords('mail.message', [['id', 'in', messageIDs]]); + for (const message of messages) { + this._mockWrite('mail.message', [[message.id], { + moderation_status: 'accepted', + }]); + this._mockMailThread_NotifyThread(model, message.channel_ids, message.id); + } + } + }, + /** + * Simulates `_message_notification_format` on `mail.message`. + * + * @private + * @returns {integer[]} ids + * @returns {Object[]} + */ + _mockMailMessage_MessageNotificationFormat(ids) { + const messages = this._getRecords('mail.message', [['id', 'in', ids]]); + return messages.map(message => { + let notifications = this._getRecords('mail.notification', [ + ['mail_message_id', '=', message.id], + ]); + notifications = this._mockMailNotification_FilteredForWebClient( + notifications.map(notification => notification.id) + ); + notifications = this._mockMailNotification_NotificationFormat( + notifications.map(notification => notification.id) + ); + return { + 'date': message.date, + 'id': message.id, + 'message_type': message.message_type, + 'model': message.model, + 'notifications': notifications, + 'res_id': message.res_id, + 'res_model_name': message.res_model_name, + }; + }); + }, + /** + * Simulates `set_message_done` on `mail.message`, which turns provided + * needaction message to non-needaction (i.e. they are marked as read from + * from the Inbox mailbox). Also notify on the longpoll bus that the + * messages have been marked as read, so that UI is updated. + * + * @private + * @param {integer[]} ids + */ + _mockMailMessageSetMessageDone(ids) { + const messages = this._getRecords('mail.message', [['id', 'in', ids]]); + + const notifications = this._getRecords('mail.notification', [ + ['res_partner_id', '=', this.currentPartnerId], + ['is_read', '=', false], + ['mail_message_id', 'in', messages.map(messages => messages.id)] + ]); + this._mockWrite('mail.notification', [ + notifications.map(notification => notification.id), + { is_read: true }, + ]); + // simulate compute that should be done based on notifications + for (const message of messages) { + this._mockWrite('mail.message', [ + [message.id], + { + needaction: false, + needaction_partner_ids: message.needaction_partner_ids.filter( + partnerId => partnerId !== this.currentPartnerId + ), + }, + ]); + // NOTE server is sending grouped notifications per channel_ids but + // this optimization is not needed here. + const data = { type: 'mark_as_read', message_ids: [message.id], channel_ids: message.channel_ids, needaction_inbox_counter: this._mockResPartnerGetNeedactionCount() }; + const busNotifications = [[[false, 'res.partner', this.currentPartnerId], data]]; + this._widget.call('bus_service', 'trigger', 'notification', busNotifications); + } + }, + /** + * Simulates `toggle_message_starred` on `mail.message`. + * + * @private + * @returns {integer[]} ids + */ + _mockMailMessageToggleMessageStarred(ids) { + const messages = this._getRecords('mail.message', [['id', 'in', ids]]); + for (const message of messages) { + const wasStared = message.starred_partner_ids.includes(this.currentPartnerId); + this._mockWrite('mail.message', [ + [message.id], + { starred_partner_ids: [[wasStared ? 3 : 4, this.currentPartnerId]] } + ]); + const notificationData = { + message_ids: [message.id], + starred: !wasStared, + type: 'toggle_star', + }; + const notifications = [[[false, 'res.partner', this.currentPartnerId], notificationData]]; + this._widget.call('bus_service', 'trigger', 'notification', notifications); + } + }, + /** + * Simulates `unstar_all` on `mail.message`. + * + * @private + */ + _mockMailMessageUnstarAll() { + const messages = this._getRecords('mail.message', [ + ['starred_partner_ids', 'in', this.currentPartnerId], + ]); + this._mockWrite('mail.message', [ + messages.map(message => message.id), + { starred_partner_ids: [[3, this.currentPartnerId]] } + ]); + const notificationData = { + message_ids: messages.map(message => message.id), + starred: false, + type: 'toggle_star', + }; + const notification = [[false, 'res.partner', this.currentPartnerId], notificationData]; + this._widget.call('bus_service', 'trigger', 'notification', [notification]); + }, + /** + * Simulates `_filtered_for_web_client` on `mail.notification`. + * + * @private + * @returns {integer[]} ids + * @returns {Object[]} + */ + _mockMailNotification_FilteredForWebClient(ids) { + return this._getRecords('mail.notification', [ + ['id', 'in', ids], + ['notification_type', '!=', 'inbox'], + ['notification_status', 'in', ['bounce', 'exception', 'canceled']], + // or "res_partner_id.partner_share" not done here for simplicity + ]); + }, + /** + * Simulates `_notification_format` on `mail.notification`. + * + * @private + * @returns {integer[]} ids + * @returns {Object[]} + */ + _mockMailNotification_NotificationFormat(ids) { + const notifications = this._getRecords('mail.notification', [['id', 'in', ids]]); + return notifications.map(notification => { + const partner = this._getRecords('res.partner', [['id', '=', notification.res_partner_id]])[0]; + return { + 'id': notification.id, + 'notification_type': notification.notification_type, + 'notification_status': notification.notification_status, + 'failure_type': notification.failure_type, + 'res_partner_id': [partner && partner.id, partner && partner.display_name], + }; + }); + }, + /** + * Simulates `_message_compute_author` on `mail.thread`. + * + * @private + * @param {string} model + * @param {integer[]} ids + * @param {Object} [context={}] + * @returns {Array} + */ + _MockMailThread_MessageComputeAuthor(model, ids, author_id, email_from, context = {}) { + if (author_id === undefined) { + // For simplicity partner is not guessed from email_from here, but + // that would be the first step on the server. + let user_id; + if ('mockedUserId' in context) { + // can be falsy to simulate not being logged in + user_id = context.mockedUserId + ? context.mockedUserId + : this.publicUserId; + } else { + user_id = this.currentUserId; + } + const user = this._getRecords( + 'res.users', + [['id', '=', user_id]], + { active_test: false }, + )[0]; + const author = this._getRecords( + 'res.partner', + [['id', '=', user.partner_id]], + { active_test: false }, + )[0]; + author_id = author.id; + email_from = `${author.display_name} <${author.email}>`; + } + + if (email_from === undefined) { + if (author_id) { + const author = this._getRecords( + 'res.partner', + [['id', '=', author_id]], + { active_test: false }, + )[0]; + email_from = `${author.display_name} <${author.email}>`; + } + } + + if (!email_from) { + throw Error("Unable to log message due to missing author email."); + } + + return [author_id, email_from]; + }, + /** + * Simulates `_message_add_suggested_recipient` on `mail.thread`. + * + * @private + * @param {string} model + * @param {integer[]} ids + * @param {Object} result + * @param {Object} [param3={}] + * @param {string} [param3.email] + * @param {integer} [param3.partner] + * @param {string} [param3.reason] + * @returns {Object} + */ + _mockMailThread_MessageAddSuggestedRecipient(model, ids, result, { email, partner, reason = '' } = {}) { + const record = this._getRecords(model, [['id', 'in', 'ids']])[0]; + // for simplicity + result[record.id].push([partner, email, reason]); + return result; + }, + /** + * Simulates `_message_get_suggested_recipients` on `mail.thread`. + * + * @private + * @param {string} model + * @param {integer[]} ids + * @returns {Object} + */ + _mockMailThread_MessageGetSuggestedRecipients(model, ids) { + const result = ids.reduce((result, id) => result[id] = [], {}); + const records = this._getRecords(model, [['id', 'in', ids]]); + for (const record in records) { + if (record.user_id) { + const user = this._getRecords('res.users', [['id', '=', record.user_id]]); + if (user.partner_id) { + const reason = this.data[model].fields['user_id'].string; + this._mockMailThread_MessageAddSuggestedRecipient(result, user.partner_id, reason); + } + } + } + return result; + }, + /** + * Simulates `_message_get_suggested_recipients` on `res.fake`. + * + * @private + * @param {string} model + * @param {integer[]} ids + * @returns {Object} + */ + _mockResFake_MessageGetSuggestedRecipients(model, ids) { + const result = {}; + const records = this._getRecords(model, [['id', 'in', ids]]); + + for (const record of records) { + result[record.id] = []; + if (record.email_cc) { + result[record.id].push([ + false, + record.email_cc, + 'CC email', + ]); + } + const partners = this._getRecords( + 'res.partner', + [['id', 'in', record.partner_ids]], + ); + if (partners.length) { + for (const partner of partners) { + result[record.id].push([ + partner.id, + partner.display_name, + 'Email partner', + ]); + } + } + } + + return result; + }, + /** + * Simulates `message_post` on `mail.thread`. + * + * @private + * @param {string} model + * @param {integer[]} ids + * @param {Object} kwargs + * @param {Object} [context] + * @returns {integer} + */ + _mockMailThreadMessagePost(model, ids, kwargs, context) { + const id = ids[0]; // ensure_one + if (kwargs.attachment_ids) { + const attachments = this._getRecords('ir.attachment', [ + ['id', 'in', kwargs.attachment_ids], + ['res_model', '=', 'mail.compose.message'], + ['res_id', '=', 0], + ]); + const attachmentIds = attachments.map(attachment => attachment.id); + this._mockWrite('ir.attachment', [ + attachmentIds, + { + res_id: id, + res_model: model, + }, + ]); + kwargs.attachment_ids = attachmentIds.map(attachmentId => [4, attachmentId]); + } + const subtype_xmlid = kwargs.subtype_xmlid || 'mail.mt_note'; + const [author_id, email_from] = this._MockMailThread_MessageComputeAuthor( + model, + ids, + kwargs.author_id, + kwargs.email_from, context, + ); + const values = Object.assign({}, kwargs, { + author_id, + email_from, + is_discussion: subtype_xmlid === 'mail.mt_comment', + is_note: subtype_xmlid === 'mail.mt_note', + model, + res_id: id, + }); + delete values.subtype_xmlid; + const messageId = this._mockCreate('mail.message', values); + this._mockMailThread_NotifyThread(model, ids, messageId); + return messageId; + }, + /** + * Simulates `message_subscribe` on `mail.thread`. + * + * @private + * @param {string} model not in server method but necessary for thread mock + * @param {integer[]} ids + * @param {integer[]} partner_ids + * @param {integer[]} channel_ids + * @param {integer[]} subtype_ids + * @returns {boolean} + */ + _mockMailThreadMessageSubscribe(model, ids, partner_ids, channel_ids, subtype_ids) { + // message_subscribe is too complex for a generic mock. + // mockRPC should be considered for a specific result. + }, + /** + * Simulates `_notify_thread` on `mail.thread`. + * Simplified version that sends notification to author and channel. + * + * @private + * @param {string} model not in server method but necessary for thread mock + * @param {integer[]} ids + * @param {integer} messageId + * @returns {boolean} + */ + _mockMailThread_NotifyThread(model, ids, messageId) { + const message = this._getRecords('mail.message', [['id', '=', messageId]])[0]; + const messageFormat = this._mockMailMessageMessageFormat([messageId])[0]; + const notifications = []; + // author + const notificationData = { + type: 'author', + message: messageFormat, + }; + if (message.author_id) { + notifications.push([[false, 'res.partner', message.author_id], notificationData]); + } + // members + const channels = this._getRecords('mail.channel', [['id', 'in', message.channel_ids]]); + for (const channel of channels) { + notifications.push([[false, 'mail.channel', channel.id], messageFormat]); + } + this._widget.call('bus_service', 'trigger', 'notification', notifications); + }, + /** + * Simulates `message_unsubscribe` on `mail.thread`. + * + * @private + * @param {string} model not in server method but necessary for thread mock + * @param {integer[]} ids + * @param {integer[]} partner_ids + * @param {integer[]} channel_ids + * @returns {boolean|undefined} + */ + _mockMailThreadMessageUnsubscribe(model, ids, partner_ids, channel_ids) { + if (!partner_ids && !channel_ids) { + return true; + } + const followers = this._getRecords('mail.followers', [ + ['res_model', '=', model], + ['res_id', 'in', ids], + '|', + ['partner_id', 'in', partner_ids || []], + ['channel_id', 'in', channel_ids || []], + ]); + this._mockUnlink(model, [followers.map(follower => follower.id)]); + }, + /** + * Simulates `get_mention_suggestions` on `res.partner`. + * + * @private + * @returns {Array[]} + */ + _mockResPartnerGetMentionSuggestions(args) { + const search = (args.args[0] || args.kwargs.search || '').toLowerCase(); + const limit = args.args[1] || args.kwargs.limit || 8; + + /** + * Returns the given list of partners after filtering it according to + * the logic of the Python method `get_mention_suggestions` for the + * given search term. The result is truncated to the given limit and + * formatted as expected by the original method. + * + * @param {Object[]} partners + * @param {string} search + * @param {integer} limit + * @returns {Object[]} + */ + const mentionSuggestionsFilter = function (partners, search, limit) { + const matchingPartners = partners + .filter(partner => { + // no search term is considered as return all + if (!search) { + return true; + } + // otherwise name or email must match search term + if (partner.name && partner.name.toLowerCase().includes(search)) { + return true; + } + if (partner.email && partner.email.toLowerCase().includes(search)) { + return true; + } + return false; + }).map(partner => { + // expected format + return { + email: partner.email, + id: partner.id, + name: partner.name, + }; + }); + // reduce results to max limit + matchingPartners.length = Math.min(matchingPartners.length, limit); + return matchingPartners; + }; + + // add main suggestions based on users + const partnersFromUsers = this._getRecords('res.users', []) + .map(user => this._getRecords('res.partner', [['id', '=', user.partner_id]])[0]) + .filter(partner => partner); + const mainMatchingPartners = mentionSuggestionsFilter(partnersFromUsers, search, limit); + + let extraMatchingPartners = []; + // if not enough results add extra suggestions based on partners + if (mainMatchingPartners.length < limit) { + const partners = this._getRecords('res.partner', [['id', 'not in', mainMatchingPartners.map(partner => partner.id)]]); + extraMatchingPartners = mentionSuggestionsFilter(partners, search, limit); + } + return [mainMatchingPartners, extraMatchingPartners]; + }, + /** + * Simulates `get_needaction_count` on `res.partner`. + * + * @private + */ + _mockResPartnerGetNeedactionCount() { + return this._getRecords('mail.notification', [ + ['res_partner_id', '=', this.currentPartnerId], + ['is_read', '=', false], + ]).length; + }, + /** + * Simulates `im_search` on `res.partner`. + * + * @private + * @param {string} [name=''] + * @param {integer} [limit=20] + * @returns {Object[]} + */ + _mockResPartnerImSearch(name = '', limit = 20) { + name = name.toLowerCase(); // simulates ILIKE + // simulates domain with relational parts (not supported by mock server) + const matchingPartners = this._getRecords('res.users', []) + .filter(user => { + const partner = this._getRecords('res.partner', [['id', '=', user.partner_id]])[0]; + // user must have a partner + if (!partner) { + return false; + } + // not current partner + if (partner.id === this.currentPartnerId) { + return false; + } + // no name is considered as return all + if (!name) { + return true; + } + if (partner.name && partner.name.toLowerCase().includes(name)) { + return true; + } + return false; + }).map(user => { + const partner = this._getRecords('res.partner', [['id', '=', user.partner_id]])[0]; + return { + id: partner.id, + im_status: user.im_status || 'offline', + name: partner.name, + user_id: user.id, + }; + }); + matchingPartners.length = Math.min(matchingPartners.length, limit); + return matchingPartners; + }, + /** + * Simulates `mail_partner_format` on `res.partner`. + * + * @private + * @returns {integer} id + * @returns {Object} + */ + _mockResPartnerMailPartnerFormat(id) { + const partner = this._getRecords( + 'res.partner', + [['id', '=', id]], + { active_test: false } + )[0]; + return { + "active": partner.active, + "display_name": partner.display_name, + "id": partner.id, + "im_status": partner.im_status, + "name": partner.name, + }; + }, +}); + +}); diff --git a/addons/mail/static/tests/mail_utils_tests.js b/addons/mail/static/tests/mail_utils_tests.js new file mode 100644 index 00000000..b330dc37 --- /dev/null +++ b/addons/mail/static/tests/mail_utils_tests.js @@ -0,0 +1,111 @@ +odoo.define('mail.mail_utils_tests', function (require) { +"use strict"; + +var utils = require('mail.utils'); + +QUnit.module('mail', {}, function () { + +QUnit.module('Mail utils'); + +QUnit.test('add_link utility function', function (assert) { + assert.expect(19); + + var testInputs = { + 'http://admin:password@example.com:8/%2020': true, + 'https://admin:password@example.com/test': true, + 'www.example.com:8/test': true, + 'https://127.0.0.5:8069': true, + 'www.127.0.0.5': false, + 'should.notmatch': false, + 'fhttps://test.example.com/test': false, + "https://www.transifex.com/odoo/odoo-11/translate/#fr/lunch?q=text%3A'La+Tartiflette'": true, + 'https://www.transifex.com/odoo/odoo-11/translate/#fr/$/119303430?q=text%3ATartiflette': true, + 'https://tenor.com/view/chỗgiặt-dog-smile-gif-13860250': true, + 'http://www.boîtenoire.be': true, + }; + + _.each(testInputs, function (willLinkify, content) { + var output = utils.parseAndTransform(content, utils.addLink); + if (willLinkify) { + assert.strictEqual(output.indexOf('<a '), 0, "There should be a link"); + assert.strictEqual(output.indexOf('</a>'), (output.length - 4), "Link should match the whole text"); + } else { + assert.strictEqual(output.indexOf('<a '), -1, "There should be no link"); + } + }); +}); + +QUnit.test('addLink: linkify inside text node (1 occurrence)', function (assert) { + assert.expect(5); + + const content = '<p>some text https://somelink.com</p>'; + const linkified = utils.parseAndTransform(content, utils.addLink); + assert.ok( + linkified.startsWith('<p>some text <a'), + "linkified text should start with non-linkified start part, followed by an '<a>' tag" + ); + assert.ok( + linkified.endsWith('</a></p>'), + "linkified text should end with closing '<a>' tag" + ); + + // linkify may add some attributes. Since we do not care of their exact + // stringified representation, we continue deeper assertion with query + // selectors. + const fragment = document.createDocumentFragment(); + const div = document.createElement('div'); + fragment.appendChild(div); + div.innerHTML = linkified; + assert.strictEqual( + div.textContent, + 'some text https://somelink.com', + "linkified text should have same text content as non-linkified version" + ); + assert.strictEqual( + div.querySelectorAll(':scope a').length, + 1, + "linkified text should have an <a> tag" + ); + assert.strictEqual( + div.querySelector(':scope a').textContent, + 'https://somelink.com', + "text content of link should be equivalent of its non-linkified version" + ); +}); + +QUnit.test('addLink: linkify inside text node (2 occurrences)', function (assert) { + assert.expect(4); + + // linkify may add some attributes. Since we do not care of their exact + // stringified representation, we continue deeper assertion with query + // selectors. + const content = '<p>some text https://somelink.com and again https://somelink2.com ...</p>'; + const linkified = utils.parseAndTransform(content, utils.addLink); + const fragment = document.createDocumentFragment(); + const div = document.createElement('div'); + fragment.appendChild(div); + div.innerHTML = linkified; + assert.strictEqual( + div.textContent, + 'some text https://somelink.com and again https://somelink2.com ...', + "linkified text should have same text content as non-linkified version" + ); + assert.strictEqual( + div.querySelectorAll(':scope a').length, + 2, + "linkified text should have 2 <a> tags" + ); + assert.strictEqual( + div.querySelectorAll(':scope a')[0].textContent, + 'https://somelink.com', + "text content of 1st link should be equivalent to its non-linkified version" + ); + assert.strictEqual( + div.querySelectorAll(':scope a')[1].textContent, + 'https://somelink2.com', + "text content of 2nd link should be equivalent to its non-linkified version" + ); +}); + +}); +}); diff --git a/addons/mail/static/tests/many2one_avatar_user_tests.js b/addons/mail/static/tests/many2one_avatar_user_tests.js new file mode 100644 index 00000000..7010cc9a --- /dev/null +++ b/addons/mail/static/tests/many2one_avatar_user_tests.js @@ -0,0 +1,123 @@ +odoo.define('mail.Many2OneAvatarUserTests', function (require) { +"use strict"; + +const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js'); + +const KanbanView = require('web.KanbanView'); +const ListView = require('web.ListView'); +const { Many2OneAvatarUser } = require('mail.Many2OneAvatarUser'); +const { dom, mock } = require('web.test_utils'); + + +QUnit.module('mail', {}, function () { + QUnit.module('Many2OneAvatarUser', { + beforeEach() { + beforeEach(this); + + // reset the cache before each test + Many2OneAvatarUser.prototype.partnerIds = {}; + + Object.assign(this.data, { + 'foo': { + fields: { + user_id: { string: "User", type: 'many2one', relation: 'res.users' }, + }, + records: [ + { id: 1, user_id: 11 }, + { id: 2, user_id: 7 }, + { id: 3, user_id: 11 }, + { id: 4, user_id: 23 }, + ], + }, + }); + + this.data['res.partner'].records.push( + { id: 11, display_name: "Partner 1" }, + { id: 12, display_name: "Partner 2" }, + { id: 13, display_name: "Partner 3" } + ); + this.data['res.users'].records.push( + { id: 11, name: "Mario", partner_id: 11 }, + { id: 7, name: "Luigi", partner_id: 12 }, + { id: 23, name: "Yoshi", partner_id: 13 } + ); + }, + afterEach() { + afterEach(this); + }, + }); + + QUnit.test('many2one_avatar_user widget in list view', async function (assert) { + assert.expect(5); + + const { widget: list } = await start({ + hasView: true, + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree><field name="user_id" widget="many2one_avatar_user"/></tree>', + mockRPC(route, args) { + if (args.method === 'read') { + assert.step(`read ${args.model} ${args.args[0]}`); + } + return this._super(...arguments); + }, + }); + + mock.intercept(list, 'open_record', () => { + assert.step('open record'); + }); + + assert.strictEqual(list.$('.o_data_cell span').text(), 'MarioLuigiMarioYoshi'); + + // sanity check: later on, we'll check that clicking on the avatar doesn't open the record + await dom.click(list.$('.o_data_row:first span')); + + await dom.click(list.$('.o_data_cell:nth(0) .o_m2o_avatar')); + await dom.click(list.$('.o_data_cell:nth(1) .o_m2o_avatar')); + await dom.click(list.$('.o_data_cell:nth(2) .o_m2o_avatar')); + + + assert.verifySteps([ + 'open record', + 'read res.users 11', + // 'call service openDMChatWindow 1', + 'read res.users 7', + // 'call service openDMChatWindow 2', + // 'call service openDMChatWindow 1', + ]); + + list.destroy(); + }); + + QUnit.test('many2one_avatar_user widget in kanban view', async function (assert) { + assert.expect(6); + + const { widget: kanban } = await start({ + hasView: true, + View: KanbanView, + model: 'foo', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="user_id" widget="many2one_avatar_user"/> + </div> + </t> + </templates> + </kanban>`, + }); + + assert.strictEqual(kanban.$('.o_kanban_record').text().trim(), ''); + assert.containsN(kanban, '.o_m2o_avatar', 4); + assert.strictEqual(kanban.$('.o_m2o_avatar:nth(0)').data('src'), '/web/image/res.users/11/image_128'); + assert.strictEqual(kanban.$('.o_m2o_avatar:nth(1)').data('src'), '/web/image/res.users/7/image_128'); + assert.strictEqual(kanban.$('.o_m2o_avatar:nth(2)').data('src'), '/web/image/res.users/11/image_128'); + assert.strictEqual(kanban.$('.o_m2o_avatar:nth(3)').data('src'), '/web/image/res.users/23/image_128'); + + kanban.destroy(); + }); +}); +}); diff --git a/addons/mail/static/tests/systray/systray_activity_menu_tests.js b/addons/mail/static/tests/systray/systray_activity_menu_tests.js new file mode 100644 index 00000000..f1d11ea1 --- /dev/null +++ b/addons/mail/static/tests/systray/systray_activity_menu_tests.js @@ -0,0 +1,276 @@ +odoo.define('mail.systray.ActivityMenuTests', function (require) { +"use strict"; + +const { + afterEach, + afterNextRender, + beforeEach, + start, +} = require('mail/static/src/utils/test_utils.js'); +var ActivityMenu = require('mail.systray.ActivityMenu'); + +var testUtils = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('ActivityMenu', { + beforeEach() { + beforeEach(this); + + Object.assign(this.data, { + 'mail.activity.menu': { + fields: { + name: { type: "char" }, + model: { type: "char" }, + type: { type: "char" }, + planned_count: { type: "integer" }, + today_count: { type: "integer" }, + overdue_count: { type: "integer" }, + total_count: { type: "integer" }, + actions: [{ + icon: { type: "char" }, + name: { type: "char" }, + action_xmlid: { type: "char" }, + }], + }, + records: [{ + name: "Contact", + model: "res.partner", + type: "activity", + planned_count: 0, + today_count: 1, + overdue_count: 0, + total_count: 1, + }, + { + name: "Task", + type: "activity", + model: "project.task", + planned_count: 1, + today_count: 0, + overdue_count: 0, + total_count: 1, + }, + { + name: "Issue", + type: "activity", + model: "project.issue", + planned_count: 1, + today_count: 1, + overdue_count: 1, + total_count: 3, + actions: [{ + icon: "fa-clock-o", + name: "summary", + }], + }, + { + name: "Note", + type: "activity", + model: "partner", + planned_count: 1, + today_count: 1, + overdue_count: 1, + total_count: 3, + actions: [{ + icon: "fa-clock-o", + name: "summary", + action_xmlid: "mail.mail_activity_type_view_tree", + }], + } + ], + }, + }); + this.session = { + uid: 10, + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('activity menu widget: menu with no records', async function (assert) { + assert.expect(1); + + const { widget } = await start({ + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'systray_get_activities') { + return Promise.resolve([]); + } + return this._super(route, args); + }, + }); + const activityMenu = new ActivityMenu(widget); + await activityMenu.appendTo($('#qunit-fixture')); + await testUtils.nextTick(); + assert.containsOnce(activityMenu, '.o_no_activity'); + widget.destroy(); +}); + +QUnit.test('activity menu widget: activity menu with 3 records', async function (assert) { + assert.expect(10); + var self = this; + + const { widget } = await start({ + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'systray_get_activities') { + return Promise.resolve(self.data['mail.activity.menu']['records']); + } + return this._super(route, args); + }, + }); + var activityMenu = new ActivityMenu(widget); + await activityMenu.appendTo($('#qunit-fixture')); + await testUtils.nextTick(); + assert.hasClass(activityMenu.$el, 'o_mail_systray_item', 'should be the instance of widget'); + // the assertion below has not been replace because there are includes of ActivityMenu that modify the length. + assert.ok(activityMenu.$('.o_mail_preview').length); + assert.containsOnce(activityMenu.$el, '.o_notification_counter', "widget should have notification counter"); + assert.strictEqual(parseInt(activityMenu.el.innerText), 8, "widget should have 8 notification counter"); + + var context = {}; + testUtils.mock.intercept(activityMenu, 'do_action', function (event) { + assert.deepEqual(event.data.action.context, context, "wrong context value"); + }, true); + + // case 1: click on "late" + context = { + force_search_count: 1, + search_default_activities_overdue: 1, + }; + await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); + assert.hasClass(activityMenu.$el, 'show', 'ActivityMenu should be open'); + await testUtils.dom.click(activityMenu.$(".o_activity_filter_button[data-model_name='Issue'][data-filter='overdue']")); + assert.doesNotHaveClass(activityMenu.$el, 'show', 'ActivityMenu should be closed'); + // case 2: click on "today" + context = { + force_search_count: 1, + search_default_activities_today: 1, + }; + await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); + await testUtils.dom.click(activityMenu.$(".o_activity_filter_button[data-model_name='Issue'][data-filter='today']")); + // case 3: click on "future" + context = { + force_search_count: 1, + search_default_activities_upcoming_all: 1, + }; + await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); + await testUtils.dom.click(activityMenu.$(".o_activity_filter_button[data-model_name='Issue'][data-filter='upcoming_all']")); + // case 4: click anywere else + context = { + force_search_count: 1, + search_default_activities_overdue: 1, + search_default_activities_today: 1, + }; + await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); + await testUtils.dom.click(activityMenu.$(".o_mail_systray_dropdown_items > div[data-model_name='Issue']")); + + widget.destroy(); +}); + +QUnit.test('activity menu widget: activity view icon', async function (assert) { + assert.expect(12); + var self = this; + + const { widget } = await start({ + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'systray_get_activities') { + return Promise.resolve(self.data['mail.activity.menu'].records); + } + return this._super(route, args); + }, + session: this.session, + }); + var activityMenu = new ActivityMenu(widget); + await activityMenu.appendTo($('#qunit-fixture')); + await testUtils.nextTick(); + assert.containsN(activityMenu, '.o_mail_activity_action', 2, + "widget should have 2 activity view icons"); + + var $first = activityMenu.$('.o_mail_activity_action').eq(0); + var $second = activityMenu.$('.o_mail_activity_action').eq(1); + assert.strictEqual($first.data('model_name'), "Issue", + "first activity action should link to 'Issue'"); + assert.hasClass($first, 'fa-clock-o', "should display the activity action icon"); + + assert.strictEqual($second.data('model_name'), "Note", + "Second activity action should link to 'Note'"); + assert.hasClass($second, 'fa-clock-o', "should display the activity action icon"); + + testUtils.mock.intercept(activityMenu, 'do_action', function (ev) { + if (ev.data.action.name) { + assert.ok(ev.data.action.domain, "should define a domain on the action"); + assert.deepEqual(ev.data.action.domain, [["activity_ids.user_id", "=", 10]], + "should set domain to user's activity only"); + assert.step('do_action:' + ev.data.action.name); + } else { + assert.step('do_action:' + ev.data.action); + } + }, true); + + // click on the "Issue" activity icon + await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); + assert.hasClass(activityMenu.$('.dropdown-menu'), 'show', + "dropdown should be expanded"); + + await testUtils.dom.click(activityMenu.$(".o_mail_activity_action[data-model_name='Issue']")); + assert.doesNotHaveClass(activityMenu.$('.dropdown-menu'), 'show', + "dropdown should be collapsed"); + + // click on the "Note" activity icon + await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); + await testUtils.dom.click(activityMenu.$(".o_mail_activity_action[data-model_name='Note']")); + + assert.verifySteps([ + 'do_action:Issue', + 'do_action:mail.mail_activity_type_view_tree' + ]); + + widget.destroy(); +}); + +QUnit.test('activity menu widget: close on messaging menu click', async function (assert) { + assert.expect(2); + + const { widget } = await start({ + data: this.data, + hasMessagingMenu: true, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return []; + } + if (args.method === 'systray_get_activities') { + return []; + } + return this._super(route, args); + }, + }); + const activityMenu = new ActivityMenu(widget); + await activityMenu.appendTo($('#qunit-fixture')); + await testUtils.nextTick(); + + await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); + assert.hasClass( + activityMenu.el.querySelector('.o_mail_systray_dropdown'), + 'show', + "activity menu should be shown after click on itself" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + assert.doesNotHaveClass( + activityMenu.el.querySelector('.o_mail_systray_dropdown'), + 'show', + "activity menu should be hidden after click on messaging menu" + ); + + widget.destroy(); +}); + +}); + +}); diff --git a/addons/mail/static/tests/tools/debug_manager_tests.js b/addons/mail/static/tests/tools/debug_manager_tests.js new file mode 100644 index 00000000..151b8902 --- /dev/null +++ b/addons/mail/static/tests/tools/debug_manager_tests.js @@ -0,0 +1,64 @@ +odoo.define('mail.debugManagerTests', function (require) { +"use strict"; + +var testUtils = require('web.test_utils'); + +var createDebugManager = testUtils.createDebugManager; + +QUnit.module('Mail DebugManager', {}, function () { + + QUnit.test("Manage Messages", async function (assert) { + assert.expect(3); + + var debugManager = await createDebugManager({ + intercepts: { + do_action: function (event) { + assert.deepEqual(event.data.action, { + context: { + default_res_model: "testModel", + default_res_id: 5, + }, + res_model: 'mail.message', + name: "Manage Messages", + views: [[false, 'list'], [false, 'form']], + type: 'ir.actions.act_window', + domain: [['res_id', '=', 5], ['model', '=', 'testModel']], + }); + }, + }, + }); + + await debugManager.appendTo($('#qunit-fixture')); + + // Simulate update debug manager from web client + var action = { + views: [{ + displayName: "Form", + fieldsView: { + view_id: 1, + }, + type: "form", + }], + }; + var view = { + viewType: "form", + getSelectedIds: function () { + return [5]; + }, + modelName: 'testModel', + }; + await testUtils.nextTick(); + await debugManager.update('action', action, view); + + var $messageMenu = debugManager.$('a[data-action=getMailMessages]'); + assert.strictEqual($messageMenu.length, 1, "should have Manage Message menu item"); + assert.strictEqual($messageMenu.text().trim(), "Manage Messages", + "should have correct menu item text"); + + await testUtils.dom.click(debugManager.$('> a')); // open dropdown + await testUtils.dom.click($messageMenu); + + debugManager.destroy(); + }); +}); +}); diff --git a/addons/mail/static/tests/tours/mail_full_composer_test_tour.js b/addons/mail/static/tests/tours/mail_full_composer_test_tour.js new file mode 100644 index 00000000..6d9e519e --- /dev/null +++ b/addons/mail/static/tests/tours/mail_full_composer_test_tour.js @@ -0,0 +1,89 @@ +odoo.define('mail/static/tests/tours/mail_full_composer_test_tour.js', function (require) { +"use strict"; + +const { + createFile, + inputFiles, +} = require('web.test_utils_file'); + +const tour = require('web_tour.tour'); + +/** + * This tour depends on data created by python test in charge of launching it. + * It is not intended to work when launched from interface. It is needed to test + * an action (action manager) which is not possible to test with QUnit. + * @see mail/tests/test_mail_full_composer.py + */ +tour.register('mail/static/tests/tours/mail_full_composer_test_tour.js', { + test: true, +}, [{ + content: "Click on Send Message", + trigger: '.o_ChatterTopbar_buttonSendMessage', +}, { + content: "Write something in composer", + trigger: '.o_ComposerTextInput_textarea', + run: 'text blahblah', +}, { + content: "Add one file in composer", + trigger: '.o_Composer_buttonAttachment', + async run() { + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ); + }, +}, { + content: "Open full composer", + trigger: '.o_Composer_buttonFullComposer', + extra_trigger: '.o_Attachment:not(.o-temporary)' // waiting the attachment to be uploaded +}, { + content: "Check the earlier provided attachment is listed", + trigger: '.o_attachment[title="text.txt"]', + run() {}, +}, { + content: "Check subject is autofilled", + trigger: 'input[name="subject"]', + run() { + const subjectValue = document.querySelector('input[name="subject"]').value; + if (subjectValue !== "Re: Test User") { + console.error( + `Full composer should have "Re: Test User" in subject input (actual: ${subjectValue})` + ); + } + }, +}, { + content: "Check composer content is kept", + trigger: '.oe_form_field[name="body"]', + run() { + const bodyContent = document.querySelector('.oe_form_field[name="body"] textarea').textContent; + if (!bodyContent.includes("blahblah")) { + console.error( + `Full composer should contain text from small composer ("blahblah") in body input (actual: ${bodyContent})` + ); + } + }, +}, { + content: "Open templates", + trigger: '.o_field_widget[name="template_id"] input', +}, { + content: "Check a template is listed", + in_modal: false, + trigger: '.ui-autocomplete .ui-menu-item a:contains("Test template")', + run() {}, +}, { + content: "Send message", + trigger: '.o_mail_send', +}, { + content: "Check message is shown", + trigger: '.o_Message:contains("blahblah")', +}, { + content: "Check message contains the attachment", + trigger: '.o_Message .o_Attachment_filename:contains("text.txt")', +}]); + +}); |
