[FIX] mail: fix disappearing moderated messages

task-2277748
task-2277811

closes odoo/odoo#53652

X-original-commit: 5ef6aedb8271fa05f79fb6f93f679caa9cd418ff
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Xavier Dubuc
2020-06-25 11:13:09 +00:00
committed by Alexandre Kühn
parent 281329361d
commit e5de39d036
3 changed files with 1227 additions and 953 deletions
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
odoo.define('mail/static/src/components/discuss/discuss_tests.js', function (require) {
odoo.define('mail/static/src/components/discuss/tests/discuss_tests.js', function (require) {
'use strict';
const BusService = require('bus.BusService');
@@ -35,10 +35,10 @@ QUnit.module('discuss_tests.js', {
};
},
afterEach() {
utilsAfterEach(this);
if (this.widget) {
this.widget.destroy();
}
utilsAfterEach(this);
},
});
@@ -4566,956 +4566,6 @@ QUnit.test('auto-focus composer on opening thread', async function (assert) {
);
});
QUnit.test('moderation: moderated channel with pending moderation message', async function (assert) {
assert.expect(37);
Object.assign(this.data.initMessaging, {
channel_slots: {
channel_channel: [{
id: 20,
channel_type: "channel",
name: "general",
moderation: true,
}],
},
is_moderator: true,
moderation_counter: 1,
moderation_channel_ids: [20],
});
this.data['mail.message'].records = [{
author_id: [2, "Someone"],
body: "<p>test</p>",
channel_ids: [20],
id: 100,
model: 'mail.channel',
moderation_status: 'pending_moderation',
need_moderation: true,
res_id: 20,
}];
await this.start();
assert.ok(
document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${
this.env.messaging.moderation.localId
}"]
`),
"should display the moderation box"
);
const mailboxCounter = document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${
this.env.messaging.moderation.localId
}"]
.o_DiscussSidebarItem_counter
`);
assert.ok(
mailboxCounter,
"there should be a counter next to the moderation mailbox in the sidebar"
);
assert.strictEqual(
mailboxCounter.textContent.trim(),
"1",
"the mailbox counter of the moderation mailbox should display '1'"
);
// 1. go to moderation mailbox
await afterNextRender(() =>
document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${
this.env.messaging.moderation.localId
}"]
`).click()
);
// check message
assert.containsOnce(
document.body,
'.o_Message',
"should be only one message in moderation box"
);
assert.strictEqual(
document.querySelector('.o_Message_content').textContent,
"test",
"this message pending moderation should have the correct content"
);
assert.containsOnce(
document.body,
'.o_Message_originThreadLink',
"thee message should have one origin"
);
assert.strictEqual(
document.querySelector('.o_Message_originThreadLink').textContent,
"#general",
"the message pending moderation should have correct origin as its linked document"
);
assert.containsOnce(
document.body,
'.o_Message_checkbox',
"there should be a moderation checkbox next to the message"
);
assert.notOk(
document.querySelector('.o_Message_checkbox').checked,
"the moderation checkbox should be unchecked by default"
);
// check select all (enabled) / unselect all (disabled) buttons
assert.containsOnce(
document.body,
'.o_widget_Discuss_controlPanelButtonSelectAll',
"there should be a 'Select All' button in the control panel"
);
assert.doesNotHaveClass(
document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll'),
'disabled',
"the 'Select All' button should not be disabled"
);
assert.containsOnce(
document.body,
'.o_widget_Discuss_controlPanelButtonUnselectAll',
"there should be a 'Unselect All' button in the control panel"
);
assert.hasClass(
document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll'),
'disabled',
"the 'Unselect All' button should be disabled"
);
// check moderate all buttons (invisible)
assert.containsN(
document.body,
'.o_widget_Discuss_controlPanelButtonModeration',
3,
"there should be 3 buttons to moderate selected messages in the control panel"
);
assert.containsOnce(
document.body,
'.o_widget_Discuss_controlPanelButtonModeration.o-accept',
"there should one moderate button to accept messages pending moderation"
);
assert.isNotVisible(
document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'),
"the moderate button 'Accept' should be invisible by default"
);
assert.containsOnce(
document.body,
'.o_widget_Discuss_controlPanelButtonModeration.o-reject',
"there should one moderate button to reject messages pending moderation"
);
assert.isNotVisible(
document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-reject'),
"the moderate button 'Reject' should be invisible by default"
);
assert.containsOnce(
document.body,
'.o_widget_Discuss_controlPanelButtonModeration.o-discard',
"there should one moderate button to discard messages pending moderation"
);
assert.isNotVisible(
document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard'),
"the moderate button 'Discard' should be invisible by default"
);
// click on message moderation checkbox
await afterNextRender(() => document.querySelector('.o_Message_checkbox').click());
assert.ok(
document.querySelector('.o_Message_checkbox').checked,
"the moderation checkbox should become checked after click"
);
// check select all (disabled) / unselect all buttons (enabled)
assert.hasClass(
document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll'),
'disabled',
"the 'Select All' button should be disabled"
);
assert.doesNotHaveClass(
document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll'),
'disabled',
"the 'Unselect All' button should not be disabled"
);
// check moderate all buttons updated (visible)
assert.isVisible(
document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'),
"the moderate button 'Accept' should be visible"
);
assert.isVisible(
document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-reject'),
"the moderate button 'Reject' should be visible"
);
assert.isVisible(
document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard'),
"the moderate button 'Discard' should be visible"
);
// test select buttons
await afterNextRender(() =>
document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll').click()
);
assert.notOk(
document.querySelector('.o_Message_checkbox').checked,
"the moderation checkbox should become unchecked after click"
);
await afterNextRender(() =>
document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll').click()
);
assert.ok(
document.querySelector('.o_Message_checkbox').checked,
"the moderation checkbox should become checked again after click"
);
// 2. go to channel 'general'
await afterNextRender(() =>
document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${
this.env.models['mail.thread'].find(thread =>
thread.id === 20 &&
thread.model === 'mail.channel'
).localId
}"]
`).click()
);
// check correct message
assert.containsOnce(
document.body,
'.o_Message',
"should be only one message in general channel"
);
assert.containsOnce(
document.body,
'.o_Message_checkbox',
"there should be a moderation checkbox next to the message"
);
assert.notOk(
document.querySelector('.o_Message_checkbox').checked,
"the moderation checkbox should not be checked here"
);
await afterNextRender(() => document.querySelector('.o_Message_checkbox').click());
// Don't test moderation actions visibility, since it is similar to moderation box.
// 3. test discard button
await afterNextRender(() =>
document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard').click()
);
assert.containsOnce(
document.body,
'.o_ModerationDiscardDialog',
"discard dialog should be open"
);
// the dialog will be tested separately
await afterNextRender(() =>
document.querySelector('.o_ModerationDiscardDialog .o-cancel').click()
);
assert.containsNone(
document.body,
'.o_ModerationDiscardDialog',
"discard dialog should be closed"
);
// 4. test reject button
await afterNextRender(() =>
document.querySelector(`
.o_widget_Discuss_controlPanelButtonModeration.o-reject
`).click()
);
assert.containsOnce(
document.body,
'.o_ModerationRejectDialog',
"reject dialog should be open"
);
// the dialog will be tested separately
await afterNextRender(() =>
document.querySelector('.o_ModerationRejectDialog .o-cancel').click()
);
assert.containsNone(
document.body,
'.o_ModerationRejectDialog',
"reject dialog should be closed"
);
// 5. test accept button
await afterNextRender(() =>
document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept').click()
);
assert.containsOnce(
document.body,
'.o_Message',
"should still be only one message in general channel"
);
assert.containsNone(
document.body,
'.o_Message_checkbox',
"there should not be a moderation checkbox next to the message"
);
});
QUnit.test('moderation: accept pending moderation message', async function (assert) {
assert.expect(12);
Object.assign(this.data.initMessaging, {
channel_slots: {
channel_channel: [{
id: 20,
channel_type: "channel",
name: "general",
moderation: true,
}],
},
is_moderator: true,
moderation_counter: 1,
moderation_channel_ids: [20],
});
this.data['mail.message'].records = [{
author_id: [2, "Someone"],
body: "<p>test</p>",
id: 100,
model: 'mail.channel',
moderation_status: 'pending_moderation',
need_moderation: true,
res_id: 20,
}];
await this.start({
async mockRPC(route, args) {
if (args.method === 'moderate') {
assert.step('moderate');
const messageIDs = args.args[0];
const decision = args.args[1];
assert.strictEqual(
messageIDs.length,
1,
"should moderate one message"
);
assert.strictEqual(
messageIDs[0],
100,
"should moderate message with ID 100"
);
assert.strictEqual(
decision,
'accept',
"should accept the message"
);
}
return this._super(...arguments);
},
});
// 1. go to moderation box
const moderationBox = document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${
this.env.messaging.moderation.localId
}"]
`);
assert.ok(
moderationBox,
"should display the moderation box"
);
await afterNextRender(() => moderationBox.click());
assert.ok(
document.querySelector(`
.o_Message[data-message-local-id="${
this.env.models['mail.message'].find(message => message.id === 100).localId
}"]
`),
"should display the message to moderate"
);
const acceptButton = document.querySelector(`
.o_Message[data-message-local-id="${
this.env.models['mail.message'].find(message => message.id === 100).localId
}"]
.o_Message_moderationAction.o-accept
`);
assert.ok(acceptButton, "should display the accept button");
await afterNextRender(() => acceptButton.click());
assert.verifySteps(['moderate']);
assert.containsOnce(
document.body,
'.o_MessageList_emptyTitle',
"should now have no message displayed in moderation box"
);
// 2. go to channel 'general'
const channel = document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${
this.env.models['mail.thread'].find(thread =>
thread.id === 20 &&
thread.model === 'mail.channel'
).localId
}"]
`);
assert.ok(
channel,
"should display the general channel"
);
await afterNextRender(() => channel.click());
const message = document.querySelector(`
.o_Message[data-message-local-id="${
this.env.models['mail.message'].find(message => message.id === 100).localId
}"]
`);
assert.ok(
message,
"should display the accepted message"
);
assert.containsNone(
message,
'.o_Message_moderationPending',
"the message should not be pending moderation"
);
});
QUnit.test('moderation: reject pending moderation message (reject with explanation)', async function (assert) {
assert.expect(23);
Object.assign(this.data.initMessaging, {
channel_slots: {
channel_channel: [{
id: 20,
channel_type: "channel",
name: "general",
moderation: true,
}],
},
is_moderator: true,
moderation_counter: 1,
moderation_channel_ids: [20],
});
this.data['mail.message'].records = [{
author_id: [2, "Someone"],
body: "<p>test</p>",
id: 100,
model: 'mail.channel',
moderation_status: 'pending_moderation',
need_moderation: true,
res_id: 20,
}];
await this.start({
async mockRPC(route, args) {
if (args.method === 'moderate') {
assert.step('moderate');
const messageIDs = args.args[0];
const decision = args.args[1];
const kwargs = args.kwargs;
assert.strictEqual(
messageIDs.length,
1,
"should moderate one message"
);
assert.strictEqual(
messageIDs[0],
100,
"should moderate message with ID 100"
);
assert.strictEqual(
decision,
'reject',
"should reject the message"
);
assert.strictEqual(
kwargs.title,
"Message Rejected",
"should have correct reject message title"
);
assert.strictEqual(
kwargs.comment,
"Your message was rejected by moderator.",
"should have correct reject message body / comment"
);
}
return this._super(...arguments);
},
});
// 1. go to moderation box
const moderationBox = document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${
this.env.messaging.moderation.localId
}"]
`);
assert.ok(
moderationBox,
"should display the moderation box"
);
await afterNextRender(() => moderationBox.click());
const pendingMessage = document.querySelector(`
.o_Message[data-message-local-id="${
this.env.models['mail.message'].find(message => message.id === 100).localId
}"]
`);
assert.ok(
pendingMessage,
"should display the message to moderate"
);
const rejectButton = pendingMessage.querySelector(':scope .o_Message_moderationAction.o-reject');
assert.ok(
rejectButton,
"should display the reject button"
);
await afterNextRender(() => rejectButton.click());
const dialog = document.querySelector('.o_ModerationRejectDialog');
assert.ok(
dialog,
"a dialog should be prompt to the moderator on click reject"
);
assert.strictEqual(
dialog.querySelector('.modal-title').textContent,
// TODO FIXME, this should be a proper title "Send explanation to author"
// see https://github.com/odoo/owl/issues/670
"[object Object]",
"dialog should have correct title"
);
const messageTitle = dialog.querySelector(':scope .o_ModerationRejectDialog_title');
assert.ok(
messageTitle,
"should have a title for rejecting"
);
assert.hasAttrValue(
messageTitle,
'placeholder',
"Subject",
"title for reject reason should have correct placeholder"
);
assert.strictEqual(
messageTitle.value,
"Message Rejected",
"title for reject reason should have correct default value"
);
const messageComment = dialog.querySelector(':scope .o_ModerationRejectDialog_comment');
assert.ok(
messageComment,
"should have a comment for rejecting"
);
assert.hasAttrValue(
messageComment,
'placeholder',
"Mail Body",
"comment for reject reason should have correct placeholder"
);
assert.strictEqual(
messageComment.value,
"Your message was rejected by moderator.",
"comment for reject reason should have correct default text content"
);
const confirmReject = dialog.querySelector(':scope .o-reject');
assert.ok(
confirmReject,
"should have reject button"
);
assert.strictEqual(
confirmReject.textContent,
"Reject"
);
await afterNextRender(() => confirmReject.click());
assert.verifySteps(['moderate']);
assert.containsOnce(
document.body,
'.o_MessageList_emptyTitle',
"should now have no message displayed in moderation box"
);
// 2. go to channel 'general'
const channel = document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${
this.env.models['mail.thread'].find(thread =>
thread.id === 20 &&
thread.model === 'mail.channel'
).localId
}"]
`);
assert.ok(
channel,
'should display the general channel'
);
await afterNextRender(() => channel.click());
assert.containsNone(
document.body,
'.o_Message',
"should now have no message in channel"
);
});
QUnit.test('moderation: discard pending moderation message (reject without explanation)', async function (assert) {
assert.expect(16);
Object.assign(this.data.initMessaging, {
channel_slots: {
channel_channel: [{
id: 20,
channel_type: "channel",
name: "general",
moderation: true,
}],
},
is_moderator: true,
moderation_counter: 1,
moderation_channel_ids: [20],
});
this.data['mail.message'].records = [{
author_id: [2, "Someone"],
body: "<p>test</p>",
id: 100,
model: 'mail.channel',
moderation_status: 'pending_moderation',
need_moderation: true,
res_id: 20,
}];
await this.start({
async mockRPC(route, args) {
if (args.method === 'moderate') {
assert.step('moderate');
const messageIDs = args.args[0];
const decision = args.args[1];
assert.strictEqual(messageIDs.length, 1, "should moderate one message");
assert.strictEqual(messageIDs[0], 100, "should moderate message with ID 100");
assert.strictEqual(decision, 'discard', "should discard the message");
}
return this._super(...arguments);
},
});
// 1. go to moderation box
const moderationBox = document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${
this.env.messaging.moderation.localId
}"]
`);
assert.ok(
moderationBox,
"should display the moderation box"
);
await afterNextRender(() => moderationBox.click());
const pendingMessage = document.querySelector(`
.o_Message[data-message-local-id="${
this.env.models['mail.message'].find(message => message.id === 100).localId
}"]
`);
assert.ok(
pendingMessage,
"should display the message to moderate"
);
const discardButton = pendingMessage.querySelector(`
:scope .o_Message_moderationAction.o-discard
`);
assert.ok(
discardButton,
"should display the discard button"
);
await afterNextRender(() => discardButton.click());
const dialog = document.querySelector('.o_ModerationDiscardDialog');
assert.ok(
dialog,
"a dialog should be prompt to the moderator on click discard"
);
assert.strictEqual(
dialog.querySelector('.modal-title').textContent,
// TODO FIXME, this should be a proper title "Confirmation"
// see https://github.com/odoo/owl/issues/670
"[object Object]",
"dialog should have correct title"
);
assert.strictEqual(
dialog.textContent,
"[object Object]×You are going to discard 1 message.Do you confirm the action?DiscardCancel",
"should warn the user on discard action"
);
const confirmDiscard = dialog.querySelector(':scope .o-discard');
assert.ok(
confirmDiscard,
"should have discard button"
);
assert.strictEqual(
confirmDiscard.textContent,
"Discard"
);
await afterNextRender(() => confirmDiscard.click());
assert.verifySteps(['moderate']);
assert.containsOnce(
document.body,
'.o_MessageList_emptyTitle',
"should now have no message displayed in moderation box"
);
// 2. go to channel 'general'
const channel = document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${
this.env.models['mail.thread'].find(thread =>
thread.id === 20 &&
thread.model === 'mail.channel'
).localId
}"]
`);
assert.ok(
channel,
"should display the general channel"
);
await afterNextRender(() => channel.click());
assert.containsNone(
document.body,
'.o_Message',
"should now have no message in channel"
);
});
QUnit.test('moderation: send message in moderated channel', async function (assert) {
assert.expect(4);
const self = this;
Object.assign(this.data.initMessaging, {
channel_slots: {
channel_channel: [{
id: 20,
channel_type: "channel",
name: "general",
moderation: true,
}],
},
});
await this.start({
async mockRPC(route, args) {
if (args.method === 'message_post') {
const message = {
id: 100,
author_id: [13, 'Someone'],
body: args.kwargs.body,
channel_ids: [20],
message_type: args.kwargs.message_type,
model: 'mail.channel',
moderation_status: 'pending_moderation',
res_id: 20,
};
const notificationData = {
type: 'author',
message: message,
};
const notification = [[false, 'res.partner', 13], notificationData];
self.widget.call('bus_service', 'trigger', 'notification', [notification]);
return message.id;
}
return this._super(...arguments);
},
});
// go to channel 'general'
const channel = document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${
this.env.models['mail.thread'].find(thread =>
thread.id === 20 &&
thread.model === 'mail.channel'
).localId
}"]
`);
assert.ok(
channel,
"should display the general channel"
);
await afterNextRender(() => channel.click());
assert.containsNone(
document.body,
'.o_Message',
"should have no message in channel"
);
// post a message
await afterNextRender(() => {
const textInput = document.querySelector('.o_ComposerTextInput_textarea');
textInput.focus();
document.execCommand('insertText', false, "Some Text");
});
await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click());
const messagePending = document.querySelector(`
.o_Message[data-message-local-id="${
this.env.models['mail.message'].find(message => message.id === 100).localId
}"]
.o_Message_moderationPending
`);
assert.ok(
messagePending,
"should display the pending message with pending info"
);
assert.hasClass(
messagePending,
'o-author',
"the message should be pending moderation as author"
);
});
QUnit.test('moderation: sent message accepted in moderated channel', async function (assert) {
assert.expect(5);
Object.assign(this.data.initMessaging, {
channel_slots: {
channel_channel: [{
id: 20,
channel_type: "channel",
name: "general",
moderation: true,
}],
},
});
this.data['mail.message'].records = [{
author_id: [13, "Someone"],
body: "<p>test</p>",
id: 100,
model: 'mail.channel',
moderation_status: 'pending_moderation',
need_moderation: true,
res_id: 20,
}];
await this.start();
const channel = document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${
this.env.models['mail.thread'].find(thread =>
thread.id === 20 &&
thread.model === 'mail.channel'
).localId
}"]
`);
assert.ok(
channel,
"should display the general channel"
);
await afterNextRender(() => channel.click());
const messagePending = document.querySelector(`
.o_Message[data-message-local-id="${
this.env.models['mail.message'].find(message => message.id === 100).localId
}"]
.o_Message_moderationPending
`);
assert.ok(
messagePending,
"should display the pending message with pending info"
);
assert.hasClass(
messagePending,
'o-author',
"the message should be pending moderation as author"
);
// simulate accepted message
await afterNextRender(() => {
const messageData = {
author_id: [13, "Someone"],
body: "<p>test</p>",
channel_ids: [20],
id: 100,
model: 'mail.channel',
moderation_status: 'accepted',
res_id: 20,
};
const notification = [[false, 'mail.channel', 20], messageData];
this.widget.call('bus_service', 'trigger', 'notification', [notification]);
});
// check message is accepted
const message = document.querySelector(`
.o_Message[data-message-local-id="${
this.env.models['mail.message'].find(message => message.id === 100).localId
}"]
`);
assert.ok(
message,
"should still display the message"
);
assert.containsNone(
message,
'.o_Message_moderationPending',
"the message should not be in pending moderation anymore"
);
});
QUnit.test('moderation: sent message rejected in moderated channel', async function (assert) {
assert.expect(4);
Object.assign(this.data.initMessaging, {
channel_slots: {
channel_channel: [{
id: 20,
channel_type: "channel",
name: "general",
moderation: true,
}],
},
});
this.data['mail.message'].records = [{
author_id: [13, "Someone"],
body: "<p>test</p>",
id: 100,
model: 'mail.channel',
moderation_status: 'pending_moderation',
need_moderation: true,
res_id: 20,
}];
await this.start();
const channel = document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${
this.env.models['mail.thread'].find(thread =>
thread.id === 20 &&
thread.model === 'mail.channel'
).localId
}"]
`);
assert.ok(
channel,
"should display the general channel"
);
await afterNextRender(() => channel.click());
const messagePending = document.querySelector(`
.o_Message[data-message-local-id="${
this.env.models['mail.message'].find(message => message.id === 100).localId
}"]
.o_Message_moderationPending
`);
assert.ok(
messagePending,
"should display the pending message with pending info"
);
assert.hasClass(
messagePending,
'o-author',
"the message should be pending moderation as author"
);
// simulate reject from moderator
await afterNextRender(() => {
const notifData = {
type: 'deletion',
message_ids: [100],
};
const notification = [[false, 'res.partner', 13], notifData];
this.widget.call('bus_service', 'trigger', 'notification', [notification]);
});
// check no message
assert.containsNone(
document.body,
'.o_Message',
"message should be removed from channel after reject"
);
});
});
});
});
+2 -1
View File
@@ -244,7 +244,8 @@
<script type="text/javascript" src="/mail/static/src/components/chatter/chatter_tests.js"></script>
<script type="text/javascript" src="/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js"></script>
<script type="text/javascript" src="/mail/static/src/components/composer/composer_tests.js"></script>
<script type="text/javascript" src="/mail/static/src/components/discuss/discuss_tests.js"></script>
<script type="text/javascript" src="/mail/static/src/components/discuss/tests/discuss_moderation_tests.js"></script>
<script type="text/javascript" src="/mail/static/src/components/discuss/tests/discuss_tests.js"></script>
<script type="text/javascript" src="/mail/static/src/components/file_uploader/file_uploader_tests.js"></script>
<script type="text/javascript" src="/mail/static/src/components/follow_button/follow_button_tests.js"></script>
<script type="text/javascript" src="/mail/static/src/components/follower/follower_tests.js"></script>