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,34 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
import { applyAirGappedRestrictionsValidation } from '../../../../../server/lib/cloud/license/airGappedRestrictionsWrapper';
let restrictionFlag = true;
const airgappedModule = {
get restricted() {
return restrictionFlag;
},
};
proxyquire.noCallThru().load('../../../../server/patches/airGappedRestrictionsWrapper.ts', {
'@rocket.chat/license': {
AirGappedRestriction: airgappedModule,
},
'../../../server/lib/cloud/license/airGappedRestrictionsWrapper': {
applyAirGappedRestrictionsValidation,
},
});
describe('#airGappedRestrictionsWrapper()', () => {
it('should throw an error when the workspace is restricted', async () => {
await expect(applyAirGappedRestrictionsValidation(sinon.stub())).to.be.rejectedWith('restricted-workspace');
});
it('should NOT throw an error when the workspace is not restricted', async () => {
restrictionFlag = false;
const spy = sinon.stub();
await expect(applyAirGappedRestrictionsValidation(spy)).to.eventually.equal(undefined);
expect(spy.calledOnce).to.be.true;
});
});

View File

@@ -0,0 +1,132 @@
import { Emitter } from '@rocket.chat/emitter';
import { registerModel } from '@rocket.chat/models';
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
let promises: Array<Promise<any>> = [];
class AirgappedRestriction extends Emitter<{ remainingDays: { days: number } }> {
computeRestriction = sinon.spy();
isWarningPeriod = sinon.stub();
override on(type: any, cb: any): any {
const newCb = (...args: any) => {
promises.push(cb(...args));
};
return super.on(type, newCb);
}
}
const airgappedRestrictionObj = new AirgappedRestriction();
const mocks = {
sendMessagesToAdmins: ({ msgs }: any) => {
msgs({ adminUser: { language: 'pt-br' } });
},
settingsUpdate: sinon.spy(),
notifySetting: sinon.spy(),
i18n: sinon.spy(),
findLastToken: sinon.stub(),
};
const licenseMock = {
validateCb: async () => undefined,
removeCb: async () => undefined,
onValidateLicense: async (cb: any) => {
licenseMock.validateCb = cb;
},
onRemoveLicense: async (cb: any) => {
licenseMock.removeCb = cb;
},
};
registerModel('IServerEventsModel', {
insertOne: () => undefined,
createAuditServerEvent: () => undefined,
} as any);
proxyquire.noCallThru().load('../../../../server/lib/license/airGappedRestrictions.ts', {
'@rocket.chat/license': {
AirGappedRestriction: airgappedRestrictionObj,
License: licenseMock,
},
'@rocket.chat/models': {
Settings: {
updateValueById: mocks.settingsUpdate,
},
Statistics: {
findLastStatsToken: mocks.findLastToken,
},
},
'../../../../server/lib/notifyListener': {
notifyOnSettingChangedById: mocks.notifySetting,
},
'../../../../server/lib/i18n': {
i18n: {
t: mocks.i18n,
},
},
'../../../../server/lib/sendMessagesToAdmins': {
sendMessagesToAdmins: mocks.sendMessagesToAdmins,
},
});
describe('airgappedRestrictions', () => {
afterEach(() => {
Object.values(mocks).forEach((mock) => {
if ('resetHistory' in mock) {
mock.resetHistory();
}
if ('reset' in mock) {
mock.reset();
}
});
airgappedRestrictionObj.computeRestriction.resetHistory();
airgappedRestrictionObj.isWarningPeriod.reset();
promises = [];
});
it('should update setting when restriction is removed', async () => {
airgappedRestrictionObj.emit('remainingDays', { days: -1 });
await Promise.all(promises);
expect(mocks.settingsUpdate.calledWith('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', -1)).to.be.true;
expect(mocks.notifySetting.calledWith('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days')).to.be.true;
expect(airgappedRestrictionObj.isWarningPeriod.called).to.be.false;
});
it('should update setting when restriction is applied', async () => {
airgappedRestrictionObj.emit('remainingDays', { days: 0 });
await Promise.all(promises);
expect(mocks.settingsUpdate.calledWith('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 0)).to.be.true;
expect(mocks.notifySetting.calledWith('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days')).to.be.true;
expect(airgappedRestrictionObj.isWarningPeriod.called).to.be.false;
});
it('should update setting and send rocket.cat message when in warning period', async () => {
airgappedRestrictionObj.emit('remainingDays', { days: 1 });
airgappedRestrictionObj.isWarningPeriod.returns(true);
await Promise.all(promises);
expect(mocks.settingsUpdate.calledWith('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 1)).to.be.true;
expect(mocks.notifySetting.calledWith('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days')).to.be.true;
expect(airgappedRestrictionObj.isWarningPeriod.called).to.be.true;
expect(mocks.i18n.calledWith('AirGapped_Restriction_Warning', { lng: 'pt-br' }));
});
it('should recompute restriction if license is applied', async () => {
mocks.findLastToken.returns('token');
await licenseMock.validateCb();
expect(mocks.findLastToken.calledOnce).to.be.true;
expect(airgappedRestrictionObj.computeRestriction.calledWith('token')).to.be.true;
});
it('should recompute restriction if license is removed', async () => {
mocks.findLastToken.returns('token');
await licenseMock.removeCb();
expect(mocks.findLastToken.calledOnce).to.be.true;
expect(airgappedRestrictionObj.computeRestriction.calledWith('token')).to.be.true;
});
});

View File

@@ -0,0 +1,405 @@
import { AppInstallationSource, type IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata';
import { SettingType } from '@rocket.chat/apps-engine/definition/settings';
import { expect } from 'chai';
import sinon from 'sinon';
import { AppRealStorage } from '../../../../server/apps/storage/AppRealStorage';
describe('AppRealStorage', () => {
let storage: AppRealStorage;
let mockAppStorageItem: IAppStorageItem;
let mockDb: {
findOne: sinon.SinonStub;
find: sinon.SinonStub;
insertOne: sinon.SinonStub;
findOneAndUpdate: sinon.SinonStub;
deleteOne: sinon.SinonStub;
updateOne: sinon.SinonStub;
};
beforeEach(() => {
mockDb = {
findOne: sinon.stub(),
find: sinon.stub(),
insertOne: sinon.stub(),
findOneAndUpdate: sinon.stub(),
deleteOne: sinon.stub(),
updateOne: sinon.stub(),
};
storage = new AppRealStorage(mockDb as any);
mockAppStorageItem = {
id: 'test-app',
status: AppStatus.INITIALIZED,
installationSource: AppInstallationSource.PRIVATE,
languageContent: {},
settings: {},
implemented: {},
permissionsGranted: [],
info: {
nameSlug: 'test-app',
name: 'Test App',
description: 'Test Description',
version: '1.0.0',
id: 'test-app',
requiredApiVersion: '1.0.0',
author: {
name: 'Test Author',
homepage: 'https://test-author.com',
support: 'https://test-author.com/support',
},
classFile: 'test-app.js',
implements: [AppInterface.IPostExternalComponentClosed],
iconFile: 'test-icon.png',
},
};
});
afterEach(() => {
sinon.restore();
});
describe('create', () => {
it('should create a new app storage item', async () => {
mockDb.findOne.resolves(null);
mockDb.insertOne.resolves({ insertedId: 'new-id' });
const result = await storage.create(mockAppStorageItem);
expect(result).to.deep.include(mockAppStorageItem);
expect(result._id).to.equal('new-id');
expect(result.createdAt).to.be.instanceOf(Date);
expect(result.updatedAt).to.be.instanceOf(Date);
});
it('should throw error if app already exists', async () => {
mockDb.findOne.resolves({ id: 'test-app' });
await expect(storage.create(mockAppStorageItem)).to.be.rejectedWith('App already exists.');
});
});
describe('retrieveOne', () => {
it('should retrieve an app by id', async () => {
mockDb.findOne.resolves(mockAppStorageItem);
const result = await storage.retrieveOne('test-app');
expect(result).to.deep.equal(mockAppStorageItem);
expect(mockDb.findOne.calledWith({ $or: [{ _id: 'test-app' }, { id: 'test-app' }] })).to.be.true;
});
});
describe('retrieveAll', () => {
it('should retrieve all apps', async () => {
const mockApps = [
{ id: 'app1', _id: 'id1' },
{ id: 'app2', _id: 'id2' },
];
mockDb.find.returns({
toArray: sinon.stub().resolves(mockApps),
});
const result = await storage.retrieveAll();
expect(result.size).to.equal(2);
expect(result.get('app1')).to.deep.equal(mockApps[0]);
expect(result.get('app2')).to.deep.equal(mockApps[1]);
});
});
describe('retrieveAllPrivate', () => {
it('should retrieve all private apps', async () => {
const mockApps = [
{ id: 'private-app1', _id: 'id1', installationSource: 'private' },
{ id: 'private-app2', _id: 'id2', installationSource: 'private' },
];
mockDb.find.returns({
toArray: sinon.stub().resolves(mockApps),
});
const result = await storage.retrieveAllPrivate();
expect(result.size).to.equal(2);
expect(result.get('private-app1')).to.deep.equal(mockApps[0]);
expect(result.get('private-app2')).to.deep.equal(mockApps[1]);
expect(mockDb.find.calledWith({ installationSource: 'private' })).to.be.true;
});
});
describe('remove', () => {
it('should remove an app', async () => {
mockDb.deleteOne.resolves({ deletedCount: 1 });
const result = await storage.remove('test-app');
expect(result).to.deep.equal({ success: true });
expect(mockDb.deleteOne.calledWith({ id: 'test-app' })).to.be.true;
});
});
describe('updatePartialAndReturnDocument', () => {
it('should throw error when _id is missing', async () => {
const itemWithoutId = { ...mockAppStorageItem };
await expect(storage.updatePartialAndReturnDocument(itemWithoutId as any)).to.be.rejectedWith(
'Property _id is required to update an app storage item',
);
});
it('should throw error when _id is null', async () => {
const itemWithNullId = { ...mockAppStorageItem, _id: null };
await expect(storage.updatePartialAndReturnDocument(itemWithNullId as any)).to.be.rejectedWith(
'Property _id is required to update an app storage item',
);
});
it('should throw error when _id is undefined', async () => {
const itemWithUndefinedId = { ...mockAppStorageItem, _id: undefined };
await expect(storage.updatePartialAndReturnDocument(itemWithUndefinedId as any)).to.be.rejectedWith(
'Property _id is required to update an app storage item',
);
});
it('should throw error when _id is empty string', async () => {
const itemWithEmptyId = { ...mockAppStorageItem, _id: '' };
await expect(storage.updatePartialAndReturnDocument(itemWithEmptyId as any)).to.be.rejectedWith(
'Property _id is required to update an app storage item',
);
});
it('should unset permissionsGranted when unsetPermissionsGranted is true', async () => {
const updatedItem = { ...mockAppStorageItem, _id: 'test-id' };
const expectedItem = { ...mockAppStorageItem };
delete expectedItem.permissionsGranted;
mockDb.findOneAndUpdate.resolves(expectedItem);
const result = await storage.updatePartialAndReturnDocument(updatedItem, { unsetPermissionsGranted: true });
expect(result).to.deep.equal(expectedItem);
expect(
mockDb.findOneAndUpdate.calledWith(
{ _id: 'test-id' },
{
$set: expectedItem,
$unset: { permissionsGranted: 1 },
},
{ returnDocument: 'after' },
),
).to.be.true;
});
it('should not unset permissionsGranted when unsetPermissionsGranted is false', async () => {
const updatedItem = { ...mockAppStorageItem, _id: 'test-id' };
mockDb.findOneAndUpdate.resolves(updatedItem);
await storage.updatePartialAndReturnDocument(updatedItem, { unsetPermissionsGranted: false });
expect(mockDb.findOneAndUpdate.calledWith({ _id: 'test-id' }, { $set: mockAppStorageItem }, { returnDocument: 'after' })).to.be.true;
});
it('should not unset permissionsGranted when no options are passed', async () => {
const updatedItem = { ...mockAppStorageItem, _id: 'test-id' };
mockDb.findOneAndUpdate.resolves(updatedItem);
await storage.updatePartialAndReturnDocument(updatedItem);
expect(mockDb.findOneAndUpdate.calledWith({ _id: 'test-id' }, { $set: mockAppStorageItem }, { returnDocument: 'after' })).to.be.true;
});
});
describe('updateStatus', () => {
it('should update app status and return true when modified', async () => {
mockDb.updateOne.resolves({ modifiedCount: 1 });
const result = await storage.updateStatus('test-id', AppStatus.AUTO_ENABLED);
expect(result).to.be.true;
expect(mockDb.updateOne.calledWith({ _id: 'test-id' }, { $set: { status: AppStatus.AUTO_ENABLED } })).to.be.true;
});
it('should return false when no document was modified', async () => {
mockDb.updateOne.resolves({ modifiedCount: 0 });
const result = await storage.updateStatus('test-id', AppStatus.DISABLED);
expect(result).to.be.false;
expect(mockDb.updateOne.calledWith({ _id: 'test-id' }, { $set: { status: AppStatus.DISABLED } })).to.be.true;
});
});
describe('updateSetting', () => {
it('should update app setting and return true when modified', async () => {
const mockSetting = {
id: 'test-setting',
type: SettingType.STRING,
packageValue: 'default-value',
value: 'updated-value',
required: false,
public: true,
i18nLabel: 'Test Setting',
};
mockDb.updateOne.resolves({ modifiedCount: 1 });
const result = await storage.updateSetting('test-id', mockSetting);
expect(result).to.be.true;
expect(mockDb.updateOne.calledWith({ _id: 'test-id' }, { $set: { 'settings.test-setting': mockSetting } })).to.be.true;
});
it('should return false when no document was modified', async () => {
const mockSetting = {
id: 'test-setting',
type: SettingType.STRING,
packageValue: 'default-value',
value: 'updated-value',
required: false,
public: true,
i18nLabel: 'Test Setting',
};
mockDb.updateOne.resolves({ modifiedCount: 0 });
const result = await storage.updateSetting('test-id', mockSetting);
expect(result).to.be.false;
expect(mockDb.updateOne.calledWith({ _id: 'test-id' }, { $set: { 'settings.test-setting': mockSetting } })).to.be.true;
});
});
describe('updateAppInfo', () => {
it('should update app info and return true when modified', async () => {
const mockAppInfo = {
id: 'test-app',
name: 'Updated Test App',
nameSlug: 'updated-test-app',
description: 'Updated Description',
version: '2.0.0',
requiredApiVersion: '2.0.0',
author: {
name: 'Updated Author',
homepage: 'https://updated-author.com',
support: 'https://updated-author.com/support',
},
classFile: 'updated-app.js',
implements: [],
iconFile: 'updated-icon.png',
};
mockDb.updateOne.resolves({ modifiedCount: 1 });
const result = await storage.updateAppInfo('test-id', mockAppInfo);
expect(result).to.be.true;
expect(mockDb.updateOne.calledWith({ _id: 'test-id' }, { $set: { info: mockAppInfo } })).to.be.true;
});
it('should return false when no document was modified', async () => {
const mockAppInfo = {
id: 'test-app',
name: 'Updated Test App',
nameSlug: 'updated-test-app',
description: 'Updated Description',
version: '2.0.0',
requiredApiVersion: '2.0.0',
author: {
name: 'Updated Author',
homepage: 'https://updated-author.com',
support: 'https://updated-author.com/support',
},
classFile: 'updated-app.js',
implements: [],
iconFile: 'updated-icon.png',
};
mockDb.updateOne.resolves({ modifiedCount: 0 });
const result = await storage.updateAppInfo('test-id', mockAppInfo);
expect(result).to.be.false;
expect(mockDb.updateOne.calledWith({ _id: 'test-id' }, { $set: { info: mockAppInfo } })).to.be.true;
});
});
describe('updateMarketplaceInfo', () => {
it('should update marketplace info and return true when modified', async () => {
const mockMarketplaceInfo = [
{
id: 'test-app',
name: 'Test App',
nameSlug: 'test-app',
version: '2.0.0',
description: 'Test Description',
requiredApiVersion: '2.0.0',
author: {
name: 'Test Author',
homepage: 'https://test-author.com',
support: 'https://test-author.com/support',
},
classFile: 'test-app.js',
implements: [],
iconFile: 'test-icon.png',
categories: ['productivity'],
status: 'published',
isVisible: true,
isPurchased: false,
isSubscribed: false,
isBundled: false,
createdDate: '2023-01-01T00:00:00.000Z',
modifiedDate: '2023-01-01T00:00:00.000Z',
price: 0,
purchaseType: 'free' as any,
},
];
mockDb.updateOne.resolves({ modifiedCount: 1 });
const result = await storage.updateMarketplaceInfo('test-id', mockMarketplaceInfo);
expect(result).to.be.true;
expect(mockDb.updateOne.calledWith({ _id: 'test-id' }, { $set: { marketplaceInfo: mockMarketplaceInfo } })).to.be.true;
});
it('should return false when no document was modified', async () => {
const mockMarketplaceInfo = [
{
id: 'test-app',
name: 'Test App',
nameSlug: 'test-app',
version: '2.0.0',
description: 'Test Description',
requiredApiVersion: '2.0.0',
author: {
name: 'Test Author',
homepage: 'https://test-author.com',
support: 'https://test-author.com/support',
},
classFile: 'test-app.js',
implements: [],
iconFile: 'test-icon.png',
categories: ['productivity'],
status: 'published',
isVisible: true,
isPurchased: false,
isSubscribed: false,
isBundled: false,
createdDate: '2023-01-01T00:00:00.000Z',
modifiedDate: '2023-01-01T00:00:00.000Z',
price: 0,
purchaseType: 'free' as any,
},
];
mockDb.updateOne.resolves({ modifiedCount: 0 });
const result = await storage.updateMarketplaceInfo('test-id', mockMarketplaceInfo);
expect(result).to.be.false;
expect(mockDb.updateOne.calledWith({ _id: 'test-id' }, { $set: { marketplaceInfo: mockMarketplaceInfo } })).to.be.true;
});
});
});

View File

@@ -0,0 +1,130 @@
import type { AppLogsProps } from '@rocket.chat/rest-typings';
import { expect } from 'chai';
import { makeAppLogsQuery } from '../../../../../../server/apps/communication/endpoints/lib/makeAppLogsQuery';
describe('makeAppLogsQuery', () => {
const appId = 'test-app-id';
it('should return an empty object when no parameters are provided', () => {
const queryParams: AppLogsProps = {};
const result = makeAppLogsQuery(queryParams);
expect(result).to.deep.equal({});
});
it('should create a basic query with appId', () => {
const queryParams: AppLogsProps = { appId };
const result = makeAppLogsQuery(queryParams);
expect(result).to.deep.equal({ appId });
});
it('should include log level filter when logLevel is provided', () => {
const queryParams: AppLogsProps = { logLevel: '1' };
const result = makeAppLogsQuery(queryParams);
expect(result).to.deep.equal({
'entries.severity': { $in: ['error', 'warn', 'info', 'log'] },
});
});
it('should include all log levels when logLevel is 2', () => {
const queryParams: AppLogsProps = { logLevel: '2' };
const result = makeAppLogsQuery(queryParams);
expect(result).to.deep.equal({
'entries.severity': { $in: ['error', 'warn', 'info', 'log', 'debug', 'success'] },
});
});
it('should include method filter when method is provided', () => {
const queryParams: AppLogsProps = { method: 'app:construct' };
const result = makeAppLogsQuery(queryParams);
expect(result).to.deep.equal({
method: 'app:construct',
});
});
it('should include instanceId filter when instanceId is provided', () => {
const queryParams: AppLogsProps = { instanceId: 'instance123' };
const result = makeAppLogsQuery(queryParams);
expect(result).to.deep.equal({
instanceId: 'instance123',
});
});
it('should include start date filter when startDate is provided', () => {
const startDate = '2024-01-01T00:00:00.000Z';
const queryParams: AppLogsProps = { startDate };
const result = makeAppLogsQuery(queryParams);
expect(result).to.deep.equal({
_updatedAt: {
$gte: new Date(startDate),
},
});
});
it('should include end date filter when endDate is provided', () => {
const endDate = '2024-01-01T00:00:00.000Z';
const queryParams: AppLogsProps = { endDate };
const result = makeAppLogsQuery(queryParams);
const expectedEndDate = new Date(endDate);
expect(result).to.deep.equal({
_updatedAt: {
$lte: expectedEndDate,
},
});
});
it('should combine start and end date filters', () => {
const startDate = '2024-01-01T00:00:00.000Z';
const endDate = '2024-01-02T00:00:00.000Z';
const queryParams: AppLogsProps = { startDate, endDate };
const result = makeAppLogsQuery(queryParams);
const expectedEndDate = new Date(endDate);
expect(result).to.deep.equal({
_updatedAt: {
$gte: new Date(startDate),
$lte: expectedEndDate,
},
});
});
it('should throw error when start date is after end date', () => {
const startDate = '2024-01-02T00:00:00.000Z';
const endDate = '2024-01-01T00:00:00.000Z';
const queryParams: AppLogsProps = { startDate, endDate };
expect(() => makeAppLogsQuery(queryParams)).to.throw('Invalid date range');
});
it('should combine all filters when all parameters are provided', () => {
const queryParams: AppLogsProps = {
logLevel: '1',
method: 'app:construct',
startDate: '2024-01-01T00:00:00.000Z',
endDate: '2024-01-02T00:00:00.000Z',
instanceId: 'instance123',
};
const result = makeAppLogsQuery(queryParams);
const expectedEndDate = new Date(queryParams.endDate as string);
expect(result).to.deep.equal({
'entries.severity': { $in: ['error', 'warn', 'info', 'log'] },
'method': 'app:construct',
'_updatedAt': {
$gte: new Date(queryParams.startDate as string),
$lte: expectedEndDate,
},
'instanceId': 'instance123',
});
});
});

View File

@@ -0,0 +1,168 @@
import { registerModel, BaseRaw } from '@rocket.chat/models';
import { expect } from 'chai';
import { before, describe, it } from 'mocha';
import sinon from 'sinon';
import { BeforeSaveCannedResponse } from '../../../../../server/hooks/messages/BeforeSaveCannedResponse';
const createMessage = (msg?: string, extra: any = {}) => ({
_id: 'msg-id',
rid: 'GENERAL',
ts: new Date(),
u: {
_id: 'user-id',
username: 'user',
},
_updatedAt: new Date(),
msg: msg as string,
...extra,
});
const createRoom = (extra: any = {}) => ({
_id: 'GENERAL',
name: 'general',
...extra,
});
const createUser = (extra: any = {}) => ({
_id: 'user-id',
name: 'User Name',
username: 'user',
emails: [{ address: 'user@user.com' }],
...extra,
});
class LivechatVisitorsModel extends BaseRaw<any> {
findOneEnabledById() {
return {};
}
}
class UsersModel extends BaseRaw<any> {
override async findOneById(): Promise<any> {
return {
name: 'John Doe Agent',
};
}
}
const db = {
collection: () => ({}),
};
describe('Omnichannel canned responses', () => {
before(() => {
registerModel('ILivechatVisitorsModel', () => new LivechatVisitorsModel(db as unknown as any, 'visitor'));
registerModel('IUsersModel', () => new UsersModel(db as unknown as any, 'user'));
});
it('should do nothing if canned response is disabled', async () => {
BeforeSaveCannedResponse.enabled = false;
const canned = new BeforeSaveCannedResponse();
const message = await canned.replacePlaceholders({
message: createMessage('{{agent.name}}'),
room: createRoom({ t: 'l', servedBy: { _id: 'agent' }, v: { _id: 'visitor' } }),
user: createUser(),
});
expect(message).to.have.property('msg', '{{agent.name}}');
});
it('should do nothing if not an omnichannel room', async () => {
BeforeSaveCannedResponse.enabled = true;
const canned = new BeforeSaveCannedResponse();
const message = await canned.replacePlaceholders({
message: createMessage('{{agent.name}}'),
room: createRoom(),
user: createUser(),
});
expect(message).to.have.property('msg', '{{agent.name}}');
});
it('should do nothing if the message is from a visitor', async () => {
BeforeSaveCannedResponse.enabled = true;
const canned = new BeforeSaveCannedResponse();
const message = await canned.replacePlaceholders({
message: createMessage('{{agent.name}}'),
room: createRoom({ t: 'l', servedBy: { _id: 'agent' }, v: { _id: 'visitor' } }),
user: createUser({ token: 'visitor-token' }),
});
expect(message).to.have.property('msg', '{{agent.name}}');
});
it('should do nothing if room is not served by an agent', async () => {
BeforeSaveCannedResponse.enabled = true;
const canned = new BeforeSaveCannedResponse();
const message = await canned.replacePlaceholders({
message: createMessage('{{agent.name}}'),
room: createRoom({ t: 'l', v: { _id: 'visitor' } }),
user: createUser(),
});
expect(message).to.have.property('msg', '{{agent.name}}');
});
it('should do nothing for an empty message', async () => {
BeforeSaveCannedResponse.enabled = true;
const canned = new BeforeSaveCannedResponse();
const message = await canned.replacePlaceholders({
message: createMessage(''),
room: createRoom({ t: 'l', servedBy: { _id: 'agent' }, v: { _id: 'visitor' } }),
user: createUser(),
});
expect(message).to.have.property('msg', '');
});
it('should replace {{agent.name}} without finding the user from DB (sender is the agent of room)', async () => {
BeforeSaveCannedResponse.enabled = true;
const usersModel = new UsersModel(db as unknown as any, 'user');
const spy = sinon.spy(usersModel, 'findOneById');
registerModel('IUsersModel', () => usersModel);
const canned = new BeforeSaveCannedResponse();
const message = await canned.replacePlaceholders({
message: createMessage('{{agent.name}}'),
room: createRoom({ t: 'l', servedBy: { _id: 'agent' }, v: { _id: 'visitor' } }),
user: createUser({ _id: 'agent', name: 'User As Agent' }),
});
expect(message).to.have.property('msg', 'User As Agent');
expect(spy.called).to.be.false;
});
it('should replace {{agent.name}} when canned response is enabled', async () => {
BeforeSaveCannedResponse.enabled = true;
const usersModel = new UsersModel(db as unknown as any, 'user');
const spy = sinon.spy(usersModel, 'findOneById');
registerModel('IUsersModel', () => usersModel);
const canned = new BeforeSaveCannedResponse();
const message = await canned.replacePlaceholders({
message: createMessage('{{agent.name}}'),
room: createRoom({ t: 'l', servedBy: { _id: 'agent' }, v: { _id: 'visitor' } }),
user: createUser(),
});
expect(message).to.have.property('msg', 'John Doe Agent');
expect(spy.called).to.be.true;
});
});

View File

@@ -0,0 +1,86 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import moment from 'moment';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const settingStub = {
watch: sinon.stub(),
get: sinon.stub(),
};
const queueMonitorStub = {
scheduleInquiry: sinon.stub(),
};
const { afterInquiryQueuedFunc: afterInquiryQueued } = proxyquire
.noCallThru()
.load('../../../../../server/hooks/omnichannel/afterInquiryQueued.ts', {
'../../../../server/settings': {
settings: settingStub,
},
'../../lib/omnichannel/QueueInactivityMonitor': {
OmnichannelQueueInactivityMonitor: queueMonitorStub,
},
'../../../../server/lib/omnichannel/hooks': {
afterInquiryQueued: { patch: sinon.stub() },
},
});
describe('hooks/afterInquiryQueued', () => {
beforeEach(() => {
queueMonitorStub.scheduleInquiry.resetHistory();
settingStub.get.resetHistory();
});
it('should return undefined if no inquiry is passed, or if inquiry doesnt have valid properties', async () => {
expect(await afterInquiryQueued(null)).to.be.equal(undefined);
expect(await afterInquiryQueued({})).to.be.equal(undefined);
expect(await afterInquiryQueued({ _id: 'invalid' })).to.be.equal(undefined);
expect(await afterInquiryQueued({ _updatedAt: new Date() }));
expect(await afterInquiryQueued({ _updatedAt: null, _id: 'afsd34asdX' })).to.be.equal(undefined);
});
it('should do nothing if timer is set to 0 or less', async () => {
const inquiry = {
_id: 'afsd34asdX',
_updatedAt: new Date(),
};
settingStub.get.returns(0);
await afterInquiryQueued(inquiry);
expect(queueMonitorStub.scheduleInquiry.callCount).to.be.equal(0);
settingStub.get.returns(-1);
await afterInquiryQueued(inquiry);
expect(queueMonitorStub.scheduleInquiry.callCount).to.be.equal(0);
});
it('should call .scheduleInquiry with proper data', async () => {
const inquiry = {
_id: 'afsd34asdX',
_updatedAt: new Date(),
};
settingStub.get.returns(1);
await afterInquiryQueued(inquiry);
const newQueueTime = moment(inquiry._updatedAt).add(1, 'minutes');
expect(queueMonitorStub.scheduleInquiry.calledWith(inquiry._id, new Date(newQueueTime.format()))).to.be.true;
});
it('should call .scheduleInquiry with proper data when more than 1 min is passed as param', async () => {
const inquiry = {
_id: 'afv34avzx',
_updatedAt: new Date(),
};
settingStub.get.returns(3);
await afterInquiryQueued(inquiry);
const newQueueTime = moment(inquiry._updatedAt).add(3, 'minutes');
expect(queueMonitorStub.scheduleInquiry.calledWith(inquiry._id, new Date(newQueueTime.format()))).to.be.true;
});
});

View File

@@ -0,0 +1,181 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const livechatDepartmentStub = {
findOneById: sinon.stub(),
addDepartmentToUnit: sinon.stub(),
removeDepartmentFromUnit: sinon.stub(),
};
const livechatUnitStub = {
findOneById: sinon.stub(),
decrementDepartmentsCount: sinon.stub(),
incrementDepartmentsCount: sinon.stub(),
};
const hasAnyRoleStub = sinon.stub();
const getUnitsFromUserStub = sinon.stub();
const { manageDepartmentUnit } = proxyquire.noCallThru().load('../../../../../server/hooks/omnichannel/manageDepartmentUnit.ts', {
'@rocket.chat/omni-core-ee': {
getUnitsFromUser: getUnitsFromUserStub,
},
'../../../../server/lib/authorization/hasRole': {
hasAnyRoleAsync: hasAnyRoleStub,
},
'@rocket.chat/models': {
LivechatDepartment: livechatDepartmentStub,
LivechatUnit: livechatUnitStub,
},
});
describe('hooks/manageDepartmentUnit', () => {
beforeEach(() => {
livechatDepartmentStub.findOneById.reset();
livechatDepartmentStub.addDepartmentToUnit.reset();
livechatDepartmentStub.removeDepartmentFromUnit.reset();
livechatUnitStub.findOneById.reset();
livechatUnitStub.decrementDepartmentsCount.reset();
livechatUnitStub.incrementDepartmentsCount.reset();
hasAnyRoleStub.reset();
});
it('should not perform any operation when an invalid department is provided', async () => {
livechatDepartmentStub.findOneById.resolves(null);
hasAnyRoleStub.resolves(true);
getUnitsFromUserStub.resolves(['unit-id']);
await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'unit-id' });
expect(livechatDepartmentStub.addDepartmentToUnit.notCalled).to.be.true;
expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true;
expect(livechatUnitStub.decrementDepartmentsCount.notCalled).to.be.true;
expect(livechatUnitStub.incrementDepartmentsCount.notCalled).to.be.true;
});
it('should not perform any operation if the provided department is already in unit', async () => {
livechatDepartmentStub.findOneById.resolves({ _id: 'department-id', ancestors: ['unit-id'], parentId: 'unit-id' });
hasAnyRoleStub.resolves(true);
getUnitsFromUserStub.resolves(['unit-id']);
await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'unit-id' });
expect(livechatDepartmentStub.addDepartmentToUnit.notCalled).to.be.true;
expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true;
expect(livechatUnitStub.decrementDepartmentsCount.notCalled).to.be.true;
expect(livechatUnitStub.incrementDepartmentsCount.notCalled).to.be.true;
});
it("should not perform any operation if user is a monitor and can't manage new unit", async () => {
livechatDepartmentStub.findOneById.resolves({ _id: 'department-id', ancestors: ['unit-id'], parentId: 'unit-id' });
hasAnyRoleStub.resolves(false);
getUnitsFromUserStub.resolves(['unit-id']);
await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'new-unit-id' });
expect(livechatDepartmentStub.addDepartmentToUnit.notCalled).to.be.true;
expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true;
expect(livechatUnitStub.decrementDepartmentsCount.notCalled).to.be.true;
expect(livechatUnitStub.incrementDepartmentsCount.notCalled).to.be.true;
});
it("should not perform any operation if user is a monitor and can't manage current unit", async () => {
livechatDepartmentStub.findOneById.resolves({ _id: 'department-id', ancestors: ['unit-id'], parentId: 'unit-id' });
hasAnyRoleStub.resolves(false);
getUnitsFromUserStub.resolves(['new-unit-id']);
await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'new-unit-id' });
expect(livechatDepartmentStub.addDepartmentToUnit.notCalled).to.be.true;
expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true;
expect(livechatUnitStub.decrementDepartmentsCount.notCalled).to.be.true;
expect(livechatUnitStub.incrementDepartmentsCount.notCalled).to.be.true;
});
it('should not perform any operation if user is an admin/manager but an invalid unit is provided', async () => {
livechatDepartmentStub.findOneById.resolves({ _id: 'department-id', ancestors: ['unit-id'], parentId: 'unit-id' });
livechatUnitStub.findOneById.resolves(undefined);
hasAnyRoleStub.resolves(true);
getUnitsFromUserStub.resolves(undefined);
await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'new-unit-id' });
expect(livechatDepartmentStub.addDepartmentToUnit.notCalled).to.be.true;
expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true;
expect(livechatUnitStub.decrementDepartmentsCount.notCalled).to.be.true;
expect(livechatUnitStub.incrementDepartmentsCount.notCalled).to.be.true;
});
it('should remove department from its current unit if user is an admin/manager', async () => {
livechatDepartmentStub.findOneById.resolves({ _id: 'department-id', ancestors: ['unit-id'], parentId: 'unit-id' });
hasAnyRoleStub.resolves(true);
getUnitsFromUserStub.resolves(undefined);
await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: undefined });
expect(livechatDepartmentStub.addDepartmentToUnit.notCalled).to.be.true;
expect(livechatUnitStub.incrementDepartmentsCount.notCalled).to.be.true;
expect(livechatDepartmentStub.removeDepartmentFromUnit.calledOnceWith('department-id')).to.be.true;
expect(livechatUnitStub.decrementDepartmentsCount.calledOnceWith('unit-id')).to.be.true;
});
it('should add department to a unit if user is an admin/manager', async () => {
livechatDepartmentStub.findOneById.resolves({ _id: 'department-id' });
livechatUnitStub.findOneById.resolves({ _id: 'unit-id' });
hasAnyRoleStub.resolves(true);
getUnitsFromUserStub.resolves(undefined);
await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'unit-id' });
expect(livechatDepartmentStub.addDepartmentToUnit.calledOnceWith('department-id', 'unit-id', ['unit-id'])).to.be.true;
expect(livechatUnitStub.incrementDepartmentsCount.calledOnceWith('unit-id')).to.be.true;
expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true;
expect(livechatUnitStub.decrementDepartmentsCount.notCalled).to.be.true;
});
it('should move department to a new unit if user is an admin/manager', async () => {
livechatDepartmentStub.findOneById.resolves({ _id: 'department-id', ancestors: ['unit-id'], parentId: 'unit-id' });
livechatUnitStub.findOneById.resolves({ _id: 'new-unit-id' });
hasAnyRoleStub.resolves(true);
getUnitsFromUserStub.resolves(undefined);
await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'new-unit-id' });
expect(livechatDepartmentStub.addDepartmentToUnit.calledOnceWith('department-id', 'new-unit-id', ['new-unit-id'])).to.be.true;
expect(livechatUnitStub.incrementDepartmentsCount.calledOnceWith('new-unit-id')).to.be.true;
expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true;
expect(livechatUnitStub.decrementDepartmentsCount.calledOnceWith('unit-id')).to.be.true;
});
it('should remove department from its current unit if user is a monitor that supervises the current unit', async () => {
livechatDepartmentStub.findOneById.resolves({ _id: 'department-id', ancestors: ['unit-id'], parentId: 'unit-id' });
hasAnyRoleStub.resolves(false);
getUnitsFromUserStub.resolves(['unit-id']);
await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: undefined });
expect(livechatDepartmentStub.addDepartmentToUnit.notCalled).to.be.true;
expect(livechatUnitStub.incrementDepartmentsCount.notCalled).to.be.true;
expect(livechatDepartmentStub.removeDepartmentFromUnit.calledOnceWith('department-id')).to.be.true;
expect(livechatUnitStub.decrementDepartmentsCount.calledOnceWith('unit-id')).to.be.true;
});
it('should add department to a unit if user is a monitor that supervises the new unit', async () => {
livechatDepartmentStub.findOneById.resolves({ _id: 'department-id' });
livechatUnitStub.findOneById.resolves({ _id: 'unit-id' });
hasAnyRoleStub.resolves(false);
getUnitsFromUserStub.resolves(['unit-id']);
await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'unit-id' });
expect(livechatDepartmentStub.addDepartmentToUnit.calledOnceWith('department-id', 'unit-id', ['unit-id'])).to.be.true;
expect(livechatUnitStub.incrementDepartmentsCount.calledOnceWith('unit-id')).to.be.true;
expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true;
expect(livechatUnitStub.decrementDepartmentsCount.notCalled).to.be.true;
});
it('should move department to a new unit if user is a monitor that supervises the current and new units', async () => {
livechatDepartmentStub.findOneById.resolves({ _id: 'department-id', ancestors: ['unit-id'], parentId: 'unit-id' });
livechatUnitStub.findOneById.resolves({ _id: 'unit-id' });
hasAnyRoleStub.resolves(false);
getUnitsFromUserStub.resolves(['unit-id', 'new-unit-id']);
await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'new-unit-id' });
expect(livechatDepartmentStub.addDepartmentToUnit.calledOnceWith('department-id', 'new-unit-id', ['new-unit-id'])).to.be.true;
expect(livechatUnitStub.incrementDepartmentsCount.calledOnceWith('new-unit-id')).to.be.true;
expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true;
expect(livechatUnitStub.decrementDepartmentsCount.calledOnceWith('unit-id')).to.be.true;
});
});

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');
});
});