snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,231 @@
import { expect, spy } from 'chai';
import proxyquire from 'proxyquire';
import type { AppServerOrchestrator } from '../../../../../server/apps/orchestrator';
const { _disableAppsWithAddonsCallback } = proxyquire.noCallThru().load('../../../../../server/lib/apps/disableAppsWithAddonsCallback', {
'../../apps': {},
'../../../../server/lib/sendMessagesToAdmins': { sendMessagesToAdmins: () => undefined },
'../../../../server/lib/i18n': {
i18n: { t: () => undefined },
},
});
/**
* I've used named "empty" functions to spy on as it is easier to
* troubleshoot if the assertion fails.
* If we use `spy()` instead, there is no clear indication on the
* error message which of the spy assertions failed
*/
describe('disableAppsWithAddonsCallback', () => {
function sendMessagesToAdmins(): any {
return undefined;
}
it('should not execute anything if not external module', async () => {
function installedApps() {
return [];
}
function getManagerDisable() {
return undefined;
}
const mockManager = {
disable: spy(getManagerDisable),
};
const AppsMock = {
installedApps: spy(installedApps),
getManager: () => mockManager,
} as unknown as AppServerOrchestrator;
await _disableAppsWithAddonsCallback({ Apps: AppsMock, sendMessagesToAdmins }, { module: 'auditing', external: false, valid: true });
expect(AppsMock.installedApps).to.not.have.been.called();
expect(AppsMock.getManager()?.disable).to.not.have.been.called();
});
it('should not execute anything if module is external and valid', async () => {
function installedApps() {
return [];
}
function getManagerDisable() {
return undefined;
}
const mockManager = {
disable: spy(getManagerDisable),
};
const AppsMock = {
installedApps: spy(installedApps),
getManager: () => mockManager,
} as unknown as AppServerOrchestrator;
await _disableAppsWithAddonsCallback({ Apps: AppsMock, sendMessagesToAdmins }, { module: 'auditing', external: true, valid: true });
expect(AppsMock.installedApps).to.not.have.been.called();
expect(AppsMock.getManager()?.disable).to.not.have.been.called();
});
it('should not throw if there are no apps installed that are enabled', async () => {
function installedApps() {
return [];
}
function getManagerDisable() {
return undefined;
}
const mockManager = {
disable: spy(getManagerDisable),
};
const AppsMock = {
installedApps: spy(installedApps),
getManager: () => mockManager,
} as unknown as AppServerOrchestrator;
await expect(
_disableAppsWithAddonsCallback({ Apps: AppsMock, sendMessagesToAdmins }, { module: 'auditing', external: true, valid: false }),
).to.not.eventually.be.rejected;
expect(AppsMock.installedApps).to.have.been.called();
expect(AppsMock.getManager()?.disable).to.not.have.been.called();
});
it('should only disable apps that require addons', async () => {
function installedApps() {
return [
{
getInfo: () => ({}),
getName: () => 'Test App Without Addon',
getID() {
return 'test-app-without-addon';
},
},
{
getInfo: () => ({ addon: 'chat.rocket.test-addon' }),
getName: () => 'Test App WITH Addon',
getID() {
return 'test-app-with-addon';
},
},
];
}
function getManagerDisable() {
return undefined;
}
const mockManager = {
disable: spy(getManagerDisable),
};
const AppsMock = {
installedApps: spy(installedApps),
getManager: () => mockManager,
} as unknown as AppServerOrchestrator;
await expect(
_disableAppsWithAddonsCallback(
{ Apps: AppsMock, sendMessagesToAdmins },
{ module: 'chat.rocket.test-addon', external: true, valid: false },
),
).to.not.eventually.be.rejected;
expect(AppsMock.installedApps).to.have.been.called();
expect(AppsMock.getManager()?.disable).to.have.been.called.once;
expect(AppsMock.getManager()?.disable).to.have.been.called.with('test-app-with-addon');
});
it('should not send messages to admins if no app was disabled', async () => {
function installedApps() {
return [
{
getInfo: () => ({}),
getName: () => 'Test App Without Addon',
getID() {
return 'test-app-without-addon';
},
},
];
}
function getManagerDisable() {
return undefined;
}
const mockManager = {
disable: spy(getManagerDisable),
};
const AppsMock = {
installedApps: spy(installedApps),
getManager: () => mockManager,
} as unknown as AppServerOrchestrator;
const sendMessagesToAdminsSpy = spy(sendMessagesToAdmins);
await expect(
_disableAppsWithAddonsCallback(
{ Apps: AppsMock, sendMessagesToAdmins: sendMessagesToAdminsSpy },
{ module: 'chat.rocket.test-addon', external: true, valid: false },
),
).to.not.eventually.be.rejected;
expect(AppsMock.installedApps).to.have.been.called();
expect(AppsMock.getManager()?.disable).to.not.have.been.called();
expect(sendMessagesToAdminsSpy).to.not.have.been.called();
});
it('should send messages to admins if some app has been disabled', async () => {
function installedApps() {
return [
{
getInfo: () => ({}),
getName: () => 'Test App Without Addon',
getID() {
return 'test-app-without-addon';
},
},
{
getInfo: () => ({ addon: 'chat.rocket.test-addon' }),
getName: () => 'Test App WITH Addon',
getID() {
return 'test-app-with-addon';
},
},
];
}
function getManagerDisable() {
return undefined;
}
const mockManager = {
disable: spy(getManagerDisable),
};
const AppsMock = {
installedApps: spy(installedApps),
getManager: () => mockManager,
} as unknown as AppServerOrchestrator;
const sendMessagesToAdminsSpy = spy(sendMessagesToAdmins);
await expect(
_disableAppsWithAddonsCallback(
{ Apps: AppsMock, sendMessagesToAdmins: sendMessagesToAdminsSpy },
{ module: 'chat.rocket.test-addon', external: true, valid: false },
),
).to.not.eventually.be.rejected;
expect(AppsMock.installedApps).to.have.been.called();
expect(AppsMock.getManager()?.disable).to.have.been.called.once;
expect(sendMessagesToAdminsSpy).to.have.been.called();
});
});

View File

@@ -0,0 +1,126 @@
import type { IMarketplaceInfo } from '@rocket.chat/apps/dist/server/marketplace/IMarketplaceInfo';
import { AppInstallationSource, type IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
import type { Apps } from '@rocket.chat/core-services';
import type { LicenseImp } from '@rocket.chat/license';
import { expect } from 'chai';
import { _canEnableApp } from '../../../../../server/lib/license/canEnableApp';
const getDefaultApp = (): IAppStorageItem => ({
_id: '6706d9258e0ca97c2f0cc885',
id: '2e14ff6e-b4d5-4c4c-b12b-b1b1d15ec630',
info: {
id: '2e14ff6e-b4d5-4c4c-b12b-b1b1d15ec630',
version: '0.0.1',
requiredApiVersion: '^1.19.0',
iconFile: 'icon.png',
author: { name: 'a', homepage: 'a', support: 'a' },
name: 'Add-on test',
nameSlug: 'add-on-test',
classFile: 'AddOnTestApp.js',
description: 'a',
implements: [],
iconFileContent: '',
},
status: AppStatus.UNKNOWN,
settings: {},
implemented: {},
installationSource: AppInstallationSource.PRIVATE,
languageContent: {},
sourcePath: 'GridFS:/6706d9258e0ca97c2f0cc880',
signature: '',
createdAt: new Date('2024-10-09T19:27:33.923Z'),
updatedAt: new Date('2024-10-09T19:27:33.923Z'),
});
describe('canEnableApp', () => {
it('should throw the message "apps-engine-not-initialized" when appropriate', () => {
const AppsMock = {
isInitialized() {
return false;
},
} as unknown as typeof Apps;
const LicenseMock = {} as unknown as LicenseImp;
const deps = { Apps: AppsMock, License: LicenseMock };
return expect(_canEnableApp(deps, getDefaultApp())).to.eventually.be.rejectedWith('apps-engine-not-initialized');
});
const AppsMock = {
isInitialized() {
return true;
},
} as unknown as typeof Apps;
const LicenseMock = {
hasModule() {
return false;
},
shouldPreventAction() {
return true;
},
hasValidLicense() {
return false;
},
} as unknown as LicenseImp;
const deps = { Apps: AppsMock, License: LicenseMock };
it('should throw the message "app-addon-not-valid" when appropriate', () => {
const app = getDefaultApp();
app.info.addon = 'chat.rocket.test-addon';
return expect(_canEnableApp(deps, app)).to.eventually.be.rejectedWith('app-addon-not-valid');
});
it('should throw the message "license-prevented" when appropriate', () => {
const privateApp = getDefaultApp();
const marketplaceApp = getDefaultApp();
marketplaceApp.installationSource = AppInstallationSource.MARKETPLACE;
return Promise.all([
expect(_canEnableApp(deps, privateApp)).to.eventually.be.rejectedWith('license-prevented'),
expect(_canEnableApp(deps, marketplaceApp)).to.eventually.be.rejectedWith('license-prevented'),
]);
});
it('should throw the message "invalid-license" when appropriate', () => {
const License = { ...LicenseMock, shouldPreventAction: () => false } as unknown as LicenseImp;
const app = getDefaultApp();
app.installationSource = AppInstallationSource.MARKETPLACE;
app.marketplaceInfo = [{ isEnterpriseOnly: true } as IMarketplaceInfo];
const deps = { Apps: AppsMock, License };
return expect(_canEnableApp(deps, app)).to.eventually.be.rejectedWith('invalid-license');
});
it('should not throw if app is migrated', () => {
const app = getDefaultApp();
app.migrated = true;
return expect(_canEnableApp(deps, app)).to.not.eventually.be.rejected;
});
it('should not throw if license allows it', () => {
const License = {
hasModule() {
return true;
},
shouldPreventAction() {
return false;
},
hasValidLicense() {
return true;
},
} as unknown as LicenseImp;
const deps = { Apps: AppsMock, License };
return expect(_canEnableApp(deps, getDefaultApp())).to.not.eventually.be.rejected;
});
});

View File

@@ -0,0 +1,256 @@
import chai, { expect } from 'chai';
import chaiDateTime from 'chai-datetime';
import { beforeEach, describe, it } from 'mocha';
import moment from 'moment';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
chai.use(chaiDateTime);
const mockAgendaConstructor = sinon.stub();
const mockAgendaStart = sinon.stub();
const mockAgendaScheduler = sinon.stub();
const mockAgendaCancel = sinon.stub();
const mockAgendaDefine = sinon.stub();
const mockLivechatCloseRoom = sinon.stub();
const mockMeteorStartup = sinon.stub();
const mockLivechatRooms = {
findOneById: sinon.stub(),
};
const mockUsers = {
findOneById: sinon.stub(),
};
class MockAgendaClass {
constructor(opts: Record<string, any>) {
mockAgendaConstructor(opts);
}
async start() {
return mockAgendaStart();
}
async schedule(...args: any) {
return mockAgendaScheduler(...args);
}
async cancel(...args: any) {
return mockAgendaCancel(...args);
}
async define(...args: any) {
return mockAgendaDefine(...args);
}
}
const infoStub = sinon.stub();
const debugStub = sinon.stub();
const mockLogger = {
section: sinon.stub().returns({
info: infoStub,
debug: debugStub,
}),
};
const mocks = {
'@rocket.chat/agenda': { Agenda: MockAgendaClass },
'meteor/meteor': { Meteor: { startup: mockMeteorStartup } },
'meteor/mongo': {
MongoInternals: {
defaultRemoteCollectionDriver: () => {
return {
mongo: { client: { db: sinon.stub() } },
};
},
},
},
'../../../../server/lib/omnichannel/closeRoom': { closeRoom: mockLivechatCloseRoom },
'./logger': { schedulerLogger: mockLogger },
'@rocket.chat/models': {
LivechatRooms: mockLivechatRooms,
Users: mockUsers,
},
};
const { AutoCloseOnHoldSchedulerClass } = proxyquire
.noCallThru()
.load('../../../../../server/lib/omnichannel/AutoCloseOnHoldScheduler', mocks);
describe('AutoCloseOnHoldScheduler', () => {
beforeEach(() => {
mockMeteorStartup.resetHistory();
});
it('should call logger.section upon instantiating', () => {
expect(mockLogger.section.called).to.be.true;
});
describe('init', () => {
beforeEach(() => {
mockAgendaStart.resetHistory();
mockAgendaScheduler.resetHistory();
});
it('should do nothing if scheduler is already running', async () => {
const scheduler = new AutoCloseOnHoldSchedulerClass();
scheduler.running = true;
await scheduler.init();
expect(mockAgendaScheduler.called).to.be.false;
});
it('should succesfully init the scheduler', async () => {
const scheduler = new AutoCloseOnHoldSchedulerClass();
await scheduler.init();
expect(mockAgendaStart.calledOnce).to.be.true;
expect(scheduler.running).to.be.true;
expect(infoStub.calledWith('Service started')).to.be.true;
});
});
describe('scheduleRoom', () => {
beforeEach(() => {
mockAgendaCancel.resetHistory();
mockAgendaDefine.resetHistory();
});
it('should fail if scheduler has not been init', async () => {
const scheduler = new AutoCloseOnHoldSchedulerClass();
try {
await scheduler.scheduleRoom('roomId', 5, 'test comment');
} catch (e: any) {
expect(e.message).to.equal('AutoCloseOnHoldScheduler is not running');
}
});
it('should schedule a room', async () => {
const scheduler = new AutoCloseOnHoldSchedulerClass();
await scheduler.init();
await scheduler.scheduleRoom('roomId', 5, 'test comment');
const myScheduleTime = moment(new Date()).add(5, 's').toDate();
expect(mockAgendaCancel.calledBefore(mockAgendaDefine)).to.be.true;
expect(mockAgendaCancel.calledWith({ name: 'omnichannel_auto_close_on_hold_scheduler-roomId' }));
expect(mockAgendaDefine.calledWithMatch('omnichannel_auto_close_on_hold_scheduler-roomId'));
const funcScheduleTime = mockAgendaScheduler.getCall(0).firstArg;
expect(funcScheduleTime).to.be.closeToTime(myScheduleTime, 5);
expect(mockAgendaScheduler.calledWithMatch('omnichannel_auto_close_on_hold_scheduler-roomId'));
});
});
describe('unscheduleRoom', () => {
beforeEach(() => {
mockAgendaCancel.resetHistory();
});
it('should fail if scheduler has not been init', async () => {
const scheduler = new AutoCloseOnHoldSchedulerClass();
try {
await scheduler.unscheduleRoom('roomId');
} catch (e: any) {
expect(e.message).to.equal('AutoCloseOnHoldScheduler is not running');
}
});
it('should call .cancel to unschedule a room', async () => {
const scheduler = new AutoCloseOnHoldSchedulerClass();
await scheduler.init();
await scheduler.unscheduleRoom('roomId');
expect(mockAgendaCancel.calledWith({ name: 'omnichannel_auto_close_on_hold_scheduler-roomId' }));
});
});
describe('executeJob', () => {
beforeEach(() => {
mockLivechatCloseRoom.resetHistory();
mockLivechatRooms.findOneById.reset();
mockUsers.findOneById.reset();
});
it('should throw if roomId is invalid', async () => {
const scheduler = new AutoCloseOnHoldSchedulerClass();
mockLivechatRooms.findOneById.returns(null);
mockUsers.findOneById.returns({ _id: 'rocket.cat' });
try {
await scheduler.executeJob({ attrs: { data: { roomId: 'roomId', comment: 'comment' } } });
} catch (e: any) {
expect(e.message).to.be.equal(
'Unable to process AutoCloseOnHoldScheduler job because room or user not found for roomId: roomId and userId: rocket.cat',
);
}
});
it('should throw if user returned from scheduleUser is invalid', async () => {
const scheduler = new AutoCloseOnHoldSchedulerClass();
mockLivechatRooms.findOneById.returns({ _id: 'me' });
mockUsers.findOneById.returns(null);
try {
await scheduler.executeJob({ attrs: { data: { roomId: 'roomId', comment: 'comment' } } });
} catch (e: any) {
expect(e.message).to.be.equal('Scheduler user not found');
}
});
it('should call Livechat.closeRoom if all data is valid', async () => {
const scheduler = new AutoCloseOnHoldSchedulerClass();
mockLivechatRooms.findOneById.returns({ _id: 'me' });
mockUsers.findOneById.returns({ _id: 'rocket.cat' });
await scheduler.executeJob({ attrs: { data: { roomId: 'roomId', comment: 'comment' } } });
expect(mockLivechatCloseRoom.calledWithMatch({ room: { _id: 'me' }, user: { _id: 'rocket.cat' }, comment: 'comment' }));
});
});
describe('getSchedulerUser', () => {
beforeEach(() => {
mockUsers.findOneById.reset();
});
it('should do nothing when schedulerUser is already set', async () => {
const scheduler = new AutoCloseOnHoldSchedulerClass();
scheduler.schedulerUser = { _id: 'me' };
const user = await scheduler.getSchedulerUser();
expect(user).to.be.equal(scheduler.schedulerUser);
});
it('should fail when rocket.cat does not exist', async () => {
const scheduler = new AutoCloseOnHoldSchedulerClass();
mockUsers.findOneById.returns(null);
try {
await scheduler.getSchedulerUser();
} catch (e: any) {
expect(e.message).to.be.equal('Scheduler user not found');
}
});
it('should return rocket.cat', async () => {
const scheduler = new AutoCloseOnHoldSchedulerClass();
mockUsers.findOneById.returns({ _id: 'rocket.cat' });
const u = await scheduler.getSchedulerUser();
expect(u).to.be.an('object').with.property('_id', 'rocket.cat');
});
});
});

View File

@@ -0,0 +1,258 @@
import chai, { expect } from 'chai';
import chaiDateTime from 'chai-datetime';
import { beforeEach, describe, it } from 'mocha';
import moment from 'moment';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
chai.use(chaiDateTime);
const mockAgendaConstructor = sinon.stub();
const mockAgendaStart = sinon.stub();
const mockAgendaScheduler = sinon.stub();
const mockAgendaCancel = sinon.stub();
const mockAgendaDefine = sinon.stub();
const returnRoomAsInquiryMock = sinon.stub();
const mockMeteorStartup = sinon.stub();
const mockLivechatRooms = {
findOneById: sinon.stub(),
setAutoTransferOngoingById: sinon.stub(),
unsetAutoTransferOngoingById: sinon.stub(),
setAutoTransferredAtById: sinon.stub(),
};
const mockUsers = {
findOneById: sinon.stub(),
};
class MockAgendaClass {
constructor(opts: Record<string, any>) {
mockAgendaConstructor(opts);
}
async start() {
return mockAgendaStart();
}
async schedule(...args: any) {
return mockAgendaScheduler(...args);
}
async cancel(...args: any) {
return mockAgendaCancel(...args);
}
async define(...args: any) {
return mockAgendaDefine(...args);
}
}
const mockLogger = {
section: sinon.stub().returns({
info: sinon.stub(),
debug: sinon.stub(),
error: sinon.stub(),
}),
};
const forwardRoomToAgent = sinon.stub();
const settingsGet = sinon.stub();
const routingConfigMock = sinon.stub();
const getNextAgent = sinon.stub();
const mocks = {
'@rocket.chat/agenda': { Agenda: MockAgendaClass },
'meteor/meteor': { Meteor: { startup: mockMeteorStartup } },
'meteor/mongo': {
MongoInternals: {
defaultRemoteCollectionDriver: () => {
return {
mongo: { client: { db: sinon.stub() } },
};
},
},
},
'../../../../server/lib/omnichannel/RoutingManager': { RoutingManager: { getConfig: routingConfigMock, getNextAgent } },
'../../../../server/lib/omnichannel/Helper': { forwardRoomToAgent },
'../../../../server/lib/omnichannel/rooms': { returnRoomAsInquiry: returnRoomAsInquiryMock },
'../../../../server/settings': { settings: { get: settingsGet } },
'./logger': { schedulerLogger: mockLogger },
'@rocket.chat/models': {
LivechatRooms: mockLivechatRooms,
Users: mockUsers,
},
};
const { AutoTransferChatSchedulerClass } = proxyquire
.noCallThru()
.load('../../../../../server/lib/omnichannel/AutoTransferChatScheduler', mocks);
describe('AutoTransferChats', () => {
describe('getSchedulerUser', () => {
it('should return rocket.cat user', async () => {
const scheduler = new AutoTransferChatSchedulerClass();
mockUsers.findOneById.resolves({ _id: 'rocket.cat' });
const user = await scheduler.getSchedulerUser();
expect(user).to.be.deep.equal({ _id: 'rocket.cat', userType: 'user' });
expect(mockUsers.findOneById.calledWith('rocket.cat')).to.be.true;
});
});
describe('scheduleRoom', () => {
it('should schedule a room', async () => {
const scheduler = new AutoTransferChatSchedulerClass();
await scheduler.init();
scheduler.unscheduleRoom = sinon.stub();
const myScheduleTime = moment(new Date()).add(10, 's').toDate();
await scheduler.scheduleRoom('roomId', 10);
expect(scheduler.unscheduleRoom.calledWith('roomId')).to.be.true;
expect(mockAgendaDefine.getCall(0).firstArg).to.be.equal('omnichannel_scheduler-roomId');
const funcScheduleTime = mockAgendaScheduler.getCall(0).firstArg;
expect(funcScheduleTime).to.be.closeToTime(myScheduleTime, 5);
expect(mockLivechatRooms.setAutoTransferOngoingById.getCall(0).firstArg).to.be.equal('roomId');
});
});
describe('unscheduleRoom', () => {
it('should unschedule a room', async () => {
const scheduler = new AutoTransferChatSchedulerClass();
await scheduler.init();
await scheduler.unscheduleRoom('roomId');
expect(mockLivechatRooms.unsetAutoTransferOngoingById.getCall(0).firstArg).to.be.equal('roomId');
expect(mockAgendaCancel.getCall(0).firstArg).to.be.deep.equal({ name: 'omnichannel_scheduler-roomId' });
});
});
describe('executeJob', () => {
it('should execute job', async () => {
const scheduler = new AutoTransferChatSchedulerClass();
await scheduler.init();
scheduler.transferRoom = sinon.stub();
await scheduler.executeJob({ attrs: { data: { roomId: 'roomId' } } });
expect(scheduler.transferRoom.getCall(0).firstArg).to.be.equal('roomId');
expect(mockLivechatRooms.setAutoTransferredAtById.getCall(0).firstArg).to.be.equal('roomId');
});
it('shouldnt fail even if job fails', async () => {
const scheduler = new AutoTransferChatSchedulerClass();
await scheduler.init();
scheduler.transferRoom = sinon.stub().throws('dummy error');
const r = await scheduler.executeJob({ attrs: { data: { roomId: 'roomId' } } });
expect(r).to.be.undefined;
});
});
describe('transferRoom', () => {
beforeEach(() => {
mockLivechatRooms.findOneById.reset();
mockUsers.findOneById.reset();
returnRoomAsInquiryMock.reset();
getNextAgent.reset();
});
it('should not transfer undefined rooms', async () => {
mockLivechatRooms.findOneById.resolves(undefined);
const scheduler = new AutoTransferChatSchedulerClass();
await scheduler.init();
await expect(scheduler.transferRoom('roomId')).to.be.rejectedWith('Room is not open or is not being served by an agent');
});
it('should not transfer closed rooms', async () => {
mockLivechatRooms.findOneById.resolves({ _id: 1 });
const scheduler = new AutoTransferChatSchedulerClass();
await scheduler.init();
await expect(scheduler.transferRoom('roomId')).to.be.rejectedWith('Room is not open or is not being served by an agent');
});
it('should not transfer unserved rooms', async () => {
mockLivechatRooms.findOneById.resolves({ _id: 1, open: true });
const scheduler = new AutoTransferChatSchedulerClass();
await scheduler.init();
await expect(scheduler.transferRoom('roomId')).to.be.rejectedWith('Room is not open or is not being served by an agent');
});
it('should return a room as inquiry when the routing method is not automatic', async () => {
mockLivechatRooms.findOneById.resolves({ _id: 1, open: true, servedBy: { _id: 2 }, departmentId: undefined });
mockUsers.findOneById.resolves({ _id: 'rocket.cat' });
settingsGet.returns(5);
const scheduler = new AutoTransferChatSchedulerClass();
await scheduler.init();
await scheduler.transferRoom('roomId');
expect(
returnRoomAsInquiryMock.calledWith(
sinon.match({ _id: 1, open: true, servedBy: { _id: 2 } }),
undefined,
sinon.match({ scope: 'autoTransferUnansweredChatsToQueue', comment: '5', transferredBy: { _id: 'rocket.cat' } }),
),
).to.be.true;
});
it('should do nothing if routing manager returns no agents for automatic algo', async () => {
mockLivechatRooms.findOneById.resolves({ _id: 1, open: true, servedBy: { _id: 2 }, departmentId: undefined });
mockUsers.findOneById.resolves({ _id: 'rocket.cat' });
routingConfigMock.returns({ autoAssignAgent: true });
getNextAgent.resolves(undefined);
settingsGet.returns(5);
const scheduler = new AutoTransferChatSchedulerClass();
await scheduler.init();
const r = await scheduler.transferRoom('roomId');
expect(getNextAgent.calledWith(undefined, 2)).to.be.true;
expect(r).to.be.undefined;
expect(forwardRoomToAgent.notCalled).to.be.true;
});
it('should do nothing if cannot find rocket.cat', async () => {
mockLivechatRooms.findOneById.resolves({ _id: 1, open: true, servedBy: { _id: 2 }, departmentId: undefined });
mockUsers.findOneById.resolves(undefined);
routingConfigMock.returns({ autoAssignAgent: true });
getNextAgent.resolves({ agentId: 3 });
settingsGet.returns(5);
const scheduler = new AutoTransferChatSchedulerClass();
await scheduler.init();
await expect(scheduler.transferRoom('roomId')).to.be.rejectedWith('error-no-cat');
expect(getNextAgent.calledWith(undefined, 2)).to.be.true;
expect(mockUsers.findOneById.called).to.be.true;
expect(forwardRoomToAgent.notCalled).to.be.true;
});
it('should forward room to selected agent', async () => {
mockLivechatRooms.findOneById.resolves({ _id: 1, open: true, servedBy: { _id: 2 }, departmentId: undefined });
mockUsers.findOneById.resolves({ _id: 'rocket.cat' });
routingConfigMock.returns({ autoAssignAgent: true });
getNextAgent.resolves({ agentId: 3 });
settingsGet.returns(5);
const scheduler = new AutoTransferChatSchedulerClass();
await scheduler.init();
const r = await scheduler.transferRoom('roomId');
expect(getNextAgent.calledWith(undefined, 2)).to.be.true;
expect(r).to.be.undefined;
expect(
forwardRoomToAgent.calledWith(
sinon.match({ _id: 1, open: true, servedBy: { _id: 2 }, departmentId: undefined }),
sinon.match({
userId: 3,
transferredBy: { _id: 'rocket.cat', userType: 'user' },
transferredTo: { agentId: 3 },
scope: 'autoTransferUnansweredChatsToAgent',
comment: '5',
}),
),
).to.be.true;
});
});
});

View File

@@ -0,0 +1,111 @@
import { expect } from 'chai';
import { beforeEach, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const settingGetMock = sinon.stub();
const usersModelMock = {
getAgentInfo: sinon.stub(),
};
const departmentsMock = { findOneById: sinon.stub() };
const mocks = {
'meteor/meteor': { Meteor: { startup: sinon.stub() } },
'./QueueInactivityMonitor': { stop: sinon.stub() },
'../../../../server/lib/omnichannel/settings': { getInquirySortMechanismSetting: sinon.stub() },
'../../../../app/livechat/lib/inquiries': { getOmniChatSortQuery: sinon.stub() },
'../../../../server/settings': { settings: { get: settingGetMock } },
'@rocket.chat/models': { Users: usersModelMock, LivechatDepartment: departmentsMock },
};
const { isAgentWithinChatLimits } = proxyquire.noCallThru().load('../../../../../server/lib/omnichannel/Helper.ts', mocks);
describe('isAgentWithinChatLimits', () => {
beforeEach(() => {
usersModelMock.getAgentInfo.reset();
departmentsMock.findOneById.reset();
settingGetMock.reset();
});
it('should return true if no limit is set', async () => {
const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 10 });
expect(res).to.be.true;
});
it('should return true when agent is under the agent limit', async () => {
usersModelMock.getAgentInfo.resolves({ livechat: { maxNumberSimultaneousChat: 15 } });
const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 10 });
expect(res).to.be.true;
});
it('should honor agent limit over global limit', async () => {
usersModelMock.getAgentInfo.resolves({ livechat: { maxNumberSimultaneousChat: 15 } });
settingGetMock.returns(5);
const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 10 });
expect(res).to.be.true;
});
it('should use global limit if agent limit is not set', async () => {
usersModelMock.getAgentInfo.resolves({ livechat: { maxNumberSimultaneousChat: undefined } });
settingGetMock.returns(5);
const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 10 });
expect(res).to.be.false;
});
it('should consider a user with the same number of chats as the limit as over the limit', async () => {
usersModelMock.getAgentInfo.resolves({ livechat: { maxNumberSimultaneousChat: 15 } });
settingGetMock.returns(5);
const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 15 });
expect(res).to.be.false;
});
it('should honor both department and agent limit when departmentId is passed', async () => {
usersModelMock.getAgentInfo.resolves({ livechat: { maxNumberSimultaneousChat: 15 } });
departmentsMock.findOneById.resolves({ maxNumberSimultaneousChat: 10 });
settingGetMock.returns(5);
const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 10, departmentId: 'dept1', departmentChats: 5 });
expect(res).to.be.true;
});
it('should return false for a user under their agent limit but above their department limit', async () => {
usersModelMock.getAgentInfo.resolves({ livechat: { maxNumberSimultaneousChat: 15 } });
departmentsMock.findOneById.resolves({ maxNumberSimultaneousChat: 10 });
settingGetMock.returns(5);
const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 11, departmentId: 'dept1', departmentChats: 11 });
expect(res).to.be.false;
});
it('should return false for a user under their department limit but above their agent limit', async () => {
usersModelMock.getAgentInfo.resolves({ livechat: { maxNumberSimultaneousChat: 10 } });
departmentsMock.findOneById.resolves({ maxNumberSimultaneousChat: 15 });
settingGetMock.returns(5);
const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 11, departmentId: 'dept1', departmentChats: 10 });
expect(res).to.be.false;
});
it('should honor both department and global when agent limit is not set', async () => {
departmentsMock.findOneById.resolves({ maxNumberSimultaneousChat: 15 });
settingGetMock.returns(5);
const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 11, departmentId: 'dept1', departmentChats: 10 });
expect(res).to.be.false;
});
it('should return false for a user under their global limit but above their department limit', async () => {
departmentsMock.findOneById.resolves({ maxNumberSimultaneousChat: 10 });
settingGetMock.returns(20);
const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 11, departmentId: 'dept1', departmentChats: 11 });
expect(res).to.be.false;
});
it('should return false for a user under their department limit but above the global limit', async () => {
departmentsMock.findOneById.resolves({ maxNumberSimultaneousChat: 10 });
settingGetMock.returns(5);
const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 11, departmentId: 'dept1', departmentChats: 3 });
expect(res).to.be.false;
});
it('should apply only the department limit if the other 2 limits are not set', async () => {
departmentsMock.findOneById.resolves({ maxNumberSimultaneousChat: 10 });
const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 11, departmentId: 'dept1', departmentChats: 3 });
expect(res).to.be.true;
});
it('should ignore agent limit if its not a valid number (or cast to number)', async () => {
usersModelMock.getAgentInfo.resolves({ livechat: { maxNumberSimultaneousChat: 'invalid' } });
const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 11 });
expect(res).to.be.true;
});
it('should ignore the department limit if it is not a valid number (or cast to number)', async () => {
departmentsMock.findOneById.resolves({ maxNumberSimultaneousChat: 'invalid' });
const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 11, departmentId: 'dept1', departmentChats: 11 });
expect(res).to.be.true;
});
});

View File

@@ -0,0 +1,198 @@
import { expect } from 'chai';
import { describe, afterEach, before } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const AgendaJobStub = {
schedule: sinon.stub(),
unique: sinon.stub(),
save: sinon.stub(),
};
const AgendaStub = {
start: sinon.stub(),
define: sinon.stub(),
cancel: sinon.stub(),
create: sinon.stub().returns(AgendaJobStub),
};
const modelsMock = {
LivechatRooms: { findOneById: sinon.stub() },
LivechatInquiry: { findOneById: sinon.stub() },
Users: { findOneById: sinon.stub() },
};
const meteorMock = { Meteor: { startup: sinon.stub() } };
const createIndexStub = sinon.stub();
const mongoMock = {
MongoInternals: {
defaultRemoteCollectionDriver: sinon.stub().returns({
mongo: { db: { collection: sinon.stub().returns({ createIndex: createIndexStub }) }, client: { db: sinon.stub() } },
}),
},
};
const livechatMock = { closeRoom: sinon.stub() };
const settingsMock = { settings: { get: sinon.stub() } };
const { OmnichannelQueueInactivityMonitorClass } = proxyquire
.noCallThru()
.load('../../../../../server/lib/omnichannel/QueueInactivityMonitor', {
'@rocket.chat/agenda': {
Agenda: sinon.stub().returns(AgendaStub),
},
'@rocket.chat/models': modelsMock,
'meteor/meteor': meteorMock,
'meteor/mongo': mongoMock,
'../../../../server/lib/omnichannel/closeRoom': livechatMock,
'../../../../server/settings': settingsMock,
'../../../../server/lib/i18n': { i18n: { t: sinon.stub().returns('Closed automatically') } },
});
describe('OmnichannelQueueInactivityMonitorClass', () => {
afterEach(() => {
modelsMock.Users.findOneById.reset();
});
describe('getRocketChatUser', () => {
it('should return rocket.cat user', async () => {
const qclass = new OmnichannelQueueInactivityMonitorClass();
await qclass.getRocketCatUser();
expect(modelsMock.Users.findOneById.calledWith('rocket.cat')).to.be.true;
});
});
describe('getName', () => {
it('should return valid name', () => {
const qclass = new OmnichannelQueueInactivityMonitorClass();
const result = qclass.getName('inquiryId');
expect(result).to.be.equal('Omnichannel-Queue-Inactivity-Monitor-inquiryId');
});
});
describe('createIndex', () => {
before(() => {
createIndexStub.reset();
});
it('should create index', () => {
const qclass = new OmnichannelQueueInactivityMonitorClass();
qclass.createIndex();
expect(createIndexStub.calledWith(sinon.match({ 'data.inquiryId': 1 }), sinon.match({ unique: true }))).to.be.true;
});
});
describe('start', () => {
before(() => {
AgendaStub.start.reset();
});
it('should do nothing if its already running', async () => {
const qclass = new OmnichannelQueueInactivityMonitorClass();
qclass.running = true;
await qclass.start();
expect(AgendaStub.start.calledOnce).to.be.false;
});
it('should start scheduler', async () => {
const qclass = new OmnichannelQueueInactivityMonitorClass();
await qclass.start();
expect(AgendaStub.start.calledOnce).to.be.true;
expect(qclass.running).to.be.true;
});
});
describe('scheduleInquiry', () => {
beforeEach(() => {
AgendaStub.define.reset();
});
it('should schedule inquiry', async () => {
const qclass = new OmnichannelQueueInactivityMonitorClass();
const now = new Date();
await qclass.scheduleInquiry('inquiryId', now);
expect(AgendaStub.cancel.calledOnce).to.be.true;
expect(AgendaStub.cancel.calledBefore(AgendaStub.define)).to.be.true;
expect(AgendaStub.define.calledOnce).to.be.true;
expect(AgendaJobStub.schedule.calledOnceWith(now)).to.be.true;
expect(AgendaJobStub.unique.calledOnceWith(sinon.match({ 'data.inquiryId': 'inquiryId' }))).to.be.true;
});
});
describe('stop', () => {
beforeEach(() => {
AgendaStub.cancel.reset();
});
it('should do nothing if process is already stopped', async () => {
const qclass = new OmnichannelQueueInactivityMonitorClass();
qclass.running = false;
await qclass.stop();
expect(AgendaStub.cancel.calledOnce).to.be.false;
});
it('should not call cancel twice if stop is called twice', async () => {
const qclass = new OmnichannelQueueInactivityMonitorClass();
qclass.running = true;
await qclass.stop();
await qclass.stop();
expect(AgendaStub.cancel.calledOnce).to.be.true;
});
it('should cancel all inquiries and flag service as not running', async () => {
const qclass = new OmnichannelQueueInactivityMonitorClass();
qclass.running = true;
await qclass.stop();
expect(AgendaStub.cancel.calledOnce).to.be.true;
expect(qclass.running).to.be.false;
});
});
describe('stopInquiry', () => {
beforeEach(() => {
AgendaStub.cancel.reset();
});
it('should cancel inquiry', async () => {
const qclass = new OmnichannelQueueInactivityMonitorClass();
await qclass.stopInquiry('inquiryId');
expect(AgendaStub.cancel.calledOnce).to.be.true;
});
});
describe('closeRoom', () => {
beforeEach(() => {
modelsMock.LivechatInquiry.findOneById.reset();
modelsMock.LivechatRooms.findOneById.reset();
livechatMock.closeRoom.reset();
});
it('should ignore the inquiry if its not in queue anymore', async () => {
const qclass = new OmnichannelQueueInactivityMonitorClass();
modelsMock.LivechatInquiry.findOneById.resolves({ status: 'taken' });
await qclass.closeRoom({ attrs: { data: { inquiryId: 'inquiryId' } } });
expect(modelsMock.LivechatInquiry.findOneById.calledWith('inquiryId')).to.be.true;
expect(livechatMock.closeRoom.notCalled).to.be.true;
});
it('should ignore an inquiry with no room', async () => {
const qclass = new OmnichannelQueueInactivityMonitorClass();
modelsMock.LivechatInquiry.findOneById.resolves({ status: 'queued', rid: 'roomId' });
modelsMock.LivechatRooms.findOneById.resolves(undefined);
await qclass.closeRoom({ attrs: { data: { inquiryId: 'inquiryId' } } });
expect(modelsMock.LivechatInquiry.findOneById.calledWith('inquiryId')).to.be.true;
expect(modelsMock.LivechatRooms.findOneById.calledWith('roomId')).to.be.true;
expect(livechatMock.closeRoom.notCalled).to.be.true;
});
it('should close a room', async () => {
const qclass = new OmnichannelQueueInactivityMonitorClass();
modelsMock.LivechatInquiry.findOneById.resolves({ status: 'queued', rid: 'roomId' });
modelsMock.LivechatRooms.findOneById.resolves({ _id: 'roomId' });
modelsMock.Users.findOneById.resolves({ _id: 'rocket.cat' });
await qclass.closeRoom({ attrs: { data: { inquiryId: 'inquiryId' } } });
expect(modelsMock.LivechatInquiry.findOneById.calledWith('inquiryId')).to.be.true;
expect(modelsMock.LivechatRooms.findOneById.calledWith('roomId')).to.be.true;
expect(
livechatMock.closeRoom.calledWith(
sinon.match({
comment: 'Closed automatically',
room: { _id: 'roomId' },
user: { _id: 'rocket.cat' },
}),
),
);
});
});
});

View File

@@ -0,0 +1,132 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const cursor = (docs: unknown[]) => ({ toArray: async () => docs });
const LivechatBusinessHoursStub = {
findOne: sinon.stub().resolves(null),
updateOne: sinon.stub().resolves({}),
insertOne: sinon.stub().resolves({ insertedId: 'new-bh-id' }),
findOneDefaultBusinessHour: sinon.stub().resolves({ _id: 'default-bh', active: false, type: 'default', workHours: [] }),
};
const LivechatDepartmentStub = {
findByBusinessHourId: sinon.stub().returns(cursor([])),
findInIds: sinon.stub().returns(cursor([])),
removeBusinessHourFromDepartmentsByIdsAndBusinessHourId: sinon.stub().resolves(undefined),
addBusinessHourToDepartmentsByIds: sinon.stub().resolves(undefined),
};
const LivechatDepartmentAgentsStub = {
findByDepartmentIds: sinon.stub().returns(cursor([])),
};
const UsersStub = {
findAgentsAvailableWithoutBusinessHours: sinon.stub().returns(cursor([])),
updateLivechatStatusByAgentIds: sinon.stub().resolves({ modifiedCount: 0 }),
closeAgentsBusinessHoursByBusinessHourIds: sinon.stub().resolves(undefined),
removeBusinessHourByAgentIds: sinon.stub().resolves(undefined),
};
const registerBusinessHourTypeStub = sinon.stub();
// Only the DB layer and the meteor-heavy imports are stubbed; Custom.ts,
// AbstractBusinessHour (real convertWorkHours/baseSaveBusinessHour) and
// Helper run for real.
proxyquire.noCallThru().load('../../../../../../server/lib/omnichannel/business-hour/Custom', {
'../../../../../server/lib/omnichannel/business-hour': {
businessHourManager: { registerBusinessHourType: registerBusinessHourTypeStub },
},
'@rocket.chat/models': {
'@global': true,
'LivechatBusinessHours': LivechatBusinessHoursStub,
'LivechatDepartment': LivechatDepartmentStub,
'LivechatDepartmentAgents': LivechatDepartmentAgentsStub,
'Users': UsersStub,
},
'../../notifyListener': {
'@global': true,
'notifyOnUserChange': sinon.stub(),
'notifyOnUserChangeAsync': sinon.stub(),
},
});
const customBusinessHour = registerBusinessHourTypeStub.firstCall.args[0];
const baseBusinessHour = {
_id: 'bh-id',
name: 'test-bh',
active: true,
type: 'custom',
timezoneName: 'America/Noronha',
workHours: [{ day: 'Monday', start: '07:00', finish: '18:00', open: true }],
};
describe('[OC] CustomBusinessHour', () => {
describe('saveBusinessHour()', () => {
beforeEach(() => {
LivechatBusinessHoursStub.updateOne.resetHistory();
LivechatDepartmentStub.removeBusinessHourFromDepartmentsByIdsAndBusinessHourId.resetHistory();
LivechatDepartmentStub.addBusinessHourToDepartmentsByIds.resetHistory();
LivechatDepartmentStub.findByBusinessHourId.reset();
LivechatDepartmentStub.findByBusinessHourId.returns(cursor([{ _id: 'dept1' }, { _id: 'dept2' }]));
LivechatDepartmentAgentsStub.findByDepartmentIds.reset();
LivechatDepartmentAgentsStub.findByDepartmentIds.returns(cursor([]));
UsersStub.removeBusinessHourByAgentIds.resetHistory();
});
it('should not touch department links when departmentsToApplyBusinessHour is not provided (internal re-save, e.g. DST verifier)', async () => {
await customBusinessHour.saveBusinessHour({ ...baseBusinessHour, workHours: structuredClone(baseBusinessHour.workHours) });
expect(LivechatDepartmentStub.removeBusinessHourFromDepartmentsByIdsAndBusinessHourId.called).to.be.false;
expect(LivechatDepartmentStub.addBusinessHourToDepartmentsByIds.called).to.be.false;
expect(LivechatBusinessHoursStub.updateOne.calledOnce).to.be.true;
});
it('should still recompute the UTC work hours from the business hour timezone on an internal re-save', async () => {
await customBusinessHour.saveBusinessHour({ ...baseBusinessHour, workHours: structuredClone(baseBusinessHour.workHours) });
const { $set } = LivechatBusinessHoursStub.updateOne.firstCall.args[1];
// 07:00 in America/Noronha (UTC-2, no DST) is 09:00 UTC
expect($set.workHours[0].start.utc).to.deep.include({ dayOfWeek: 'Monday', time: '09:00' });
expect($set.workHours[0].finish.utc).to.deep.include({ dayOfWeek: 'Monday', time: '20:00' });
expect($set.timezone).to.deep.equal({ name: 'America/Noronha', utc: '-02:00' });
});
it('should unlink all departments when departmentsToApplyBusinessHour is an empty string', async () => {
await customBusinessHour.saveBusinessHour({
...baseBusinessHour,
workHours: structuredClone(baseBusinessHour.workHours),
departmentsToApplyBusinessHour: '',
});
expect(LivechatDepartmentStub.removeBusinessHourFromDepartmentsByIdsAndBusinessHourId.calledOnceWith(['dept1', 'dept2'], 'bh-id')).to
.be.true;
expect(LivechatDepartmentStub.addBusinessHourToDepartmentsByIds.called).to.be.false;
});
it('should reconcile department links when departmentsToApplyBusinessHour is provided', async () => {
await customBusinessHour.saveBusinessHour({
...baseBusinessHour,
workHours: structuredClone(baseBusinessHour.workHours),
departmentsToApplyBusinessHour: 'dept1,dept3',
});
expect(LivechatDepartmentStub.removeBusinessHourFromDepartmentsByIdsAndBusinessHourId.calledOnceWith(['dept2'], 'bh-id')).to.be.true;
expect(LivechatDepartmentStub.addBusinessHourToDepartmentsByIds.calledOnceWith(['dept3'], 'bh-id')).to.be.true;
});
it('should remove the business hour from the agents of removed departments', async () => {
LivechatDepartmentAgentsStub.findByDepartmentIds.withArgs(['dept2'], sinon.match.any).returns(cursor([{ agentId: 'agent2' }]));
await customBusinessHour.saveBusinessHour({
...baseBusinessHour,
workHours: structuredClone(baseBusinessHour.workHours),
departmentsToApplyBusinessHour: 'dept1',
});
expect(UsersStub.removeBusinessHourByAgentIds.calledOnceWith(['agent2'], 'bh-id')).to.be.true;
});
});
});

View File

@@ -0,0 +1,72 @@
import { expect } from 'chai';
import { describe, it, beforeEach, after } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const workOnPdfStub = sinon.stub();
const queueWorkStub = sinon.stub();
const { requestPdfTranscript } = proxyquire.noCallThru().load('../../../../../server/lib/omnichannel/requestPdfTranscript.ts', {
'@rocket.chat/core-services': {
OmnichannelTranscript: {
workOnPdf: workOnPdfStub,
},
QueueWorker: {
queueWork: queueWorkStub,
},
},
});
describe('requestPdfTranscript', () => {
const currentTestModeValue = process.env.TEST_MODE;
beforeEach(() => {
workOnPdfStub.reset();
queueWorkStub.reset();
});
after(() => {
process.env.TEST_MODE = currentTestModeValue;
});
it('should throw an error if room is still open', async () => {
await expect(requestPdfTranscript({ open: true }, 'userId')).to.be.rejectedWith('room-still-open');
});
it('should throw an error if room doesnt have a v property', async () => {
await expect(requestPdfTranscript({}, 'userId')).to.be.rejectedWith('improper-room-state');
});
it('should not allow to request a transcript if it already exists', async () => {
const result = await requestPdfTranscript({ _id: 'roomIdxx', v: 1, pdfTranscriptFileId: 'afsdafadsfs' }, 'userId');
expect(result).to.be.undefined;
});
it('should not allow to request a transcript if it was already requested during the previous 15 seconds', async () => {
await requestPdfTranscript({ _id: 'roomId', v: 1 }, 'userId');
expect(workOnPdfStub.callCount).to.equal(0);
expect(queueWorkStub.callCount).to.equal(1);
const result = await requestPdfTranscript({ _id: 'roomId', v: 1 }, 'userId');
expect(workOnPdfStub.callCount).to.equal(0);
expect(queueWorkStub.callCount).to.equal(1);
expect(result).to.be.undefined;
});
it('should call workOnPdf if TEST_MODE is true', async () => {
process.env.TEST_MODE = 'true';
await requestPdfTranscript({ _id: 'roomId-fasdafsdas', v: {} }, 'userId');
expect(
workOnPdfStub
.getCall(0)
.calledWithExactly({ details: { rid: 'roomId-fasdafsdas', userId: 'userId', from: 'omnichannel-transcript' } }),
).to.be.true;
expect(queueWorkStub.calledOnce).to.be.false;
});
it('should queue work if TEST_MODE is not set', async () => {
delete process.env.TEST_MODE;
await requestPdfTranscript({ _id: 'roomId-afsdfaefzv', v: {} }, 'userId');
expect(workOnPdfStub.calledOnce).to.be.false;
expect(
queueWorkStub.getCall(0).calledWithExactly('work', 'omnichannel-transcript.workOnPdf', {
details: { rid: 'roomId-afsdfaefzv', userId: 'userId', from: 'omnichannel-transcript' },
}),
).to.be.true;
});
});

View File

@@ -0,0 +1,301 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
type RestrictQueryParams = {
originalQuery?: Record<string, any>;
unitsFilter?: string[];
userId?: string;
};
describe('restrictQuery', () => {
const modulePath = '../../../../../server/lib/omnichannel/restrictQuery';
// Helper to require the SUT with injected stubs
const loadSut = ({
getUnitsFromUserResult,
departmentsToReturn = [],
captureFindArgs = false,
}: {
getUnitsFromUserResult: any;
departmentsToReturn?: Array<{ _id: string }>;
captureFindArgs?: boolean;
}) => {
const getUnitsFromUserStub = sinon.stub().callsFake(async () => getUnitsFromUserResult);
const findArgs: any[] = [];
const findStub = sinon.stub().callsFake((query: any, projection: any) => {
if (captureFindArgs) {
findArgs.push({ query, projection });
}
return {
toArray: async () => departmentsToReturn,
};
});
const debugStub = sinon.stub();
const { restrictQuery } = proxyquire.noCallThru().load(modulePath, {
'@rocket.chat/models': {
LivechatDepartment: { find: findStub },
},
'@rocket.chat/omni-core-ee': {
getUnitsFromUser: getUnitsFromUserStub,
},
'./logger': {
cbLogger: { debug: debugStub },
},
});
return { restrictQuery, getUnitsFromUserStub, findStub, debugStub, findArgs };
};
afterEach(() => {
sinon.restore();
});
it('returns the original query untouched when user has no units and no unitsFilter is provided', async () => {
const { restrictQuery, getUnitsFromUserStub, findStub, debugStub } = loadSut({
getUnitsFromUserResult: null,
});
const originalQuery = { status: 'open' };
const result = await (restrictQuery as (p: RestrictQueryParams) => Promise<any>)({
originalQuery,
userId: 'user-id',
});
expect(getUnitsFromUserStub.calledOnce).to.equal(true);
expect(getUnitsFromUserStub.firstCall.args).to.deep.equal(['user-id']);
expect(findStub.called).to.equal(false);
expect(debugStub.called).to.equal(false);
// It must be a shallow copy but equivalent content
expect(result).to.deep.equal(originalQuery);
// Ensure original wasn't mutated
expect(originalQuery).to.not.have.property('$and');
});
it('returns the original query untouched when user has no units and no unitsFilter is provided', async () => {
const { restrictQuery, getUnitsFromUserStub, findStub, debugStub } = loadSut({
getUnitsFromUserResult: null,
});
const result = await (restrictQuery as (p: RestrictQueryParams) => Promise<any>)({
originalQuery: undefined,
userId: 'user-id',
});
expect(getUnitsFromUserStub.calledOnce).to.equal(true);
expect(getUnitsFromUserStub.firstCall.args).to.deep.equal(['user-id']);
expect(findStub.called).to.equal(false);
expect(debugStub.called).to.equal(false);
// It must be a shallow copy but equivalent content
expect(result).to.deep.equal({});
});
it('applies departmentAncestors filter directly when user has no units but a unitsFilter is provided', async () => {
const { restrictQuery, getUnitsFromUserStub, findStub, debugStub } = loadSut({
getUnitsFromUserResult: undefined,
});
const originalQuery = { status: { $ne: 'closed' } };
const unitsFilter = ['unit-a', 'unit-b'];
const result = await (restrictQuery as (p: RestrictQueryParams) => Promise<any>)({
originalQuery,
unitsFilter,
});
expect(getUnitsFromUserStub.calledOnce).to.equal(true);
expect(findStub.called).to.equal(false);
expect(debugStub.called).to.equal(false);
expect(result).to.deep.equal({
status: { $ne: 'closed' },
departmentAncestors: { $in: unitsFilter },
});
// Ensure original wasn't mutated
expect(originalQuery).to.not.have.property('departmentAncestors');
});
it('adds $and restriction when user has units and no unitsFilter is provided', async () => {
const { restrictQuery, findArgs, debugStub } = loadSut({
getUnitsFromUserResult: ['unit-1', 'unit-2'],
departmentsToReturn: [{ _id: 'dep-1' }, { _id: 'dep-2' }],
captureFindArgs: true,
});
const originalQuery = { status: 'open' };
const result = await (restrictQuery as (p: RestrictQueryParams) => Promise<any>)({
originalQuery,
userId: 'u',
});
// Verify LivechatDepartment.find was called with the proper query and projection
expect(findArgs).to.have.length(1);
expect(findArgs[0].query).to.deep.equal({
$or: [{ ancestors: { $in: ['unit-1', 'unit-2'] } }, { _id: { $in: ['unit-1', 'unit-2'] } }],
});
expect(findArgs[0].projection).to.deep.equal({ projection: { _id: 1 } });
// Verify the resulting query
expect(result).to.have.property('$and').that.is.an('array').with.lengthOf(1);
expect(result.$and[0]).to.deep.equal({
$or: [
{ departmentAncestors: { $in: ['unit-1', 'unit-2'] } },
{ departmentId: { $in: ['dep-1', 'dep-2'] } },
{ departmentId: { $exists: false } },
],
});
expect(result.status).to.equal('open');
// Logger should be called with the final query and computed userUnits
expect(debugStub.callCount).to.equal(1);
const debugArg = debugStub.firstCall.args[0];
expect(debugArg).to.include({ msg: 'Applying room query restrictions' });
expect(debugArg.userUnits).to.deep.equal(['unit-1', 'unit-2']);
expect(debugArg.query).to.deep.equal(result);
});
it('intersects user units with unitsFilter and applies restrictions accordingly', async () => {
const { restrictQuery, findArgs, debugStub } = loadSut({
getUnitsFromUserResult: ['unit-1', 'unit-2'],
departmentsToReturn: [{ _id: 'dep-3' }],
captureFindArgs: true,
});
const result = await (restrictQuery as (p: RestrictQueryParams) => Promise<any>)({
originalQuery: { priority: 'high' },
unitsFilter: ['unit-2', 'unit-3'], // intersection => ['unit-2']
userId: 'u',
});
// find must be called using the intersected set ['unit-2']
expect(findArgs).to.have.length(1);
expect(findArgs[0].query).to.deep.equal({
$or: [{ ancestors: { $in: ['unit-2'] } }, { _id: { $in: ['unit-2'] } }],
});
// Resulting $and must use the intersected units as well
expect(result.$and).to.have.length(1);
expect(result.$and[0]).to.deep.equal({
$or: [{ departmentAncestors: { $in: ['unit-2'] } }, { departmentId: { $in: ['dep-3'] } }, { departmentId: { $exists: false } }],
});
expect(result.priority).to.equal('high');
expect(debugStub.callCount).to.equal(1);
const debugArg = debugStub.firstCall.args[0];
expect(debugArg.userUnits).to.deep.equal(['unit-2']);
});
it('places the new restriction before existing $and expressions and preserves other fields', async () => {
const { restrictQuery } = loadSut({
getUnitsFromUserResult: ['u-a'],
departmentsToReturn: [{ _id: 'dep-a' }],
});
const originalQuery = { $and: [{ foo: 1 }], bar: 2 };
const result = await (restrictQuery as (p: RestrictQueryParams) => Promise<any>)({
originalQuery,
});
expect(result).to.have.property('bar', 2);
expect(result).to.have.property('$and').that.is.an('array').with.lengthOf(2);
// First element is the newly added condition
expect(result.$and[0]).to.deep.equal({
$or: [{ departmentAncestors: { $in: ['u-a'] } }, { departmentId: { $in: ['dep-a'] } }, { departmentId: { $exists: false } }],
});
// Then the existing ones
expect(result.$and[1]).to.deep.equal({ foo: 1 });
// Ensure original $and was not mutated by reference insertion (since we recompose it)
expect(originalQuery.$and).to.deep.equal([{ foo: 1 }]);
});
it('handles empty intersections by producing restrictive filters while still allowing rooms without department', async () => {
const { restrictQuery, findArgs } = loadSut({
getUnitsFromUserResult: ['unit-x'],
departmentsToReturn: [], // none will match
captureFindArgs: true,
});
const result = await (restrictQuery as (p: RestrictQueryParams) => Promise<any>)({
originalQuery: {},
unitsFilter: ['unit-y'], // intersection => []
});
// find called with empty $in arrays
expect(findArgs).to.have.length(1);
expect(findArgs[0].query).to.deep.equal({
$or: [{ ancestors: { $in: [] } }, { _id: { $in: [] } }],
});
// Resulting condition must reflect empty unit and department lists,
// but still allow rooms where departmentId does not exist
expect(result.$and).to.have.length(1);
expect(result.$and[0]).to.deep.equal({
$or: [{ departmentAncestors: { $in: [] } }, { departmentId: { $in: [] } }, { departmentId: { $exists: false } }],
});
});
// New test: Validate the exact parameters passed to LivechatDepartment.find
it('calls LivechatDepartment.find with correct query and projection parameters', async () => {
const userUnits = ['unit-10', 'unit-20', 'unit-30'];
const { restrictQuery, findArgs, findStub } = loadSut({
getUnitsFromUserResult: userUnits,
departmentsToReturn: [{ _id: 'dep-10' }],
captureFindArgs: true,
});
const originalQuery = { $and: [{ state: { $ne: 'archived' } }], custom: 'x' };
const result = await (restrictQuery as (p: RestrictQueryParams) => Promise<any>)({
originalQuery,
userId: 'abc',
});
// Ensure find has been called exactly once
expect(findStub.callCount).to.equal(1);
expect(findArgs).to.have.length(1);
// Validate query argument
expect(findArgs[0].query).to.deep.equal({
$or: [{ ancestors: { $in: userUnits } }, { _id: { $in: userUnits } }],
});
// Validate projection argument is exactly as expected
expect(findArgs[0].projection).to.deep.equal({ projection: { _id: 1 } });
// And the resulting query contains our original bits
expect(result).to.have.property('custom', 'x');
expect(result.$and[1]).to.deep.equal({ state: { $ne: 'archived' } });
});
// New test: Ensure departments returned by find are added to the query's departmentId $in clause
it('uses departments returned by find to build departmentId $in condition', async () => {
const userUnits = ['unit-a'];
const departments = [{ _id: 'dep-a' }, { _id: 'dep-b' }, { _id: 'dep-c' }];
const { restrictQuery } = loadSut({
getUnitsFromUserResult: userUnits,
departmentsToReturn: departments,
captureFindArgs: true,
});
const result = await (restrictQuery as (p: RestrictQueryParams) => Promise<any>)({
originalQuery: { tag: 'vip' },
userId: 'some-user',
});
// Validate that all department ids are present in the $in list
expect(result).to.have.property('$and');
expect(result.$and).to.be.an('array').with.lengthOf(1);
const orCondition = result.$and[0].$or;
expect(orCondition).to.be.an('array').with.lengthOf(3);
// Extract the departmentId condition
const deptIdCond = orCondition.find((c: any) => Object.keys(c)[0] === 'departmentId');
expect(deptIdCond).to.deep.equal({ departmentId: { $in: ['dep-a', 'dep-b', 'dep-c'] } });
expect(result.tag).to.equal('vip');
});
});