summaryrefslogtreecommitdiff
path: root/addons/mail/static/tests
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/tests
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mail/static/tests')
-rw-r--r--addons/mail/static/tests/activity_tests.js562
-rw-r--r--addons/mail/static/tests/chatter_tests.js568
-rw-r--r--addons/mail/static/tests/document_viewer_tests.js232
-rw-r--r--addons/mail/static/tests/helpers/mock_models.js258
-rw-r--r--addons/mail/static/tests/helpers/mock_server.js1809
-rw-r--r--addons/mail/static/tests/mail_utils_tests.js111
-rw-r--r--addons/mail/static/tests/many2one_avatar_user_tests.js123
-rw-r--r--addons/mail/static/tests/systray/systray_activity_menu_tests.js276
-rw-r--r--addons/mail/static/tests/tools/debug_manager_tests.js64
-rw-r--r--addons/mail/static/tests/tours/mail_full_composer_test_tour.js89
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")',
+}]);
+
+});