diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/tests/helpers | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mail/static/tests/helpers')
| -rw-r--r-- | addons/mail/static/tests/helpers/mock_models.js | 258 | ||||
| -rw-r--r-- | addons/mail/static/tests/helpers/mock_server.js | 1809 |
2 files changed, 2067 insertions, 0 deletions
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, + }; + }, +}); + +}); |
