From d3fbcf9dda4e613c29a627ed2dd4052a246e3455 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 9 Mar 2026 23:17:51 -0300 Subject: [PATCH 01/57] move offline agents filtering to query root --- apps/meteor/tests/data/livechat/users.ts | 3 +-- .../tests/end-to-end/api/livechat/00-rooms.ts | 2 +- packages/models/src/models/Users.ts | 23 +++++++------------ 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts index 8e7b4213aadab..878661702d04b 100644 --- a/apps/meteor/tests/data/livechat/users.ts +++ b/apps/meteor/tests/data/livechat/users.ts @@ -5,7 +5,7 @@ import { Random } from '@rocket.chat/random'; import { api, credentials, request } from '../api-data'; import { password } from '../user'; -import { createUser, login, setUserAway, setUserStatus } from '../users.helper'; +import { createUser, login, setUserStatus } from '../users.helper'; import { createAgent, makeAgentAvailable, makeAgentUnavailable } from './rooms'; export const createBotAgent = async (): Promise<{ @@ -102,7 +102,6 @@ export const createAnAwayAgent = async (): Promise<{ await createAgent(agent.username); await makeAgentAvailable(createdUserCredentials); await setUserStatus(createdUserCredentials, UserStatus.AWAY); - await setUserAway(createdUserCredentials); return { credentials: createdUserCredentials, diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index d606feac77a87..c41be39241281 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -1714,7 +1714,7 @@ describe('LIVECHAT - rooms', () => { async () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: false }); + const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({ allowReceiveForwardOffline: false }); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); diff --git a/packages/models/src/models/Users.ts b/packages/models/src/models/Users.ts index d8ff89c93fa7e..1b5b9b152b8de 100644 --- a/packages/models/src/models/Users.ts +++ b/packages/models/src/models/Users.ts @@ -37,22 +37,15 @@ const queryStatusAgentOnline = (extraFilters = {}, isLivechatEnabledWhenAgentIdl roles: 'livechat-agent', // ignore deactivated users active: true, - ...(!isLivechatEnabledWhenAgentIdle && { - $or: [ - { - status: { - $exists: true, - $ne: UserStatus.OFFLINE, - }, - roles: { - $ne: 'bot', - }, - }, - { - roles: 'bot', + $or: [ + { roles: 'bot' }, + { + status: { + $exists: true, + $ne: UserStatus.OFFLINE, }, - ], - }), + }, + ], ...extraFilters, ...(isLivechatEnabledWhenAgentIdle === false && { statusConnection: { $ne: 'away' }, From 04dc75a9ec2ad0003288a12f5224eb8e057861a1 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 10 Mar 2026 22:35:11 -0300 Subject: [PATCH 02/57] fix user presence update from test users --- apps/meteor/tests/data/livechat/users.ts | 3 ++- apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts | 2 +- ee/packages/presence/src/lib/processConnectionStatus.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts index 878661702d04b..8e7b4213aadab 100644 --- a/apps/meteor/tests/data/livechat/users.ts +++ b/apps/meteor/tests/data/livechat/users.ts @@ -5,7 +5,7 @@ import { Random } from '@rocket.chat/random'; import { api, credentials, request } from '../api-data'; import { password } from '../user'; -import { createUser, login, setUserStatus } from '../users.helper'; +import { createUser, login, setUserAway, setUserStatus } from '../users.helper'; import { createAgent, makeAgentAvailable, makeAgentUnavailable } from './rooms'; export const createBotAgent = async (): Promise<{ @@ -102,6 +102,7 @@ export const createAnAwayAgent = async (): Promise<{ await createAgent(agent.username); await makeAgentAvailable(createdUserCredentials); await setUserStatus(createdUserCredentials, UserStatus.AWAY); + await setUserAway(createdUserCredentials); return { credentials: createdUserCredentials, diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index c41be39241281..d606feac77a87 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -1714,7 +1714,7 @@ describe('LIVECHAT - rooms', () => { async () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({ allowReceiveForwardOffline: false }); + const { department: forwardToOfflineDepartment } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: false }); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); diff --git a/ee/packages/presence/src/lib/processConnectionStatus.ts b/ee/packages/presence/src/lib/processConnectionStatus.ts index a14aa45219e8a..6c17df87334f0 100644 --- a/ee/packages/presence/src/lib/processConnectionStatus.ts +++ b/ee/packages/presence/src/lib/processConnectionStatus.ts @@ -36,7 +36,7 @@ export const processPresenceAndStatus = ( userSessions: IUserSessionConnection[] = [], statusDefault = UserStatus.ONLINE, ): { status: UserStatus; statusConnection: UserStatus } => { - const statusConnection = userSessions.map((s) => s.status).reduce(processConnectionStatus, UserStatus.OFFLINE); + const statusConnection = process.env.TEST_MODE ? statusDefault : userSessions.map((s) => s.status).reduce(processConnectionStatus, UserStatus.OFFLINE); const status = processStatus(statusConnection, statusDefault); From 81cf18e2768bb3542f4d1cc6b6cdb86144e5551b Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 11 Mar 2026 10:48:20 -0300 Subject: [PATCH 03/57] fix linter error --- ee/packages/presence/src/lib/processConnectionStatus.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ee/packages/presence/src/lib/processConnectionStatus.ts b/ee/packages/presence/src/lib/processConnectionStatus.ts index 6c17df87334f0..685ddc8dc00d3 100644 --- a/ee/packages/presence/src/lib/processConnectionStatus.ts +++ b/ee/packages/presence/src/lib/processConnectionStatus.ts @@ -36,7 +36,9 @@ export const processPresenceAndStatus = ( userSessions: IUserSessionConnection[] = [], statusDefault = UserStatus.ONLINE, ): { status: UserStatus; statusConnection: UserStatus } => { - const statusConnection = process.env.TEST_MODE ? statusDefault : userSessions.map((s) => s.status).reduce(processConnectionStatus, UserStatus.OFFLINE); + const statusConnection = process.env.TEST_MODE + ? statusDefault + : userSessions.map((s) => s.status).reduce(processConnectionStatus, UserStatus.OFFLINE); const status = processStatus(statusConnection, statusDefault); From cae2152f1cc2f12b2383b39d21f1fbe2bf6d2b51 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 11 Mar 2026 19:32:05 -0300 Subject: [PATCH 04/57] rollback TEST_MODE conditional --- ee/packages/presence/src/lib/processConnectionStatus.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ee/packages/presence/src/lib/processConnectionStatus.ts b/ee/packages/presence/src/lib/processConnectionStatus.ts index 685ddc8dc00d3..a14aa45219e8a 100644 --- a/ee/packages/presence/src/lib/processConnectionStatus.ts +++ b/ee/packages/presence/src/lib/processConnectionStatus.ts @@ -36,9 +36,7 @@ export const processPresenceAndStatus = ( userSessions: IUserSessionConnection[] = [], statusDefault = UserStatus.ONLINE, ): { status: UserStatus; statusConnection: UserStatus } => { - const statusConnection = process.env.TEST_MODE - ? statusDefault - : userSessions.map((s) => s.status).reduce(processConnectionStatus, UserStatus.OFFLINE); + const statusConnection = userSessions.map((s) => s.status).reduce(processConnectionStatus, UserStatus.OFFLINE); const status = processStatus(statusConnection, statusDefault); From 0942d4a3d76ed6d165f35127cb3c78cdf030b5d3 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 12 Mar 2026 13:18:03 -0300 Subject: [PATCH 05/57] add ws user helpers to fix connectionStatus on tests --- apps/meteor/tests/data/livechat/users.ts | 5 +- apps/meteor/tests/data/users.helper.ts | 83 ++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts index 8e7b4213aadab..55f4b005cc064 100644 --- a/apps/meteor/tests/data/livechat/users.ts +++ b/apps/meteor/tests/data/livechat/users.ts @@ -5,7 +5,7 @@ import { Random } from '@rocket.chat/random'; import { api, credentials, request } from '../api-data'; import { password } from '../user'; -import { createUser, login, setUserAway, setUserStatus } from '../users.helper'; +import { createUser, login, setUserAwayWS, ddpLogin, setUserStatus } from '../users.helper'; import { createAgent, makeAgentAvailable, makeAgentUnavailable } from './rooms'; export const createBotAgent = async (): Promise<{ @@ -99,10 +99,11 @@ export const createAnAwayAgent = async (): Promise<{ const { body } = await request.post(api('users.create')).set(credentials).send({ email, name: username, username, password }); const agent = body.user; const createdUserCredentials = await login(agent.username, password); + const ws = await ddpLogin(createdUserCredentials['X-Auth-Token']); await createAgent(agent.username); await makeAgentAvailable(createdUserCredentials); await setUserStatus(createdUserCredentials, UserStatus.AWAY); - await setUserAway(createdUserCredentials); + await setUserAwayWS(ws); return { credentials: createdUserCredentials, diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index b8835cc5ef79b..49c66913a22ea 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -172,6 +172,89 @@ export const setUserAway = (overrideCredentials = credentials, config?: IRequest }); }; +export const ddpLogin = (resume: string): Promise => + new Promise((resolve, reject) => { + const ws = new WebSocket('ws://localhost:4000/websocket'); + + ws.onopen = () => { + ws.send( + JSON.stringify({ + msg: 'connect', + version: '1', + support: ['1'], + }), + ); + }; + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + switch (data.msg) { + case 'connected': + ws.send( + JSON.stringify({ + msg: 'method', + id: 'login-1', + method: 'login', + params: [{ resume }], + }), + ); + break; + + case 'result': + if (data.id === 'login-1') { + // NO cerrar el websocket + resolve(ws); + } + break; + + case 'ping': + ws.close(); + break; + + case 'error': + reject(data); + break; + } + }; + + ws.onerror = reject; + }); + +export const setUserAwayWS = (ws: WebSocket): Promise => + new Promise((resolve, reject) => { + const id = 'away-1'; + + const handler = (event: MessageEvent) => { + const data = JSON.parse(event.data); + + if (data.msg === 'result' && data.id === id) { + ws.removeEventListener('message', handler); + resolve(); + } + + if (data.msg === 'ping') { + ws.send(JSON.stringify({ msg: 'pong' })); + } + + if (data.msg === 'error') { + ws.removeEventListener('message', handler); + reject(data); + } + }; + + ws.addEventListener('message', handler); + + ws.send( + JSON.stringify({ + msg: 'method', + method: 'UserPresence:away', + params: [], + id, + }), + ); + }); + export const setUserOnline = (overrideCredentials = credentials, config?: IRequestConfig) => { const requestInstance = config?.request || request; return requestInstance From 9102e164def3de3903a11522c81b2fa98aaf2bb1 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 12 Mar 2026 14:05:26 -0300 Subject: [PATCH 06/57] test on CI without closing ws --- apps/meteor/tests/data/users.helper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 49c66913a22ea..46c2dc79279f1 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -209,7 +209,7 @@ export const ddpLogin = (resume: string): Promise => break; case 'ping': - ws.close(); + ws.send(JSON.stringify({ msg: 'pong' })); break; case 'error': From 395c57ba75314e62019238c5cc0c9ff9b456cc0b Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 12 Mar 2026 16:15:38 -0300 Subject: [PATCH 07/57] add use of setUserAwayWS to routing test --- .../end-to-end/api/livechat/24-routing.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index 3bc9c8d9f218a..b10a1b818cb12 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -18,7 +18,16 @@ import { } from '../../../data/livechat/rooms'; import { updateSetting } from '../../../data/permissions.helper'; import { password } from '../../../data/user'; -import { createUser, deleteUser, login, setUserActiveStatus, setUserAway, setUserStatus } from '../../../data/users.helper'; +import { + createUser, + deleteUser, + login, + ddpLogin, + setUserAwayWS, + setUserActiveStatus, + setUserAway, + setUserStatus, +} from '../../../data/users.helper'; import { IS_EE } from '../../../e2e/config/constants'; (IS_EE ? describe : describe.skip)('Omnichannel - Routing', () => { @@ -358,10 +367,13 @@ import { IS_EE } from '../../../e2e/config/constants'; expect(roomInfo.servedBy).to.be.undefined; }); it('should not route to an idle user', async () => { + await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); - await setUserAway(testUser.credentials); + const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); + await setUserAwayWS(ws1); await setUserStatus(testUser3.credentials, UserStatus.AWAY); - await setUserAway(testUser3.credentials); + const ws2 = await ddpLogin(testUser3.credentials['X-Auth-Token']); + await setUserAwayWS(ws2); // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); From 6ebec4241b47711199a79ab1a10e41b063b69d19 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 13 Mar 2026 15:24:03 -0300 Subject: [PATCH 08/57] enhance ws connection, add ws closures --- apps/meteor/tests/data/livechat/department.ts | 4 ++- apps/meteor/tests/data/livechat/users.ts | 2 ++ apps/meteor/tests/data/users.helper.ts | 34 +++++++++++-------- .../tests/end-to-end/api/livechat/00-rooms.ts | 20 ++++++++--- .../end-to-end/api/livechat/24-routing.ts | 17 +++++++--- 5 files changed, 53 insertions(+), 24 deletions(-) diff --git a/apps/meteor/tests/data/livechat/department.ts b/apps/meteor/tests/data/livechat/department.ts index 2064bb8b03b4b..f8b2ed5298a0e 100644 --- a/apps/meteor/tests/data/livechat/department.ts +++ b/apps/meteor/tests/data/livechat/department.ts @@ -175,8 +175,9 @@ export const createDepartmentWithAnAwayAgent = async ({ credentials: Credentials; user: WithRequiredProperty; }; + ws: WebSocket; }> => { - const { user, credentials } = await createAnAwayAgent(); + const { user, credentials, ws } = await createAnAwayAgent(); const department = (await createDepartment({ allowReceiveForwardOffline, @@ -192,6 +193,7 @@ export const createDepartmentWithAnAwayAgent = async ({ credentials, user, }, + ws, }; }; diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts index 55f4b005cc064..2afc321e8f3a7 100644 --- a/apps/meteor/tests/data/livechat/users.ts +++ b/apps/meteor/tests/data/livechat/users.ts @@ -93,6 +93,7 @@ export const createAnOfflineAgent = async (): Promise<{ export const createAnAwayAgent = async (): Promise<{ credentials: Credentials; user: IUser & { username: string }; + ws: WebSocket; }> => { const username = `user.test.${Date.now()}.away`; const email = `${username}.offline@rocket.chat`; @@ -108,6 +109,7 @@ export const createAnAwayAgent = async (): Promise<{ return { credentials: createdUserCredentials, user: agent, + ws, }; }; diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 46c2dc79279f1..d7c63071de4c3 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -175,18 +175,9 @@ export const setUserAway = (overrideCredentials = credentials, config?: IRequest export const ddpLogin = (resume: string): Promise => new Promise((resolve, reject) => { const ws = new WebSocket('ws://localhost:4000/websocket'); + const loginId = `login-${Date.now()}-${Math.random()}`; - ws.onopen = () => { - ws.send( - JSON.stringify({ - msg: 'connect', - version: '1', - support: ['1'], - }), - ); - }; - - ws.onmessage = (event) => { + const handler = (event: MessageEvent) => { const data = JSON.parse(event.data); switch (data.msg) { @@ -194,7 +185,7 @@ export const ddpLogin = (resume: string): Promise => ws.send( JSON.stringify({ msg: 'method', - id: 'login-1', + id: loginId, method: 'login', params: [{ resume }], }), @@ -202,8 +193,8 @@ export const ddpLogin = (resume: string): Promise => break; case 'result': - if (data.id === 'login-1') { - // NO cerrar el websocket + if (data.id === loginId) { + ws.removeEventListener('message', handler); resolve(ws); } break; @@ -213,17 +204,30 @@ export const ddpLogin = (resume: string): Promise => break; case 'error': + ws.removeEventListener('message', handler); reject(data); break; } }; + ws.addEventListener('message', handler); + + ws.onopen = () => { + ws.send( + JSON.stringify({ + msg: 'connect', + version: '1', + support: ['1'], + }), + ); + }; + ws.onerror = reject; }); export const setUserAwayWS = (ws: WebSocket): Promise => new Promise((resolve, reject) => { - const id = 'away-1'; + const id = `away-${Date.now()}-${Math.random()}`; const handler = (event: MessageEvent) => { const data = JSON.parse(event.data); diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index d606feac77a87..68b3868514a29 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -1547,7 +1547,7 @@ describe('LIVECHAT - rooms', () => { await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); await updateSetting('Livechat_enabled_when_agent_idle', false); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment } = await createDepartmentWithAnAwayAgent({ + const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true, }); @@ -1582,6 +1582,8 @@ describe('LIVECHAT - rooms', () => { expect(res.body).to.have.property('count'); }); + ws.close(); + await Promise.all([ deleteDepartment(initialDepartment._id), deleteDepartment(forwardToOfflineDepartment._id), @@ -1675,7 +1677,7 @@ describe('LIVECHAT - rooms', () => { async () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true }); + const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true }); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1698,6 +1700,8 @@ describe('LIVECHAT - rooms', () => { expect(inquiry.status).to.equal('queued'); expect(inquiry.department).to.equal(forwardToOfflineDepartment._id); + ws.close(); + await Promise.all([ deleteDepartment(initialDepartment._id), deleteDepartment(forwardToOfflineDepartment._id), @@ -1714,7 +1718,7 @@ describe('LIVECHAT - rooms', () => { async () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: false }); + const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: false }); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1735,6 +1739,8 @@ describe('LIVECHAT - rooms', () => { expect(res.status).to.equal(400); expect(res.body).to.have.property('error', 'error-no-agents-available-for-service-on-department'); + ws.close(); + await Promise.all([ deleteDepartment(initialDepartment._id), deleteDepartment(forwardToOfflineDepartment._id), @@ -1791,7 +1797,11 @@ describe('LIVECHAT - rooms', () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); await updateSetting('Livechat_enabled_when_agent_idle', true); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, agent } = await createDepartmentWithAnAwayAgent({ + const { + department: forwardToOfflineDepartment, + agent, + ws, + } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true, }); @@ -1814,6 +1824,8 @@ describe('LIVECHAT - rooms', () => { expect(roomInfo.servedBy).to.have.property('_id', agent.user._id); expect(roomInfo.departmentId).to.be.equal(forwardToOfflineDepartment._id); + ws.close(); + await Promise.all([ deleteDepartment(initialDepartment._id), deleteDepartment(forwardToOfflineDepartment._id), diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index b10a1b818cb12..c1500e49e8479 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -227,6 +227,7 @@ import { IS_EE } from '../../../e2e/config/constants'; let testUser3: { user: IUser; credentials: Credentials }; let testDepartment: ILivechatDepartment; let visitorEmail: string; + const sockets: WebSocket[] = []; before(async () => { const user = await createUser(); @@ -280,14 +281,20 @@ import { IS_EE } from '../../../e2e/config/constants'; }); }); - after(async () => - Promise.all([ + after(async () => { + await Promise.all([ deleteUser(testUser.user), deleteUser(testUser2.user), deleteUser(testUser3.user), updateSetting('Livechat_enabled_when_agent_idle', true), - ]), - ); + ]); + + for (const ws of sockets) { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + } + }); it('should route a room to an available agent', async () => { const visitor = await createVisitor(testDepartment._id); @@ -370,9 +377,11 @@ import { IS_EE } from '../../../e2e/config/constants'; await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(ws1); await setUserAwayWS(ws1); await setUserStatus(testUser3.credentials, UserStatus.AWAY); const ws2 = await ddpLogin(testUser3.credentials['X-Auth-Token']); + sockets.push(ws2); await setUserAwayWS(ws2); // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); From 8b41cd9ab86cfd4c9a7da8dcb9a4a3fe7a5e8ca3 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 13 Mar 2026 15:26:12 -0300 Subject: [PATCH 09/57] change ws port to 3000 --- apps/meteor/tests/data/users.helper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index d7c63071de4c3..63635c099bddf 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -174,7 +174,7 @@ export const setUserAway = (overrideCredentials = credentials, config?: IRequest export const ddpLogin = (resume: string): Promise => new Promise((resolve, reject) => { - const ws = new WebSocket('ws://localhost:4000/websocket'); + const ws = new WebSocket('ws://localhost:3000/websocket'); const loginId = `login-${Date.now()}-${Math.random()}`; const handler = (event: MessageEvent) => { From 6f3c33f448adb156a7da3bbfd5e2b0c83ea63544 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 13 Mar 2026 17:33:13 -0300 Subject: [PATCH 10/57] update idle status tests from routing --- .../end-to-end/api/livechat/24-routing.ts | 74 ++++++++++--------- 1 file changed, 39 insertions(+), 35 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index c1500e49e8479..8c65eaa08fe77 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -18,23 +18,20 @@ import { } from '../../../data/livechat/rooms'; import { updateSetting } from '../../../data/permissions.helper'; import { password } from '../../../data/user'; -import { - createUser, - deleteUser, - login, - ddpLogin, - setUserAwayWS, - setUserActiveStatus, - setUserAway, - setUserStatus, -} from '../../../data/users.helper'; +import { createUser, deleteUser, login, ddpLogin, setUserAwayWS, setUserActiveStatus, setUserStatus } from '../../../data/users.helper'; import { IS_EE } from '../../../e2e/config/constants'; (IS_EE ? describe : describe.skip)('Omnichannel - Routing', () => { + const sockets: WebSocket[] = []; before((done) => getCredentials(done)); after(async () => { await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + for (const ws of sockets) { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + } }); // Basically: if there's a bot in the department, it should be assigned to the conversation @@ -227,7 +224,6 @@ import { IS_EE } from '../../../e2e/config/constants'; let testUser3: { user: IUser; credentials: Credentials }; let testDepartment: ILivechatDepartment; let visitorEmail: string; - const sockets: WebSocket[] = []; before(async () => { const user = await createUser(); @@ -281,20 +277,14 @@ import { IS_EE } from '../../../e2e/config/constants'; }); }); - after(async () => { - await Promise.all([ + after(async () => + Promise.all([ deleteUser(testUser.user), deleteUser(testUser2.user), deleteUser(testUser3.user), updateSetting('Livechat_enabled_when_agent_idle', true), - ]); - - for (const ws of sockets) { - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { - ws.close(); - } - } - }); + ]), + ); it('should route a room to an available agent', async () => { const visitor = await createVisitor(testDepartment._id); @@ -363,16 +353,6 @@ import { IS_EE } from '../../../e2e/config/constants'; const roomInfo = await getLivechatRoomInfo(room._id); expect(roomInfo.servedBy).to.be.undefined; }); - it('should ignore offline users when Livechat_enabled_when_agent_idle is false', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', false); - await setUserStatus(testUser.credentials, UserStatus.OFFLINE); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.undefined; - }); it('should not route to an idle user', async () => { await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); @@ -383,6 +363,7 @@ import { IS_EE } from '../../../e2e/config/constants'; const ws2 = await ddpLogin(testUser3.credentials['X-Auth-Token']); sockets.push(ws2); await setUserAwayWS(ws2); + // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); @@ -533,9 +514,15 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should not route to an idle user', async () => { await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); - await setUserAway(testUser.credentials); + const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(ws1); + await setUserAwayWS(ws1); await setUserStatus(testUser2.credentials, UserStatus.AWAY); - await setUserAway(testUser2.credentials); + const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); + sockets.push(ws2); + await setUserAwayWS(ws2); + + await new Promise((r) => setTimeout(r, 9000)); // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); @@ -548,7 +535,13 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should route to agents even if theyre idle when setting is enabled', async () => { await updateSetting('Livechat_enabled_when_agent_idle', true); await setUserStatus(testUser.credentials, UserStatus.AWAY); + const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(ws1); + await setUserAwayWS(ws1); await setUserStatus(testUser2.credentials, UserStatus.AWAY); + const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); + sockets.push(ws2); + await setUserAwayWS(ws2); const visitor = await createVisitor(testDepartment._id); const room = await createLivechatRoom(visitor.token); @@ -641,9 +634,14 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should not route to an idle user', async () => { await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); - await setUserAway(testUser.credentials); + const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(ws1); + await setUserAwayWS(ws1); await setUserStatus(testUser2.credentials, UserStatus.AWAY); - await setUserAway(testUser2.credentials); + const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); + sockets.push(ws2); + await setUserAwayWS(ws2); + // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); @@ -656,7 +654,13 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should route to agents even if theyre idle when setting is enabled', async () => { await updateSetting('Livechat_enabled_when_agent_idle', true); await setUserStatus(testUser.credentials, UserStatus.AWAY); + const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(ws1); + await setUserAwayWS(ws1); await setUserStatus(testUser2.credentials, UserStatus.AWAY); + const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); + sockets.push(ws2); + await setUserAwayWS(ws2); const visitor = await createVisitor(testDepartment._id); const room = await createLivechatRoom(visitor.token); From 79d8eb9c219eda0ffbcf6f10bd07b71997bd247c Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 16 Mar 2026 10:43:37 -0300 Subject: [PATCH 11/57] fix playwright tests --- .../tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts | 2 ++ .../e2e/omnichannel/omnichannel-custom-field-usage.spec.ts | 3 ++- apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts index c3ceeae55d853..10a1c221d628f 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts @@ -1,3 +1,4 @@ +import { ddpLogin } from '../../data/users.helper'; import { createFakeVisitor } from '../../mocks/data'; import { IS_EE } from '../config/constants'; import { Users } from '../fixtures/userStates'; @@ -33,6 +34,7 @@ test.describe('OC - Tags Visibility', () => { test.beforeAll('Create agent', async ({ api }) => { agent = await createAgent(api, 'user1'); + await ddpLogin(Users.user1.data.loginToken); }); test.beforeAll('Add agents to departments', async ({ api }) => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts index ff9e1ff384e74..e62e86df89622 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts @@ -1,5 +1,6 @@ import { faker } from '@faker-js/faker'; +import { ddpLogin } from '../../data/users.helper'; import { createFakeVisitor } from '../../mocks/data'; import { Users } from '../fixtures/userStates'; import { HomeOmnichannel } from '../page-objects'; @@ -32,7 +33,7 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => let visitorCustomField: Awaited>; test.beforeAll('Set up agent, manager and custom fields', async ({ api }) => { - [agent, manager] = await Promise.all([createAgent(api, 'user1'), createManager(api, 'user1')]); + [agent, manager] = await Promise.all([createAgent(api, 'user1'), createManager(api, 'user1'), ddpLogin(Users.user1.data.loginToken)]); [roomCustomField, visitorCustomField, conversation] = await Promise.all([ createCustomField(api, { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts index 2822a9c691321..12d7caa30aa01 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts @@ -1,5 +1,6 @@ import type { Page } from 'playwright-core'; +import { ddpLogin } from '../../data/users.helper'; import { createFakeVisitor } from '../../mocks/data'; import { createAuxContext } from '../fixtures/createAuxContext'; import { Users } from '../fixtures/userStates'; @@ -267,6 +268,7 @@ test.describe('OC - Livechat - Close chat using widget', () => { test.beforeAll(async ({ api }) => { agent = await createAgent(api, 'user1'); + await ddpLogin(Users.user1.data.loginToken); }); test.beforeEach(async ({ page, api }) => { From 46d52d058e27999b6d25624e240ed58f2e36dff1 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 16 Mar 2026 10:53:30 -0300 Subject: [PATCH 12/57] add ws closure to playwright tests --- .../e2e/omnichannel/omnichannel-assign-room-tags.spec.ts | 4 +++- .../omnichannel/omnichannel-custom-field-usage.spec.ts | 8 +++++++- .../tests/e2e/omnichannel/omnichannel-livechat.spec.ts | 4 +++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts index 10a1c221d628f..6407447e53dab 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts @@ -26,6 +26,7 @@ test.describe('OC - Tags Visibility', () => { let tagB: Awaited>; let globalTag: Awaited>; let sharedTag: Awaited>; + let ws: WebSocket; test.beforeAll('Create departments', async ({ api }) => { departmentA = await createDepartment(api, { name: 'Department A' }); @@ -34,7 +35,7 @@ test.describe('OC - Tags Visibility', () => { test.beforeAll('Create agent', async ({ api }) => { agent = await createAgent(api, 'user1'); - await ddpLogin(Users.user1.data.loginToken); + ws = await ddpLogin(Users.user1.data.loginToken); }); test.beforeAll('Add agents to departments', async ({ api }) => { @@ -71,6 +72,7 @@ test.describe('OC - Tags Visibility', () => { await agent.delete(); await departmentA.delete(); await departmentB.delete(); + ws.close(); }); test('Verify agent should see correct tags based on department association', async () => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts index e62e86df89622..1bb9b8616ec23 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts @@ -31,9 +31,14 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => let conversation: Awaited>; let roomCustomField: Awaited>; let visitorCustomField: Awaited>; + let ws: WebSocket; test.beforeAll('Set up agent, manager and custom fields', async ({ api }) => { - [agent, manager] = await Promise.all([createAgent(api, 'user1'), createManager(api, 'user1'), ddpLogin(Users.user1.data.loginToken)]); + [agent, manager, ws] = await Promise.all([ + createAgent(api, 'user1'), + createManager(api, 'user1'), + ddpLogin(Users.user1.data.loginToken), + ]); [roomCustomField, visitorCustomField, conversation] = await Promise.all([ createCustomField(api, { @@ -68,6 +73,7 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => test.afterAll('Remove agent, manager, custom fields and conversation', async () => { await Promise.all([agent.delete(), manager.delete(), roomCustomField.delete(), visitorCustomField.delete(), conversation.delete()]); + ws.close(); }); test('Should be allowed to set room custom field for a conversation', async () => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts index 12d7caa30aa01..035b66f2bb9a7 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts @@ -265,10 +265,11 @@ test.describe('OC - Livechat - Resume chat after closing', () => { test.describe('OC - Livechat - Close chat using widget', () => { let poLiveChat: OmnichannelLiveChat; let agent: Awaited>; + let ws: WebSocket; test.beforeAll(async ({ api }) => { agent = await createAgent(api, 'user1'); - await ddpLogin(Users.user1.data.loginToken); + ws = await ddpLogin(Users.user1.data.loginToken); }); test.beforeEach(async ({ page, api }) => { @@ -279,6 +280,7 @@ test.describe('OC - Livechat - Close chat using widget', () => { test.afterAll(async () => { await agent.delete(); + ws.close(); }); test('OC - Livechat - Close Chat', async () => { From 6c3b0102d3c588c6b98711bbd665f940a182985f Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 16 Mar 2026 14:59:21 -0300 Subject: [PATCH 13/57] improve ws cleanup on rooms test, make ws port depend on CI env variable --- apps/meteor/tests/data/users.helper.ts | 40 ++++++++++++------- .../tests/end-to-end/api/livechat/00-rooms.ts | 15 +++++-- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 63635c099bddf..330892920a687 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -172,11 +172,26 @@ export const setUserAway = (overrideCredentials = credentials, config?: IRequest }); }; -export const ddpLogin = (resume: string): Promise => +const connectWS = (port: number): Promise => new Promise((resolve, reject) => { - const ws = new WebSocket('ws://localhost:3000/websocket'); - const loginId = `login-${Date.now()}-${Math.random()}`; + const ws = new WebSocket(`ws://localhost:${port}/websocket`); + ws.onopen = () => resolve(ws); + ws.onerror = () => reject(new Error(`WS connection failed on ${port}`)); + }); + +export const ddpLogin = async (resume: string): Promise => { + let ws: WebSocket; + + if (process.env.CI) { + ws = await connectWS(3000); + } else if (process.env.IS_EE) { + ws = await connectWS(4000); + } + + const loginId = `login-${Date.now()}-${Math.random()}`; + + return new Promise((resolve, reject) => { const handler = (event: MessageEvent) => { const data = JSON.parse(event.data); @@ -212,18 +227,15 @@ export const ddpLogin = (resume: string): Promise => ws.addEventListener('message', handler); - ws.onopen = () => { - ws.send( - JSON.stringify({ - msg: 'connect', - version: '1', - support: ['1'], - }), - ); - }; - - ws.onerror = reject; + ws.send( + JSON.stringify({ + msg: 'connect', + version: '1', + support: ['1'], + }), + ); }); +}; export const setUserAwayWS = (ws: WebSocket): Promise => new Promise((resolve, reject) => { diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index 68b3868514a29..6a5dd1314bd2f 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -84,6 +84,7 @@ describe('LIVECHAT - rooms', () => { let visitor: ILivechatVisitor; let room: IOmnichannelRoom; let appId: string; + const sockets: WebSocket[] = []; before((done) => getCredentials(done)); @@ -129,6 +130,11 @@ describe('LIVECHAT - rooms', () => { .set(credentials) .expect(200); } + for (const ws of sockets) { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + } }); describe('livechat/room', () => { @@ -1550,6 +1556,7 @@ describe('LIVECHAT - rooms', () => { const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true, }); + sockets.push(ws); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1582,8 +1589,6 @@ describe('LIVECHAT - rooms', () => { expect(res.body).to.have.property('count'); }); - ws.close(); - await Promise.all([ deleteDepartment(initialDepartment._id), deleteDepartment(forwardToOfflineDepartment._id), @@ -1678,6 +1683,7 @@ describe('LIVECHAT - rooms', () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true }); + sockets.push(ws); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1719,6 +1725,7 @@ describe('LIVECHAT - rooms', () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: false }); + sockets.push(ws); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1804,7 +1811,7 @@ describe('LIVECHAT - rooms', () => { } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true, }); - + // await new Promise((r) => setTimeout(r, 7000)); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1820,7 +1827,7 @@ describe('LIVECHAT - rooms', () => { }); const roomInfo = await getLivechatRoomInfo(newRoom._id); - + console.log(roomInfo); expect(roomInfo.servedBy).to.have.property('_id', agent.user._id); expect(roomInfo.departmentId).to.be.equal(forwardToOfflineDepartment._id); From 2780e2c3de255678a0603a0ab3e13b88e942e7fc Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 16 Mar 2026 20:11:13 -0300 Subject: [PATCH 14/57] pass the acceptChatsWithNoAgents as flag to agent routing methods --- .../app/apps/server/bridges/livechat.ts | 2 + .../app/livechat/imports/server/rest/sms.ts | 3 +- .../app/livechat/server/api/v1/message.ts | 5 +- .../app/livechat/server/api/v1/visitor.ts | 5 +- apps/meteor/app/livechat/server/lib/Helper.ts | 16 ++++- .../app/livechat/server/lib/QueueManager.ts | 7 +- .../app/livechat/server/lib/RoutingManager.ts | 7 +- .../app/livechat/server/lib/departmentsLib.ts | 7 +- .../livechat/server/lib/routing/External.ts | 1 + .../app/livechat/server/lib/service-status.ts | 24 +++++-- .../app/livechat/server/lib/takeInquiry.ts | 7 +- .../hooks/handleNextAgentPreferredEvents.ts | 13 +++- .../server/lib/routing/LoadBalancing.ts | 1 + .../server/lib/routing/LoadRotation.ts | 1 + .../EmailInbox/EmailInbox_Incoming.ts | 5 +- .../models/ILivechatDepartmentAgentsModel.ts | 1 + .../model-typings/src/models/IUsersModel.ts | 14 +++- .../src/helpers/omnichannel/agentStatus.ts | 23 ++++--- .../src/models/LivechatDepartmentAgents.ts | 2 + packages/models/src/models/Users.ts | 45 +++++++++---- packages/omni-core/src/visitor/create.spec.ts | 66 +++++++++++-------- packages/omni-core/src/visitor/create.ts | 13 ++-- 22 files changed, 197 insertions(+), 71 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 6952ac6f5457c..6182d0883ac09 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -214,6 +214,7 @@ export class AppLivechatBridge extends LivechatBridge { const livechatVisitor = await registerGuest(registerData, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'), }); if (!livechatVisitor) { @@ -239,6 +240,7 @@ export class AppLivechatBridge extends LivechatBridge { const livechatVisitor = await registerGuest(registerData, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'), }); return this.orch.getConverters()?.get('visitors').convertVisitor(livechatVisitor); diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts index e0fdf587e60e3..533409d342ac2 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -73,7 +73,8 @@ const defineVisitor = async (smsNumber: string, targetDepartment?: string) => { data.department = targetDepartment; } - const livechatVisitor = await registerGuest(data, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') }); + const livechatVisitor = await registerGuest(data, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents')}); if (!livechatVisitor) { throw new Meteor.Error('error-invalid-visitor', 'Invalid visitor'); diff --git a/apps/meteor/app/livechat/server/api/v1/message.ts b/apps/meteor/app/livechat/server/api/v1/message.ts index 3e90c1df1efe5..8d5d99352c355 100644 --- a/apps/meteor/app/livechat/server/api/v1/message.ts +++ b/apps/meteor/app/livechat/server/api/v1/message.ts @@ -272,7 +272,10 @@ API.v1.addRoute( guest.connectionData = normalizeHttpHeaderData(this.request.headers); } - visitor = await registerGuest(guest, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') }); + visitor = await registerGuest(guest, { + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'), + }); if (!visitor) { throw new Error('error-livechat-visitor-registration'); } diff --git a/apps/meteor/app/livechat/server/api/v1/visitor.ts b/apps/meteor/app/livechat/server/api/v1/visitor.ts index 706260f80afba..ec4c93d4ff5cf 100644 --- a/apps/meteor/app/livechat/server/api/v1/visitor.ts +++ b/apps/meteor/app/livechat/server/api/v1/visitor.ts @@ -59,7 +59,10 @@ API.v1.addRoute( connectionData: normalizeHttpHeaderData(this.request.headers), }; - const visitor = await registerGuest(guest, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') }); + const visitor = await registerGuest(guest, { + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'), + }); if (!visitor) { throw new Meteor.Error('error-livechat-visitor-registration', 'Error registering visitor', { method: 'livechat/visitor', diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 719996542d057..aab92592f3033 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -493,7 +493,12 @@ export const forwardRoomToAgent = async (room: IOmnichannelRoom, transferData: T if (!agentId) { throw new Error('error-invalid-agent'); } - const user = await Users.findOneOnlineAgentById(agentId, settings.get('Livechat_enabled_when_agent_idle')); + const user = await Users.findOneOnlineAgentById( + agentId, + settings.get('Livechat_enabled_when_agent_idle'), + {}, + settings.get('Livechat_accept_chats_with_no_agents'), + ); if (!user) { logger.debug({ msg: 'Agent is offline. Cannot forward', @@ -654,7 +659,12 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi departmentId, agentId, }); - const user = await Users.findOneOnlineAgentById(agentId, settings.get('Livechat_enabled_when_agent_idle')); + const user = await Users.findOneOnlineAgentById( + agentId, + settings.get('Livechat_enabled_when_agent_idle'), + {}, + settings.get('Livechat_accept_chats_with_no_agents'), + ); if (!user) { throw new Error('error-user-is-offline'); } @@ -724,7 +734,7 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi } const { servedBy, chatQueued } = roomTaken; - if (!chatQueued && oldServedBy && oldServedBy._id === servedBy?._id) { + if (!chatQueued && oldServedBy && servedBy && oldServedBy._id === servedBy._id) { if (!department?.fallbackForwardDepartment?.length) { logger.debug({ msg: 'Cannot forward room. Chat assigned to agent instead', diff --git a/apps/meteor/app/livechat/server/lib/QueueManager.ts b/apps/meteor/app/livechat/server/lib/QueueManager.ts index c012a8a7d8a0b..858db7279981e 100644 --- a/apps/meteor/app/livechat/server/lib/QueueManager.ts +++ b/apps/meteor/app/livechat/server/lib/QueueManager.ts @@ -443,7 +443,12 @@ export class QueueManager { let defaultAgent: SelectedAgent | undefined; const isAgentAvailable = (username: string) => - Users.findOneOnlineAgentByUserList(username, { projection: { _id: 1 } }, settings.get('Livechat_enabled_when_agent_idle')); + Users.findOneOnlineAgentByUserList( + username, + { projection: { _id: 1 } }, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); if (servedBy?.username && (await isAgentAvailable(servedBy.username))) { defaultAgent = { agentId: servedBy._id, username: servedBy.username }; diff --git a/apps/meteor/app/livechat/server/lib/RoutingManager.ts b/apps/meteor/app/livechat/server/lib/RoutingManager.ts index 8c6fb51c08b2d..581ecfe1f6d94 100644 --- a/apps/meteor/app/livechat/server/lib/RoutingManager.ts +++ b/apps/meteor/app/livechat/server/lib/RoutingManager.ts @@ -111,7 +111,12 @@ export const RoutingManager: Routing = { if ( !agent || (agent.username && - !(await Users.findOneOnlineAgentByUserList(agent.username, {}, settings.get('Livechat_enabled_when_agent_idle'))) && + !(await Users.findOneOnlineAgentByUserList( + agent.username, + {}, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + )) && !(await allowAgentSkipQueue(agent))) ) { logger.debug({ msg: 'Agent offline or invalid. Using routing method to get next agent', inquiryId: inquiry._id }); diff --git a/apps/meteor/app/livechat/server/lib/departmentsLib.ts b/apps/meteor/app/livechat/server/lib/departmentsLib.ts index 3612ba48415a6..3b063672781c9 100644 --- a/apps/meteor/app/livechat/server/lib/departmentsLib.ts +++ b/apps/meteor/app/livechat/server/lib/departmentsLib.ts @@ -295,6 +295,7 @@ export async function checkOnlineForDepartment(departmentId: string) { depUsers.map((agent) => agent.username), { projection: { _id: 1 } }, settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), ); return !!onlineForDep; @@ -304,5 +305,9 @@ export async function getOnlineForDepartment(departmentId: string) { const agents = await LivechatDepartmentAgents.findByDepartmentId(departmentId, { projection: { username: 1 } }).toArray(); const usernames = agents.map(({ username }) => username); - return Users.findOnlineUserFromList([...new Set(usernames)], settings.get('Livechat_enabled_when_agent_idle')); + return Users.findOnlineUserFromList( + [...new Set(usernames)], + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); } diff --git a/apps/meteor/app/livechat/server/lib/routing/External.ts b/apps/meteor/app/livechat/server/lib/routing/External.ts index b5aaad05472e1..98222c318c10b 100644 --- a/apps/meteor/app/livechat/server/lib/routing/External.ts +++ b/apps/meteor/app/livechat/server/lib/routing/External.ts @@ -61,6 +61,7 @@ class ExternalQueue implements IRoutingMethod { result.username, {}, settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), ); if (!agent?.username) { diff --git a/apps/meteor/app/livechat/server/lib/service-status.ts b/apps/meteor/app/livechat/server/lib/service-status.ts index 471ab72558dda..94d1e06aeb64b 100644 --- a/apps/meteor/app/livechat/server/lib/service-status.ts +++ b/apps/meteor/app/livechat/server/lib/service-status.ts @@ -8,13 +8,21 @@ import { settings } from '../../../settings/server'; export async function getOnlineAgents(department?: string, agent?: SelectedAgent | null): Promise | undefined> { if (agent?.agentId) { - return Users.findOnlineAgents(agent.agentId, settings.get('Livechat_enabled_when_agent_idle')); + return Users.findOnlineAgents( + agent.agentId, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); } if (department) { return getOnlineForDepartment(department); } - return Users.findOnlineAgents(undefined, settings.get('Livechat_enabled_when_agent_idle')); + return Users.findOnlineAgents( + undefined, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); } export async function online(department?: string, skipNoAgentSetting = false, skipFallbackCheck = false): Promise { @@ -43,7 +51,11 @@ export async function online(department?: string, skipNoAgentSetting = false, sk export async function checkOnlineAgents(department?: string, agent?: { agentId: string }, skipFallbackCheck = false): Promise { if (agent?.agentId) { - return Users.checkOnlineAgents(agent.agentId, settings.get('Livechat_enabled_when_agent_idle')); + return Users.checkOnlineAgents( + agent.agentId, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); } if (department) { @@ -62,7 +74,11 @@ export async function checkOnlineAgents(department?: string, agent?: { agentId: return checkOnlineAgents(dep?.fallbackForwardDepartment); } - return Users.checkOnlineAgents(undefined, settings.get('Livechat_enabled_when_agent_idle')); + return Users.checkOnlineAgents( + undefined, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); } async function countBotAgents(department?: string) { diff --git a/apps/meteor/app/livechat/server/lib/takeInquiry.ts b/apps/meteor/app/livechat/server/lib/takeInquiry.ts index 1c7541b276f72..4245048745add 100644 --- a/apps/meteor/app/livechat/server/lib/takeInquiry.ts +++ b/apps/meteor/app/livechat/server/lib/takeInquiry.ts @@ -26,7 +26,12 @@ export const takeInquiry = async ( }); } - const user = await Users.findOneOnlineAgentById(userId, settings.get('Livechat_enabled_when_agent_idle')); + const user = await Users.findOneOnlineAgentById( + userId, + settings.get('Livechat_enabled_when_agent_idle'), + {}, + settings.get('Livechat_accept_chats_with_no_agents'), + ); if (!user) { throw new Meteor.Error('error-agent-status-service-offline', 'Agent status is offline or Omnichannel service is not active', { method: 'livechat:takeInquiry', diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/handleNextAgentPreferredEvents.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/handleNextAgentPreferredEvents.ts index f7ed6e38282c7..cae3703a9d967 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/handleNextAgentPreferredEvents.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/handleNextAgentPreferredEvents.ts @@ -23,9 +23,14 @@ const getDefaultAgent = async ({ username, id }: { username?: string; id?: strin } if (id) { - const agent = await Users.findOneOnlineAgentById(id, settings.get('Livechat_enabled_when_agent_idle'), { - projection: { _id: 1, username: 1 }, - }); + const agent = await Users.findOneOnlineAgentById( + id, + settings.get('Livechat_enabled_when_agent_idle'), + { + projection: { _id: 1, username: 1 }, + }, + settings.get('Livechat_accept_chats_with_no_agents'), + ); if (agent) { return normalizeDefaultAgent(agent); } @@ -43,6 +48,7 @@ const getDefaultAgent = async ({ username, id }: { username?: string; id?: strin username || [], { projection: { _id: 1, username: 1 } }, settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), ), ); }; @@ -131,6 +137,7 @@ checkDefaultAgentOnNewRoom.patch(async (_next, defaultAgent, { visitorId, source usernameByRoom, { projection: { _id: 1, username: 1 } }, settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), ), ); return lastRoomAgent; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts index 53f7093aaf430..b7f67a36b930e 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts @@ -41,6 +41,7 @@ class LoadBalancing { ignoreAgentId, enabledWhenIdle, unavailableUsers.map((u) => u.username), + settings.get('Livechat_accept_chats_with_no_agents'), ); if (!nextAgent) { return; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts index 0f731973c68ed..3b1192e3c0e11 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts @@ -41,6 +41,7 @@ class LoadRotation { ignoreAgentId, enabledWhenIdle, unavailableUsers.map((user) => user.username), + settings.get('Livechat_accept_chats_with_no_agents'), ); if (!nextAgent?.username) { return; diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts index c6b41fa66356a..57ca854038960 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts @@ -48,7 +48,10 @@ async function getGuestByEmail(email: string, name: string, department = ''): Pr email, department, }, - { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') }, + { + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'), + }, ); if (!livechatVisitor) { diff --git a/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts b/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts index 651a14847b39a..69c1d7ce63f4f 100644 --- a/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts +++ b/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts @@ -58,6 +58,7 @@ export interface ILivechatDepartmentAgentsModel extends IBaseModel, + acceptChatsWithNoAgents?: boolean, ): Promise | null | undefined>; getBotsForDepartment(departmentId: string): Promise>; countBotsForDepartment(departmentId: string): Promise; diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index 383db7efaf337..9e63348665ff9 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -110,12 +110,14 @@ export interface IUsersModel extends IBaseModel { ignoreAgentId?: string, isEnabledWhenAgentIdle?: boolean, ignoreUsernames?: string[], + acceptChatsWithNoAgents?: boolean, ): Promise<{ agentId: string; username?: string; lastRoutingTime?: Date; count: number }>; getLastAvailableAgentRouted( department?: string, ignoreAgentId?: string, isEnabledWhenAgentIdle?: boolean, ignoreUsernames?: string[], + acceptChatsWithNoAgents?: boolean, ): Promise<{ agentId: string; username?: string; lastRoutingTime?: Date }>; setLastRoutingTime(userId: IUser['_id']): Promise | null>; @@ -250,6 +252,7 @@ export interface IUsersModel extends IBaseModel { findOnlineUserFromList( userList: string | string[], isLivechatEnabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, ): FindCursor; countOnlineUserFromList(userList: string | string[], isLivechatEnabledWhenAgentIdle?: boolean): Promise; getUnavailableAgents( @@ -261,6 +264,7 @@ export interface IUsersModel extends IBaseModel { userList: string[] | string, options?: FindOptions, isLivechatEnabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, ): Promise; findBotAgents(usernameList?: string | string[]): FindCursor; @@ -281,14 +285,19 @@ export interface IUsersModel extends IBaseModel { loginTokenObject: AtLeast; }): Promise; findPersonalAccessTokenByTokenNameAndUserId({ userId, tokenName }: { userId: IUser['_id']; tokenName: string }): Promise; - checkOnlineAgents(agentId?: string, isLivechatEnabledWhenIdle?: boolean): Promise; - findOnlineAgents(agentId?: IUser['_id'], isLivechatEnabledWhenIdle?: boolean): FindCursor; + checkOnlineAgents(agentId?: string, isLivechatEnabledWhenIdle?: boolean, acceptChatsWithNoAgents?: boolean): Promise; + findOnlineAgents( + agentId?: IUser['_id'], + isLivechatEnabledWhenIdle?: boolean, + acceptChatsWithNoAgents?: boolean, + ): FindCursor; countOnlineAgents(agentId: string): Promise; findOneBotAgent(): Promise; findOneOnlineAgentById( agentId: string, isLivechatEnabledWhenAgentIdle?: boolean, options?: FindOptions, + acceptChatsWithNoAgents?: boolean, ): Promise; findAgents(): FindCursor; countAgents(): Promise; @@ -296,6 +305,7 @@ export interface IUsersModel extends IBaseModel { ignoreAgentId?: string, extraQuery?: Filter, enabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, ): Promise<{ agentId: string; username?: string } | null>; getNextBotAgent(ignoreAgentId?: string): Promise<{ agentId: string; username?: string } | null>; setLivechatStatus(userId: string, status: ILivechatAgentStatus): Promise; diff --git a/packages/models/src/helpers/omnichannel/agentStatus.ts b/packages/models/src/helpers/omnichannel/agentStatus.ts index 020751a29d35a..5a9e90bdb99b4 100644 --- a/packages/models/src/helpers/omnichannel/agentStatus.ts +++ b/packages/models/src/helpers/omnichannel/agentStatus.ts @@ -2,24 +2,23 @@ import { UserStatus } from '@rocket.chat/core-typings'; import type { IUser } from '@rocket.chat/core-typings'; import type { Filter } from 'mongodb'; -export const queryStatusAgentOnline = (extraFilters = {}, isLivechatEnabledWhenAgentIdle?: boolean): Filter => ({ +export const queryStatusAgentOnline = ( + extraFilters = {}, + isLivechatEnabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, +): Filter => ({ statusLivechat: 'available', roles: 'livechat-agent', // ignore deactivated users active: true, - ...(!isLivechatEnabledWhenAgentIdle && { + ...(!acceptChatsWithNoAgents && { $or: [ + { roles: 'bot' }, { status: { $exists: true, $ne: UserStatus.OFFLINE, }, - roles: { - $ne: 'bot', - }, - }, - { - roles: 'bot', }, ], }), @@ -29,8 +28,12 @@ export const queryStatusAgentOnline = (extraFilters = {}, isLivechatEnabledWhenA }), }); -export const queryAvailableAgentsForSelection = (extraFilters = {}, isLivechatEnabledWhenAgentIdle?: boolean): Filter => ({ - ...queryStatusAgentOnline(extraFilters, isLivechatEnabledWhenAgentIdle), +export const queryAvailableAgentsForSelection = ( + extraFilters = {}, + isLivechatEnabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, +): Filter => ({ + ...queryStatusAgentOnline(extraFilters, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents), $and: [ { $or: [{ agentLocked: { $exists: false } }, { agentLockedAt: { $lt: new Date(Date.now() - 5000) } }], diff --git a/packages/models/src/models/LivechatDepartmentAgents.ts b/packages/models/src/models/LivechatDepartmentAgents.ts index c9c29a74568d1..52d387c031fcc 100644 --- a/packages/models/src/models/LivechatDepartmentAgents.ts +++ b/packages/models/src/models/LivechatDepartmentAgents.ts @@ -178,6 +178,7 @@ export class LivechatDepartmentAgentsRaw extends BaseRaw, + acceptChatsWithNoAgents?: boolean, ): Promise | null | undefined> { const agents = await this.findByDepartmentId(departmentId).toArray(); @@ -188,6 +189,7 @@ export class LivechatDepartmentAgentsRaw extends BaseRaw agent.username), isLivechatEnabledWhenAgentIdle, + acceptChatsWithNoAgents, ).toArray(); const onlineUsernames = onlineUsers.map((user) => user.username).filter(isStringValue); diff --git a/packages/models/src/models/Users.ts b/packages/models/src/models/Users.ts index 95cc31246380a..d44722a7e00c3 100644 --- a/packages/models/src/models/Users.ts +++ b/packages/models/src/models/Users.ts @@ -556,10 +556,12 @@ export class UsersRaw extends BaseRaw> implements IU ignoreAgentId?: string, isEnabledWhenAgentIdle?: boolean, ignoreUsernames?: string[], + acceptChatsWithNoAgents?: boolean, ): Promise<{ agentId: string; username?: string; lastRoutingTime?: Date; count: number; departments?: any[] }> { const match = queryAvailableAgentsForSelection( { ...(ignoreAgentId && { _id: { $ne: ignoreAgentId } }), ...(ignoreUsernames?.length && { username: { $nin: ignoreUsernames } }) }, isEnabledWhenAgentIdle, + acceptChatsWithNoAgents, ); const departmentFilter = department @@ -637,10 +639,12 @@ export class UsersRaw extends BaseRaw> implements IU ignoreAgentId?: string, isEnabledWhenAgentIdle?: boolean, ignoreUsernames?: string[], + acceptChatsWithNoAgents?: boolean, ): Promise<{ agentId: string; username?: string; lastRoutingTime?: Date; departments?: any[] }> { const match = queryAvailableAgentsForSelection( { ...(ignoreAgentId && { _id: { $ne: ignoreAgentId } }), ...(ignoreUsernames?.length && { username: { $nin: ignoreUsernames } }) }, isEnabledWhenAgentIdle, + acceptChatsWithNoAgents, ); const departmentFilter = department ? [ @@ -1616,13 +1620,17 @@ export class UsersRaw extends BaseRaw> implements IU }); } - findOnlineUserFromList(userList: string | string[], isLivechatEnabledWhenAgentIdle?: boolean) { + findOnlineUserFromList( + userList: string | string[], + isLivechatEnabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, + ) { // TODO: Create class Agent const username = { $in: ([] as string[]).concat(userList), }; - const query = queryStatusAgentOnline({ username }, isLivechatEnabledWhenAgentIdle); + const query = queryStatusAgentOnline({ username }, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents); return this.find(query); } @@ -1638,13 +1646,18 @@ export class UsersRaw extends BaseRaw> implements IU return this.countDocuments(query); } - findOneOnlineAgentByUserList(userList: string | string[], options?: FindOptions, isLivechatEnabledWhenAgentIdle?: boolean) { + findOneOnlineAgentByUserList( + userList: string | string[], + options?: FindOptions, + isLivechatEnabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, + ) { // TODO:: Create class Agent const username = { $in: ([] as string[]).concat(userList), }; - const query = queryStatusAgentOnline({ username }, isLivechatEnabledWhenAgentIdle); + const query = queryStatusAgentOnline({ username }, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents); return this.findOne(query, options); } @@ -1861,16 +1874,20 @@ export class UsersRaw extends BaseRaw> implements IU return this.findOne(query); } - async checkOnlineAgents(agentId: IUser['_id'], isLivechatEnabledWhenAgentIdle?: boolean) { + async checkOnlineAgents(agentId: IUser['_id'], isLivechatEnabledWhenAgentIdle?: boolean, acceptChatsWithNoAgents?: boolean) { // TODO:: Create class Agent - const query = queryStatusAgentOnline(agentId && { _id: agentId }, isLivechatEnabledWhenAgentIdle); + const query = queryStatusAgentOnline(agentId && { _id: agentId }, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents); return !!(await this.findOne(query)); } - findOnlineAgents(agentId?: IUser['_id'], isLivechatEnabledWhenAgentIdle?: boolean) { + findOnlineAgents( + agentId?: IUser['_id'], + isLivechatEnabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, + ) { // TODO:: Create class Agent - const query = queryStatusAgentOnline(agentId && { _id: agentId }, isLivechatEnabledWhenAgentIdle); + const query = queryStatusAgentOnline(agentId && { _id: agentId }, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents); return this.find(query); } @@ -1897,9 +1914,10 @@ export class UsersRaw extends BaseRaw> implements IU _id: IUser['_id'], isLivechatEnabledWhenAgentIdle?: boolean, options?: FindOptions, + acceptChatsWithNoAgents?: boolean, ) { // TODO: Create class Agent - const query = queryStatusAgentOnline({ _id }, isLivechatEnabledWhenAgentIdle); + const query = queryStatusAgentOnline({ _id }, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents); return this.findOne(query, options); } @@ -1923,7 +1941,12 @@ export class UsersRaw extends BaseRaw> implements IU } // 2 - async getNextAgent(ignoreAgentId?: string, extraQuery?: Filter, enabledWhenAgentIdle?: boolean) { + async getNextAgent( + ignoreAgentId?: string, + extraQuery?: Filter, + enabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, + ) { // TODO: Create class Agent // fetch all unavailable agents, and exclude them from the selection const unavailableAgents = (await this.getUnavailableAgents(undefined, extraQuery, enabledWhenAgentIdle)).map((u) => u.username); @@ -1933,7 +1956,7 @@ export class UsersRaw extends BaseRaw> implements IU username: { $nin: unavailableAgents }, }; - const query = queryAvailableAgentsForSelection(extraFilters, enabledWhenAgentIdle); + const query = queryAvailableAgentsForSelection(extraFilters, enabledWhenAgentIdle, acceptChatsWithNoAgents); const sort: Record = { livechatCount: 1, diff --git a/packages/omni-core/src/visitor/create.spec.ts b/packages/omni-core/src/visitor/create.spec.ts index 2dee062386877..3089abbc1980c 100644 --- a/packages/omni-core/src/visitor/create.spec.ts +++ b/packages/omni-core/src/visitor/create.spec.ts @@ -69,7 +69,9 @@ describe('registerGuest', () => { it('should throw error when token is not provided', async () => { const guestData = {}; - await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('error-invalid-token'); + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false })).rejects.toThrow( + 'error-invalid-token', + ); }); it('should throw error when token is empty string', async () => { @@ -77,7 +79,9 @@ describe('registerGuest', () => { token: '', }; - await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('error-invalid-token'); + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false })).rejects.toThrow( + 'error-invalid-token', + ); }); }); @@ -106,11 +110,16 @@ describe('registerGuest', () => { email, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(mockValidateEmail).toHaveBeenCalledWith('test@example.com'); expect(findContactByEmailAndContactManagerSpy).toHaveBeenCalledWith('test@example.com'); - expect(findOneOnlineAgentByIdSpy).toHaveBeenCalledWith(agentId, false, { projection: { _id: 1, username: 1, name: 1, emails: 1 } }); + expect(findOneOnlineAgentByIdSpy).toHaveBeenCalledWith( + agentId, + false, + { projection: { _id: 1, username: 1, name: 1, emails: 1 } }, + false, + ); // Verify the data passed to updateOneByIdOrToken expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( @@ -147,7 +156,7 @@ describe('registerGuest', () => { email, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); // Verify contact manager is not included in the data expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( @@ -175,7 +184,7 @@ describe('registerGuest', () => { email, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(mockValidateEmail).toHaveBeenCalledWith('test@example.com'); expect(findContactByEmailAndContactManagerSpy).toHaveBeenCalledWith('test@example.com'); @@ -207,7 +216,7 @@ describe('registerGuest', () => { department, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(findOneByIdOrNameSpy).toHaveBeenCalledWith(department, { projection: { _id: 1 } }); @@ -236,7 +245,9 @@ describe('registerGuest', () => { department, }; - await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('error-invalid-department'); + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false })).rejects.toThrow( + 'error-invalid-department', + ); // Verify updateOneByIdOrToken is not called when department validation fails expect(updateOneByIdOrTokenSpy).not.toHaveBeenCalled(); @@ -257,7 +268,7 @@ describe('registerGuest', () => { department, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); // Department validation should be skipped expect(findOneByIdOrNameSpy).not.toHaveBeenCalled(); @@ -289,7 +300,7 @@ describe('registerGuest', () => { name: 'Updated Name', }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(getVisitorByTokenSpy).toHaveBeenCalledWith(token, { projection: { _id: 1 } }); @@ -322,7 +333,7 @@ describe('registerGuest', () => { phone: { number: phoneNumber }, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(findOneVisitorByPhoneSpy).toHaveBeenCalledWith(phoneNumber); @@ -355,7 +366,7 @@ describe('registerGuest', () => { email, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(findOneGuestByEmailAddressSpy).toHaveBeenCalledWith(email); @@ -387,7 +398,7 @@ describe('registerGuest', () => { username, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); // Verify new visitor data is created with provided values expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( @@ -415,7 +426,7 @@ describe('registerGuest', () => { token, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(getNextVisitorUsernameSpy).toHaveBeenCalled(); @@ -445,7 +456,7 @@ describe('registerGuest', () => { username: providedUsername, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(getNextVisitorUsernameSpy).not.toHaveBeenCalled(); @@ -476,7 +487,7 @@ describe('registerGuest', () => { phone: { number: phoneNumber }, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -504,7 +515,7 @@ describe('registerGuest', () => { email, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -529,7 +540,7 @@ describe('registerGuest', () => { token, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -555,7 +566,7 @@ describe('registerGuest', () => { status, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -590,7 +601,7 @@ describe('registerGuest', () => { connectionData, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -624,7 +635,7 @@ describe('registerGuest', () => { connectionData, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -654,7 +665,7 @@ describe('registerGuest', () => { connectionData, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -684,7 +695,7 @@ describe('registerGuest', () => { token, }; - const result = await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + const result = await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(result).toBeNull(); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( @@ -710,12 +721,14 @@ describe('registerGuest', () => { email, }; - await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('Invalid email'); + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false })).rejects.toThrow( + 'Invalid email', + ); }); }); describe('shouldConsiderIdleAgent parameter', () => { - it('should pass shouldConsiderIdleAgent to findOneOnlineAgentById', async () => { + it('should pass shouldConsiderIdleAgent and shouldConsiderOfflineAgent to findOneOnlineAgentById', async () => { const token = 'test-token'; const email = 'test@example.com'; const agentId = 'agent-123'; @@ -743,12 +756,13 @@ describe('registerGuest', () => { email, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: true }); + await registerGuest(guestData, { shouldConsiderIdleAgent: true, shouldConsiderOfflineAgent: true }); expect(findOneOnlineAgentByIdSpy).toHaveBeenCalledWith( agentId, true, // shouldConsiderIdleAgent should be true { projection: { _id: 1, username: 1, name: 1, emails: 1 } }, + true, // shouldConsiderOfflineAgent should be true ); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( diff --git a/packages/omni-core/src/visitor/create.ts b/packages/omni-core/src/visitor/create.ts index 8e91b7c49efa3..8fdcfe003b986 100644 --- a/packages/omni-core/src/visitor/create.ts +++ b/packages/omni-core/src/visitor/create.ts @@ -16,7 +16,7 @@ type RegisterGuestType = Partial => { if (!token) { throw Error('error-invalid-token'); @@ -38,9 +38,14 @@ export const registerGuest = makeFunction( const contact = await LivechatContacts.findContactByEmailAndContactManager(visitorEmail); if (contact?.contactManager) { - const agent = await Users.findOneOnlineAgentById(contact.contactManager, shouldConsiderIdleAgent, { - projection: { _id: 1, username: 1, name: 1, emails: 1 }, - }); + const agent = await Users.findOneOnlineAgentById( + contact.contactManager, + shouldConsiderIdleAgent, + { + projection: { _id: 1, username: 1, name: 1, emails: 1 }, + }, + shouldConsiderOfflineAgent, + ); if (agent?.username && agent.name && agent.emails) { visitorDataToUpdate.contactManager = { _id: agent._id, From 7c2f1506f38d7b79bc54bdbfadffd1a1fc668aad Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 17 Mar 2026 16:42:37 -0300 Subject: [PATCH 15/57] remove unintended test lines --- apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index 6a5dd1314bd2f..ba7d1c3407552 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -1811,7 +1811,6 @@ describe('LIVECHAT - rooms', () => { } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true, }); - // await new Promise((r) => setTimeout(r, 7000)); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1827,7 +1826,6 @@ describe('LIVECHAT - rooms', () => { }); const roomInfo = await getLivechatRoomInfo(newRoom._id); - console.log(roomInfo); expect(roomInfo.servedBy).to.have.property('_id', agent.user._id); expect(roomInfo.departmentId).to.be.equal(forwardToOfflineDepartment._id); From 02239eff05c2780fb19538958263204d73d5fe79 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 17 Mar 2026 18:23:37 -0300 Subject: [PATCH 16/57] add changeset --- .changeset/purple-boxes-shout.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/purple-boxes-shout.md diff --git a/.changeset/purple-boxes-shout.md b/.changeset/purple-boxes-shout.md new file mode 100644 index 0000000000000..e338ac60ed7f6 --- /dev/null +++ b/.changeset/purple-boxes-shout.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Fixes an issue where offline agents were being assigned to visitors. From ba26d7fa0db0e62647e1021f2a7d663026c9fa41 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 17 Mar 2026 20:24:31 -0300 Subject: [PATCH 17/57] remove unneeded ws closures --- .../tests/end-to-end/api/livechat/00-rooms.ts | 7 +- .../tests/end-to-end/api/livechat/test.ts | 92 +++++++++++++++++++ 2 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 apps/meteor/tests/end-to-end/api/livechat/test.ts diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index ba7d1c3407552..35b69cdf6be6f 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -1706,8 +1706,6 @@ describe('LIVECHAT - rooms', () => { expect(inquiry.status).to.equal('queued'); expect(inquiry.department).to.equal(forwardToOfflineDepartment._id); - ws.close(); - await Promise.all([ deleteDepartment(initialDepartment._id), deleteDepartment(forwardToOfflineDepartment._id), @@ -1746,8 +1744,6 @@ describe('LIVECHAT - rooms', () => { expect(res.status).to.equal(400); expect(res.body).to.have.property('error', 'error-no-agents-available-for-service-on-department'); - ws.close(); - await Promise.all([ deleteDepartment(initialDepartment._id), deleteDepartment(forwardToOfflineDepartment._id), @@ -1811,6 +1807,7 @@ describe('LIVECHAT - rooms', () => { } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true, }); + sockets.push(ws); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1829,8 +1826,6 @@ describe('LIVECHAT - rooms', () => { expect(roomInfo.servedBy).to.have.property('_id', agent.user._id); expect(roomInfo.departmentId).to.be.equal(forwardToOfflineDepartment._id); - ws.close(); - await Promise.all([ deleteDepartment(initialDepartment._id), deleteDepartment(forwardToOfflineDepartment._id), diff --git a/apps/meteor/tests/end-to-end/api/livechat/test.ts b/apps/meteor/tests/end-to-end/api/livechat/test.ts new file mode 100644 index 0000000000000..12f8e4fb4d820 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/test.ts @@ -0,0 +1,92 @@ +import fs from 'fs'; +import path from 'path'; + +import { faker } from '@faker-js/faker'; +import type { Credentials } from '@rocket.chat/api-client'; +import type { + IOmnichannelRoom, + ILivechatVisitor, + IOmnichannelSystemMessage, + ILivechatPriority, + ILivechatDepartment, + ISubscription, + IOmnichannelBusinessUnit, + IUser, +} from '@rocket.chat/core-typings'; +import { LivechatPriorityWeight } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, afterEach, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import type { SuccessResult } from '../../../../app/api/server/definition'; +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { apps, APP_URL } from '../../../data/apps/apps-data'; +import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; +import type { OnlineAgent } from '../../../data/livechat/department'; +import { + createDepartmentWith2OnlineAgents, + createDepartmentWithAnAwayAgent, + createDepartmentWithAnOfflineAgent, + createDepartmentWithAnOnlineAgent, + deleteDepartment, +} from '../../../data/livechat/department'; +import { createSLA, getRandomPriority } from '../../../data/livechat/priorities'; +import { + createVisitor, + createLivechatRoom, + createAgent, + makeAgentAvailable, + getLivechatRoomInfo, + sendMessage, + startANewLivechatRoomAndTakeIt, + createManager, + closeOmnichannelRoom, + createDepartment, + fetchMessages, + deleteVisitor, + makeAgentUnavailable, + sendAgentMessage, + fetchInquiry, + takeInquiry, +} from '../../../data/livechat/rooms'; +import { saveTags } from '../../../data/livechat/tags'; +import { createMonitor, createUnit, deleteUnit } from '../../../data/livechat/units'; +import type { DummyResponse } from '../../../data/livechat/utils'; +import { sleep } from '../../../data/livechat/utils'; +import { + restorePermissionToRoles, + addPermissions, + removePermissionFromAllRoles, + updateEEPermission, + updatePermission, + updateSetting, + updateEESetting, +} from '../../../data/permissions.helper'; +import { adminUsername, password } from '../../../data/user'; +import type { TestUser } from '../../../data/users.helper'; +import { createUser, deleteUser, login } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + + +describe.only('LIVECHAT - rooms', () => { + const sockets: WebSocket[] = []; + + before((done) => getCredentials(done)); + + it( + 'when manager forward to offline (agent away, accept when agent idle off) department the inquiry should be set to the queue', + async () => { + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + await updateSetting('Livechat_enabled_when_agent_idle', false); + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ + allowReceiveForwardOffline: true, + }); + sockets.push(ws); + + }, + ); + + +}) + From e287b5b894310decbb9bb6565e77120f6c84b9bc Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 17 Mar 2026 20:35:21 -0300 Subject: [PATCH 18/57] handle errors on websockets result --- apps/meteor/tests/data/users.helper.ts | 52 ++++++----- .../tests/end-to-end/api/livechat/test.ts | 92 ------------------- 2 files changed, 27 insertions(+), 117 deletions(-) delete mode 100644 apps/meteor/tests/end-to-end/api/livechat/test.ts diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 330892920a687..bb67293231e11 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -195,33 +195,31 @@ export const ddpLogin = async (resume: string): Promise => { const handler = (event: MessageEvent) => { const data = JSON.parse(event.data); - switch (data.msg) { - case 'connected': - ws.send( - JSON.stringify({ - msg: 'method', - id: loginId, - method: 'login', - params: [{ resume }], - }), - ); - break; - - case 'result': - if (data.id === loginId) { - ws.removeEventListener('message', handler); - resolve(ws); - } - break; + if (data.msg === 'connected') { + ws.send( + JSON.stringify({ + msg: 'method', + id: loginId, + method: 'login', + params: [{ resume }], + }), + ); + } else if (data.msg === 'result') { + if (data.id === loginId) { + ws.removeEventListener('message', handler); - case 'ping': - ws.send(JSON.stringify({ msg: 'pong' })); - break; + if (data.error) { + reject(data.error); + return; + } - case 'error': - ws.removeEventListener('message', handler); - reject(data); - break; + resolve(ws); + } + } else if (data.msg === 'ping') { + ws.send(JSON.stringify({ msg: 'pong' })); + } else if (data.msg === 'error') { + ws.removeEventListener('message', handler); + reject(data); } }; @@ -246,6 +244,10 @@ export const setUserAwayWS = (ws: WebSocket): Promise => if (data.msg === 'result' && data.id === id) { ws.removeEventListener('message', handler); + if (data.error) { + reject(data.error); + return; + } resolve(); } diff --git a/apps/meteor/tests/end-to-end/api/livechat/test.ts b/apps/meteor/tests/end-to-end/api/livechat/test.ts deleted file mode 100644 index 12f8e4fb4d820..0000000000000 --- a/apps/meteor/tests/end-to-end/api/livechat/test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import { faker } from '@faker-js/faker'; -import type { Credentials } from '@rocket.chat/api-client'; -import type { - IOmnichannelRoom, - ILivechatVisitor, - IOmnichannelSystemMessage, - ILivechatPriority, - ILivechatDepartment, - ISubscription, - IOmnichannelBusinessUnit, - IUser, -} from '@rocket.chat/core-typings'; -import { LivechatPriorityWeight } from '@rocket.chat/core-typings'; -import { expect } from 'chai'; -import { after, afterEach, before, describe, it } from 'mocha'; -import type { Response } from 'supertest'; - -import type { SuccessResult } from '../../../../app/api/server/definition'; -import { getCredentials, api, request, credentials } from '../../../data/api-data'; -import { apps, APP_URL } from '../../../data/apps/apps-data'; -import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; -import type { OnlineAgent } from '../../../data/livechat/department'; -import { - createDepartmentWith2OnlineAgents, - createDepartmentWithAnAwayAgent, - createDepartmentWithAnOfflineAgent, - createDepartmentWithAnOnlineAgent, - deleteDepartment, -} from '../../../data/livechat/department'; -import { createSLA, getRandomPriority } from '../../../data/livechat/priorities'; -import { - createVisitor, - createLivechatRoom, - createAgent, - makeAgentAvailable, - getLivechatRoomInfo, - sendMessage, - startANewLivechatRoomAndTakeIt, - createManager, - closeOmnichannelRoom, - createDepartment, - fetchMessages, - deleteVisitor, - makeAgentUnavailable, - sendAgentMessage, - fetchInquiry, - takeInquiry, -} from '../../../data/livechat/rooms'; -import { saveTags } from '../../../data/livechat/tags'; -import { createMonitor, createUnit, deleteUnit } from '../../../data/livechat/units'; -import type { DummyResponse } from '../../../data/livechat/utils'; -import { sleep } from '../../../data/livechat/utils'; -import { - restorePermissionToRoles, - addPermissions, - removePermissionFromAllRoles, - updateEEPermission, - updatePermission, - updateSetting, - updateEESetting, -} from '../../../data/permissions.helper'; -import { adminUsername, password } from '../../../data/user'; -import type { TestUser } from '../../../data/users.helper'; -import { createUser, deleteUser, login } from '../../../data/users.helper'; -import { IS_EE } from '../../../e2e/config/constants'; - - -describe.only('LIVECHAT - rooms', () => { - const sockets: WebSocket[] = []; - - before((done) => getCredentials(done)); - - it( - 'when manager forward to offline (agent away, accept when agent idle off) department the inquiry should be set to the queue', - async () => { - await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); - await updateSetting('Livechat_enabled_when_agent_idle', false); - const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ - allowReceiveForwardOffline: true, - }); - sockets.push(ws); - - }, - ); - - -}) - From cf2ee475478918178d9b97e4fd5b0fb8bfc880c6 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 17 Mar 2026 23:19:43 -0300 Subject: [PATCH 19/57] add auto selection routing test --- .../app/livechat/server/lib/routing/AutoSelection.ts | 8 +++++++- .../meteor/tests/end-to-end/api/livechat/24-routing.ts | 10 +++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts b/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts index 25feaa8de1e11..1accf6347e3e8 100644 --- a/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts +++ b/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts @@ -35,10 +35,16 @@ class AutoSelection implements IRoutingMethod { settings.get('Livechat_enabled_when_agent_idle'), ignoreAgentId, extraQuery, + settings.get('Livechat_accept_chats_with_no_agents'), ); } - return Users.getNextAgent(ignoreAgentId, extraQuery, settings.get('Livechat_enabled_when_agent_idle')); + return Users.getNextAgent( + ignoreAgentId, + extraQuery, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); } } diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index 3bc9c8d9f218a..c94ff96e3d17c 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -317,15 +317,19 @@ import { IS_EE } from '../../../e2e/config/constants'; const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); }); - it('should accept a conversation but not route to anyone when Livechat_accept_chats_with_no_agents is true', async () => { + it('should accept a conversation and route to an offline agent when Livechat_accept_chats_with_no_agents is true', async () => { await updateSetting('Livechat_accept_chats_with_no_agents', true); - + await makeAgentAvailable(testUser3.credentials); + await setUserStatus(testUser3.credentials, UserStatus.OFFLINE); const visitor = await createVisitor(testDepartment._id); const room = await createLivechatRoom(visitor.token); const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.undefined; + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser3.user._id); + await makeAgentUnavailable(testUser3.credentials); + await setUserStatus(testUser3.credentials, UserStatus.ONLINE); }); it('should not allow users to take more than Livechat_maximum_chats_per_agent chats', async () => { await updateSetting('Livechat_maximum_chats_per_agent', 2); From 1ae62309ee72a866c079ea79806ad7bda0807e72 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 18 Mar 2026 12:24:23 -0300 Subject: [PATCH 20/57] remove accidental timeout --- apps/meteor/tests/end-to-end/api/livechat/24-routing.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index 8c65eaa08fe77..e6eaa6bb47915 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -522,7 +522,6 @@ import { IS_EE } from '../../../e2e/config/constants'; sockets.push(ws2); await setUserAwayWS(ws2); - await new Promise((r) => setTimeout(r, 9000)); // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); From 81fe8ada3a2521e31295af651c3d598fee76c4f7 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 18 Mar 2026 15:11:14 -0300 Subject: [PATCH 21/57] fix auto selection tests --- .../end-to-end/api/livechat/24-routing.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index 7fb7201919196..8d2ec4d1a706f 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -323,10 +323,16 @@ import { IS_EE } from '../../../e2e/config/constants'; const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); }); - it('should accept a conversation and route to an offline agent when Livechat_accept_chats_with_no_agents is true', async () => { - await updateSetting('Livechat_accept_chats_with_no_agents', true); + it('should fail to start a conversation if there is an available but offline agent and Livechat_accept_chats_with_no_agents is false', async () => { await makeAgentAvailable(testUser3.credentials); await setUserStatus(testUser3.credentials, UserStatus.OFFLINE); + + const visitor = await createVisitor(testDepartment._id); + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); + expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); + }); + it('should accept a conversation and route to an offline agent when Livechat_accept_chats_with_no_agents is true', async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', true); const visitor = await createVisitor(testDepartment._id); const room = await createLivechatRoom(visitor.token); @@ -338,14 +344,20 @@ import { IS_EE } from '../../../e2e/config/constants'; await setUserStatus(testUser3.credentials, UserStatus.ONLINE); }); it('should not allow users to take more than Livechat_maximum_chats_per_agent chats', async () => { - await updateSetting('Livechat_maximum_chats_per_agent', 2); + await updateSetting('Livechat_maximum_chats_per_agent', 1); + await makeAgentAvailable(testUser3.credentials); const visitor = await createVisitor(testDepartment._id); const room = await createLivechatRoom(visitor.token); - const roomInfo = await getLivechatRoomInfo(room._id); + await getLivechatRoomInfo(room._id); + const visitor2 = await createVisitor(testDepartment._id); + const room2 = await createLivechatRoom(visitor2.token); + + const roomInfo = await getLivechatRoomInfo(room2._id); expect(roomInfo.servedBy).to.be.undefined; + await makeAgentUnavailable(testUser3.credentials); }); it('should ignore disabled users', async () => { await updateSetting('Livechat_maximum_chats_per_agent', 0); @@ -355,6 +367,7 @@ import { IS_EE } from '../../../e2e/config/constants'; const room = await createLivechatRoom(visitor.token); const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; }); it('should not route to an idle user', async () => { From 8883278b37d7c3c0c31c98a62fbbdc89a7763d73 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 18 Mar 2026 16:57:34 -0300 Subject: [PATCH 22/57] add load rotation test --- .../end-to-end/api/livechat/24-routing.ts | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index 8d2ec4d1a706f..ce46431d0e4fa 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -16,7 +16,8 @@ import { makeAgentUnavailable, switchLivechatStatus, } from '../../../data/livechat/rooms'; -import { updateSetting } from '../../../data/permissions.helper'; +import { getAgent } from '../../../data/livechat/users'; +import { getSettingValueById, updateSetting } from '../../../data/permissions.helper'; import { password } from '../../../data/user'; import { createUser, deleteUser, login, ddpLogin, setUserAwayWS, setUserActiveStatus, setUserStatus } from '../../../data/users.helper'; import { IS_EE } from '../../../e2e/config/constants'; @@ -685,5 +686,52 @@ import { IS_EE } from '../../../e2e/config/constants'; // Not checking who, just checking it's served expect(roomInfo.servedBy).to.be.an('object'); }); + + describe('Load_Rotation - Livechat_accept_chats_with_no_agents', async () => { + let initialSettingValue: any; + let testUserInitialState: any; + let testUser2InitialState: any; + + before(async () => { + testUserInitialState = await getAgent(testUser.user._id); + testUser2InitialState = await getAgent(testUser2.user._id); + initialSettingValue = await getSettingValueById('Livechat_accept_chats_with_no_agents'); + }); + + after(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', initialSettingValue); + await switchLivechatStatus(testUserInitialState.statusLivechat, testUser.credentials); + await switchLivechatStatus(testUser2InitialState.statusLivechat, testUser2.credentials); + await setUserStatus(testUser2.credentials, testUser2InitialState.status); + }); + describe('Livechat_accept_chats_with_no_agents is false', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', false); + await switchLivechatStatus('not-available', testUser.credentials); + await switchLivechatStatus('available', testUser2.credentials); + await setUserStatus(testUser2.credentials, UserStatus.OFFLINE); + }); + + it('should fail to start a conversation if there is an available but offline agent and Livechat_accept_chats_with_no_agents is false', async () => { + const visitor = await createVisitor(testDepartment._id); + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); + expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); + }); + }); + describe('Livechat_accept_chats_with_no_agents is true', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', true); + await switchLivechatStatus('available', testUser2.credentials); + await setUserStatus(testUser2.credentials, UserStatus.OFFLINE); + }); + it('should accept a conversation and route to an offline agent', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); + }); + }); + }); }); }); From dbeef1bbc85ea0c2d0ec01d87716614c58ab50b4 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 18 Mar 2026 17:05:30 -0300 Subject: [PATCH 23/57] add load balancing test --- .../end-to-end/api/livechat/24-routing.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index ce46431d0e4fa..b176d45572939 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -567,6 +567,52 @@ import { IS_EE } from '../../../e2e/config/constants'; // Not checking who, just checking it's served expect(roomInfo.servedBy).to.be.an('object'); }); + describe('Load_Balancing - Livechat_accept_chats_with_no_agents', async () => { + let initialSettingValue: any; + let testUserInitialState: any; + let testUser2InitialState: any; + + before(async () => { + testUserInitialState = await getAgent(testUser.user._id); + testUser2InitialState = await getAgent(testUser2.user._id); + initialSettingValue = await getSettingValueById('Livechat_accept_chats_with_no_agents'); + }); + + after(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', initialSettingValue); + await switchLivechatStatus(testUserInitialState.statusLivechat, testUser.credentials); + await switchLivechatStatus(testUser2InitialState.statusLivechat, testUser2.credentials); + await setUserStatus(testUser2.credentials, testUser2InitialState.status); + }); + describe('Livechat_accept_chats_with_no_agents is false', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', false); + await switchLivechatStatus('not-available', testUser.credentials); + await switchLivechatStatus('available', testUser2.credentials); + await setUserStatus(testUser2.credentials, UserStatus.OFFLINE); + }); + + it('should fail to start a conversation if there is an available but offline agent and Livechat_accept_chats_with_no_agents is false', async () => { + const visitor = await createVisitor(testDepartment._id); + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); + expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); + }); + }); + describe('Livechat_accept_chats_with_no_agents is true', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', true); + await switchLivechatStatus('available', testUser2.credentials); + await setUserStatus(testUser2.credentials, UserStatus.OFFLINE); + }); + it('should accept a conversation and route to an offline agent', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); + }); + }); + }); }); describe('Load Rotation', () => { before(async () => { From 6e47218140286dc309f342c3eb3ed3770e5d6e0f Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 18 Mar 2026 19:45:42 -0300 Subject: [PATCH 24/57] refactor tests --- .../end-to-end/api/livechat/24-routing.ts | 101 ++++++++++-------- 1 file changed, 59 insertions(+), 42 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index b176d45572939..73ab776b0c451 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import type { Credentials } from '@rocket.chat/api-client'; import { UserStatus } from '@rocket.chat/core-typings'; -import type { ILivechatDepartment, IUser } from '@rocket.chat/core-typings'; +import type { ILivechatDepartment, IUser, ILivechatAgent } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { after, before, describe, it } from 'mocha'; @@ -324,26 +324,6 @@ import { IS_EE } from '../../../e2e/config/constants'; const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); }); - it('should fail to start a conversation if there is an available but offline agent and Livechat_accept_chats_with_no_agents is false', async () => { - await makeAgentAvailable(testUser3.credentials); - await setUserStatus(testUser3.credentials, UserStatus.OFFLINE); - - const visitor = await createVisitor(testDepartment._id); - const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); - expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); - }); - it('should accept a conversation and route to an offline agent when Livechat_accept_chats_with_no_agents is true', async () => { - await updateSetting('Livechat_accept_chats_with_no_agents', true); - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - - expect(roomInfo.servedBy).to.be.an('object'); - expect(roomInfo.servedBy?._id).to.be.equal(testUser3.user._id); - await makeAgentUnavailable(testUser3.credentials); - await setUserStatus(testUser3.credentials, UserStatus.ONLINE); - }); it('should not allow users to take more than Livechat_maximum_chats_per_agent chats', async () => { await updateSetting('Livechat_maximum_chats_per_agent', 1); @@ -365,11 +345,8 @@ import { IS_EE } from '../../../e2e/config/constants'; await setUserActiveStatus(testUser2.user._id, false); const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - - expect(roomInfo.servedBy).to.be.undefined; + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); + expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); }); it('should not route to an idle user', async () => { await updateSetting('Livechat_enabled_when_agent_idle', false); @@ -386,10 +363,8 @@ import { IS_EE } from '../../../e2e/config/constants'; await switchLivechatStatus('available', testUser.credentials); const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.undefined; + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); + expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); }); it('should route to an idle user', async () => { await updateSetting('Livechat_enabled_when_agent_idle', true); @@ -448,6 +423,52 @@ import { IS_EE } from '../../../e2e/config/constants'; expect(roomInfo.servedBy).to.be.an('object').that.has.property('_id').that.is.not.equal(testUser3.user._id); }); }); + describe('Auto_Selection - Livechat_accept_chats_with_no_agents', async () => { + let initialSettingValue: any; + let testUserInitialState: ILivechatAgent; + let testUser3InitialState: ILivechatAgent; + + before(async () => { + testUserInitialState = await getAgent(testUser.user._id); + testUser3InitialState = await getAgent(testUser3.user._id); + initialSettingValue = await getSettingValueById('Livechat_accept_chats_with_no_agents'); + }); + + after(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', initialSettingValue); + await switchLivechatStatus(testUserInitialState.statusLivechat, testUser.credentials); + await switchLivechatStatus(testUser3InitialState.statusLivechat, testUser3.credentials); + await setUserStatus(testUser3.credentials, testUser3InitialState.status); + }); + describe('Livechat_accept_chats_with_no_agents is false', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', false); + await switchLivechatStatus('not-available', testUser.credentials); + await switchLivechatStatus('available', testUser3.credentials); + await setUserStatus(testUser3.credentials, UserStatus.OFFLINE); + }); + + it('should fail to start a conversation if there is an available but offline agent and Livechat_accept_chats_with_no_agents is false', async () => { + const visitor = await createVisitor(testDepartment._id); + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); + expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); + }); + }); + describe('Livechat_accept_chats_with_no_agents is true', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', true); + await switchLivechatStatus('available', testUser3.credentials); + await setUserStatus(testUser3.credentials, UserStatus.OFFLINE); + }); + it('should accept a conversation and route to an offline agent', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser3.user._id); + }); + }); + }); }); describe('Load Balancing', () => { before(async () => { @@ -544,10 +565,8 @@ import { IS_EE } from '../../../e2e/config/constants'; await switchLivechatStatus('available', testUser.credentials); const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.undefined; + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); + expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); }); it('should route to agents even if theyre idle when setting is enabled', async () => { await updateSetting('Livechat_enabled_when_agent_idle', true); @@ -569,8 +588,8 @@ import { IS_EE } from '../../../e2e/config/constants'; }); describe('Load_Balancing - Livechat_accept_chats_with_no_agents', async () => { let initialSettingValue: any; - let testUserInitialState: any; - let testUser2InitialState: any; + let testUserInitialState: ILivechatAgent; + let testUser2InitialState: ILivechatAgent; before(async () => { testUserInitialState = await getAgent(testUser.user._id); @@ -709,10 +728,8 @@ import { IS_EE } from '../../../e2e/config/constants'; await switchLivechatStatus('available', testUser.credentials); const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.undefined; + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); + expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); }); it('should route to agents even if theyre idle when setting is enabled', async () => { await updateSetting('Livechat_enabled_when_agent_idle', true); @@ -735,8 +752,8 @@ import { IS_EE } from '../../../e2e/config/constants'; describe('Load_Rotation - Livechat_accept_chats_with_no_agents', async () => { let initialSettingValue: any; - let testUserInitialState: any; - let testUser2InitialState: any; + let testUserInitialState: ILivechatAgent; + let testUser2InitialState: ILivechatAgent; before(async () => { testUserInitialState = await getAgent(testUser.user._id); From 6134a5c17489039e5f3e7663b4f8bb2bec5073b2 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 19 Mar 2026 13:01:50 -0300 Subject: [PATCH 25/57] add DDP_LOGIN_PORT env variable for ws connection --- apps/meteor/tests/data/users.helper.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index bb67293231e11..1aa56cf6dfa11 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -172,7 +172,7 @@ export const setUserAway = (overrideCredentials = credentials, config?: IRequest }); }; -const connectWS = (port: number): Promise => +const connectWS = (port: string): Promise => new Promise((resolve, reject) => { const ws = new WebSocket(`ws://localhost:${port}/websocket`); @@ -181,13 +181,7 @@ const connectWS = (port: number): Promise => }); export const ddpLogin = async (resume: string): Promise => { - let ws: WebSocket; - - if (process.env.CI) { - ws = await connectWS(3000); - } else if (process.env.IS_EE) { - ws = await connectWS(4000); - } + const ws: WebSocket = await connectWS(process.env.DDP_LOGIN_PORT || '3000'); const loginId = `login-${Date.now()}-${Math.random()}`; From fca1d9a40047a151216b2ca0c09befbe03e4357a Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli <84046180+nazabucciarelli@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:07:28 -0300 Subject: [PATCH 26/57] Correct phrasing in changeset description --- .changeset/purple-boxes-shout.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/purple-boxes-shout.md b/.changeset/purple-boxes-shout.md index e338ac60ed7f6..f5a908b5458db 100644 --- a/.changeset/purple-boxes-shout.md +++ b/.changeset/purple-boxes-shout.md @@ -3,4 +3,4 @@ '@rocket.chat/meteor': patch --- -Fixes an issue where offline agents were being assigned to visitors. +Fixes an issue where offline livechat agents were being assigned to visitors. From 0b3395ca823aa2cf79c83dd2dafda6d9f85738a7 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 19 Mar 2026 15:15:07 -0300 Subject: [PATCH 27/57] add forgotten CI new env variable --- .github/workflows/ci-test-e2e.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index d8fd21a6e707d..df0ec331aa778 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -65,6 +65,7 @@ env: TOOL_NODE_FLAGS: ${{ vars.TOOL_NODE_FLAGS }} LOWERCASE_REPOSITORY: ${{ inputs.lowercase-repo }} DOCKER_TAG: ${{ inputs.gh-docker-tag }}-amd64 + DDP_LOGIN_PORT: 3000 jobs: test: From 6d760f1799e9884e8dc36dc5c08da88743ad854c Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 19 Mar 2026 15:18:04 -0300 Subject: [PATCH 28/57] safe ws closure --- apps/meteor/tests/data/users.helper.ts | 1 + .../e2e/omnichannel/omnichannel-custom-field-usage.spec.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 1aa56cf6dfa11..e4d8979d86af4 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -203,6 +203,7 @@ export const ddpLogin = async (resume: string): Promise => { ws.removeEventListener('message', handler); if (data.error) { + ws.close(); reject(data.error); return; } diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts index 1bb9b8616ec23..e059f54e4d1cd 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts @@ -73,7 +73,7 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => test.afterAll('Remove agent, manager, custom fields and conversation', async () => { await Promise.all([agent.delete(), manager.delete(), roomCustomField.delete(), visitorCustomField.delete(), conversation.delete()]); - ws.close(); + ws?.close(); }); test('Should be allowed to set room custom field for a conversation', async () => { From 7199cc4faefecce171e52a4cd3c8aca067ee6368 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 19 Mar 2026 21:45:56 -0300 Subject: [PATCH 29/57] enhance overall WS connection logic --- apps/meteor/tests/data/users.helper.ts | 117 +++++++++++-------------- 1 file changed, 49 insertions(+), 68 deletions(-) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index e4d8979d86af4..00b66c5c731a6 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -176,97 +176,78 @@ const connectWS = (port: string): Promise => new Promise((resolve, reject) => { const ws = new WebSocket(`ws://localhost:${port}/websocket`); - ws.onopen = () => resolve(ws); + ws.onopen = () => { + ws.addEventListener('message', (event: MessageEvent) => { + const data = JSON.parse(event.data); + if (data.msg === 'ping') { + ws.send(JSON.stringify({ msg: 'pong' })); + } + }); + resolve(ws); + }; ws.onerror = () => reject(new Error(`WS connection failed on ${port}`)); }); -export const ddpLogin = async (resume: string): Promise => { - const ws: WebSocket = await connectWS(process.env.DDP_LOGIN_PORT || '3000'); +const waitForDDP = (ws: WebSocket, id: string | 'handshake', sendAction: () => void): Promise => { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + ws.close(); + reject(new Error(`Timeout waiting for DDP id: ${id}`)); + }, 5000); + + const cleanup = () => { + clearTimeout(timeout); + ws.removeEventListener('message', handler); + ws.removeEventListener('close', onClose); + }; - const loginId = `login-${Date.now()}-${Math.random()}`; + const onClose = () => { + cleanup(); + reject(new Error(`WS closed while waiting for id: ${id}`)); + }; - return new Promise((resolve, reject) => { const handler = (event: MessageEvent) => { - const data = JSON.parse(event.data); - - if (data.msg === 'connected') { - ws.send( - JSON.stringify({ - msg: 'method', - id: loginId, - method: 'login', - params: [{ resume }], - }), - ); - } else if (data.msg === 'result') { - if (data.id === loginId) { - ws.removeEventListener('message', handler); + try { + const data = JSON.parse(event.data); + const isHandshake = id === 'handshake' && data.msg === 'connected'; + const isResult = data.id === id && (data.msg === 'result' || data.msg === 'error'); + if (isHandshake || isResult) { + cleanup(); if (data.error) { ws.close(); - reject(data.error); - return; + return reject(data.error); } - - resolve(ws); + resolve(data); } - } else if (data.msg === 'ping') { - ws.send(JSON.stringify({ msg: 'pong' })); - } else if (data.msg === 'error') { - ws.removeEventListener('message', handler); - reject(data); + } catch (e) { + // Ignore no JSON message } }; ws.addEventListener('message', handler); - - ws.send( - JSON.stringify({ - msg: 'connect', - version: '1', - support: ['1'], - }), - ); + ws.addEventListener('close', onClose); + sendAction(); }); }; -export const setUserAwayWS = (ws: WebSocket): Promise => - new Promise((resolve, reject) => { - const id = `away-${Date.now()}-${Math.random()}`; - - const handler = (event: MessageEvent) => { - const data = JSON.parse(event.data); +export const ddpLogin = async (resume: string): Promise => { + const ws = await connectWS(process.env.DDP_LOGIN_PORT || '3000'); + const loginId = `login-${Date.now()}`; - if (data.msg === 'result' && data.id === id) { - ws.removeEventListener('message', handler); - if (data.error) { - reject(data.error); - return; - } - resolve(); - } + await waitForDDP(ws, 'handshake', () => ws.send(JSON.stringify({ msg: 'connect', version: '1', support: ['1'] }))); - if (data.msg === 'ping') { - ws.send(JSON.stringify({ msg: 'pong' })); - } + await waitForDDP(ws, loginId, () => ws.send(JSON.stringify({ msg: 'method', id: loginId, method: 'login', params: [{ resume }] }))); - if (data.msg === 'error') { - ws.removeEventListener('message', handler); - reject(data); - } - }; + return ws; +}; - ws.addEventListener('message', handler); +export const setUserAwayWS = async (ws: WebSocket): Promise => { + const id = `away-${Date.now()}`; - ws.send( - JSON.stringify({ - msg: 'method', - method: 'UserPresence:away', - params: [], - id, - }), - ); - }); + await waitForDDP(ws, id, () => ws.send(JSON.stringify({ msg: 'method', method: 'UserPresence:away', params: [], id }))); +}; export const setUserOnline = (overrideCredentials = credentials, config?: IRequestConfig) => { const requestInstance = config?.request || request; From d808a27b2015a856bd17097203d56a4ead2e666c Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 19 Mar 2026 21:57:36 -0300 Subject: [PATCH 30/57] add onError handler for ws --- apps/meteor/tests/data/users.helper.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 00b66c5c731a6..2626f385b0431 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -200,6 +200,7 @@ const waitForDDP = (ws: WebSocket, id: string | 'handshake', sendAction: () => v clearTimeout(timeout); ws.removeEventListener('message', handler); ws.removeEventListener('close', onClose); + ws.removeEventListener('error', onError); }; const onClose = () => { @@ -207,6 +208,12 @@ const waitForDDP = (ws: WebSocket, id: string | 'handshake', sendAction: () => v reject(new Error(`WS closed while waiting for id: ${id}`)); }; + const onError = (error: any) => { + cleanup(); + ws.close(); + reject(error || new Error(`WS error during operation id: ${id}`)); + }; + const handler = (event: MessageEvent) => { try { const data = JSON.parse(event.data); @@ -228,6 +235,8 @@ const waitForDDP = (ws: WebSocket, id: string | 'handshake', sendAction: () => v ws.addEventListener('message', handler); ws.addEventListener('close', onClose); + ws.addEventListener('error', onError); + sendAction(); }); }; From 1bebc26611777b5acdad90854903581e971c4a93 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 20 Mar 2026 14:15:41 -0300 Subject: [PATCH 31/57] extract setting value in a const and add missing value in error message --- apps/meteor/app/livechat/server/lib/takeInquiry.ts | 1 + .../livechat-enterprise/server/lib/routing/LoadBalancing.ts | 3 ++- .../app/livechat-enterprise/server/lib/routing/LoadRotation.ts | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/meteor/app/livechat/server/lib/takeInquiry.ts b/apps/meteor/app/livechat/server/lib/takeInquiry.ts index 4245048745add..ea96a9ef2440c 100644 --- a/apps/meteor/app/livechat/server/lib/takeInquiry.ts +++ b/apps/meteor/app/livechat/server/lib/takeInquiry.ts @@ -37,6 +37,7 @@ export const takeInquiry = async ( method: 'livechat:takeInquiry', ...(process.env.TEST_MODE && { Livechat_enabled_when_agent_idle: settings.get('Livechat_enabled_when_agent_idle'), + Livechat_accept_chats_with_no_agents: settings.get('Livechat_accept_chats_with_no_agents'), user: await Users.findOneById(userId), }), }); diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts index b7f67a36b930e..f97e160dfb8a4 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts @@ -32,6 +32,7 @@ class LoadBalancing { async getNextAgent(department?: string, ignoreAgentId?: string) { const enabledWhenIdle = settings.get('Livechat_enabled_when_agent_idle'); + const enabledWhenOffline = settings.get('Livechat_enabled_when_agent_offline'); const extraQuery = await getChatLimitsQuery(department); const unavailableUsers = await Users.getUnavailableAgents(department, extraQuery, enabledWhenIdle); logger.debug({ msg: 'Ignoring unavailable agents from assignment', unavailableUsers, department, enabledWhenIdle }); @@ -41,7 +42,7 @@ class LoadBalancing { ignoreAgentId, enabledWhenIdle, unavailableUsers.map((u) => u.username), - settings.get('Livechat_accept_chats_with_no_agents'), + enabledWhenOffline, ); if (!nextAgent) { return; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts index 3b1192e3c0e11..a58eb152e7e6b 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts @@ -31,6 +31,7 @@ class LoadRotation { public async getNextAgent(department?: string, ignoreAgentId?: string): Promise { const enabledWhenIdle = settings.get('Livechat_enabled_when_agent_idle'); + const enabledWhenOffline = settings.get('Livechat_enabled_when_agent_offline'); const extraQuery = await getChatLimitsQuery(department); const unavailableUsers = await Users.getUnavailableAgents(department, extraQuery, enabledWhenIdle); @@ -41,7 +42,7 @@ class LoadRotation { ignoreAgentId, enabledWhenIdle, unavailableUsers.map((user) => user.username), - settings.get('Livechat_accept_chats_with_no_agents'), + enabledWhenOffline, ); if (!nextAgent?.username) { return; From 027d8edb52dd5515a231e22991a45fe84296ac88 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 20 Mar 2026 16:05:40 -0300 Subject: [PATCH 32/57] wip --- .../end-to-end/api/livechat/24-routing.ts | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index 73ab776b0c451..8d0886e764a27 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -215,7 +215,7 @@ import { IS_EE } from '../../../e2e/config/constants'; }); }); - describe('Auto-Selection', () => { + describe.only('Auto-Selection', () => { before(async () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); }); @@ -325,28 +325,30 @@ import { IS_EE } from '../../../e2e/config/constants'; expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); }); it('should not allow users to take more than Livechat_maximum_chats_per_agent chats', async () => { - await updateSetting('Livechat_maximum_chats_per_agent', 1); - - await makeAgentAvailable(testUser3.credentials); + await updateSetting('Livechat_maximum_chats_per_agent', 2); + await makeAgentAvailable(testUser.credentials); const visitor = await createVisitor(testDepartment._id); const room = await createLivechatRoom(visitor.token); - await getLivechatRoomInfo(room._id); + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + // at this point, testUser reached the limit of chats (2) const visitor2 = await createVisitor(testDepartment._id); const room2 = await createLivechatRoom(visitor2.token); - const roomInfo = await getLivechatRoomInfo(room2._id); - expect(roomInfo.servedBy).to.be.undefined; - await makeAgentUnavailable(testUser3.credentials); + const roomInfo2 = await getLivechatRoomInfo(room2._id); + expect(roomInfo2.servedBy).to.be.undefined; }); it('should ignore disabled users', async () => { - await updateSetting('Livechat_maximum_chats_per_agent', 0); await setUserActiveStatus(testUser2.user._id, false); const visitor = await createVisitor(testDepartment._id); - const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); - expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; }); it('should not route to an idle user', async () => { await updateSetting('Livechat_enabled_when_agent_idle', false); @@ -362,6 +364,9 @@ import { IS_EE } from '../../../e2e/config/constants'; // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); +console.log("pausa") + await new Promise((r) => setTimeout(r, 9000)); + const visitor = await createVisitor(testDepartment._id); const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); From 2bdff92b354c16fd3ddd6bbe72cd033ee85446e1 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 20 Mar 2026 18:32:18 -0300 Subject: [PATCH 33/57] fix remaining routing tests --- .../end-to-end/api/livechat/24-routing.ts | 67 +++++++------------ 1 file changed, 25 insertions(+), 42 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index 8d0886e764a27..a46a675ba57c5 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -215,7 +215,7 @@ import { IS_EE } from '../../../e2e/config/constants'; }); }); - describe.only('Auto-Selection', () => { + describe('Auto-Selection', () => { before(async () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); }); @@ -332,9 +332,9 @@ import { IS_EE } from '../../../e2e/config/constants'; const roomInfo = await getLivechatRoomInfo(room._id); expect(roomInfo.servedBy).to.be.an('object'); - expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); - // at this point, testUser reached the limit of chats (2) + // at this point, testUser reached the limit of chats (2) const visitor2 = await createVisitor(testDepartment._id); const room2 = await createLivechatRoom(visitor2.token); @@ -351,25 +351,20 @@ import { IS_EE } from '../../../e2e/config/constants'; expect(roomInfo.servedBy).to.be.undefined; }); it('should not route to an idle user', async () => { + // we set Livechat_maximum_chats_per_agent to 0, meaning testUser can take chats in following tests + await updateSetting('Livechat_maximum_chats_per_agent', 0); + await updateSetting('Livechat_accept_chats_with_no_agents', true); await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); sockets.push(ws1); await setUserAwayWS(ws1); - await setUserStatus(testUser3.credentials, UserStatus.AWAY); - const ws2 = await ddpLogin(testUser3.credentials['X-Auth-Token']); - sockets.push(ws2); - await setUserAwayWS(ws2); - - // Agent is available but should be ignored - await switchLivechatStatus('available', testUser.credentials); - -console.log("pausa") - await new Promise((r) => setTimeout(r, 9000)); const visitor = await createVisitor(testDepartment._id); - const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); - expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; }); it('should route to an idle user', async () => { await updateSetting('Livechat_enabled_when_agent_idle', true); @@ -379,9 +374,9 @@ console.log("pausa") const roomInfo = await getLivechatRoomInfo(room._id); expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); }); it('should route to another available agent if contact manager is unavailable and Omnichannel_contact_manager_routing is enabled', async () => { - await makeAgentAvailable(testUser.credentials); const visitor = await createVisitor(testDepartment._id, faker.person.fullName(), visitorEmail); const room = await createLivechatRoom(visitor.token); @@ -556,22 +551,18 @@ console.log("pausa") expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); }); it('should not route to an idle user', async () => { + await makeAgentUnavailable(testUser2.credentials); await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); sockets.push(ws1); await setUserAwayWS(ws1); - await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(ws2); - await setUserAwayWS(ws2); - - // Agent is available but should be ignored - await switchLivechatStatus('available', testUser.credentials); const visitor = await createVisitor(testDepartment._id); - const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); - expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; }); it('should route to agents even if theyre idle when setting is enabled', async () => { await updateSetting('Livechat_enabled_when_agent_idle', true); @@ -579,10 +570,6 @@ console.log("pausa") const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); sockets.push(ws1); await setUserAwayWS(ws1); - await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(ws2); - await setUserAwayWS(ws2); const visitor = await createVisitor(testDepartment._id); const room = await createLivechatRoom(visitor.token); @@ -590,6 +577,7 @@ console.log("pausa") const roomInfo = await getLivechatRoomInfo(room._id); // Not checking who, just checking it's served expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); }); describe('Load_Balancing - Livechat_accept_chats_with_no_agents', async () => { let initialSettingValue: any; @@ -632,6 +620,7 @@ console.log("pausa") const visitor = await createVisitor(testDepartment._id); const room = await createLivechatRoom(visitor.token); const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); }); @@ -719,22 +708,18 @@ console.log("pausa") expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); }); it('should not route to an idle user', async () => { + await makeAgentUnavailable(testUser2.credentials); await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); sockets.push(ws1); await setUserAwayWS(ws1); - await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(ws2); - await setUserAwayWS(ws2); - - // Agent is available but should be ignored - await switchLivechatStatus('available', testUser.credentials); const visitor = await createVisitor(testDepartment._id); - const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); - expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; }); it('should route to agents even if theyre idle when setting is enabled', async () => { await updateSetting('Livechat_enabled_when_agent_idle', true); @@ -742,10 +727,6 @@ console.log("pausa") const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); sockets.push(ws1); await setUserAwayWS(ws1); - await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(ws2); - await setUserAwayWS(ws2); const visitor = await createVisitor(testDepartment._id); const room = await createLivechatRoom(visitor.token); @@ -753,6 +734,7 @@ console.log("pausa") const roomInfo = await getLivechatRoomInfo(room._id); // Not checking who, just checking it's served expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); }); describe('Load_Rotation - Livechat_accept_chats_with_no_agents', async () => { @@ -796,6 +778,7 @@ console.log("pausa") const visitor = await createVisitor(testDepartment._id); const room = await createLivechatRoom(visitor.token); const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); }); From 753a0287c71061f8c242fc5cfe4f25bb0263d904 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 20 Mar 2026 18:43:07 -0300 Subject: [PATCH 34/57] add setting in missing places --- .../server/lib/routing/LoadBalancing.ts | 7 ++++--- .../livechat-enterprise/server/lib/routing/LoadRotation.ts | 6 +++--- apps/meteor/ee/server/models/raw/Users.ts | 4 +++- packages/model-typings/src/models/IUsersModel.ts | 1 + packages/models/src/models/LivechatDepartmentAgents.ts | 6 +++--- packages/models/src/models/Users.ts | 5 ++++- 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts index f97e160dfb8a4..3974f96067dab 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts @@ -32,10 +32,11 @@ class LoadBalancing { async getNextAgent(department?: string, ignoreAgentId?: string) { const enabledWhenIdle = settings.get('Livechat_enabled_when_agent_idle'); - const enabledWhenOffline = settings.get('Livechat_enabled_when_agent_offline'); + const enabledWhenOffline = settings.get('Livechat_accept_chats_with_no_agents'); + const extraQuery = await getChatLimitsQuery(department); - const unavailableUsers = await Users.getUnavailableAgents(department, extraQuery, enabledWhenIdle); - logger.debug({ msg: 'Ignoring unavailable agents from assignment', unavailableUsers, department, enabledWhenIdle }); + const unavailableUsers = await Users.getUnavailableAgents(department, extraQuery, enabledWhenIdle, enabledWhenOffline); + logger.debug({ msg: 'Ignoring unavailable agents from assignment', unavailableUsers, department, enabledWhenIdle, enabledWhenOffline }); const nextAgent = await Users.getNextLeastBusyAgent( department, diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts index a58eb152e7e6b..3e4fab5f94da5 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts @@ -31,11 +31,11 @@ class LoadRotation { public async getNextAgent(department?: string, ignoreAgentId?: string): Promise { const enabledWhenIdle = settings.get('Livechat_enabled_when_agent_idle'); - const enabledWhenOffline = settings.get('Livechat_enabled_when_agent_offline'); + const enabledWhenOffline = settings.get('Livechat_accept_chats_with_no_agents'); const extraQuery = await getChatLimitsQuery(department); - const unavailableUsers = await Users.getUnavailableAgents(department, extraQuery, enabledWhenIdle); - logger.debug({ msg: 'Ignoring unavailable agents from assignment', unavailableUsers, department, enabledWhenIdle }); + const unavailableUsers = await Users.getUnavailableAgents(department, extraQuery, enabledWhenIdle, enabledWhenOffline); + logger.debug({ msg: 'Ignoring unavailable agents from assignment', unavailableUsers, department, enabledWhenIdle, enabledWhenOffline }); const nextAgent = await Users.getLastAvailableAgentRouted( department, diff --git a/apps/meteor/ee/server/models/raw/Users.ts b/apps/meteor/ee/server/models/raw/Users.ts index 0912a3c80b03d..3804a1ea3c06f 100644 --- a/apps/meteor/ee/server/models/raw/Users.ts +++ b/apps/meteor/ee/server/models/raw/Users.ts @@ -10,6 +10,7 @@ declare module '@rocket.chat/model-typings' { departmentId: string, customFilter: Filter, enabledWhenIdle?: boolean, + enabledWhenOffline?: boolean, ): Promise[]>; } } @@ -23,6 +24,7 @@ export class UsersEE extends UsersRaw { departmentId: string, customFilter: Filter, enabledWhenIdle = false, + enabledWhenOffline = false, ): Promise[]> { // if department is provided, remove the agents that are not from the selected department const departmentFilter = departmentId @@ -53,7 +55,7 @@ export class UsersEE extends UsersRaw { .aggregate( [ { - $match: queryStatusAgentOnline({}, enabledWhenIdle), + $match: queryStatusAgentOnline({}, enabledWhenIdle, enabledWhenOffline), }, ...departmentFilter, { diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index 9e63348665ff9..c16bb7285d62c 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -259,6 +259,7 @@ export interface IUsersModel extends IBaseModel { departmentId?: string, extraQuery?: Filter, isLivechatEnabledWhenIdle?: boolean, + acceptChatsWithNoAgents?: boolean, ): Promise[]>; findOneOnlineAgentByUserList( userList: string[] | string, diff --git a/packages/models/src/models/LivechatDepartmentAgents.ts b/packages/models/src/models/LivechatDepartmentAgents.ts index 52d387c031fcc..a0e15e6f1df3f 100644 --- a/packages/models/src/models/LivechatDepartmentAgents.ts +++ b/packages/models/src/models/LivechatDepartmentAgents.ts @@ -195,9 +195,9 @@ export class LivechatDepartmentAgentsRaw extends BaseRaw user.username).filter(isStringValue); // get fully booked agents, to ignore them from the query - const currentUnavailableAgents = (await Users.getUnavailableAgents(departmentId, extraQuery, isLivechatEnabledWhenAgentIdle)).map( - (u) => u.username, - ); + const currentUnavailableAgents = ( + await Users.getUnavailableAgents(departmentId, extraQuery, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents) + ).map((u) => u.username); const query: Filter = { departmentId, diff --git a/packages/models/src/models/Users.ts b/packages/models/src/models/Users.ts index d44722a7e00c3..1316ff9f440c2 100644 --- a/packages/models/src/models/Users.ts +++ b/packages/models/src/models/Users.ts @@ -1666,6 +1666,7 @@ export class UsersRaw extends BaseRaw> implements IU _departmentId?: string, _extraQuery?: Filter, _isLivechatEnabledWhenAgentIdle?: boolean, + _acceptChatsWithNoAgent?: boolean, ): Promise[]> { return []; } @@ -1949,7 +1950,9 @@ export class UsersRaw extends BaseRaw> implements IU ) { // TODO: Create class Agent // fetch all unavailable agents, and exclude them from the selection - const unavailableAgents = (await this.getUnavailableAgents(undefined, extraQuery, enabledWhenAgentIdle)).map((u) => u.username); + const unavailableAgents = (await this.getUnavailableAgents(undefined, extraQuery, enabledWhenAgentIdle, acceptChatsWithNoAgents)).map( + (u) => u.username, + ); const extraFilters = { ...(ignoreAgentId && { _id: { $ne: ignoreAgentId } }), // limit query to remove booked agents From b19afad8313778537aef52ea42804c8c4d3033fe Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 8 Apr 2026 18:44:02 -0300 Subject: [PATCH 35/57] modify changeset from patch to minor --- .changeset/purple-boxes-shout.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/purple-boxes-shout.md b/.changeset/purple-boxes-shout.md index f5a908b5458db..2fe8eca446613 100644 --- a/.changeset/purple-boxes-shout.md +++ b/.changeset/purple-boxes-shout.md @@ -1,6 +1,6 @@ --- -'@rocket.chat/models': patch -'@rocket.chat/meteor': patch +'@rocket.chat/models': minor +'@rocket.chat/meteor': minor --- -Fixes an issue where offline livechat agents were being assigned to visitors. +Updates the behavior of the `Livechat_enabled_when_agent_idle` setting. When enabled, the routing query now excludes agents with an offline status, ensuring visitors are not assigned to them. From 06820bce1729c82968a0d60a4581457c64b7f36b Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 8 Apr 2026 18:54:45 -0300 Subject: [PATCH 36/57] remove sendAction parameter in favor of stringifiedJsonPayload --- apps/meteor/tests/data/users.helper.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 2626f385b0431..9a02663b5bb4c 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -188,7 +188,7 @@ const connectWS = (port: string): Promise => ws.onerror = () => reject(new Error(`WS connection failed on ${port}`)); }); -const waitForDDP = (ws: WebSocket, id: string | 'handshake', sendAction: () => void): Promise => { +const waitForDDP = (ws: WebSocket, id: string | 'handshake', stringifiedJsonPayload: string): Promise => { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { cleanup(); @@ -237,7 +237,7 @@ const waitForDDP = (ws: WebSocket, id: string | 'handshake', sendAction: () => v ws.addEventListener('close', onClose); ws.addEventListener('error', onError); - sendAction(); + ws.send(stringifiedJsonPayload); }); }; @@ -245,9 +245,9 @@ export const ddpLogin = async (resume: string): Promise => { const ws = await connectWS(process.env.DDP_LOGIN_PORT || '3000'); const loginId = `login-${Date.now()}`; - await waitForDDP(ws, 'handshake', () => ws.send(JSON.stringify({ msg: 'connect', version: '1', support: ['1'] }))); + await waitForDDP(ws, 'handshake', JSON.stringify({ msg: 'connect', version: '1', support: ['1'] })); - await waitForDDP(ws, loginId, () => ws.send(JSON.stringify({ msg: 'method', id: loginId, method: 'login', params: [{ resume }] }))); + await waitForDDP(ws, loginId, JSON.stringify({ msg: 'method', id: loginId, method: 'login', params: [{ resume }] })); return ws; }; @@ -255,7 +255,7 @@ export const ddpLogin = async (resume: string): Promise => { export const setUserAwayWS = async (ws: WebSocket): Promise => { const id = `away-${Date.now()}`; - await waitForDDP(ws, id, () => ws.send(JSON.stringify({ msg: 'method', method: 'UserPresence:away', params: [], id }))); + await waitForDDP(ws, id, JSON.stringify({ msg: 'method', method: 'UserPresence:away', params: [], id })); }; export const setUserOnline = (overrideCredentials = credentials, config?: IRequestConfig) => { From c75d663d496e280a32981f7e22192f9c7bc2e555 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 8 Apr 2026 19:19:33 -0300 Subject: [PATCH 37/57] add comment to document auto-assignment bussines rule --- packages/models/src/helpers/omnichannel/agentStatus.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/models/src/helpers/omnichannel/agentStatus.ts b/packages/models/src/helpers/omnichannel/agentStatus.ts index 1fa28ee085a71..235956871379b 100644 --- a/packages/models/src/helpers/omnichannel/agentStatus.ts +++ b/packages/models/src/helpers/omnichannel/agentStatus.ts @@ -2,6 +2,15 @@ import { UserStatus } from '@rocket.chat/core-typings'; import type { IUser } from '@rocket.chat/core-typings'; import type { Filter } from 'mongodb'; +/** + * Builds the query to find online and available agents for livechat auto-assignment. + * * According to product rules, the primary conditions for auto-assignment are: + * - User must have the 'livechat-agent' role. + * - Livechat service status (`statusLivechat`) must be 'available'. + * - User's global status must NOT be 'offline' (Bots are exempt from this rule). + * - If the "Accept new omnichannel requests when the agent is idle" (aka `Livechat_enabled_when_agent_idle`) setting is OFF, + * then the statusConnection must NOT be 'away'. + */ export const queryStatusAgentOnline = (extraFilters = {}, isLivechatEnabledWhenAgentIdle?: boolean): Filter => ({ statusLivechat: 'available', roles: 'livechat-agent', From 402aad3290f1e8c371a14cc6fcd8b97edd162ff1 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 8 Apr 2026 19:29:26 -0300 Subject: [PATCH 38/57] change websocket variables naming --- .../tests/end-to-end/api/livechat/00-rooms.ts | 20 ++++--- .../end-to-end/api/livechat/24-routing.ts | 60 +++++++++---------- 2 files changed, 42 insertions(+), 38 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index 35b69cdf6be6f..3f1dfc5f51c1e 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -1553,10 +1553,10 @@ describe('LIVECHAT - rooms', () => { await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); await updateSetting('Livechat_enabled_when_agent_idle', false); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ + const { department: forwardToOfflineDepartment, ws: awayAgentWebSocket } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true, }); - sockets.push(ws); + sockets.push(awayAgentWebSocket); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1682,8 +1682,10 @@ describe('LIVECHAT - rooms', () => { async () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true }); - sockets.push(ws); + const { department: forwardToOfflineDepartment, ws: awayAgentWebSocket } = await createDepartmentWithAnAwayAgent({ + allowReceiveForwardOffline: true, + }); + sockets.push(awayAgentWebSocket); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1722,8 +1724,10 @@ describe('LIVECHAT - rooms', () => { async () => { await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, ws } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: false }); - sockets.push(ws); + const { department: forwardToOfflineDepartment, ws: awayAgentWebSocket } = await createDepartmentWithAnAwayAgent({ + allowReceiveForwardOffline: false, + }); + sockets.push(awayAgentWebSocket); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); @@ -1803,11 +1807,11 @@ describe('LIVECHAT - rooms', () => { const { department: forwardToOfflineDepartment, agent, - ws, + ws: awayAgentWebSocket, } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true, }); - sockets.push(ws); + sockets.push(awayAgentWebSocket); const newVisitor = await createVisitor(initialDepartment._id); const newRoom = await createLivechatRoom(newVisitor.token); diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index e6eaa6bb47915..c4621dc38174a 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -356,13 +356,13 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should not route to an idle user', async () => { await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); - const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(ws1); - await setUserAwayWS(ws1); + const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(firstAwayUserWebSocket); + await setUserAwayWS(firstAwayUserWebSocket); await setUserStatus(testUser3.credentials, UserStatus.AWAY); - const ws2 = await ddpLogin(testUser3.credentials['X-Auth-Token']); - sockets.push(ws2); - await setUserAwayWS(ws2); + const secondAwayUserWebSocket = await ddpLogin(testUser3.credentials['X-Auth-Token']); + sockets.push(secondAwayUserWebSocket); + await setUserAwayWS(secondAwayUserWebSocket); // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); @@ -514,13 +514,13 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should not route to an idle user', async () => { await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); - const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(ws1); - await setUserAwayWS(ws1); + const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(firstAwayUserWebSocket); + await setUserAwayWS(firstAwayUserWebSocket); await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(ws2); - await setUserAwayWS(ws2); + const secondAwayUserWebSocket = await ddpLogin(testUser2.credentials['X-Auth-Token']); + sockets.push(secondAwayUserWebSocket); + await setUserAwayWS(secondAwayUserWebSocket); // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); @@ -534,13 +534,13 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should route to agents even if theyre idle when setting is enabled', async () => { await updateSetting('Livechat_enabled_when_agent_idle', true); await setUserStatus(testUser.credentials, UserStatus.AWAY); - const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(ws1); - await setUserAwayWS(ws1); + const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(firstAwayUserWebSocket); + await setUserAwayWS(firstAwayUserWebSocket); await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(ws2); - await setUserAwayWS(ws2); + const secondAwayUserWebSocket = await ddpLogin(testUser2.credentials['X-Auth-Token']); + sockets.push(secondAwayUserWebSocket); + await setUserAwayWS(secondAwayUserWebSocket); const visitor = await createVisitor(testDepartment._id); const room = await createLivechatRoom(visitor.token); @@ -633,13 +633,13 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should not route to an idle user', async () => { await updateSetting('Livechat_enabled_when_agent_idle', false); await setUserStatus(testUser.credentials, UserStatus.AWAY); - const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(ws1); - await setUserAwayWS(ws1); + const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(firstAwayUserWebSocket); + await setUserAwayWS(firstAwayUserWebSocket); await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(ws2); - await setUserAwayWS(ws2); + const secondAwayUserWebSocket = await ddpLogin(testUser2.credentials['X-Auth-Token']); + sockets.push(secondAwayUserWebSocket); + await setUserAwayWS(secondAwayUserWebSocket); // Agent is available but should be ignored await switchLivechatStatus('available', testUser.credentials); @@ -653,13 +653,13 @@ import { IS_EE } from '../../../e2e/config/constants'; it('should route to agents even if theyre idle when setting is enabled', async () => { await updateSetting('Livechat_enabled_when_agent_idle', true); await setUserStatus(testUser.credentials, UserStatus.AWAY); - const ws1 = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(ws1); - await setUserAwayWS(ws1); + const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); + sockets.push(firstAwayUserWebSocket); + await setUserAwayWS(firstAwayUserWebSocket); await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const ws2 = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(ws2); - await setUserAwayWS(ws2); + const secondAwayUserWebSocket = await ddpLogin(testUser2.credentials['X-Auth-Token']); + sockets.push(secondAwayUserWebSocket); + await setUserAwayWS(secondAwayUserWebSocket); const visitor = await createVisitor(testDepartment._id); const room = await createLivechatRoom(visitor.token); From f4c0f10668e6172c13569cbcc955b9d450ded16f Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 13 Apr 2026 09:17:36 -0600 Subject: [PATCH 39/57] Update .changeset/purple-boxes-shout.md --- .changeset/purple-boxes-shout.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/purple-boxes-shout.md b/.changeset/purple-boxes-shout.md index 2fe8eca446613..a6884404692c0 100644 --- a/.changeset/purple-boxes-shout.md +++ b/.changeset/purple-boxes-shout.md @@ -3,4 +3,4 @@ '@rocket.chat/meteor': minor --- -Updates the behavior of the `Livechat_enabled_when_agent_idle` setting. When enabled, the routing query now excludes agents with an offline status, ensuring visitors are not assigned to them. +Updates the behavior of the `Livechat_enabled_when_agent_idle` setting. When enabled, the routing query now excludes `offline` agents, ensuring no new conversations are assigned to them. From d03991ef2cddf5f3d3efad2c4caeb298a008f2a9 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 13 Apr 2026 09:21:32 -0600 Subject: [PATCH 40/57] Update .changeset/purple-boxes-shout.md Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .changeset/purple-boxes-shout.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/purple-boxes-shout.md b/.changeset/purple-boxes-shout.md index a6884404692c0..9e15fbd28faaf 100644 --- a/.changeset/purple-boxes-shout.md +++ b/.changeset/purple-boxes-shout.md @@ -3,4 +3,4 @@ '@rocket.chat/meteor': minor --- -Updates the behavior of the `Livechat_enabled_when_agent_idle` setting. When enabled, the routing query now excludes `offline` agents, ensuring no new conversations are assigned to them. +Updates omnichannel routing so agents with `offline` status are always excluded from assignment. The `Livechat_enabled_when_agent_idle` setting now only affects agents with `away` status. From 928583fde67c9c8749bd4e43f77541c5e389fbbf Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 13 Apr 2026 21:07:09 -0300 Subject: [PATCH 41/57] remove ddpLogin from livechat test --- .../meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts index 035b66f2bb9a7..2822a9c691321 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts @@ -1,6 +1,5 @@ import type { Page } from 'playwright-core'; -import { ddpLogin } from '../../data/users.helper'; import { createFakeVisitor } from '../../mocks/data'; import { createAuxContext } from '../fixtures/createAuxContext'; import { Users } from '../fixtures/userStates'; @@ -265,11 +264,9 @@ test.describe('OC - Livechat - Resume chat after closing', () => { test.describe('OC - Livechat - Close chat using widget', () => { let poLiveChat: OmnichannelLiveChat; let agent: Awaited>; - let ws: WebSocket; test.beforeAll(async ({ api }) => { agent = await createAgent(api, 'user1'); - ws = await ddpLogin(Users.user1.data.loginToken); }); test.beforeEach(async ({ page, api }) => { @@ -280,7 +277,6 @@ test.describe('OC - Livechat - Close chat using widget', () => { test.afterAll(async () => { await agent.delete(); - ws.close(); }); test('OC - Livechat - Close Chat', async () => { From a4845d943a03d90c016134915063ec914607a64c Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 14 Apr 2026 10:45:05 -0300 Subject: [PATCH 42/57] update e2e tests to not use ddpLogin --- .../omnichannel-assign-room-tags.spec.ts | 29 +++++++++------- .../omnichannel-custom-field-usage.spec.ts | 34 ++++++++----------- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts index 6e45a358f869e..97b089f882c46 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts @@ -1,4 +1,3 @@ -import { ddpLogin } from '../../data/users.helper'; import { createFakeVisitor } from '../../mocks/data'; import { IS_EE } from '../config/constants'; import { Users } from '../fixtures/userStates'; @@ -27,7 +26,6 @@ test.describe('OC - Tags Visibility', () => { let tagB: Awaited>; let globalTag: Awaited>; let sharedTag: Awaited>; - let ws: WebSocket; test.beforeAll('Create departments', async ({ api }) => { expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200); @@ -37,7 +35,6 @@ test.describe('OC - Tags Visibility', () => { test.beforeAll('Create agent', async ({ api }) => { agent = await createAgent(api, 'user1'); - ws = await ddpLogin(Users.user1.data.loginToken); }); test.beforeAll('Add agents to departments', async ({ api }) => { @@ -56,16 +53,25 @@ test.describe('OC - Tags Visibility', () => { }); }); - test.beforeAll('Create conversations', async ({ api }) => { - conversations = await Promise.all([ - createConversation(api, { visitorName: visitorA.name, agentId: 'user1', departmentId: departmentA.data._id }), - createConversation(api, { visitorName: visitorB.name, agentId: 'user1', departmentId: departmentB.data._id }), - ]); - }); - - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page, api }) => { poOmnichannel = new HomeOmnichannel(page); await page.goto('/'); + await poOmnichannel.waitForHome(); + + if (conversations.length === 0) { + conversations = await Promise.all([ + createConversation(api, { + visitorName: visitorA.name, + agentId: 'user1', + departmentId: departmentA.data._id, + }), + createConversation(api, { + visitorName: visitorB.name, + agentId: 'user1', + departmentId: departmentB.data._id, + }), + ]); + } }); test.afterAll(async ({ api }) => { @@ -74,7 +80,6 @@ test.describe('OC - Tags Visibility', () => { await agent.delete(); await departmentA.delete(); await departmentB.delete(); - ws.close(); expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).status()).toBe(200); }); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts index e059f54e4d1cd..2c537eae01aca 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts @@ -1,6 +1,5 @@ import { faker } from '@faker-js/faker'; -import { ddpLogin } from '../../data/users.helper'; import { createFakeVisitor } from '../../mocks/data'; import { Users } from '../fixtures/userStates'; import { HomeOmnichannel } from '../page-objects'; @@ -31,16 +30,11 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => let conversation: Awaited>; let roomCustomField: Awaited>; let visitorCustomField: Awaited>; - let ws: WebSocket; test.beforeAll('Set up agent, manager and custom fields', async ({ api }) => { - [agent, manager, ws] = await Promise.all([ - createAgent(api, 'user1'), - createManager(api, 'user1'), - ddpLogin(Users.user1.data.loginToken), - ]); + [agent, manager] = await Promise.all([createAgent(api, 'user1'), createManager(api, 'user1')]); - [roomCustomField, visitorCustomField, conversation] = await Promise.all([ + [roomCustomField, visitorCustomField] = await Promise.all([ createCustomField(api, { field: roomCustomFieldLabel, label: roomCustomFieldName, @@ -51,12 +45,19 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => label: visitorCustomFieldName, scope: 'visitor', }), - createConversation(api, { - visitorName: visitor.name, - agentId: 'user1', - visitorToken, - }), ]); + }); + + test.beforeEach(async ({ page, api }) => { + poHomeChannel = new HomeOmnichannel(page); + await page.goto('/'); + await poHomeChannel.waitForHome(); + + conversation = await createConversation(api, { + visitorName: visitor.name, + agentId: 'user1', + visitorToken, + }); await setVisitorCustomFieldValue(api, { token: visitorToken, @@ -65,15 +66,8 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => }); }); - test.beforeEach(async ({ page }) => { - poHomeChannel = new HomeOmnichannel(page); - await page.goto('/'); - await poHomeChannel.waitForHome(); - }); - test.afterAll('Remove agent, manager, custom fields and conversation', async () => { await Promise.all([agent.delete(), manager.delete(), roomCustomField.delete(), visitorCustomField.delete(), conversation.delete()]); - ws?.close(); }); test('Should be allowed to set room custom field for a conversation', async () => { From b047bc04bcb35c91758e1c7025d7b2bfed7ac930 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 14 Apr 2026 17:20:44 -0300 Subject: [PATCH 43/57] implement omnichannel room forward e2e tests --- .../omnichannel-rooms-forward.spec.ts | 258 ++++++++++++++++++ .../e2e/utils/omnichannel/departments.ts | 3 + 2 files changed, 261 insertions(+) create mode 100644 apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts new file mode 100644 index 0000000000000..0ea4c8bbf0490 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts @@ -0,0 +1,258 @@ +import type { Page } from '@playwright/test'; +import type { IRoom } from '@rocket.chat/core-typings'; + +import { sleep } from '../../../lib/utils/sleep'; +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent } from '../utils/omnichannel/agents'; +import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; +import { createManager } from '../utils/omnichannel/managers'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe('OC - Forwarding to away agents (EE)', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + + let poHomeOmnichannel: HomeOmnichannel; + let poLivechat: OmnichannelLiveChat; + + let livechatPage: Page; + let omnichannelPage: Page; + + let manager: Awaited>; + let onlineAgent: Awaited>; + let awayAgent: Awaited>; + let visitor: { name: string; email: string }; + + let initialDepartment: Awaited>; + let forwardToOfflineDepartment: Awaited>; + + test.beforeAll(async ({ api }) => { + [manager, onlineAgent, awayAgent, initialDepartment, forwardToOfflineDepartment] = await Promise.all([ + createManager(api, 'user1'), + createAgent(api, 'user1'), + createAgent(api, 'user2'), + createDepartment(api, { name: 'Initial Dept' }), + createDepartment(api, { name: 'Forward Dept', allowReceiveForwardOffline: true }), + setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300), + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200), + ]); + + await Promise.all([ + addAgentToDepartment(api, { department: initialDepartment.data, agentId: 'user1' }), + addAgentToDepartment(api, { department: forwardToOfflineDepartment.data, agentId: 'user2' }), + ]); + }); + + test.beforeEach(async ({ page, browser, api }) => { + visitor = createFakeVisitor(); + poHomeOmnichannel = new HomeOmnichannel(page); + + ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); + poLivechat = new OmnichannelLiveChat(livechatPage, api); + + await page.goto('/'); + await page.locator('#main-content').waitFor(); + }); + + test.afterEach(async ({ api }) => { + if (livechatPage) await livechatPage.context().close(); + if (omnichannelPage) await omnichannelPage.context().close(); + + await setSettingValueById(api, 'Livechat_waiting_queue', false); + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + initialDepartment.delete(), + forwardToOfflineDepartment.delete(), + manager.delete(), + onlineAgent.delete(), + awayAgent.delete(), + setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'), + setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false), + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).status()).toBe(200), + ]); + }); + + test('when manager forward to offline (agent away, accept when agent idle off) department the inquiry should be set to the queue', async ({ + api, + browser, + }) => { + await test.step('Setup routing settings', async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', 'Manual_Selection'); + await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false); + }); + + await test.step('Visitor initiates chat', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('test'); + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Set user2 away by idle timeout', async () => { + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); + await sleep(2000); // we give the agent time to go statusConnection: away + }); + + await test.step('Manager forwards chat', async () => { + await poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannel.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannel.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannel.content.forwardChatModal.btnForward).not.toBeVisible(); + }); + + await test.step('Check inquiry status via API is queued', async () => { + const roomInfoResp = await api.get(`/livechat/rooms`); + const roomBody = await roomInfoResp.json(); + const roomId = roomBody.rooms.find((room: IRoom) => room.fname === visitor.name)._id; + + const inquiryResp = await api.get(`/livechat/inquiries.getOne?roomId=${roomId}`); + const inquiryBody = await inquiryResp.json(); + + expect(inquiryBody.inquiry.status).toBe('queued'); + expect(inquiryBody.inquiry.department).toBe(forwardToOfflineDepartment.data._id); + }); + }); + + test('when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is true, chat should end in departments queue', async ({ + api, + browser, + }) => { + await test.step('Setup routing settings', async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'); + }); + + await test.step('Visitor initiates chat', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('test'); + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Set user2 away by idle timeout', async () => { + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); + await sleep(2000); // we give the agent time to go statusConnection: away + }); + + await test.step('Manager enables queue and forwards chat', async () => { + await poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); + await setSettingValueById(api, 'Livechat_waiting_queue', true); + + await poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannel.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannel.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannel.content.forwardChatModal.btnForward).not.toBeVisible(); + }); + + await test.step('Check inquiry status via API is queued', async () => { + const roomInfoResp = await api.get(`/livechat/rooms`); + const roomBody = await roomInfoResp.json(); + const roomId = roomBody.rooms.find((room: IRoom) => room.fname === visitor.name)._id; + + const inquiryResp = await api.get(`/livechat/inquiries.getOne?roomId=${roomId}`); + const inquiryBody = await inquiryResp.json(); + + expect(inquiryBody.inquiry.status).toBe('queued'); + expect(inquiryBody.inquiry.department).toBe(forwardToOfflineDepartment.data._id); + }); + }); + + test('when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is false, transfer should fail', async ({ + page, + api, + browser, + }) => { + await test.step('Setup routing and department settings', async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'); + await api.put(`/livechat/department/${forwardToOfflineDepartment.data._id}`, { + department: { ...forwardToOfflineDepartment.data, allowReceiveForwardOffline: false }, + }); + }); + + await test.step('Visitor initiates chat', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('test'); + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Set user2 away by idle timeout', async () => { + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); + await sleep(2000); // we give the agent time to go statusConnection: away + }); + + await test.step('Manager attempts to forward and sees error', async () => { + await poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); + await setSettingValueById(api, 'Livechat_waiting_queue', true); + + await poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannel.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannel.content.forwardChatModal.btnForward.click(); + + await expect(page.locator('.rcx-toastbar')).toContainText('No agents are available for service on this department.'); + }); + + await test.step('Restore department setting', async () => { + await api.put(`/livechat/department/${forwardToOfflineDepartment.data._id}`, { + department: { ...forwardToOfflineDepartment.data, allowReceiveForwardOffline: true }, + }); + }); + }); + + test('when manager forward to online (agent away, accept when agent idle on) department the inquiry should not be set to the queue', async ({ + api, + browser, + }) => { + await test.step('Setup routing settings', async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'); + await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', true); + }); + + await test.step('Visitor initiates chat', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('test'); + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Set user2 away by idle timeout', async () => { + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); + await sleep(2000); // we give the agent time to go statusConnection: away + }); + + await test.step('Manager forwards chat successfully', async () => { + await poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannel.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannel.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannel.content.forwardChatModal.btnForward).not.toBeVisible(); + }); + + await test.step('Check room routing via API serves to agent 2', async () => { + const roomInfoResp = await api.get(`/livechat/rooms`); + const roomBody = await roomInfoResp.json(); + const room = roomBody.rooms.find((room: IRoom) => room.fname === visitor.name); + + expect(room.servedBy._id).toBe(awayAgent.data._id); + expect(room.departmentId).toBe(forwardToOfflineDepartment.data._id); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/utils/omnichannel/departments.ts b/apps/meteor/tests/e2e/utils/omnichannel/departments.ts index fb7d8cd14ec32..e268ff8a52ae9 100644 --- a/apps/meteor/tests/e2e/utils/omnichannel/departments.ts +++ b/apps/meteor/tests/e2e/utils/omnichannel/departments.ts @@ -18,6 +18,7 @@ type CreateDepartmentParams = { departmentsAllowedToForward?: string[]; fallbackForwardDepartment?: string; maxNumberSimultaneousChat?: number; + allowReceiveForwardOffline?: boolean; }; export const createDepartment = async ( @@ -37,6 +38,7 @@ export const createDepartment = async ( departmentsAllowedToForward = [], fallbackForwardDepartment = '', maxNumberSimultaneousChat, + allowReceiveForwardOffline, }: CreateDepartmentParams = {}, ) => { const response = await api.post('/livechat/department', { @@ -55,6 +57,7 @@ export const createDepartment = async ( departmentsAllowedToForward, fallbackForwardDepartment, maxNumberSimultaneousChat, + allowReceiveForwardOffline, }, }); From b35f583b35b69d6c9d9452ad453b27b4fba4aa24 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 14 Apr 2026 18:19:05 -0300 Subject: [PATCH 44/57] remove converted api tests and ws logic from 00-rooms.ts --- .../tests/end-to-end/api/livechat/00-rooms.ts | 184 ------------------ 1 file changed, 184 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index 3f1dfc5f51c1e..848e47c5546f7 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -25,7 +25,6 @@ import { createCustomField, deleteCustomField } from '../../../data/livechat/cus import type { OnlineAgent } from '../../../data/livechat/department'; import { createDepartmentWith2OnlineAgents, - createDepartmentWithAnAwayAgent, createDepartmentWithAnOfflineAgent, createDepartmentWithAnOnlineAgent, deleteDepartment, @@ -84,7 +83,6 @@ describe('LIVECHAT - rooms', () => { let visitor: ILivechatVisitor; let room: IOmnichannelRoom; let appId: string; - const sockets: WebSocket[] = []; before((done) => getCredentials(done)); @@ -130,11 +128,6 @@ describe('LIVECHAT - rooms', () => { .set(credentials) .expect(200); } - for (const ws of sockets) { - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { - ws.close(); - } - } }); describe('livechat/room', () => { @@ -1547,58 +1540,6 @@ describe('LIVECHAT - rooms', () => { }, ); - (IS_EE ? it : it.skip)( - 'when manager forward to offline (agent away, accept when agent idle off) department the inquiry should be set to the queue', - async () => { - await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); - await updateSetting('Livechat_enabled_when_agent_idle', false); - const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, ws: awayAgentWebSocket } = await createDepartmentWithAnAwayAgent({ - allowReceiveForwardOffline: true, - }); - sockets.push(awayAgentWebSocket); - - const newVisitor = await createVisitor(initialDepartment._id); - const newRoom = await createLivechatRoom(newVisitor.token); - - const manager = await createUser(); - const managerCredentials = await login(manager.username, password); - await createManager(manager.username); - - await request.post(api('livechat/room.forward')).set(managerCredentials).send({ - roomId: newRoom._id, - departmentId: forwardToOfflineDepartment._id, - clientAction: true, - comment: 'test comment', - }); - - await request - .get(api(`livechat/queue`)) - .set(credentials) - .query({ - count: 1, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res: Response) => { - expect(res.body).to.have.property('success', true); - expect(res.body.queue).to.be.an('array'); - expect(res.body.queue[0].chats).not.to.undefined; - expect(res.body).to.have.property('offset'); - expect(res.body).to.have.property('total'); - expect(res.body).to.have.property('count'); - }); - - await Promise.all([ - deleteDepartment(initialDepartment._id), - deleteDepartment(forwardToOfflineDepartment._id), - closeOmnichannelRoom(newRoom._id), - deleteVisitor(newVisitor.token), - deleteUser(manager), - ]); - }, - ); - (IS_EE ? it : it.skip)( 'when manager forwards a chat that hasnt been assigned to a user to another department with no online agents, chat should end ready in department (not queued)', async () => { @@ -1677,88 +1618,6 @@ describe('LIVECHAT - rooms', () => { }, ); - (IS_EE ? it : it.skip)( - 'when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is true, chat should end in departments queue', - async () => { - await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); - const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, ws: awayAgentWebSocket } = await createDepartmentWithAnAwayAgent({ - allowReceiveForwardOffline: true, - }); - sockets.push(awayAgentWebSocket); - - const newVisitor = await createVisitor(initialDepartment._id); - const newRoom = await createLivechatRoom(newVisitor.token); - - const manager = await createUser(); - const managerCredentials = await login(manager.username, password); - await createManager(manager.username); - - // Waiting queue enabled after assignement but before transfer, otherwise, chat will fall on previous test case - await updateSetting('Livechat_waiting_queue', true); - await request.post(api('livechat/room.forward')).set(managerCredentials).send({ - roomId: newRoom._id, - departmentId: forwardToOfflineDepartment._id, - clientAction: true, - comment: 'test comment', - }); - - const inquiry = await fetchInquiry(newRoom._id); - - expect(inquiry.status).to.equal('queued'); - expect(inquiry.department).to.equal(forwardToOfflineDepartment._id); - - await Promise.all([ - deleteDepartment(initialDepartment._id), - deleteDepartment(forwardToOfflineDepartment._id), - closeOmnichannelRoom(newRoom._id), - deleteVisitor(newVisitor.token), - deleteUser(manager), - updateSetting('Livechat_waiting_queue', false), - ]); - }, - ); - - (IS_EE ? it : it.skip)( - 'when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is false, transfer should fail', - async () => { - await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); - const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, ws: awayAgentWebSocket } = await createDepartmentWithAnAwayAgent({ - allowReceiveForwardOffline: false, - }); - sockets.push(awayAgentWebSocket); - - const newVisitor = await createVisitor(initialDepartment._id); - const newRoom = await createLivechatRoom(newVisitor.token); - - const manager = await createUser(); - const managerCredentials = await login(manager.username, password); - await createManager(manager.username); - - // Waiting queue enabled after assignement but before transfer, otherwise, chat will fall on previous test case - await updateSetting('Livechat_waiting_queue', true); - const res = await request.post(api('livechat/room.forward')).set(managerCredentials).send({ - roomId: newRoom._id, - departmentId: forwardToOfflineDepartment._id, - clientAction: true, - comment: 'test comment', - }); - - expect(res.status).to.equal(400); - expect(res.body).to.have.property('error', 'error-no-agents-available-for-service-on-department'); - - await Promise.all([ - deleteDepartment(initialDepartment._id), - deleteDepartment(forwardToOfflineDepartment._id), - updateSetting('Livechat_waiting_queue', false), - closeOmnichannelRoom(newRoom._id), - deleteVisitor(newVisitor.token), - deleteUser(manager), - ]); - }, - ); - (IS_EE ? it : it.skip)( 'when manager forward to a department while waiting_queue is disabled and allowReceiveForwardOffline is false, but department is online, transfer should succeed', async () => { @@ -1798,49 +1657,6 @@ describe('LIVECHAT - rooms', () => { }, ); - (IS_EE ? it : it.skip)( - 'when manager forward to online (agent away, accept when agent idle on) department the inquiry should not be set to the queue', - async () => { - await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); - await updateSetting('Livechat_enabled_when_agent_idle', true); - const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { - department: forwardToOfflineDepartment, - agent, - ws: awayAgentWebSocket, - } = await createDepartmentWithAnAwayAgent({ - allowReceiveForwardOffline: true, - }); - sockets.push(awayAgentWebSocket); - const newVisitor = await createVisitor(initialDepartment._id); - const newRoom = await createLivechatRoom(newVisitor.token); - - const manager = await createUser(); - const managerCredentials = await login(manager.username, password); - await createManager(manager.username); - - await request.post(api('livechat/room.forward')).set(managerCredentials).send({ - roomId: newRoom._id, - departmentId: forwardToOfflineDepartment._id, - clientAction: true, - comment: 'test comment', - }); - - const roomInfo = await getLivechatRoomInfo(newRoom._id); - expect(roomInfo.servedBy).to.have.property('_id', agent.user._id); - expect(roomInfo.departmentId).to.be.equal(forwardToOfflineDepartment._id); - - await Promise.all([ - deleteDepartment(initialDepartment._id), - deleteDepartment(forwardToOfflineDepartment._id), - closeOmnichannelRoom(newRoom._id), - deleteVisitor(newVisitor.token), - deleteUser(manager), - updateSetting('Livechat_enabled_when_agent_idle', false), - ]); - }, - ); - (IS_EE ? it : it.skip)( 'when manager forward to a department while waiting_queue is enabled, but department is online, transfer should succeed but it should end queued on target', async () => { From 99144ed7434d7d3e5b1be827bcc34fd065b045c7 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 14 Apr 2026 20:51:44 -0300 Subject: [PATCH 45/57] add e2e test for livechat_enabled_when_agent_idle setting --- ...hannel-livechat-agent-idle-setting.spec.ts | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts new file mode 100644 index 0000000000000..3bdb6e171a5f6 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts @@ -0,0 +1,125 @@ +import type { Page } from '@playwright/test'; + +import { sleep } from '../../../lib/utils/sleep'; +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent } from '../utils/omnichannel/agents'; +import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe.only('OC - Routing to Idle Agents', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + + let poLivechat: OmnichannelLiveChat; + let livechatPage: Page; + + let agent: Awaited>; + + let visitor: { name: string; email: string }; + + let testDepartment: Awaited>; + + const routingMethods = ['Auto_Selection', 'Load_Balancing', 'Load_Rotation']; + + test.beforeAll(async ({ api }) => { + [agent, testDepartment] = await Promise.all([ + createAgent(api, 'user1'), + createDepartment(api, { name: 'Idle Routing Dept' }), + setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300), + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200), + ]); + + await Promise.all([addAgentToDepartment(api, { department: testDepartment.data, agentId: 'user1' })]); + }); + + test.beforeEach(async ({ page, browser, api }) => { + visitor = createFakeVisitor(); + + ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); + poLivechat = new OmnichannelLiveChat(livechatPage, api); + + await page.goto('/'); + await page.locator('#main-content').waitFor(); + }); + + test.afterEach(async ({ api }) => { + if (livechatPage) await livechatPage.context().close(); + + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + testDepartment.delete(), + agent.delete(), + setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'), + setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false), + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).status()).toBe(200), + ]); + }); + + routingMethods.forEach((routingMethod) => { + test.describe(`Routing method: ${routingMethod}`, () => { + test(`should not route to idle agents`, async ({ api, page }) => { + await test.step(`Setup routing method to ${routingMethod} and ignore idle agents`, async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', routingMethod); + await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false); + }); + + await test.step('Visitor tries to initiate a conversation with the away agent', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('Hello from visitor'); + + // Force Agent to become away by idle timeout + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + await page.reload(); + await page.locator('#main-content').waitFor(); + + await sleep(2000); // we give the agent time to go statusConnection: away + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Verify visitor is not taken by agent', async () => { + await expect( + poLivechat.alertMessage('Error starting a new conversation: Sorry, no online agents [no-agent-online]'), + ).toBeVisible(); + }); + }); + + test(`should route to agents even if they are idle when setting is enabled`, async ({ api, page }) => { + await test.step(`Setup routing method to ${routingMethod} and allow idle agents`, async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', routingMethod); + await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', true); + }); + + await test.step('Force agent to become away by idle timeout', async () => { + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + await page.reload(); + await page.locator('#main-content').waitFor(); + + await sleep(2000); // we give the agent time to go statusConnection: away + }); + + await test.step('Visitor initiates chat', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('test message'); + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Verify chat is served to an agent', async () => { + await expect(poLivechat.headerTitle).toHaveText(agent.data.username); + }); + }); + }); + }); +}); From 17ffb0802ec09df5c887843584b67f0c2a3b078d Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 14 Apr 2026 20:55:26 -0300 Subject: [PATCH 46/57] remove converted tests from 24-routing.ts --- .../end-to-end/api/livechat/24-routing.ts | 115 +----------------- 1 file changed, 1 insertion(+), 114 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index c4621dc38174a..3be747694cd8d 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -1,6 +1,5 @@ import { faker } from '@faker-js/faker'; import type { Credentials } from '@rocket.chat/api-client'; -import { UserStatus } from '@rocket.chat/core-typings'; import type { ILivechatDepartment, IUser } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { after, before, describe, it } from 'mocha'; @@ -14,24 +13,17 @@ import { createLivechatRoom, getLivechatRoomInfo, makeAgentUnavailable, - switchLivechatStatus, } from '../../../data/livechat/rooms'; import { updateSetting } from '../../../data/permissions.helper'; import { password } from '../../../data/user'; -import { createUser, deleteUser, login, ddpLogin, setUserAwayWS, setUserActiveStatus, setUserStatus } from '../../../data/users.helper'; +import { createUser, deleteUser, login, setUserActiveStatus } from '../../../data/users.helper'; import { IS_EE } from '../../../e2e/config/constants'; (IS_EE ? describe : describe.skip)('Omnichannel - Routing', () => { - const sockets: WebSocket[] = []; before((done) => getCredentials(done)); after(async () => { await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); - for (const ws of sockets) { - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { - ws.close(); - } - } }); // Basically: if there's a bot in the department, it should be assigned to the conversation @@ -353,35 +345,6 @@ import { IS_EE } from '../../../e2e/config/constants'; const roomInfo = await getLivechatRoomInfo(room._id); expect(roomInfo.servedBy).to.be.undefined; }); - it('should not route to an idle user', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', false); - await setUserStatus(testUser.credentials, UserStatus.AWAY); - const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(firstAwayUserWebSocket); - await setUserAwayWS(firstAwayUserWebSocket); - await setUserStatus(testUser3.credentials, UserStatus.AWAY); - const secondAwayUserWebSocket = await ddpLogin(testUser3.credentials['X-Auth-Token']); - sockets.push(secondAwayUserWebSocket); - await setUserAwayWS(secondAwayUserWebSocket); - - // Agent is available but should be ignored - await switchLivechatStatus('available', testUser.credentials); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.undefined; - }); - it('should route to an idle user', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', true); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.an('object'); - }); it('should route to another available agent if contact manager is unavailable and Omnichannel_contact_manager_routing is enabled', async () => { await makeAgentAvailable(testUser.credentials); const visitor = await createVisitor(testDepartment._id, faker.person.fullName(), visitorEmail); @@ -511,44 +474,6 @@ import { IS_EE } from '../../../e2e/config/constants'; expect(roomInfo.servedBy).to.be.an('object'); expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); }); - it('should not route to an idle user', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', false); - await setUserStatus(testUser.credentials, UserStatus.AWAY); - const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(firstAwayUserWebSocket); - await setUserAwayWS(firstAwayUserWebSocket); - await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const secondAwayUserWebSocket = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(secondAwayUserWebSocket); - await setUserAwayWS(secondAwayUserWebSocket); - - // Agent is available but should be ignored - await switchLivechatStatus('available', testUser.credentials); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.undefined; - }); - it('should route to agents even if theyre idle when setting is enabled', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', true); - await setUserStatus(testUser.credentials, UserStatus.AWAY); - const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(firstAwayUserWebSocket); - await setUserAwayWS(firstAwayUserWebSocket); - await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const secondAwayUserWebSocket = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(secondAwayUserWebSocket); - await setUserAwayWS(secondAwayUserWebSocket); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - // Not checking who, just checking it's served - expect(roomInfo.servedBy).to.be.an('object'); - }); }); describe('Load Rotation', () => { before(async () => { @@ -630,43 +555,5 @@ import { IS_EE } from '../../../e2e/config/constants'; expect(roomInfo.servedBy).to.be.an('object'); expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); }); - it('should not route to an idle user', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', false); - await setUserStatus(testUser.credentials, UserStatus.AWAY); - const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(firstAwayUserWebSocket); - await setUserAwayWS(firstAwayUserWebSocket); - await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const secondAwayUserWebSocket = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(secondAwayUserWebSocket); - await setUserAwayWS(secondAwayUserWebSocket); - - // Agent is available but should be ignored - await switchLivechatStatus('available', testUser.credentials); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.undefined; - }); - it('should route to agents even if theyre idle when setting is enabled', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', true); - await setUserStatus(testUser.credentials, UserStatus.AWAY); - const firstAwayUserWebSocket = await ddpLogin(testUser.credentials['X-Auth-Token']); - sockets.push(firstAwayUserWebSocket); - await setUserAwayWS(firstAwayUserWebSocket); - await setUserStatus(testUser2.credentials, UserStatus.AWAY); - const secondAwayUserWebSocket = await ddpLogin(testUser2.credentials['X-Auth-Token']); - sockets.push(secondAwayUserWebSocket); - await setUserAwayWS(secondAwayUserWebSocket); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - // Not checking who, just checking it's served - expect(roomInfo.servedBy).to.be.an('object'); - }); }); }); From 8dae157e627cd1241c9b886202db98a5f958ef35 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 14 Apr 2026 22:14:12 -0300 Subject: [PATCH 47/57] rollback ws logic --- apps/meteor/tests/data/livechat/department.ts | 4 +- apps/meteor/tests/data/livechat/users.ts | 7 +- apps/meteor/tests/data/users.helper.ts | 86 ------------------- 3 files changed, 3 insertions(+), 94 deletions(-) diff --git a/apps/meteor/tests/data/livechat/department.ts b/apps/meteor/tests/data/livechat/department.ts index f8b2ed5298a0e..2064bb8b03b4b 100644 --- a/apps/meteor/tests/data/livechat/department.ts +++ b/apps/meteor/tests/data/livechat/department.ts @@ -175,9 +175,8 @@ export const createDepartmentWithAnAwayAgent = async ({ credentials: Credentials; user: WithRequiredProperty; }; - ws: WebSocket; }> => { - const { user, credentials, ws } = await createAnAwayAgent(); + const { user, credentials } = await createAnAwayAgent(); const department = (await createDepartment({ allowReceiveForwardOffline, @@ -193,7 +192,6 @@ export const createDepartmentWithAnAwayAgent = async ({ credentials, user, }, - ws, }; }; diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts index 2afc321e8f3a7..8e7b4213aadab 100644 --- a/apps/meteor/tests/data/livechat/users.ts +++ b/apps/meteor/tests/data/livechat/users.ts @@ -5,7 +5,7 @@ import { Random } from '@rocket.chat/random'; import { api, credentials, request } from '../api-data'; import { password } from '../user'; -import { createUser, login, setUserAwayWS, ddpLogin, setUserStatus } from '../users.helper'; +import { createUser, login, setUserAway, setUserStatus } from '../users.helper'; import { createAgent, makeAgentAvailable, makeAgentUnavailable } from './rooms'; export const createBotAgent = async (): Promise<{ @@ -93,23 +93,20 @@ export const createAnOfflineAgent = async (): Promise<{ export const createAnAwayAgent = async (): Promise<{ credentials: Credentials; user: IUser & { username: string }; - ws: WebSocket; }> => { const username = `user.test.${Date.now()}.away`; const email = `${username}.offline@rocket.chat`; const { body } = await request.post(api('users.create')).set(credentials).send({ email, name: username, username, password }); const agent = body.user; const createdUserCredentials = await login(agent.username, password); - const ws = await ddpLogin(createdUserCredentials['X-Auth-Token']); await createAgent(agent.username); await makeAgentAvailable(createdUserCredentials); await setUserStatus(createdUserCredentials, UserStatus.AWAY); - await setUserAwayWS(ws); + await setUserAway(createdUserCredentials); return { credentials: createdUserCredentials, user: agent, - ws, }; }; diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index 9a02663b5bb4c..b8835cc5ef79b 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -172,92 +172,6 @@ export const setUserAway = (overrideCredentials = credentials, config?: IRequest }); }; -const connectWS = (port: string): Promise => - new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://localhost:${port}/websocket`); - - ws.onopen = () => { - ws.addEventListener('message', (event: MessageEvent) => { - const data = JSON.parse(event.data); - if (data.msg === 'ping') { - ws.send(JSON.stringify({ msg: 'pong' })); - } - }); - resolve(ws); - }; - ws.onerror = () => reject(new Error(`WS connection failed on ${port}`)); - }); - -const waitForDDP = (ws: WebSocket, id: string | 'handshake', stringifiedJsonPayload: string): Promise => { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - cleanup(); - ws.close(); - reject(new Error(`Timeout waiting for DDP id: ${id}`)); - }, 5000); - - const cleanup = () => { - clearTimeout(timeout); - ws.removeEventListener('message', handler); - ws.removeEventListener('close', onClose); - ws.removeEventListener('error', onError); - }; - - const onClose = () => { - cleanup(); - reject(new Error(`WS closed while waiting for id: ${id}`)); - }; - - const onError = (error: any) => { - cleanup(); - ws.close(); - reject(error || new Error(`WS error during operation id: ${id}`)); - }; - - const handler = (event: MessageEvent) => { - try { - const data = JSON.parse(event.data); - const isHandshake = id === 'handshake' && data.msg === 'connected'; - const isResult = data.id === id && (data.msg === 'result' || data.msg === 'error'); - - if (isHandshake || isResult) { - cleanup(); - if (data.error) { - ws.close(); - return reject(data.error); - } - resolve(data); - } - } catch (e) { - // Ignore no JSON message - } - }; - - ws.addEventListener('message', handler); - ws.addEventListener('close', onClose); - ws.addEventListener('error', onError); - - ws.send(stringifiedJsonPayload); - }); -}; - -export const ddpLogin = async (resume: string): Promise => { - const ws = await connectWS(process.env.DDP_LOGIN_PORT || '3000'); - const loginId = `login-${Date.now()}`; - - await waitForDDP(ws, 'handshake', JSON.stringify({ msg: 'connect', version: '1', support: ['1'] })); - - await waitForDDP(ws, loginId, JSON.stringify({ msg: 'method', id: loginId, method: 'login', params: [{ resume }] })); - - return ws; -}; - -export const setUserAwayWS = async (ws: WebSocket): Promise => { - const id = `away-${Date.now()}`; - - await waitForDDP(ws, id, JSON.stringify({ msg: 'method', method: 'UserPresence:away', params: [], id })); -}; - export const setUserOnline = (overrideCredentials = credentials, config?: IRequestConfig) => { const requestInstance = config?.request || request; return requestInstance From 9303454e0e836150d6a84e992e4c8fa1ac1c77cc Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 14 Apr 2026 22:15:48 -0300 Subject: [PATCH 48/57] remove ci-test-e2e.yml added env variable --- .github/workflows/ci-test-e2e.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index f63f33909c0d0..99395082a9fbc 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -65,7 +65,6 @@ env: TOOL_NODE_FLAGS: ${{ vars.TOOL_NODE_FLAGS }} LOWERCASE_REPOSITORY: ${{ inputs.lowercase-repo }} DOCKER_TAG: ${{ inputs.gh-docker-tag }}-amd64 - DDP_LOGIN_PORT: 3000 jobs: test: From 489e27f5cd084197055d7d1162404632a5448864 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 14 Apr 2026 22:27:37 -0300 Subject: [PATCH 49/57] remove .only --- .../omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts index 3bdb6e171a5f6..daa049587602d 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts @@ -13,7 +13,7 @@ import { test, expect } from '../utils/test'; test.use({ storageState: Users.user1.state }); -test.describe.only('OC - Routing to Idle Agents', () => { +test.describe('OC - Routing to Idle Agents', () => { test.skip(!IS_EE, 'Enterprise Edition Only'); let poLivechat: OmnichannelLiveChat; From 484e3c2756270cc945ef0bcb88dae7bdf2b22cf8 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 15 Apr 2026 10:40:00 -0300 Subject: [PATCH 50/57] fix livechat test by adding agent browser context --- .../meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts index 2822a9c691321..336a0cbc355aa 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts @@ -265,8 +265,10 @@ test.describe('OC - Livechat - Close chat using widget', () => { let poLiveChat: OmnichannelLiveChat; let agent: Awaited>; - test.beforeAll(async ({ api }) => { + test.beforeAll(async ({ api, browser }) => { agent = await createAgent(api, 'user1'); + + await createAuxContext(browser, Users.user1, '/', true); }); test.beforeEach(async ({ page, api }) => { From 5aec5db5c52bd33dd9ec703fdab4f7ba749ea864 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 15 Apr 2026 11:53:37 -0300 Subject: [PATCH 51/57] improve tests by using element waiter instead of hardcoded sleep and tweak minor things --- ...hannel-livechat-agent-idle-setting.spec.ts | 29 +++---- .../omnichannel-rooms-forward.spec.ts | 80 ++++++++++--------- .../e2e/page-objects/fragments/navbar.ts | 4 + 3 files changed, 58 insertions(+), 55 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts index daa049587602d..4a1569380b5f6 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts @@ -1,10 +1,10 @@ import type { Page } from '@playwright/test'; -import { sleep } from '../../../lib/utils/sleep'; import { createFakeVisitor } from '../../mocks/data'; import { IS_EE } from '../config/constants'; import { createAuxContext } from '../fixtures/createAuxContext'; import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; import { OmnichannelLiveChat } from '../page-objects/omnichannel'; import { setSettingValueById } from '../utils'; import { createAgent } from '../utils/omnichannel/agents'; @@ -16,14 +16,14 @@ test.use({ storageState: Users.user1.state }); test.describe('OC - Routing to Idle Agents', () => { test.skip(!IS_EE, 'Enterprise Edition Only'); + let poHomeOmnichannel: HomeOmnichannel; let poLivechat: OmnichannelLiveChat; + let livechatPage: Page; let agent: Awaited>; - - let visitor: { name: string; email: string }; - let testDepartment: Awaited>; + let visitor: { name: string; email: string }; const routingMethods = ['Auto_Selection', 'Load_Balancing', 'Load_Rotation']; @@ -40,12 +40,12 @@ test.describe('OC - Routing to Idle Agents', () => { test.beforeEach(async ({ page, browser, api }) => { visitor = createFakeVisitor(); + poHomeOmnichannel = new HomeOmnichannel(page); + await page.goto('/'); + await page.locator('#main-content').waitFor(); ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); poLivechat = new OmnichannelLiveChat(livechatPage, api); - - await page.goto('/'); - await page.locator('#main-content').waitFor(); }); test.afterEach(async ({ api }) => { @@ -66,7 +66,7 @@ test.describe('OC - Routing to Idle Agents', () => { routingMethods.forEach((routingMethod) => { test.describe(`Routing method: ${routingMethod}`, () => { - test(`should not route to idle agents`, async ({ api, page }) => { + test(`should not route to idle agents`, async ({ api }) => { await test.step(`Setup routing method to ${routingMethod} and ignore idle agents`, async () => { await setSettingValueById(api, 'Livechat_Routing_Method', routingMethod); await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false); @@ -80,10 +80,9 @@ test.describe('OC - Routing to Idle Agents', () => { // Force Agent to become away by idle timeout await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); - await page.reload(); - await page.locator('#main-content').waitFor(); + await poHomeOmnichannel.page.reload(); + await expect(poHomeOmnichannel.navbar.getUserStatusBadge('away')).toBeVisible(); - await sleep(2000); // we give the agent time to go statusConnection: away await poLivechat.btnSendMessageToOnlineAgent.click(); }); @@ -94,7 +93,7 @@ test.describe('OC - Routing to Idle Agents', () => { }); }); - test(`should route to agents even if they are idle when setting is enabled`, async ({ api, page }) => { + test(`should route to agents even if they are idle when setting is enabled`, async ({ api }) => { await test.step(`Setup routing method to ${routingMethod} and allow idle agents`, async () => { await setSettingValueById(api, 'Livechat_Routing_Method', routingMethod); await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', true); @@ -102,10 +101,8 @@ test.describe('OC - Routing to Idle Agents', () => { await test.step('Force agent to become away by idle timeout', async () => { await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); - await page.reload(); - await page.locator('#main-content').waitFor(); - - await sleep(2000); // we give the agent time to go statusConnection: away + await poHomeOmnichannel.page.reload(); + await expect(poHomeOmnichannel.navbar.getUserStatusBadge('away')).toBeVisible(); }); await test.step('Visitor initiates chat', async () => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts index 0ea4c8bbf0490..60424cafc96a8 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts @@ -1,7 +1,6 @@ import type { Page } from '@playwright/test'; import type { IRoom } from '@rocket.chat/core-typings'; -import { sleep } from '../../../lib/utils/sleep'; import { createFakeVisitor } from '../../mocks/data'; import { IS_EE } from '../config/constants'; import { createAuxContext } from '../fixtures/createAuxContext'; @@ -19,7 +18,8 @@ test.use({ storageState: Users.user1.state }); test.describe('OC - Forwarding to away agents (EE)', () => { test.skip(!IS_EE, 'Enterprise Edition Only'); - let poHomeOmnichannel: HomeOmnichannel; + let poHomeOmnichannelOnlineAgent: HomeOmnichannel; + let poHomeOmnichannelAwayAgent: HomeOmnichannel; let poLivechat: OmnichannelLiveChat; let livechatPage: Page; @@ -28,10 +28,9 @@ test.describe('OC - Forwarding to away agents (EE)', () => { let manager: Awaited>; let onlineAgent: Awaited>; let awayAgent: Awaited>; - let visitor: { name: string; email: string }; - let initialDepartment: Awaited>; let forwardToOfflineDepartment: Awaited>; + let visitor: { name: string; email: string }; test.beforeAll(async ({ api }) => { [manager, onlineAgent, awayAgent, initialDepartment, forwardToOfflineDepartment] = await Promise.all([ @@ -52,13 +51,12 @@ test.describe('OC - Forwarding to away agents (EE)', () => { test.beforeEach(async ({ page, browser, api }) => { visitor = createFakeVisitor(); - poHomeOmnichannel = new HomeOmnichannel(page); + poHomeOmnichannelOnlineAgent = new HomeOmnichannel(page); + await page.goto('/'); + await page.locator('#main-content').waitFor(); ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); poLivechat = new OmnichannelLiveChat(livechatPage, api); - - await page.goto('/'); - await page.locator('#main-content').waitFor(); }); test.afterEach(async ({ api }) => { @@ -99,18 +97,19 @@ test.describe('OC - Forwarding to away agents (EE)', () => { await poLivechat.btnSendMessageToOnlineAgent.click(); }); - await test.step('Set user2 away by idle timeout', async () => { + await test.step('Set user2 agent away by idle timeout', async () => { await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); - await sleep(2000); // we give the agent time to go statusConnection: away + poHomeOmnichannelAwayAgent = new HomeOmnichannel(omnichannelPage); + await expect(poHomeOmnichannelAwayAgent.navbar.getUserStatusBadge('away')).toBeVisible(); }); await test.step('Manager forwards chat', async () => { - await poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); - await poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); - await poHomeOmnichannel.content.forwardChatModal.selectDepartment('Forward Dept'); - await poHomeOmnichannel.content.forwardChatModal.btnForward.click(); - await expect(poHomeOmnichannel.content.forwardChatModal.btnForward).not.toBeVisible(); + await poHomeOmnichannelOnlineAgent.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeOmnichannelOnlineAgent.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward).not.toBeVisible(); }); await test.step('Check inquiry status via API is queued', async () => { @@ -142,20 +141,21 @@ test.describe('OC - Forwarding to away agents (EE)', () => { await poLivechat.btnSendMessageToOnlineAgent.click(); }); - await test.step('Set user2 away by idle timeout', async () => { + await test.step('Set user2 agent away by idle timeout', async () => { await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); - await sleep(2000); // we give the agent time to go statusConnection: away + poHomeOmnichannelAwayAgent = new HomeOmnichannel(omnichannelPage); + await expect(poHomeOmnichannelAwayAgent.navbar.getUserStatusBadge('away')).toBeVisible(); }); await test.step('Manager enables queue and forwards chat', async () => { - await poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeOmnichannelOnlineAgent.sidebar.getSidebarItemByName(visitor.name).click(); await setSettingValueById(api, 'Livechat_waiting_queue', true); - await poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); - await poHomeOmnichannel.content.forwardChatModal.selectDepartment('Forward Dept'); - await poHomeOmnichannel.content.forwardChatModal.btnForward.click(); - await expect(poHomeOmnichannel.content.forwardChatModal.btnForward).not.toBeVisible(); + await poHomeOmnichannelOnlineAgent.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward).not.toBeVisible(); }); await test.step('Check inquiry status via API is queued', async () => { @@ -172,7 +172,6 @@ test.describe('OC - Forwarding to away agents (EE)', () => { }); test('when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is false, transfer should fail', async ({ - page, api, browser, }) => { @@ -191,21 +190,23 @@ test.describe('OC - Forwarding to away agents (EE)', () => { await poLivechat.btnSendMessageToOnlineAgent.click(); }); - await test.step('Set user2 away by idle timeout', async () => { + await test.step('Set user2 agent away by idle timeout', async () => { await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); - await sleep(2000); // we give the agent time to go statusConnection: away + poHomeOmnichannelAwayAgent = new HomeOmnichannel(omnichannelPage); + await expect(poHomeOmnichannelAwayAgent.navbar.getUserStatusBadge('away')).toBeVisible(); }); await test.step('Manager attempts to forward and sees error', async () => { - await poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeOmnichannelOnlineAgent.sidebar.getSidebarItemByName(visitor.name).click(); await setSettingValueById(api, 'Livechat_waiting_queue', true); - await poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); - await poHomeOmnichannel.content.forwardChatModal.selectDepartment('Forward Dept'); - await poHomeOmnichannel.content.forwardChatModal.btnForward.click(); - - await expect(page.locator('.rcx-toastbar')).toContainText('No agents are available for service on this department.'); + await poHomeOmnichannelOnlineAgent.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannelOnlineAgent.page.locator('role=alert')).toContainText( + 'No agents are available for service on this department.', + ); }); await test.step('Restore department setting', async () => { @@ -232,21 +233,22 @@ test.describe('OC - Forwarding to away agents (EE)', () => { await poLivechat.btnSendMessageToOnlineAgent.click(); }); - await test.step('Set user2 away by idle timeout', async () => { + await test.step('Set user2 agent away by idle timeout', async () => { await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); - await sleep(2000); // we give the agent time to go statusConnection: away + poHomeOmnichannelAwayAgent = new HomeOmnichannel(omnichannelPage); + await expect(poHomeOmnichannelAwayAgent.navbar.getUserStatusBadge('away')).toBeVisible(); }); await test.step('Manager forwards chat successfully', async () => { - await poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); - await poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); - await poHomeOmnichannel.content.forwardChatModal.selectDepartment('Forward Dept'); - await poHomeOmnichannel.content.forwardChatModal.btnForward.click(); - await expect(poHomeOmnichannel.content.forwardChatModal.btnForward).not.toBeVisible(); + await poHomeOmnichannelOnlineAgent.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeOmnichannelOnlineAgent.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward).not.toBeVisible(); }); - await test.step('Check room routing via API serves to agent 2', async () => { + await test.step('Check room routing via API serves to away agent', async () => { const roomInfoResp = await api.get(`/livechat/rooms`); const roomBody = await roomInfoResp.json(); const room = roomBody.rooms.find((room: IRoom) => room.fname === visitor.name); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts b/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts index 7a3ad3a8c0e7c..5a6a653645298 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts @@ -268,4 +268,8 @@ export class Navbar { const newStatus = await this.btnSwitchOmnichannelStatus.getAttribute('title'); expect(newStatus).toBe(status === 'offline' ? StatusTitleMap.offline : StatusTitleMap.online); } + + getUserStatusBadge(status: 'online' | 'away' | 'busy' | 'offline'): Locator { + return this.btnUserMenu.locator(`svg[class*="${status}"]`); + } } From 851a449b8fb73404adf209140690af29b381bc6c Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 15 Apr 2026 14:24:50 -0300 Subject: [PATCH 52/57] move setting update to the end of needed test cases --- .../e2e/omnichannel/omnichannel-rooms-forward.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts index 60424cafc96a8..523aa346d96f1 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts @@ -63,7 +63,6 @@ test.describe('OC - Forwarding to away agents (EE)', () => { if (livechatPage) await livechatPage.context().close(); if (omnichannelPage) await omnichannelPage.context().close(); - await setSettingValueById(api, 'Livechat_waiting_queue', false); await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300); }); @@ -169,6 +168,10 @@ test.describe('OC - Forwarding to away agents (EE)', () => { expect(inquiryBody.inquiry.status).toBe('queued'); expect(inquiryBody.inquiry.department).toBe(forwardToOfflineDepartment.data._id); }); + + await test.step('Disable waiting queue', async () => { + await setSettingValueById(api, 'Livechat_waiting_queue', false); + }); }); test('when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is false, transfer should fail', async ({ @@ -214,6 +217,10 @@ test.describe('OC - Forwarding to away agents (EE)', () => { department: { ...forwardToOfflineDepartment.data, allowReceiveForwardOffline: true }, }); }); + + await test.step('Disable waiting queue', async () => { + await setSettingValueById(api, 'Livechat_waiting_queue', false); + }); }); test('when manager forward to online (agent away, accept when agent idle on) department the inquiry should not be set to the queue', async ({ From e7898f23ebcf3c0d2d0e9ec88fd4d860200c6fc4 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 15 Apr 2026 14:28:15 -0300 Subject: [PATCH 53/57] use long ifs --- .../omnichannel-livechat-agent-idle-setting.spec.ts | 4 +++- .../e2e/omnichannel/omnichannel-rooms-forward.spec.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts index 4a1569380b5f6..7d9fdb4d39421 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts @@ -49,7 +49,9 @@ test.describe('OC - Routing to Idle Agents', () => { }); test.afterEach(async ({ api }) => { - if (livechatPage) await livechatPage.context().close(); + if (livechatPage) { + await livechatPage.context().close(); + } await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300); }); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts index 523aa346d96f1..bdbce796241a1 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts @@ -60,8 +60,12 @@ test.describe('OC - Forwarding to away agents (EE)', () => { }); test.afterEach(async ({ api }) => { - if (livechatPage) await livechatPage.context().close(); - if (omnichannelPage) await omnichannelPage.context().close(); + if (livechatPage) { + await livechatPage.context().close(); + } + if (omnichannelPage) { + await omnichannelPage.context().close(); + } await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300); }); From 1de82a7bc772faa338084205ec97c964b66af108 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 15 Apr 2026 14:39:23 -0300 Subject: [PATCH 54/57] use toBeOK instead of toBe for 200 status assertion --- .../omnichannel-livechat-agent-idle-setting.spec.ts | 4 ++-- .../tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts index 7d9fdb4d39421..3f83ac989480e 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts @@ -32,7 +32,7 @@ test.describe('OC - Routing to Idle Agents', () => { createAgent(api, 'user1'), createDepartment(api, { name: 'Idle Routing Dept' }), setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300), - expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200), + expect(await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).toBeOK(), ]); await Promise.all([addAgentToDepartment(api, { department: testDepartment.data, agentId: 'user1' })]); @@ -62,7 +62,7 @@ test.describe('OC - Routing to Idle Agents', () => { agent.delete(), setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'), setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false), - expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).status()).toBe(200), + expect(await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).toBeOK(), ]); }); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts index bdbce796241a1..f7540a9cc09b9 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts @@ -40,7 +40,7 @@ test.describe('OC - Forwarding to away agents (EE)', () => { createDepartment(api, { name: 'Initial Dept' }), createDepartment(api, { name: 'Forward Dept', allowReceiveForwardOffline: true }), setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300), - expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200), + expect(await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).toBeOK(), ]); await Promise.all([ @@ -79,7 +79,7 @@ test.describe('OC - Forwarding to away agents (EE)', () => { awayAgent.delete(), setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'), setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false), - expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).status()).toBe(200), + expect(await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).toBeOK(), ]); }); From 81430ae6cf4a66638ba07a09782735071a157222 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 15 Apr 2026 16:59:30 -0300 Subject: [PATCH 55/57] rollback undesired change --- apps/meteor/app/livechat/server/lib/Helper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index aafce4b2cc97f..dbb44c17f74ce 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -737,7 +737,7 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi } const { servedBy, chatQueued } = roomTaken; - if (!chatQueued && oldServedBy && servedBy && oldServedBy._id === servedBy._id) { + if (!chatQueued && oldServedBy && oldServedBy._id === servedBy?._id) { if (!department?.fallbackForwardDepartment?.length) { logger.debug({ msg: 'Cannot forward room. Chat assigned to agent instead', From 731872377dc041be4a02a538c293207a187f9795 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 15 Apr 2026 17:30:36 -0300 Subject: [PATCH 56/57] update agents fetch comment according to acceptChatsWithNoAgents --- packages/models/src/helpers/omnichannel/agentStatus.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/models/src/helpers/omnichannel/agentStatus.ts b/packages/models/src/helpers/omnichannel/agentStatus.ts index 4f0000c81c1c5..4c50e68721b20 100644 --- a/packages/models/src/helpers/omnichannel/agentStatus.ts +++ b/packages/models/src/helpers/omnichannel/agentStatus.ts @@ -7,7 +7,7 @@ import type { Filter } from 'mongodb'; * * According to product rules, the primary conditions for auto-assignment are: * - User must have the 'livechat-agent' role. * - Livechat service status (`statusLivechat`) must be 'available'. - * - User's global status must NOT be 'offline' (Bots are exempt from this rule). + * - User's global status must NOT be 'offline' Exceptions: Bots are exempt from this rule, and this check is skipped entirely if the `acceptChatsWithNoAgents` setting is enabled (allowing offline human agents to be assigned). * - If the "Accept new omnichannel requests when the agent is idle" (aka `Livechat_enabled_when_agent_idle`) setting is OFF, * then the statusConnection must NOT be 'away'. */ From 0205f9266e9000efff388a91f60b806ed5519b5d Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 15 Apr 2026 17:41:52 -0300 Subject: [PATCH 57/57] add changeset --- .changeset/wet-pandas-pump.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/wet-pandas-pump.md diff --git a/.changeset/wet-pandas-pump.md b/.changeset/wet-pandas-pump.md new file mode 100644 index 0000000000000..0fcc3dc5a2c1f --- /dev/null +++ b/.changeset/wet-pandas-pump.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/model-typings': patch +'@rocket.chat/omni-core': patch +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Fixed an issue where the Omnichannel routing system ignored the `Livechat_accept_chats_with_no_agents` setting. The agent availability queries have been updated to properly evaluate this setting, ensuring offline agents are correctly included in the routing pool when allowed.