diff options
| author | Sébastien Theys <seb@odoo.com> | 2020-08-24 10:37:47 +0000 |
|---|---|---|
| committer | Sébastien Theys <seb@odoo.com> | 2020-08-24 13:21:45 +0000 |
| commit | 72b9335334c076d8deaa36e404feaa3f4d30c351 (patch) | |
| tree | aea8c816b33d89b409fedbc75bcca3d2d6597717 | |
| parent | 0834d241624330ab3fd3925366a3217fe8145dfc (diff) | |
[FIX] mail, im_livechat: make livechat work properly
- fix traceback on receiving first livechat message due to bad computation of
correspondent
- fix missing [0] in return of country name_get in channel_info
- fix visitor/correspondent computation to work when chatting with oneself
- reintroduce livechat ordered by last message in discuss sidebar
- improve mock server to make these fixes possible to test
- introduce corresponding tests
PR: #56243
task-2323670
closes odoo/odoo#56414
X-original-commit: f623bfd2c8819604c709bc723153c071dca6e3cc
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
16 files changed, 1129 insertions, 136 deletions
diff --git a/addons/im_livechat/models/mail_channel.py b/addons/im_livechat/models/mail_channel.py index 783e576e71f..f15c5d54155 100644 --- a/addons/im_livechat/models/mail_channel.py +++ b/addons/im_livechat/models/mail_channel.py @@ -89,7 +89,7 @@ class MailChannel(models.Model): channel_infos_dict[channel.id]['livechat_visitor'] = channel._channel_get_livechat_visitor_info() last_msg = self.env['mail.message'].search([("channel_ids", "in", [channel.id])], limit=1) if last_msg: - channel_infos_dict[channel.id]['last_message_date'] = last_msg.date + channel_infos_dict[channel.id]['last_message_id'] = last_msg.id return list(channel_infos_dict.values()) @api.model @@ -100,23 +100,24 @@ class MailChannel(models.Model): return values def _channel_get_livechat_visitor_info(self): - partners = self.channel_partner_ids - self.livechat_operator_id - if partners: + self.ensure_one() + # remove active test to ensure public partner is taken into account + channel_partner_ids = self.with_context(active_test=False).channel_partner_ids + partners = channel_partner_ids - self.livechat_operator_id + if not partners: + # operator probably testing the livechat with his own user + partners = channel_partner_ids + if partners and partners[0] != self.env.ref('base.public_partner'): + # legit non-public partner return { - 'country': partners[0].country_id.name_get() if partners[0].country_id else False, + 'country': partners[0].country_id.name_get()[0] if partners[0].country_id else False, 'id': partners[0].id, 'name': partners[0].name, } - if self.anonymous_name: - return { - 'country': False, - 'id': False, - 'name': self.anonymous_name, - } return { - 'country': False, + 'country': self.country_id.name_get()[0] if self.country_id else False, 'id': False, - 'name': _("Visitor"), + 'name': self.anonymous_name or _("Visitor"), } def _channel_get_livechat_partner_name(self): diff --git a/addons/im_livechat/static/src/components/discuss/discuss_tests.js b/addons/im_livechat/static/src/components/discuss/discuss_tests.js index 22f4c9a235c..166e4fd679f 100644 --- a/addons/im_livechat/static/src/components/discuss/discuss_tests.js +++ b/addons/im_livechat/static/src/components/discuss/discuss_tests.js @@ -1,7 +1,13 @@ odoo.define('im_livechat/static/src/components/discuss/discuss_tests.js', function (require) { 'use strict'; -const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js'); +const { + afterEach, + afterNextRender, + beforeEach, + nextAnimationFrame, + start, +} = require('mail/static/src/utils/test_utils.js'); QUnit.module('im_livechat', {}, function () { QUnit.module('components', {}, function () { @@ -28,13 +34,12 @@ QUnit.module('discuss_tests.js', { QUnit.test('livechat in the sidebar: basic rendering', async function (assert) { assert.expect(5); - // channel that is expected to be found in the sidebar this.data['mail.channel'].records.push({ - channel_type: 'livechat', // channel is expected to be livechat - id: 11, // random unique id, will be referenced in the test - livechat_visitor: { - name: "Visitor 11", // random name, will be asserted during the test - }, + anonymous_name: "Visitor 11", + channel_type: 'livechat', + id: 11, + livechat_operator_id: this.data.currentPartnerId, + members: [this.data.currentPartnerId, this.data.publicPartnerId], }); await this.start(); assert.containsOnce(document.body, '.o_Discuss_sidebar', @@ -69,6 +74,343 @@ QUnit.test('livechat in the sidebar: basic rendering', async function (assert) { ); }); +QUnit.test('livechat in the sidebar: existing user with country', async function (assert) { + assert.expect(3); + + this.data['res.country'].records.push({ + code: 'be', + id: 10, + name: "Belgium", + }); + this.data['res.partner'].records.push({ + country_id: 10, + id: 10, + name: "Jean", + }); + this.data['mail.channel'].records.push({ + channel_type: 'livechat', + id: 11, + livechat_operator_id: this.data.currentPartnerId, + members: [this.data.currentPartnerId, 10], + }); + await this.start(); + assert.containsOnce( + document.body, + '.o_DiscussSidebar_groupLivechat', + "should have a channel group livechat in the side bar" + ); + const livechat = document.querySelector('.o_DiscussSidebar_groupLivechat .o_DiscussSidebarItem'); + assert.ok( + livechat, + "should have a livechat in sidebar" + ); + assert.strictEqual( + livechat.textContent, + "Jean (Belgium)", + "should have user name and country as livechat name" + ); +}); + +QUnit.test('do not add livechat in the sidebar on visitor opening his chat', async function (assert) { + assert.expect(2); + + const currentUser = this.data['res.users'].records.find(user => + user.id === this.data.currentUserId + ); + currentUser.im_status = 'online'; + this.data['im_livechat.channel'].records.push({ + id: 10, + user_ids: [this.data.currentUserId], + }); + await this.start(); + assert.containsNone( + document.body, + '.o_DiscussSidebar_groupLivechat', + "should not have any livechat in the sidebar initially" + ); + + // simulate livechat visitor opening his chat + await this.env.services.rpc({ + route: '/im_livechat/get_session', + params: { + context: { + mockedUserId: false, + }, + channel_id: 10, + }, + }); + await nextAnimationFrame(); + assert.containsNone( + document.body, + '.o_DiscussSidebar_groupLivechat', + "should still not have any livechat in the sidebar after visitor opened his chat" + ); +}); + +QUnit.test('do not add livechat in the sidebar on visitor typing', async function (assert) { + assert.expect(2); + + const currentUser = this.data['res.users'].records.find(user => + user.id === this.data.currentUserId + ); + currentUser.im_status = 'online'; + this.data['im_livechat.channel'].records.push({ + id: 10, + user_ids: [this.data.currentUserId], + }); + this.data['mail.channel'].records.push({ + channel_type: 'livechat', + id: 10, + is_pinned: false, + livechat_channel_id: 10, + livechat_operator_id: this.data.currentPartnerId, + members: [this.data.publicPartnerId, this.data.currentPartnerId], + }); + await this.start(); + assert.containsNone( + document.body, + '.o_DiscussSidebar_groupLivechat', + "should not have any livechat in the sidebar initially" + ); + + // simulate livechat visitor typing + const channel = this.data['mail.channel'].records.find(channel => channel.id === 10); + await this.env.services.rpc({ + route: '/im_livechat/notify_typing', + params: { + context: { + mockedPartnerId: this.publicPartnerId, + }, + is_typing: true, + uuid: channel.uuid, + }, + }); + await nextAnimationFrame(); + assert.containsNone( + document.body, + '.o_DiscussSidebar_groupLivechat', + "should still not have any livechat in the sidebar after visitor started typing" + ); +}); + +QUnit.test('add livechat in the sidebar on visitor sending first message', async function (assert) { + assert.expect(4); + + const currentUser = this.data['res.users'].records.find(user => + user.id === this.data.currentUserId + ); + currentUser.im_status = 'online'; + this.data['res.country'].records.push({ + code: 'be', + id: 10, + name: "Belgium", + }); + this.data['im_livechat.channel'].records.push({ + id: 10, + user_ids: [this.data.currentUserId], + }); + this.data['mail.channel'].records.push({ + anonymous_name: "Visitor (Belgium)", + channel_type: 'livechat', + country_id: 10, + id: 10, + is_pinned: false, + livechat_channel_id: 10, + livechat_operator_id: this.data.currentPartnerId, + members: [this.data.publicPartnerId, this.data.currentPartnerId], + }); + await this.start(); + assert.containsNone( + document.body, + '.o_DiscussSidebar_groupLivechat', + "should not have any livechat in the sidebar initially" + ); + + // simulate livechat visitor sending a message + const channel = this.data['mail.channel'].records.find(channel => channel.id === 10); + await afterNextRender(async () => this.env.services.rpc({ + route: '/mail/chat_post', + params: { + context: { + mockedUserId: false, + }, + uuid: channel.uuid, + message_content: "new message", + }, + })); + assert.containsOnce( + document.body, + '.o_DiscussSidebar_groupLivechat', + "should have a channel group livechat in the side bar after receiving first message" + ); + assert.containsOnce( + document.body, + '.o_DiscussSidebar_groupLivechat .o_DiscussSidebar_item', + "should have a livechat in the sidebar after receiving first message" + ); + assert.strictEqual( + document.querySelector('.o_DiscussSidebar_groupLivechat .o_DiscussSidebar_item').textContent, + "Visitor (Belgium)", + "should have visitor name and country as livechat name" + ); +}); + +QUnit.test('livechats are sorted by last message date in the sidebar: most recent at the top', async function (assert) { + /** + * For simplicity the code that is covered in this test is considering + * messages to be more/less recent than others based on their ids instead of + * their actual creation date. + */ + assert.expect(7); + + this.data['mail.message'].records.push( + { id: 11, channel_ids: [11] }, // least recent message due to smaller id + { id: 12, channel_ids: [12] }, // most recent message due to higher id + ); + this.data['mail.channel'].records.push( + { + anonymous_name: "Visitor 11", + channel_type: 'livechat', + id: 11, + livechat_operator_id: this.data.currentPartnerId, + members: [this.data.currentPartnerId, this.data.publicPartnerId], + }, + { + anonymous_name: "Visitor 12", + channel_type: 'livechat', + id: 12, + livechat_operator_id: this.data.currentPartnerId, + members: [this.data.currentPartnerId, this.data.publicPartnerId], + }, + ); + await this.start(); + const livechat11 = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 11, + model: 'mail.channel', + }); + const livechat12 = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 12, + model: 'mail.channel', + }); + assert.containsOnce( + document.body, + '.o_DiscussSidebar_groupLivechat', + "should have a channel group livechat" + ); + const initialLivechats = document.querySelectorAll('.o_DiscussSidebar_groupLivechat .o_DiscussSidebarItem'); + assert.strictEqual( + initialLivechats.length, + 2, + "should have 2 livechats in the sidebar" + ); + assert.strictEqual( + initialLivechats[0].dataset.threadLocalId, + livechat12.localId, + "first livechat should be the one with the most recent message" + ); + assert.strictEqual( + initialLivechats[1].dataset.threadLocalId, + livechat11.localId, + "second livechat should be the one with the least recent message" + ); + + // post a new message on the last channel + await afterNextRender(() => initialLivechats[1].click()); + await afterNextRender(() => document.execCommand('insertText', false, "Blabla")); + await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click()); + const livechats = document.querySelectorAll('.o_DiscussSidebar_groupLivechat .o_DiscussSidebarItem'); + assert.strictEqual( + livechats.length, + 2, + "should still have 2 livechats in the sidebar after posting a new message" + ); + assert.strictEqual( + livechats[0].dataset.threadLocalId, + livechat11.localId, + "first livechat should now be the one on which the new message was posted" + ); + assert.strictEqual( + livechats[1].dataset.threadLocalId, + livechat12.localId, + "second livechat should now be the one on which the message was not posted" + ); +}); + +QUnit.test('livechats with no messages are sorted by creation date in the sidebar: most recent at the top', async function (assert) { + /** + * For simplicity the code that is covered in this test is considering + * channels to be more/less recent than others based on their ids instead of + * their actual creation date. + */ + assert.expect(5); + + this.data['mail.message'].records.push( + { id: 13, channel_ids: [13] }, + ); + this.data['mail.channel'].records.push( + { + anonymous_name: "Visitor 11", + channel_type: 'livechat', + id: 11, // least recent channel due to smallest id + livechat_operator_id: this.data.currentPartnerId, + members: [this.data.currentPartnerId, this.data.publicPartnerId], + }, + { + anonymous_name: "Visitor 12", + channel_type: 'livechat', + id: 12, // most recent channel that does not have a message + livechat_operator_id: this.data.currentPartnerId, + members: [this.data.currentPartnerId, this.data.publicPartnerId], + }, + { + anonymous_name: "Visitor 13", + channel_type: 'livechat', + id: 13, // most recent channel (but it has a message) + livechat_operator_id: this.data.currentPartnerId, + members: [this.data.currentPartnerId, this.data.publicPartnerId], + }, + ); + await this.start(); + const livechat11 = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 11, + model: 'mail.channel', + }); + const livechat12 = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 12, + model: 'mail.channel', + }); + const livechat13 = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 13, + model: 'mail.channel', + }); + assert.containsOnce( + document.body, + '.o_DiscussSidebar_groupLivechat', + "should have a channel group livechat" + ); + const initialLivechats = document.querySelectorAll('.o_DiscussSidebar_groupLivechat .o_DiscussSidebarItem'); + assert.strictEqual( + initialLivechats.length, + 3, + "should have 3 livechats in the sidebar" + ); + assert.strictEqual( + initialLivechats[0].dataset.threadLocalId, + livechat12.localId, + "first livechat should be the most recent channel without message" + ); + assert.strictEqual( + initialLivechats[1].dataset.threadLocalId, + livechat11.localId, + "second livechat should be the second most recent channel without message" + ); + assert.strictEqual( + initialLivechats[2].dataset.threadLocalId, + livechat13.localId, + "third livechat should be the channel with a message" + ); +}); + }); }); }); diff --git a/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js b/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js index 36656c877c7..2dafeed4c79 100644 --- a/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js +++ b/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js @@ -19,11 +19,27 @@ patch(components.DiscussSidebar, 'im_livechat/static/src/components/discuss_side * @returns {mail.thread[]} */ quickSearchOrderedAndPinnedLivechatList() { - const allOrderedAndPinnedLivechats = this.env.models['mail.thread'].all(thread => - thread.channel_type === 'livechat' && - thread.isPinned && - thread.model === 'mail.channel' - ); + const allOrderedAndPinnedLivechats = this.env.models['mail.thread'] + .all(thread => + thread.channel_type === 'livechat' && + thread.isPinned && + thread.model === 'mail.channel' + ).sort((c1, c2) => { + // sort by: last message id (desc), id (desc) + if (c1.lastMessage && c2.lastMessage) { + return c2.lastMessage.id - c1.lastMessage.id; + } + // a channel without a last message is assumed to be a new + // channel just created with the intent of posting a new + // message on it, in which case it should be moved up. + if (!c1.lastMessage) { + return -1; + } + if (!c2.lastMessage) { + return 1; + } + return c2.id - c1.id; + }); if (!this.discuss.sidebarQuickSearchValue) { return allOrderedAndPinnedLivechats; } @@ -59,7 +75,6 @@ patch(components.DiscussSidebar, 'im_livechat/static/src/components/discuss_side thread.isPinned && thread.model === 'mail.channel' ) - .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1) .map(livechat => livechat.__state), } ); diff --git a/addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js b/addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js index 32fb4583108..6ff7767af25 100644 --- a/addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js +++ b/addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js @@ -42,13 +42,12 @@ QUnit.module('thread_icon_tests.js', { QUnit.test('livechat: public website visitor is typing', async function (assert) { assert.expect(4); - // channel that will be used for testing the typing feature this.data['mail.channel'].records.push({ - channel_type: 'livechat', // channel is expected to be livechat - id: 20, // random unique id, will be referenced in the test - livechat_visitor: { - name: "Visitor 20", // random name, will be asserted during the test - }, + anonymous_name: "Visitor 20", + channel_type: 'livechat', + id: 20, + livechat_operator_id: this.data.currentPartnerId, + members: [this.data.currentPartnerId, this.data.publicPartnerId], }); await this.start(); const thread = this.env.models['mail.thread'].find(thread => diff --git a/addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js b/addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js index e085c03b8d7..b3cbe96e277 100644 --- a/addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js +++ b/addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js @@ -42,15 +42,12 @@ QUnit.module('thread_textual_typing_status_tests.js', { QUnit.test('receive visitor typing status "is typing"', async function (assert) { assert.expect(2); - // channel that will be used for testing the typing feature this.data['mail.channel'].records.push({ - // channel is expected to be livechat, but channel_type set only for - // consistency, not actually useful in the scope of this test + anonymous_name: "Visitor 20", channel_type: 'livechat', - id: 20, // random unique id, will be referenced in the test - livechat_visitor: { - name: "Visitor 20", // random name, will be asserted during the test - }, + id: 20, + livechat_operator_id: this.data.currentPartnerId, + members: [this.data.currentPartnerId, this.data.publicPartnerId], }); await this.start(); const thread = this.env.models['mail.thread'].find(thread => diff --git a/addons/im_livechat/static/src/models/thread/thread.js b/addons/im_livechat/static/src/models/thread/thread.js index 148e7b7c5ee..190a2088066 100644 --- a/addons/im_livechat/static/src/models/thread/thread.js +++ b/addons/im_livechat/static/src/models/thread/thread.js @@ -17,12 +17,35 @@ registerClassPatchModel('mail.thread', 'im_livechat/static/src/models/thread/thr */ convertData(data) { const data2 = this._super(data); + if ('last_message_id' in data) { + if (!data2.messagesAsServerChannel) { + data2.messagesAsServerChannel = []; + } + data2.messagesAsServerChannel.push(['insert', { id: data.last_message_id }]); + } if ('livechat_visitor' in data && data.livechat_visitor) { if (!data2.members) { data2.members = []; } + // `livechat_visitor` without `id` is the anonymous visitor. if (!data.livechat_visitor.id) { - // Create partner derived from public partner. + /** + * Create partner derived from public partner and replace the + * public partner. + * + * Indeed the anonymous visitor is registered as a member of the + * channel as the public partner in the database to avoid + * polluting the contact list with many temporary partners. + * + * But the issue with public partner is that it is the same + * record for every livechat, whereas every correspondent should + * actually have its own visitor name, typing status, etc. + * + * Due to JS being temporary by nature there is no such notion + * of polluting the database, it is therefore acceptable and + * easier to handle one temporary partner per channel. + */ + data2.members.push(['unlink', this.env.messaging.publicPartner]); const partner = this.env.models['mail.partner'].create( Object.assign( this.env.models['mail.partner'].convertData(data.livechat_visitor), @@ -50,6 +73,9 @@ registerInstancePatchModel('mail.thread', 'im_livechat/static/src/models/thread/ */ _computeDisplayName() { if (this.channel_type === 'livechat' && this.correspondent) { + if (this.correspondent.country) { + return `${this.correspondent.nameOrDisplayName} (${this.correspondent.country.name})`; + } return this.correspondent.nameOrDisplayName; } return this._super(); diff --git a/addons/im_livechat/static/tests/helpers/mock_models.js b/addons/im_livechat/static/tests/helpers/mock_models.js index c618e7f9b32..db18e8f776b 100644 --- a/addons/im_livechat/static/tests/helpers/mock_models.js +++ b/addons/im_livechat/static/tests/helpers/mock_models.js @@ -15,9 +15,23 @@ MockModels.patch('im_livechat/static/tests/helpers/mock_models.js', T => */ static generateData() { const data = super.generateData(...arguments); + Object.assign(data, { + 'im_livechat.channel': { + fields: { + user_ids: { string: "Operators", type: 'many2many', relation: 'res.users' } + }, + records: [], + } + }); Object.assign(data['mail.channel'].fields, { - // fake type, not on Python models but is the result of a formatter - livechat_visitor: { string: 'livechat_visitor', type: 'Object', default: false }, + anonymous_name: { string: "Anonymous Name", type: 'char' }, + country_id: { string: "Country", type: 'many2one', relation: 'res.country' }, + livechat_active: { string: "Is livechat ongoing?", type: 'boolean', default: false }, + livechat_channel_id: { string: "Channel", type: 'many2one', relation: 'im_livechat.channel' }, + livechat_operator_id: { string: "Operator", type: 'many2one', relation: 'res.partner' }, + }); + Object.assign(data['res.users'].fields, { + livechat_username: { string: 'Livechat Username', type: 'string' }, }); return data; } diff --git a/addons/im_livechat/static/tests/helpers/mock_server.js b/addons/im_livechat/static/tests/helpers/mock_server.js index d3f02f81fa2..b8f597dd418 100644 --- a/addons/im_livechat/static/tests/helpers/mock_server.js +++ b/addons/im_livechat/static/tests/helpers/mock_server.js @@ -13,6 +13,76 @@ MockServer.include({ /** * @override */ + async _performRpc(route, args) { + if (route === '/im_livechat/get_session') { + const channel_id = args.channel_id; + const anonymous_name = args.anonymous_name; + const previous_operator_id = args.previous_operator_id; + const context = args.context; + return this._mockRouteImLivechatGetSession(channel_id, anonymous_name, previous_operator_id, context); + } + if (route === '/im_livechat/notify_typing') { + const uuid = args.uuid; + const is_typing = args.is_typing; + const context = args.context; + return this._mockRouteImLivechatNotifyTyping(uuid, is_typing, context); + } + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private Mocked Routes + //-------------------------------------------------------------------------- + + /** + * Simulates the `/im_livechat/get_session` route. + * + * @private + * @param {integer} channel_id + * @param {string} anonymous_name + * @param {integer} [previous_operator_id] + * @param {Object} [context={}] + * @returns {Object} + */ + _mockRouteImLivechatGetSession(channel_id, anonymous_name, previous_operator_id, context = {}) { + let user_id; + let country_id; + if ('mockedUserId' in context) { + // can be falsy to simulate not being logged in + user_id = context.mockedUserId; + } else { + user_id = this.currentUserId; + } + // don't use the anonymous name if the user is logged in + if (user_id) { + const user = this._getRecords('res.users', [['id', '=', user_id]])[0]; + country_id = user.country_id; + } else { + // simulate geoip + const countryCode = context.mockedCountryCode; + const country = this._getRecords('res.country', [['code', '=', countryCode]])[0]; + if (country) { + country_id = country.id; + anonymous_name = anonymous_name + ' (' + country.name + ')'; + } + } + return this._mockImLivechatChannel_openLivechatMailChannel(channel_id, anonymous_name, previous_operator_id, user_id, country_id); + }, + /** + * Simulates the `/im_livechat/notify_typing` route. + * + * @private + * @param {string} uuid + * @param {boolean} is_typing + * @param {Object} [context] + */ + _mockRouteImLivechatNotifyTyping(uuid, is_typing, context) { + const mailChannel = this._getRecords('mail.channel', [['uuid', '=', uuid]])[0]; + this._mockMailChannelNotifyTyping([mailChannel.id], is_typing, context); + }, + /** + * @override + */ _mockRouteMailInitMessaging() { const initMessaging = this._super(...arguments); @@ -27,6 +97,172 @@ MockServer.include({ return initMessaging; }, + + //-------------------------------------------------------------------------- + // Private Mocked Methods + //-------------------------------------------------------------------------- + + /** + * Simulates `_channel_get_livechat_visitor_info` on `mail.channel`. + * + * @private + * @param {integer[]} ids + * @returns {Object} + */ + _mockMailChannel_ChannelGetLivechatVisitorInfo(ids) { + const id = ids[0]; // ensure_one + const mailChannel = this._getRecords('mail.channel', [['id', '=', id]])[0]; + // remove active test to ensure public partner is taken into account + let members = this._getRecords( + 'res.partner', + [['id', 'in', mailChannel.members]], + { active_test: false }, + ); + members = members.filter(member => member.id !== mailChannel.livechat_operator_id); + if (members.length === 0 && mailChannel.livechat_operator_id) { + // operator probably testing the livechat with his own user + members = [mailChannel.livechat_operator_id]; + } + if (members.length > 0 && members[0].id !== this.publicPartnerId) { + // legit non-public partner + const country = this._getRecords('res.country', [['id', '=', members[0].country_id]])[0]; + return { + 'country': country ? [country.id, country.name] : false, + 'id': members[0].id, + 'name': members[0].name, + }; + } + return { + 'country': false, + 'id': false, + 'name': mailChannel.anonymous_name || "Visitor", + }; + }, + /** + * @override + */ + _mockMailChannelChannelInfo(ids, extra_info) { + const channelInfos = this._super(...arguments); + for (const channelInfo of channelInfos) { + const channel = this._getRecords('mail.channel', [['id', '=', channelInfo.id]])[0]; + // add the last message date + if (channel.channel_type === 'livechat') { + // add the operator id + if (channel.livechat_operator_id) { + const operator = this._getRecords('res.partner', [['id', '=', channel.livechat_operator_id]])[0]; + // livechat_username ignored for simplicity + channelInfo.operator_pid = [operator.id, operator.display_name.replace(',', '')]; + } + // add the anonymous or partner name + channelInfo.livechat_visitor = this._mockMailChannel_ChannelGetLivechatVisitorInfo([channel.id]); + const messages = this._getRecords('mail.message', [['channel_ids', 'in', channel.id]]); + const last_msg = messages.sort((m1, m2) => m1.id > m2.id ? -1 : 1)[0]; + if (last_msg) { + channelInfo.last_message_id = last_msg.id; + } + } + } + return channelInfos; + }, + /** + * Simulates `_get_available_users` on `im_livechat.channel`. + * + * @private + * @param {integer} id + * @returns {Object} + */ + _mockImLivechatChannel_getAvailableUsers(id) { + const livechatChannel = this._getRecords('im_livechat.channel', [['id', '=', id]])[0]; + const users = this._getRecords('res.users', [['id', 'in', livechatChannel.user_ids]]); + return users.filter(user => user.im_status === 'online'); + }, + /** + * Simulates `_get_livechat_mail_channel_vals` on `im_livechat.channel`. + * + * @private + * @param {integer} id + * @returns {Object} + */ + _mockImLivechatChannel_getLivechatMailChannelVals(id, anonymous_name, operator, user_id, country_id) { + // partner to add to the mail.channel + const operator_partner_id = operator.partner_id; + const members = [[4, operator_partner_id]]; + let visitor_user; + if (user_id) { + const visitor_user = this._getRecords('res.users', [['id', '=', user_id]])[0]; + if (visitor_user && visitor_user.active) { + // valid session user (not public) + members.push([4, visitor_user.partner_id.id]); + } + } else { + // for simplicity of not having mocked channel.partner, add public partner here + members.push([4, this.publicPartnerId]); + } + const membersName = [ + visitor_user ? visitor_user.display_name : anonymous_name, + operator.livechat_username ? operator.livechat_username : operator.name, + ]; + return { + // Limitation of mocked models not having channel.partner: is_pinned + // is not supposed to be false for the visitor but must be false for + // the operator (writing on channel_partner_ids does not trigger the + // defaults that would set it to true) and here operator and visitor + // can't be differentiated in that regard. + 'is_pinned': false, + 'members': members, // channel_partner_ids + 'livechat_active': true, + 'livechat_operator_id': operator_partner_id, + 'livechat_channel_id': id, + 'anonymous_name': user_id ? false : anonymous_name, + 'country_id': country_id, + 'channel_type': 'livechat', + 'name': membersName.join(' '), + 'public': 'private', + 'mass_mailing': false, // email_send + }; + }, + /** + * Simulates `_get_random_operator` on `im_livechat.channel`. + * Simplified mock implementation: returns the first available operator. + * + * @private + * @param {integer} id + * @returns {Object} + */ + _mockImLivechatChannel_getRandomOperator(id) { + const availableUsers = this._mockImLivechatChannel_getAvailableUsers(id); + return availableUsers[0]; + }, + /** + * Simulates `_open_livechat_mail_channel` on `im_livechat.channel`. + * + * @private + * @param {integer} id + * @param {string} anonymous_name + * @param {integer} [previous_operator_id] + * @param {integer} [user_id] + * @param {integer} [country_id] + * @returns {Object} + */ + _mockImLivechatChannel_openLivechatMailChannel(id, anonymous_name, previous_operator_id, user_id, country_id) { + let operator; + if (previous_operator_id) { + const availableUsers = this._mockImLivechatChannel_getAvailableUsers(id); + operator = availableUsers.find(user => user.partner_id === previous_operator_id); + } + if (!operator) { + operator = this._mockImLivechatChannel_getRandomOperator(id); + } + if (!operator) { + // no one available + return false; + } + // create the session, and add the link with the given channel + const mailChannelVals = this._mockImLivechatChannel_getLivechatMailChannelVals(id, anonymous_name, operator, user_id, country_id); + const mailChannelId = this._mockCreate('mail.channel', mailChannelVals); + this._mockMailChannel_broadcast([mailChannelId], [operator.partner_id]); + return this._mockMailChannelChannelInfo([mailChannelId])[0]; + }, }); }); diff --git a/addons/im_livechat/tests/test_get_mail_channel.py b/addons/im_livechat/tests/test_get_mail_channel.py index f6b769d8d75..30deee55d4a 100644 --- a/addons/im_livechat/tests/test_get_mail_channel.py +++ b/addons/im_livechat/tests/test_get_mail_channel.py @@ -52,6 +52,34 @@ class TestGetMailChannel(TransactionCase): channel_operator_ids = [channel_operator[0] for channel_operator in channel_operators] self.assertTrue(all(partner_id in channel_operator_ids for partner_id in self.operators.mapped('partner_id').ids)) + def test_channel_get_livechat_visitor_info(self): + belgium = self.env.ref('base.be') + public_user = self.env.ref('base.public_user') + test_user = self.env['res.users'].create({'name': 'Roger', 'login': 'roger', 'country_id': belgium.id}) + + # ensure visitor info are correct with anonymous + channel_info = self.livechat_channel.with_user(public_user)._open_livechat_mail_channel(anonymous_name='Visitor 22', country_id=belgium.id) + visitor_info = channel_info['livechat_visitor'] + self.assertFalse(visitor_info['id']) + self.assertEqual(visitor_info['name'], "Visitor 22") + self.assertEqual(visitor_info['country'], (20, "Belgium")) + + # ensure visitor info are correct with real user + channel_info = self.livechat_channel.with_user(test_user)._open_livechat_mail_channel(anonymous_name='whatever', user_id=test_user.id) + visitor_info = channel_info['livechat_visitor'] + self.assertEqual(visitor_info['id'], test_user.partner_id.id) + self.assertEqual(visitor_info['name'], "Roger") + self.assertEqual(visitor_info['country'], (20, "Belgium")) + + # ensure visitor info are correct when operator is testing himself + operator = self.operators[0] + channel_info = self.livechat_channel.with_user(operator)._open_livechat_mail_channel(anonymous_name='whatever', previous_operator_id=operator.partner_id.id, user_id=operator.id) + self.assertEqual(channel_info['operator_pid'], (operator.partner_id.id, "Michel")) + visitor_info = channel_info['livechat_visitor'] + self.assertEqual(visitor_info['id'], operator.partner_id.id) + self.assertEqual(visitor_info['name'], "Michel") + self.assertFalse(visitor_info['country']) + def _open_livechat_mail_channel(self): mail_channels = [] diff --git a/addons/mail/static/src/models/message/message.js b/addons/mail/static/src/models/message/message.js index 582745e28f2..e982df36667 100644 --- a/addons/mail/static/src/models/message/message.js +++ b/addons/mail/static/src/models/message/message.js @@ -607,12 +607,13 @@ function factory(dependencies) { tracking_value_ids: attr({ default: [], }), - /** - * All channels that this message is linked to (from server message - * format). + * All channels containing this message on the server. + * Equivalent of python field `channel_ids`. */ - serverChannels: many2many('mail.thread'), + serverChannels: many2many('mail.thread', { + inverse: 'messagesAsServerChannel', + }), }; Message.modelName = 'mail.message'; diff --git a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js index 49f07bc5423..d89992d8a4b 100644 --- a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js +++ b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js @@ -56,7 +56,7 @@ function factory(dependencies) { /** * @private * @param {Object[]} notifications - * @param {Array} notifications[i][0] meta-data of the notification. + * @param {Array|string} notifications[i][0] meta-data of the notification. * @param {string} notifications[i][0][0] name of database this * notification comes from. * @param {string} notifications[i][0][1] type of notification. @@ -68,20 +68,25 @@ function factory(dependencies) { async _handleNotifications(notifications) { const filteredNotifications = this._filterNotificationsOnUnsubscribe(notifications); const proms = filteredNotifications.map(notification => { - const [[, model, id], data] = notification; + const [channel, message] = notification; + if (typeof channel === 'string') { + // uuid notification, only for (livechat) public handler + return; + } + const [, model, id] = channel; switch (model) { case 'ir.needaction': - return this._handleNotificationNeedaction(data); + return this._handleNotificationNeedaction(message); case 'mail.channel': return this._handleNotificationChannel(Object.assign({ channelId: id, - }, data)); + }, message)); case 'res.partner': if (id !== this.env.messaging.currentPartner.id) { // ignore broadcast to other partners return; } - return this._handleNotificationPartner(Object.assign({}, data)); + return this._handleNotificationPartner(Object.assign({}, message)); default: console.warn(`mail.messaging_notification_handler: Unhandled notification "${model}"`); return; diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js index 74cae5d25ab..e3d48d54e35 100644 --- a/addons/mail/static/src/models/thread/thread.js +++ b/addons/mail/static/src/models/thread/thread.js @@ -779,8 +779,13 @@ function factory(dependencies) { partner !== this.env.messaging.currentPartner ); if (correspondents.length === 1) { + // 2 members chat return [['link', correspondents[0]]]; } + if (this.members.length === 1) { + // chat with oneself + return [['link', this.members[0]]]; + } return [['unlink']]; } @@ -1360,6 +1365,13 @@ function factory(dependencies) { messages: many2many('mail.message', { inverse: 'threads', }), + /** + * All messages that are contained on this channel on the server. + * Equivalent to the inverse of python field `channel_ids`. + */ + messagesAsServerChannel: many2many('mail.message', { + inverse: 'serverChannels', + }), messageSeenIndicators: one2many('mail.message_seen_indicator', { inverse: 'thread', isCausal: true, diff --git a/addons/mail/static/src/utils/test_utils.js b/addons/mail/static/src/utils/test_utils.js index dfad584dfeb..4ceb05d7736 100644 --- a/addons/mail/static/src/utils/test_utils.js +++ b/addons/mail/static/src/utils/test_utils.js @@ -260,33 +260,41 @@ const afterNextRender = (function () { function beforeEach(self) { const data = MockModels.generateData(); + data.partnerRootId = 2; data['res.partner'].records.push({ active: false, display_name: "OdooBot", - id: 2, + id: data.partnerRootId, }); - data.partnerRootId = 2; + data.currentPartnerId = 3; data['res.partner'].records.push({ display_name: "Your Company, Mitchell Admin", - id: 3, + id: data.currentPartnerId, name: "Mitchell Admin", }); - data.currentPartnerId = 3; + data.currentUserId = 2; data['res.users'].records.push({ display_name: "Your Company, Mitchell Admin", - id: 2, + id: data.currentUserId, name: "Mitchell Admin", partner_id: data.currentPartnerId, }); - data.currentUserId = 2; + data.publicPartnerId = 4; data['res.partner'].records.push({ active: false, display_name: "Public user", - id: 4, + id: data.publicPartnerId, + }); + data.publicUserId = 3; + data['res.users'].records.push({ + active: false, + display_name: "Public user", + id: data.publicUserId, + name: "Public user", + partner_id: data.publicPartnerId, }); - data.publicPartnerId = 4; const originals = { '_.debounce': _.debounce, diff --git a/addons/mail/static/tests/chatter_tests.js b/addons/mail/static/tests/chatter_tests.js index 5dac33ba543..006b9ff5fe5 100644 --- a/addons/mail/static/tests/chatter_tests.js +++ b/addons/mail/static/tests/chatter_tests.js @@ -133,12 +133,16 @@ QUnit.test('list activity widget with no activity', async function (assert) { QUnit.test('list activity widget with activities', async function (assert) { assert.expect(7); - const lastUserIndex = this.data['res.users'].records.length - 1; - this.data['res.users'].records[lastUserIndex].activity_ids = [1, 4]; - this.data['res.users'].records[lastUserIndex].activity_state = 'today'; - this.data['res.users'].records[lastUserIndex].activity_summary = 'Call with Al'; - this.data['res.users'].records[lastUserIndex].activity_type_id = 3; - this.data['res.users'].records[lastUserIndex].activity_type_icon = 'fa-phone'; + 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, @@ -179,13 +183,17 @@ QUnit.test('list activity widget with activities', async function (assert) { QUnit.test('list activity widget with exception', async function (assert) { assert.expect(5); - const lastUserIndex = this.data['res.users'].records.length - 1; - this.data['res.users'].records[lastUserIndex].activity_ids = [1]; - this.data['res.users'].records[lastUserIndex].activity_state = 'today'; - this.data['res.users'].records[lastUserIndex].activity_summary = 'Call with Al'; - this.data['res.users'].records[lastUserIndex].activity_type_id = 3; - this.data['res.users'].records[lastUserIndex].activity_exception_decoration = 'warning'; - this.data['res.users'].records[lastUserIndex].activity_exception_icon = 'fa-warning'; + 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, @@ -213,11 +221,15 @@ QUnit.test('list activity widget with exception', async function (assert) { QUnit.test('list activity widget: open dropdown', async function (assert) { assert.expect(10); - const lastUserIndex = this.data['res.users'].records.length - 1; - this.data['res.users'].records[lastUserIndex].activity_ids = [1, 4]; - this.data['res.users'].records[lastUserIndex].activity_state = 'today'; - this.data['res.users'].records[lastUserIndex].activity_summary = 'Call with Al'; - this.data['res.users'].records[lastUserIndex].activity_type_id = 3; + 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, @@ -225,8 +237,8 @@ QUnit.test('list activity widget: open dropdown', async function (assert) { date_deadline: moment().format("YYYY-MM-DD"), // now can_write: true, state: "today", - user_id: 2, - create_uid: 2, + user_id: this.data.currentUserId, + create_uid: this.data.currentUserId, activity_type_id: 3, }, { @@ -235,13 +247,13 @@ QUnit.test('list activity widget: open dropdown', async function (assert) { date_deadline: moment().add(1, 'day').format("YYYY-MM-DD"), // tomorrow can_write: true, state: "planned", - user_id: 2, - create_uid: 2, + user_id: this.data.currentUserId, + create_uid: this.data.currentUserId, activity_type_id: 1, } ); - const { widget: list } = await start({ + const { env, widget: list } = await start({ hasView: true, View: ListView, model: 'res.users', @@ -254,10 +266,15 @@ QUnit.test('list activity widget: open dropdown', async function (assert) { mockRPC: function (route, args) { assert.step(args.method || route); if (args.method === 'action_feedback') { - this.data['res.users'].records[lastUserIndex].activity_ids = [4]; - this.data['res.users'].records[lastUserIndex].activity_state = 'planned'; - this.data['res.users'].records[lastUserIndex].activity_summary = 'Meet FP'; - this.data['res.users'].records[lastUserIndex].activity_type_id = 1; + 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); @@ -299,8 +316,10 @@ QUnit.test('list activity widget: open dropdown', async function (assert) { QUnit.test('list activity exception widget with activity', async function (assert) { assert.expect(3); - const lastUserIndex = this.data['res.users'].records.length - 1; - this.data['res.users'].records[lastUserIndex].activity_ids = [1]; + 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, @@ -390,8 +409,8 @@ QUnit.test('fieldmany2many tags email', function (assert) { assert.expect(13); var done = assert.async(); - const lastUserIndex = this.data['res.users'].records.length - 1; - this.data['res.users'].records[lastUserIndex].timmy = [12, 14]; + 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({ @@ -451,8 +470,8 @@ QUnit.test('fieldmany2many tags email', function (assert) { QUnit.test('fieldmany2many tags email (edition)', async function (assert) { assert.expect(15); - const lastUserIndex = this.data['res.users'].records.length - 1; - this.data['res.users'].records[lastUserIndex].timmy = [12]; + const user11 = this.data['res.users'].records.find(user => user.id === 11); + user11.timmy = [12]; var { widget: form } = await start({ hasView: true, @@ -512,12 +531,12 @@ QUnit.test('fieldmany2many tags email (edition)', async function (assert) { QUnit.test('many2many_tags_email widget can load more than 40 records', async function (assert) { assert.expect(3); - const lastUserIndex = this.data['res.users'].records.length - 1; + 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' }; - this.data['res.users'].records[lastUserIndex].partner_ids = []; + user11.partner_ids = []; for (let i = 100; i < 200; i++) { this.data['res.users'].records.push({ id: i, display_name: `partner${i}` }); - this.data['res.users'].records[lastUserIndex].partner_ids.push(i); + user11.partner_ids.push(i); } const { widget: form } = await start({ diff --git a/addons/mail/static/tests/helpers/mock_models.js b/addons/mail/static/tests/helpers/mock_models.js index 085eb3e0234..d46a205d558 100644 --- a/addons/mail/static/tests/helpers/mock_models.js +++ b/addons/mail/static/tests/helpers/mock_models.js @@ -117,6 +117,7 @@ class MockModels { 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' }, @@ -169,11 +170,19 @@ class MockModels { }, 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' }, @@ -192,6 +201,7 @@ class MockModels { 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' }, }, diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js index f57ca20a508..0cc23dabc79 100644 --- a/addons/mail/static/tests/helpers/mock_server.js +++ b/addons/mail/static/tests/helpers/mock_server.js @@ -15,6 +15,7 @@ MockServer.include({ * @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) { @@ -34,6 +35,10 @@ MockServer.include({ 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); @@ -92,6 +97,12 @@ MockServer.include({ */ 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/init_messaging') { return this._mockRouteMailInitMessaging(); } @@ -146,6 +157,14 @@ MockServer.include({ : 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; } @@ -163,10 +182,14 @@ MockServer.include({ if (args.model === 'mail.channel' && args.method === 'message_post') { const id = args.args[0]; const kwargs = args.kwargs; - return this._mockMailChannelMessagePost(id, kwargs); + const context = args.context; + return this._mockMailChannelMessagePost(id, kwargs, context); } if (args.model === 'mail.channel' && args.method === 'notify_typing') { - return; + 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') { @@ -222,7 +245,8 @@ MockServer.include({ if (args.method === 'message_post') { const id = args.args[0]; const kwargs = args.kwargs; - return this._mockMailThreadMessagePost(id, args.model, kwargs); + const context = args.kwargs.context; + return this._mockMailThreadMessagePost(args.model, [id], kwargs, context); } return this._super(route, args); }, @@ -232,10 +256,59 @@ MockServer.include({ //-------------------------------------------------------------------------- /** + * 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.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 the `/mail/init_messaging` route. * * @private - * @return {Object} + * @returns {Object} */ _mockRouteMailInitMessaging() { const channels = this._getRecords('mail.channel', [ @@ -302,7 +375,7 @@ MockServer.include({ }, commands: [], current_partner: currentPartnerFormat, - curret_user_id: this.currentUserId, + current_user_id: this.currentUserId, mail_failures: mailFailures, mention_partner_suggestions: [], menu_id: false, @@ -320,7 +393,7 @@ MockServer.include({ * * @private * @param {integer[]} follower_ids - * @return {Object} one key for list of followers and one for subtypes + * @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 @@ -337,7 +410,7 @@ MockServer.include({ * * @private * @param {integer} follower_id - * @return {Object[]} list of followed subtypes + * @returns {Object[]} list of followed subtypes */ async _mockRouteMailReadSubscriptionData(follower_id) { const follower = this._getRecords('mail.followers', [['id', '=', follower_id]])[0]; @@ -377,7 +450,7 @@ MockServer.include({ * @private * @param {string} res_model * @param {string} domain - * @return {Object} + * @returns {Object} */ _mockMailActivityGetActivityData(res_model, domain) { const self = this; @@ -446,10 +519,48 @@ MockServer.include({ }; }, /** + * 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]]); @@ -485,7 +596,7 @@ MockServer.include({ * * @private * @param {integer[]} ids - * @return {Object[]} list of channels previews + * @returns {Object[]} list of channels previews */ _mockMailChannelChannelFetchPreview(ids) { const channels = this._getRecords('mail.channel', [['id', 'in', ids]]); @@ -562,10 +673,11 @@ MockServer.include({ * Simulates `channel_info` on `mail.channel`. * * @private - * @param {integer[]} [ids=[]] + * @param {integer[]} ids + * @param {string} [extra_info] * @returns {Object[]} */ - _mockMailChannelChannelInfo(ids = []) { + _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]; @@ -590,17 +702,49 @@ MockServer.include({ ['mail_message_id', 'in', messages.map(message => message.id)], ]).length; return Object.assign({}, channel, { + info: extra_info, members, message_needaction_counter: messageNeedactionCounter, }); }); }, /** + * 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 `channel_set_custom_name` on `mail.channel`. * * @private * @param {integer} channel_id - * @return {string} [name] + * @returns {string} [name] */ _mockMailChannelChannelSetCustomName(channel_id, name) { this._mockWrite('mail.channel', [ @@ -642,28 +786,70 @@ MockServer.include({ * @private * @param {integer} id * @param {Object} kwargs - * @return {integer|false} + * @param {Object} [context] + * @returns {integer|false} */ - _mockMailChannelMessagePost(id, kwargs) { + _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( - id, 'mail.channel', + [id], Object.assign(kwargs, { + channel_ids, message_type, moderation_status, - }) + }), + context, ); 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 data = { + 'info': 'typing_status', + 'is_typing': is_typing, + 'partner_id': partner_id, + }; + 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 @@ -672,7 +858,11 @@ MockServer.include({ * @returns {Object[]} */ _mockMailChannelPartnerInfo(all_partners, direct_partners) { - const partners = this._getRecords('res.partner', [['id', 'in', all_partners]]); + const partners = this._getRecords( + 'res.partner', + [['id', 'in', all_partners]], + { active_test: false }, + ); const partnerInfos = {}; for (const partner of partners) { const partnerInfo = { @@ -692,7 +882,7 @@ MockServer.include({ * * @private * @param {Array[]} [domain] - * @return {integer[]} + * @returns {integer[]} */ _mockMailMessageMarkAllAsRead(domain) { const notifDomain = [ @@ -741,7 +931,7 @@ MockServer.include({ * @param {Array[]} domain * @param {string} [limit=20] * @param {Object} [moderated_channel_ids] - * @return {Object[]} + * @returns {Object[]} */ _mockMailMessageMessageFetch(domain, limit = 20, moderated_channel_ids) { let messages = this._getRecords('mail.message', domain); @@ -767,7 +957,7 @@ MockServer.include({ * Simulates `message_fetch_failed` on `mail.message`. * * @private - * @return {Object[]} + * @returns {Object[]} */ _mockMailMessageMessageFetchFailed() { const messages = this._getRecords('mail.message', [ @@ -791,8 +981,8 @@ MockServer.include({ * Simulates `message_format` on `mail.message`. * * @private - * @return {integer[]} ids - * @return {Object[]} + * @returns {integer[]} ids + * @returns {Object[]} */ _mockMailMessageMessageFormat(ids) { const messages = this._getRecords('mail.message', [['id', 'in', ids]]); @@ -806,7 +996,11 @@ MockServer.include({ ])[0]; let formattedAuthor; if (message.author_id) { - const author = this._getRecords('res.partner', [['id', '=', message.author_id]])[0]; + 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]; @@ -888,8 +1082,8 @@ MockServer.include({ * Simulates `_message_notification_format` on `mail.message`. * * @private - * @return {integer[]} ids - * @return {Object[]} + * @returns {integer[]} ids + * @returns {Object[]} */ _mockMailMessage_MessageNotificationFormat(ids) { const messages = this._getRecords('mail.message', [['id', 'in', ids]]); @@ -957,7 +1151,7 @@ MockServer.include({ * Simulates `toggle_message_starred` on `mail.message`. * * @private - * @return {integer[]} ids + * @returns {integer[]} ids */ _mockMailMessageToggleMessageStarred(ids) { const messages = this._getRecords('mail.message', [['id', 'in', ids]]); @@ -1001,8 +1195,8 @@ MockServer.include({ * Simulates `_filtered_for_web_client` on `mail.notification`. * * @private - * @return {integer[]} ids - * @return {Object[]} + * @returns {integer[]} ids + * @returns {Object[]} */ _mockMailNotification_FilteredForWebClient(ids) { return this._getRecords('mail.notification', [ @@ -1016,8 +1210,8 @@ MockServer.include({ * Simulates `_notification_format` on `mail.notification`. * * @private - * @return {integer[]} ids - * @return {Object[]} + * @returns {integer[]} ids + * @returns {Object[]} */ _mockMailNotification_NotificationFormat(ids) { const notifications = this._getRecords('mail.notification', [['id', 'in', ids]]); @@ -1033,15 +1227,70 @@ MockServer.include({ }); }, /** + * 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_post` on `mail.thread`. * * @private - * @param {integer} id * @param {string} model + * @param {integer[]} ids * @param {Object} kwargs - * @return {integer} + * @param {Object} [context] + * @returns {integer} */ - _mockMailThreadMessagePost(id, model, kwargs) { + _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], @@ -1059,7 +1308,15 @@ MockServer.include({ 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, @@ -1067,13 +1324,7 @@ MockServer.include({ }); delete values.subtype_xmlid; const messageId = this._mockCreate('mail.message', values); - const message = this._getRecords('mail.message', [['id', '=', messageId]])[0]; - const notificationData = { - type: 'author', - message: this._mockMailMessageMessageFormat([messageId])[0], - }; - const notification = [[false, 'res.partner', message.author_id], notificationData]; - this._widget.call('bus_service', 'trigger', 'notification', [notification]); + this._mockMailThread_NotifyThread(model, ids, messageId, values); return messageId; }, /** @@ -1092,6 +1343,35 @@ MockServer.include({ // 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 @@ -1223,8 +1503,8 @@ MockServer.include({ * Simulates `mail_partner_format` on `res.partner`. * * @private - * @return {integer} id - * @return {Object} + * @returns {integer} id + * @returns {Object} */ _mockResPartnerMailPartnerFormat(id) { const partner = this._getRecords( |
