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
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
111
apps/meteor/ee/tests/unit/server/lib/omnichannel/Helper.tests.ts
Normal file
111
apps/meteor/ee/tests/unit/server/lib/omnichannel/Helper.tests.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user