[IMP] mail, *: remove setup manager patches in test
*: im_livechat, website_livechat. Before this PR, services has to be added to the setup manager in order to be loaded in tests. This was cumbersome because the setup manager was patched by each sub-feature to add feature related services to the registry. The same had to be done for systray/main components registries. This PR automates this process in order to remove the patches. closes odoo/odoo#129705 Related: odoo/enterprise#44596 Signed-off-by: Matthieu Stockbauer (tsm) <tsm@odoo.com>
This commit is contained in:
@@ -90,7 +90,7 @@ QUnit.module("M2XAvatarUser", ({ beforeEach }) => {
|
||||
res_id: avatarUserId,
|
||||
views: [[false, "kanban"]],
|
||||
});
|
||||
|
||||
|
||||
patchWithCleanup(browser, {
|
||||
setTimeout: (callback, delay) => {
|
||||
assert.step(`setTimeout of ${delay}ms`);
|
||||
@@ -99,7 +99,7 @@ QUnit.module("M2XAvatarUser", ({ beforeEach }) => {
|
||||
});
|
||||
// Open card
|
||||
await triggerEvent(target, ".o_m2o_avatar > img", "mouseover");
|
||||
assert.verifySteps(["setTimeout of 350ms", "user read"]);
|
||||
assert.verifySteps(["setTimeout of 350ms", "setTimeout of 250ms", "user read"]);
|
||||
assert.containsOnce(target, ".o_avatar_card");
|
||||
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_card_user_infos > *")), ['Mario', 'sub manager', 'Managemment', ' Mario@odoo.test', ' +585555555']);
|
||||
// Close card
|
||||
|
||||
@@ -133,8 +133,8 @@ Help your customers with this chat, and analyse their feedback.
|
||||
('remove', 'web/static/tests/mock_server_tests.js'),
|
||||
('remove', 'im_livechat/static/**'),
|
||||
'im_livechat/static/tests/helpers/**',
|
||||
('remove', 'im_livechat/static/tests/helpers/**/web/**'),
|
||||
'im_livechat/static/src/embed/**/*',
|
||||
('remove', 'im_livechat/static/src/embed/frontend/**/*'),
|
||||
('remove', 'im_livechat/static/src/embed/external/**/*'),
|
||||
],
|
||||
'im_livechat.qunit_embed_suite': [
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
import { getPyEnv } from "@bus/../tests/helpers/mock_python_environment";
|
||||
|
||||
import { chatBotService } from "@im_livechat/embed/chatbot/chatbot_service";
|
||||
import { autoPopupService } from "@im_livechat/embed/core/autopopup_service";
|
||||
import { livechatService } from "@im_livechat/embed/core/livechat_service";
|
||||
import { LivechatButton } from "@im_livechat/embed/core_ui/livechat_button";
|
||||
|
||||
import { ChatWindowContainer } from "@mail/core/common/chat_window_container";
|
||||
@@ -79,32 +76,6 @@ patch(App.prototype, "im_livechat", {
|
||||
},
|
||||
});
|
||||
|
||||
patch(setupManager, "im_livechat", {
|
||||
setupServices(...args) {
|
||||
const services = this._super(...args);
|
||||
return {
|
||||
"im_livechat.livechat": livechatService,
|
||||
"im_livechat.autopopup": autoPopupService,
|
||||
"im_livechat.chatbot": chatBotService,
|
||||
cookie: {
|
||||
start() {
|
||||
const service = fakeCookieService.start(...arguments);
|
||||
return {
|
||||
...service,
|
||||
get current() {
|
||||
return {
|
||||
...service.current,
|
||||
...cookie,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
...services,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Mount the livechat button into the webclient.
|
||||
*
|
||||
@@ -112,7 +83,25 @@ patch(setupManager, "im_livechat", {
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function start({ mockRPC } = {}) {
|
||||
await setupManager.setupMessagingServiceRegistries();
|
||||
setupManager.setupServiceRegistries();
|
||||
registry.category("services").add(
|
||||
"cookie",
|
||||
{
|
||||
start() {
|
||||
const service = fakeCookieService.start(...arguments);
|
||||
return {
|
||||
...service,
|
||||
get current() {
|
||||
return {
|
||||
...service.current,
|
||||
...cookie,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{ force: true }
|
||||
);
|
||||
const mainComponentRegistry = registry.category("main_components");
|
||||
mainComponentRegistry.add("LivechatButton", { Component: LivechatButton });
|
||||
mainComponentRegistry.add("ChatWindowContainer", { Component: ChatWindowContainer });
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { livechatCoreWeb } from "@im_livechat/core/web/livechat_core_web_service";
|
||||
|
||||
import { setupManager } from "@mail/../tests/helpers/webclient_setup";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(setupManager, "im_livechat/core/web", {
|
||||
setupServices(...args) {
|
||||
return {
|
||||
...this._super(...args),
|
||||
"im_livechat.core.web": livechatCoreWeb,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -758,7 +758,7 @@ QUnit.test("channel - states: open should update the value on the server", async
|
||||
|
||||
QUnit.test("channel - states: close from the bus", async (assert) => {
|
||||
const pyEnv = await startServer();
|
||||
pyEnv["discuss.channel"].create({ name: "test" });
|
||||
pyEnv["discuss.channel"].create({ name: "channel1" });
|
||||
const userSettingsId = pyEnv["res.users.settings"].create({
|
||||
user_id: pyEnv.currentUserId,
|
||||
is_discuss_sidebar_category_channel_open: true,
|
||||
@@ -774,12 +774,12 @@ QUnit.test("channel - states: close from the bus", async (assert) => {
|
||||
});
|
||||
});
|
||||
assert.containsOnce($, ".o-mail-DiscussCategory-channel .oi-chevron-right");
|
||||
assert.containsNone($, "button:contains(test)");
|
||||
assert.containsNone($, "button:contains(channel1)");
|
||||
});
|
||||
|
||||
QUnit.test("channel - states: open from the bus", async (assert) => {
|
||||
const pyEnv = await startServer();
|
||||
pyEnv["discuss.channel"].create({ name: "test" });
|
||||
pyEnv["discuss.channel"].create({ name: "channel1" });
|
||||
const userSettingsId = pyEnv["res.users.settings"].create({
|
||||
user_id: pyEnv.currentUserId,
|
||||
is_discuss_sidebar_category_channel_open: false,
|
||||
@@ -795,25 +795,25 @@ QUnit.test("channel - states: open from the bus", async (assert) => {
|
||||
});
|
||||
});
|
||||
assert.containsOnce($, ".o-mail-DiscussCategory-channel .oi-chevron-down");
|
||||
assert.containsOnce($, "button:contains(test)");
|
||||
assert.containsOnce($, "button:contains(channel1)");
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"channel - states: the active category item should be visible even if the category is closed",
|
||||
async (assert) => {
|
||||
const pyEnv = await startServer();
|
||||
pyEnv["discuss.channel"].create({ name: "test" });
|
||||
pyEnv["discuss.channel"].create({ name: "channel1" });
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
await click(".o-mail-DiscussCategoryItem:contains(test)");
|
||||
assert.containsOnce($, "button:contains(test).o-active");
|
||||
await click(".o-mail-DiscussCategoryItem:contains(channel1)");
|
||||
assert.containsOnce($, "button:contains(channel1).o-active");
|
||||
|
||||
await click(".o-mail-DiscussCategory span:contains(Channels)");
|
||||
assert.containsOnce($, ".o-mail-DiscussCategory-channel .oi-chevron-right");
|
||||
assert.containsOnce($, "button:contains(test)");
|
||||
assert.containsOnce($, "button:contains(channel1)");
|
||||
|
||||
await click("button:contains(Inbox)");
|
||||
assert.containsNone($, "button:contains(test)");
|
||||
assert.containsNone($, "button:contains(channel1)");
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { mailCoreCommon } from "@mail/core/common/mail_core_common_service";
|
||||
import { setupManager } from "@mail/../tests/helpers/webclient_setup";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(setupManager, "mail/core/common", {
|
||||
setupServices(...args) {
|
||||
return {
|
||||
...this._super(...args),
|
||||
"mail.core.common": mailCoreCommon,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { mailCoreWeb } from "@mail/core/web/mail_core_web_service";
|
||||
import { setupManager } from "@mail/../tests/helpers/webclient_setup";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(setupManager, "mail/core/web", {
|
||||
setupServices(...args) {
|
||||
return {
|
||||
...this._super(...args),
|
||||
"mail.core.web": mailCoreWeb,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { CallInvitations } from "@mail/discuss/call/common/call_invitations";
|
||||
import { CallMenu } from "@mail/discuss/call/common/call_menu";
|
||||
import { rtcService } from "@mail/discuss/call/common/rtc_service";
|
||||
import { setupManager } from "@mail/../tests/helpers/webclient_setup";
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(setupManager, "discuss/call", {
|
||||
setupServices(...args) {
|
||||
return {
|
||||
...this._super(...args),
|
||||
"discuss.rtc": rtcService,
|
||||
};
|
||||
},
|
||||
setupMainComponentRegistry() {
|
||||
this._super();
|
||||
registry.category("main_components").add("discuss.CallInvitations", {
|
||||
Component: CallInvitations,
|
||||
});
|
||||
},
|
||||
setupMessagingServiceRegistries(...args) {
|
||||
this._super(...args);
|
||||
registry
|
||||
.category("systray")
|
||||
.add("discuss.CallMenu", { Component: CallMenu }, { sequence: 15 });
|
||||
},
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { discussCoreCommon } from "@mail/discuss/core/common/discuss_core_common_service";
|
||||
import { setupManager } from "@mail/../tests/helpers/webclient_setup";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(setupManager, "discuss/core/common", {
|
||||
setupServices(...args) {
|
||||
return {
|
||||
...this._super(...args),
|
||||
"discuss.core.common": discussCoreCommon,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { discussCoreWeb } from "@mail/discuss/core/web/discuss_core_web_service";
|
||||
import { setupManager } from "@mail/../tests/helpers/webclient_setup";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(setupManager, "discuss/core/web", {
|
||||
setupServices(...args) {
|
||||
return {
|
||||
...this._super(...args),
|
||||
"discuss.core.web": discussCoreWeb,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { messagePinService } from "@mail/discuss/message_pin/common/message_pin_service";
|
||||
import { setupManager } from "@mail/../tests/helpers/webclient_setup";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(setupManager, "discuss/message_pin", {
|
||||
setupServices() {
|
||||
return {
|
||||
...this._super(...arguments),
|
||||
"discuss.message.pin": messagePinService,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { discussTypingService } from "@mail/discuss/typing/common/typing_service";
|
||||
import { setupManager } from "@mail/../tests/helpers/webclient_setup";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(setupManager, "discuss/typing", {
|
||||
setupServices() {
|
||||
return {
|
||||
...this._super(...arguments),
|
||||
"discuss.typing": discussTypingService,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,39 +1,14 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { busParametersService } from "@bus/bus_parameters_service";
|
||||
import { imStatusService } from "@bus/im_status_service";
|
||||
import { multiTabService } from "@bus/multi_tab_service";
|
||||
import { busService } from "@bus/services/bus_service";
|
||||
import { makeFakePresenceService } from "@bus/../tests/helpers/mock_services";
|
||||
|
||||
import { attachmentService } from "@mail/core/common/attachment_service";
|
||||
import { channelMemberService } from "@mail/core/common/channel_member_service";
|
||||
import { ChatWindowContainer } from "@mail/core/common/chat_window_container";
|
||||
import { chatWindowService } from "@mail/core/common/chat_window_service";
|
||||
import { messageService } from "@mail/core/common/message_service";
|
||||
import { messagingService } from "@mail/core/common/messaging_service";
|
||||
import { notificationPermissionService } from "@mail/core/common/notification_permission_service";
|
||||
import { outOfFocusService } from "@mail/core/common/out_of_focus_service";
|
||||
import { personaService } from "@mail/core/common/persona_service";
|
||||
import { soundEffects } from "@mail/core/common/sound_effects_service";
|
||||
import { storeService } from "@mail/core/common/store_service";
|
||||
import { suggestionService } from "@mail/core/common/suggestion_service";
|
||||
import { threadService } from "@mail/core/common/thread_service";
|
||||
import { userSettingsService } from "@mail/core/common/user_settings_service";
|
||||
import { ActivityMenu } from "@mail/core/web/activity_menu";
|
||||
import { activityService } from "@mail/core/web/activity_service";
|
||||
import { DiscussClientAction } from "@mail/core/web/discuss_client_action";
|
||||
import { MessagingMenu } from "@mail/core/web/messaging_menu";
|
||||
|
||||
import { effectService } from "@web/core/effects/effect_service";
|
||||
import { fileUploadService } from "@web/core/file_upload/file_upload_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { session } from "@web/session";
|
||||
import { makeMockXHR } from "@web/../tests/helpers/mock_services";
|
||||
import { makeMockXHR, mocks } from "@web/../tests/helpers/mock_services";
|
||||
import { patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { createWebClient } from "@web/../tests/webclient/helpers";
|
||||
import { gifPickerService } from "@mail/discuss/gif_picker/common/gif_picker_service";
|
||||
|
||||
const ROUTES_TO_IGNORE = [
|
||||
"/web/webclient/load_menus",
|
||||
@@ -54,6 +29,35 @@ const SERVICES_PARAMETER_NAMES = new Set([
|
||||
"services",
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param {import("@web/core/registry").Registry} source
|
||||
* @param {import("@web/core/registry").Registry} target
|
||||
*/
|
||||
export function copyRegistry(source, target) {
|
||||
for (const [name, service] of source.getEntries()) {
|
||||
target.add(name, service);
|
||||
}
|
||||
source.addEventListener("UPDATE", ({ operation, key, value }) => {
|
||||
if (operation === "add") {
|
||||
target.add(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Copy registries before they are cleared by the test setup in
|
||||
// order to restore them during `getWebClientReady`.
|
||||
const mailServicesRegistry = registry.category("mail.services");
|
||||
const webServicesRegistry = registry.category("services");
|
||||
copyRegistry(webServicesRegistry, mailServicesRegistry);
|
||||
|
||||
const mailMainComponentsRegistry = registry.category("mail.main_components");
|
||||
const webMainComponentsRegistry = registry.category("main_components");
|
||||
copyRegistry(webMainComponentsRegistry, mailMainComponentsRegistry);
|
||||
|
||||
const mailSystrayRegistry = registry.category("mail.systray");
|
||||
const webSystrayRegistry = registry.category("systray");
|
||||
copyRegistry(webSystrayRegistry, mailSystrayRegistry);
|
||||
|
||||
/**
|
||||
* @returns function that returns an `XMLHttpRequest`-like object whose response
|
||||
* is computed by the given mock server.
|
||||
@@ -98,14 +102,23 @@ export const setupManager = {
|
||||
* Add required components to the main component registry.
|
||||
*/
|
||||
setupMainComponentRegistry() {
|
||||
const mainComponentRegistry = registry.category("main_components");
|
||||
mainComponentRegistry.add("mail.ChatWindowContainer", {
|
||||
Component: ChatWindowContainer,
|
||||
});
|
||||
for (const [name, component] of mailMainComponentsRegistry.getEntries()) {
|
||||
webMainComponentsRegistry.add(name, component);
|
||||
}
|
||||
if (!registry.category("actions").contains("mail.action_discuss")) {
|
||||
registry.category("actions").add("mail.action_discuss", DiscussClientAction);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Add required components to the systray registry.
|
||||
*/
|
||||
setupSystrayRegistry() {
|
||||
for (const [name, component] of mailSystrayRegistry.getEntries()) {
|
||||
if (!webSystrayRegistry.contains(name)) {
|
||||
webSystrayRegistry.add(name, component);
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Setup both legacy and new service registries.
|
||||
*
|
||||
@@ -117,13 +130,7 @@ export const setupManager = {
|
||||
* @returns {LegacyRegistry} The registry containing all the legacy services that will be passed
|
||||
* to the webClient as a legacy parameter.
|
||||
*/
|
||||
async setupMessagingServiceRegistries({
|
||||
loadingBaseDelayDuration = 0,
|
||||
messagingBus,
|
||||
services,
|
||||
} = {}) {
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
setupServiceRegistries({ loadingBaseDelayDuration = 0, messagingBus, services = {} } = {}) {
|
||||
const OriginalAudio = window.Audio;
|
||||
patchWithCleanup(
|
||||
window,
|
||||
@@ -137,8 +144,8 @@ export const setupManager = {
|
||||
},
|
||||
{ pure: true }
|
||||
);
|
||||
|
||||
const messagingValues = {
|
||||
patchWithCleanup(session, { show_effect: true });
|
||||
services["messagingValues"] = services["messagingValues"] ?? {
|
||||
start() {
|
||||
return {
|
||||
isInQUnitTest: true,
|
||||
@@ -149,10 +156,8 @@ export const setupManager = {
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
services = setupManager.setupServices(services, messagingValues);
|
||||
if (!serviceRegistry.contains("file_upload")) {
|
||||
serviceRegistry.add("file_upload", {
|
||||
if (!webServicesRegistry.contains("file_upload")) {
|
||||
webServicesRegistry.add("file_upload", {
|
||||
...fileUploadService,
|
||||
start(env, ...args) {
|
||||
this.env = env;
|
||||
@@ -161,47 +166,14 @@ export const setupManager = {
|
||||
createXhr: getCreateXHR(),
|
||||
});
|
||||
}
|
||||
patchWithCleanup(session, { show_effect: true });
|
||||
Object.entries(services).forEach(([serviceName, service]) => {
|
||||
if (!serviceRegistry.contains(serviceName)) {
|
||||
serviceRegistry.add(serviceName, service);
|
||||
for (const [name, service] of Object.entries(services)) {
|
||||
webServicesRegistry.add(name, service);
|
||||
}
|
||||
for (const [name, service] of mailServicesRegistry.getEntries()) {
|
||||
if (!mocks[name] && !name.includes("legacy_") && !webServicesRegistry.contains(name)) {
|
||||
webServicesRegistry.add(name, service);
|
||||
}
|
||||
});
|
||||
registry
|
||||
.category("systray")
|
||||
.add("mail.activity_menu", { Component: ActivityMenu }, { sequence: 20 });
|
||||
registry
|
||||
.category("systray")
|
||||
.add("mail.messaging_menu", { Component: MessagingMenu }, { sequence: 25 });
|
||||
},
|
||||
setupServices(services, messagingValues) {
|
||||
return {
|
||||
bus_service: busService,
|
||||
"bus.parameters": busParametersService,
|
||||
im_status: imStatusService,
|
||||
effect: effectService,
|
||||
"discuss.channel.member": channelMemberService,
|
||||
"discuss.gifPicker": gifPickerService,
|
||||
"mail.notification.permission": notificationPermissionService,
|
||||
"mail.suggestion": suggestionService,
|
||||
"mail.store": storeService,
|
||||
"mail.activity": activityService,
|
||||
"mail.attachment": attachmentService,
|
||||
"mail.thread": threadService,
|
||||
"mail.message": messageService,
|
||||
"mail.chat_window": chatWindowService,
|
||||
"mail.messaging": messagingService,
|
||||
"mail.sound_effects": soundEffects,
|
||||
"mail.user_settings": userSettingsService,
|
||||
"mail.persona": personaService,
|
||||
"mail.out_of_focus": outOfFocusService,
|
||||
messagingValues,
|
||||
presence: makeFakePresenceService({
|
||||
isOdooFocused: () => true,
|
||||
}),
|
||||
multi_tab: multiTabService,
|
||||
...services,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -226,7 +198,8 @@ async function getWebClientReady(param0) {
|
||||
servicesParameters[parameterName] = value;
|
||||
}
|
||||
}
|
||||
await setupManager.setupMessagingServiceRegistries(servicesParameters);
|
||||
setupManager.setupServiceRegistries(servicesParameters);
|
||||
setupManager.setupSystrayRegistry();
|
||||
|
||||
const webClientParameters = {};
|
||||
for (const [parameterName, value] of param0Entries) {
|
||||
|
||||
@@ -385,7 +385,7 @@ QUnit.test("avatar card preview", async (assert) => {
|
||||
});
|
||||
// Open card
|
||||
await triggerEvent(document, ".o_m2o_avatar > img", "mouseover");
|
||||
assert.verifySteps(["setTimeout of 350ms", "user read"]);
|
||||
assert.verifySteps(["setTimeout of 350ms", "setTimeout of 250ms", "user read"]);
|
||||
assert.containsOnce(document.body, ".o_avatar_card");
|
||||
assert.strictEqual(
|
||||
document.querySelector(".o_card_user_infos").textContent,
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { setupManager } from "@mail/../tests/helpers/webclient_setup";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { websiteLivechatNotifications } from "@website_livechat/core/website_livechat_notification_handler";
|
||||
|
||||
patch(setupManager, "website_livechat", {
|
||||
setupServices() {
|
||||
return {
|
||||
...this._super(...arguments),
|
||||
"website_livechat.notifications": websiteLivechatNotifications,
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user