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,377 @@
import { expect } from 'chai';
import { beforeEach, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const License = {
hasModule: sinon.stub(),
};
const cachedSettings = {
get: sinon.stub(),
};
const Messages = {
findVisibleByIds: sinon.stub(),
};
const Rooms = {
findByIds: sinon.stub(),
findOneByNameOrFname: sinon.stub(),
};
const Subscriptions = {
findByUserId: sinon.stub(),
findByUserIdAndRoomIds: sinon.stub(),
};
const Users = {
findOneById: sinon.stub(),
};
const serverFetch = sinon.stub();
const { AISearchService } = proxyquire.noCallThru().load('../../../../../server/services/ai-search/service', {
'@rocket.chat/core-services': {
License,
ServiceClass: class {
protected name = '';
},
},
'../../settings': {
settings: cachedSettings,
},
'@rocket.chat/logger': {
Logger: class {
debug = sinon.stub();
warn = sinon.stub();
},
},
'@rocket.chat/models': {
Messages,
Rooms,
Subscriptions,
Users,
},
'@rocket.chat/server-fetch': {
serverFetch,
},
});
type CursorResult<T> = {
toArray(): Promise<T[]>;
map<U>(callback: (item: T) => U): CursorResult<U>;
};
const cursor = <T>(items: T[]): CursorResult<T> => ({
toArray: async () => items,
map: (callback) => cursor(items.map(callback)),
});
const settings: Record<string, unknown> = {
AI_Intelligent_Search_Enabled: true,
AI_Intelligent_Search_Pipeline_Base_URL: 'https://pipeline.example.com',
AI_Intelligent_Search_Pipeline_ID: 'workspace',
AI_Intelligent_Search_API_Key: 'key',
AI_Intelligent_Search_API_Key_Secret: 'secret',
AI_Intelligent_Search_Query_Template: '',
AI_Intelligent_Search_Min_Similarity_Percent: 61,
AI_Intelligent_Search_Answer_Enabled: true,
AI_LLM_OpenAI_Base_URL: 'https://llm.example.com',
AI_LLM_OpenAI_API_Key: 'llm-key',
AI_LLM_OpenAI_Model: 'gpt-test',
AI_Intelligent_Search_Answer_System_Prompt: 'Use sources only.',
SSRF_Allowlist: 'pipeline.example.com,llm.example.com',
};
const createService = (): InstanceType<typeof AISearchService> => new AISearchService();
describe('AISearchService', () => {
beforeEach(() => {
License.hasModule.reset();
cachedSettings.get.reset();
Messages.findVisibleByIds.reset();
Rooms.findByIds.reset();
Rooms.findOneByNameOrFname.reset();
Subscriptions.findByUserId.reset();
Subscriptions.findByUserIdAndRoomIds.reset();
Users.findOneById.reset();
serverFetch.reset();
License.hasModule.resolves(true);
cachedSettings.get.callsFake((key: string) => settings[key]);
Users.findOneById.resolves({ roles: ['admin'] });
Rooms.findByIds.callsFake((roomIds: string[]) =>
cursor(
roomIds.map((roomId) => ({
_id: roomId,
t: 'c',
name: roomId === 'allowed' ? 'general' : roomId,
fname: roomId === 'allowed' ? 'General' : roomId,
})),
),
);
Subscriptions.findByUserIdAndRoomIds.callsFake((_userId: string, roomIds: string[]) =>
cursor(roomIds.filter((roomId) => roomId === 'allowed' || roomId === 'room-general').map((rid) => ({ rid }))),
);
Messages.findVisibleByIds.callsFake((msgIds: string[]) =>
cursor(
msgIds.map((msgId) => ({
_id: msgId,
rid: msgId === 'blocked-msg' ? 'blocked' : 'allowed',
msg: `${msgId} from db`,
ts: new Date('2026-01-05T12:00:00.000Z'),
u: { username: 'alice', name: 'Alice' },
})),
),
);
});
describe('status', () => {
it('reports availability from license, settings, pipeline, and LLM configuration', async () => {
expect(await createService().status()).to.deep.equal({
hasIntelligentSearchLicense: true,
intelligentSearchEnabled: true,
intelligentSearchConfigured: true,
answerGenerationConfigured: true,
});
});
it('marks answer generation unavailable when the answer setting is off', async () => {
cachedSettings.get.callsFake((key: string) => (key === 'AI_Intelligent_Search_Answer_Enabled' ? false : settings[key]));
expect(await createService().status()).to.include({
answerGenerationConfigured: false,
});
});
it('marks answer generation unavailable when the pipeline is not configured', async () => {
cachedSettings.get.callsFake((key: string) => (key === 'AI_Intelligent_Search_Pipeline_Base_URL' ? '' : settings[key]));
expect(await createService().status()).to.include({
intelligentSearchConfigured: false,
answerGenerationConfigured: false,
});
});
});
describe('search', () => {
it('does not call the pipeline when license, setting, or configuration is unavailable', async () => {
License.hasModule.resolves(false);
expect(await createService().search({ query: 'fruit', userId: 'user-id' })).to.deep.equal([]);
expect(serverFetch.called).to.be.false;
});
it('sends subscribed room scope to the pipeline for broad searches', async () => {
const subscribedRoomIds = [...Array.from({ length: 1001 }, (_, index) => `room-${index}`), 'allowed'];
Subscriptions.findByUserId.returns(cursor([...subscribedRoomIds.map((rid) => ({ rid })), { rid: 'banned-room', status: 'BANNED' }]));
serverFetch.resolves({
ok: true,
status: 200,
json: async () => ({
results: [
{ metadata: { room_id: 'blocked', msg_id: 'blocked-msg' }, text: 'blocked pipeline text', score: 0.1 },
{ metadata: { room_id: 'allowed', msg_id: 'allowed-msg' }, text: 'allowed pipeline text', score: 0.39 },
],
}),
text: async () => '',
});
const results = await createService().search({ query: 'fruit', userId: 'user-id', limit: 5 });
expect(results).to.deep.equal([
{
_id: 'allowed-msg',
rid: 'allowed',
msgId: 'allowed-msg',
text: 'allowed-msg from db',
ts: '2026-01-05T12:00:00.000Z',
u: { username: 'alice', name: 'Alice' },
score: 0.61,
room: { _id: 'allowed', t: 'c', name: 'general', fname: 'General' },
},
]);
const [, options] = serverFetch.firstCall.args;
const body = JSON.parse(options.body);
expect(body.params.k).to.equal(5);
expect(body.filters).to.deep.equal({
room_id: { $in: subscribedRoomIds },
});
expect(options).to.include({
ignoreSsrfValidation: false,
allowList: 'pipeline.example.com,llm.example.com',
});
expect(
Subscriptions.findByUserId.calledWith('user-id', {
projection: { rid: 1, status: 1 },
}),
).to.be.true;
expect(
Subscriptions.findByUserIdAndRoomIds.calledWith('user-id', ['blocked', 'allowed'], {
projection: { rid: 1, status: 1 },
}),
).to.be.true;
});
it('resolves room-name filters before querying the pipeline', async () => {
Rooms.findOneByNameOrFname.resolves({ _id: 'room-general' });
Subscriptions.findByUserId.returns(cursor([{ rid: 'room-general' }]));
serverFetch.resolves({
ok: true,
status: 200,
json: async () => ({
results: [
{ metadata: { room_id: 'room-general', msg_id: 'general-msg' }, text: 'general pipeline text', similarity: 0.8 },
{ metadata: { room_id: 'blocked', msg_id: 'blocked-msg' }, text: 'blocked pipeline text', similarity: 0.99 },
],
}),
text: async () => '',
});
Messages.findVisibleByIds.returns(
cursor([
{
_id: 'general-msg',
rid: 'room-general',
msg: 'general message from db',
ts: new Date('2026-01-05T12:00:00.000Z'),
u: { username: 'alice', name: 'Alice' },
},
]),
);
Rooms.findByIds.returns(cursor([{ _id: 'room-general', t: 'c', name: 'general', fname: 'General' }]));
const results = await createService().search({
query: 'fruit',
userId: 'user-id',
filters: {
roomNames: ['general'],
fromUsernames: ['alice'],
startDate: '2026-01-01T00:00:00.000Z',
endDate: '2026-01-31T00:00:00.000Z',
},
limit: 5,
});
expect(results).to.have.lengthOf(1);
expect(results[0]).to.include({ _id: 'general-msg', rid: 'room-general', score: 0.8 });
expect(
Messages.findVisibleByIds.calledWith(['general-msg', 'blocked-msg'], {
projection: { _id: 1, rid: 1, msg: 1, ts: 1, u: 1 },
}),
).to.be.true;
const [, options] = serverFetch.firstCall.args;
expect(JSON.parse(options.body).filters).to.deep.equal({
room_id: { $eq: 'room-general' },
username: { $eq: 'alice' },
timestamp: {
$ge: '2026-01-01T00:00:00.000Z',
$le: '2026-01-31T00:00:00.000Z',
},
});
});
it('returns no results when room-name filters do not resolve to subscribed rooms', async () => {
Rooms.findOneByNameOrFname.resolves(null);
expect(
await createService().search({ query: 'fruit', userId: 'user-id', filters: { roomNames: ['unknown-room'] }, limit: 5 }),
).to.deep.equal([]);
expect(serverFetch.called).to.be.false;
});
it('drops pipeline hits that do not reference a message', async () => {
Subscriptions.findByUserId.returns(cursor([{ rid: 'allowed' }]));
serverFetch.resolves({
ok: true,
status: 200,
json: async () => ({
results: [{ metadata: { room_id: 'allowed' }, text: 'index text without a message reference', score: 0.1 }],
}),
text: async () => '',
});
expect(await createService().search({ query: 'fruit', userId: 'user-id', limit: 5 })).to.deep.equal([]);
});
it('drops pipeline hits when the referenced message is not visible', async () => {
Subscriptions.findByUserId.returns(cursor([{ rid: 'allowed' }]));
serverFetch.resolves({
ok: true,
status: 200,
json: async () => ({
results: [{ metadata: { room_id: 'allowed', msg_id: 'hidden-msg' }, text: 'pipeline text should not leak', score: 0.1 }],
}),
text: async () => '',
});
Messages.findVisibleByIds.returns(cursor([]));
expect(await createService().search({ query: 'fruit', userId: 'user-id', limit: 5 })).to.deep.equal([]);
});
it('drops pipeline hits from rooms where the user subscription is banned', async () => {
Subscriptions.findByUserId.returns(cursor([{ rid: 'allowed' }]));
Subscriptions.findByUserIdAndRoomIds.returns(cursor([{ rid: 'allowed', status: 'BANNED' }]));
serverFetch.resolves({
ok: true,
status: 200,
json: async () => ({
results: [{ metadata: { room_id: 'allowed', msg_id: 'allowed-msg' }, text: 'pipeline text', similarity: 0.8 }],
}),
text: async () => '',
});
expect(await createService().search({ query: 'fruit', userId: 'user-id', limit: 5 })).to.deep.equal([]);
});
});
describe('answer', () => {
it('rejects answer generation when AI Search or answer generation is unavailable', async () => {
cachedSettings.get.callsFake((key: string) => (key === 'AI_Intelligent_Search_Answer_Enabled' ? false : settings[key]));
try {
await createService().answer({ query: 'fruit', messages: [{ text: 'oranges are green' }] });
throw new Error('Expected answer generation to fail');
} catch (error) {
expect((error as Error).message).to.equal('error-ai-not-enabled');
}
expect(serverFetch.called).to.be.false;
});
it('generates an answer from source messages with the configured LLM provider', async () => {
serverFetch.resolves({
ok: true,
status: 200,
json: async () => ({ choices: [{ message: { content: 'Oranges are green.' } }] }),
text: async () => '',
});
expect(
await createService().answer({
query: 'fruit colors',
messages: [{ text: 'oranges are green', username: 'alice', roomName: 'general', score: 0.61 }],
}),
).to.deep.equal({
answer: 'Oranges are green.',
provider: { name: 'OpenAI compatible', model: 'gpt-test' },
});
const [url, options] = serverFetch.firstCall.args;
expect(url).to.equal('https://llm.example.com/chat/completions');
expect(options.headers.Authorization).to.equal('Bearer llm-key');
expect(JSON.parse(options.body).messages[0]).to.deep.equal({ role: 'system', content: 'Use sources only.' });
});
});
describe('models', () => {
it('does not contact the provider without the AI add-on', async () => {
License.hasModule.resolves(false);
expect(await createService().models()).to.deep.equal([]);
expect(serverFetch.called).to.be.false;
});
});
});

View File

@@ -0,0 +1,223 @@
import type { IAppsEngineService } from '@rocket.chat/core-services';
import { expect } from 'chai';
import { afterEach, beforeEach, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const AppsMock = {
self: {
isInitialized: sinon.stub(),
getManager: sinon.stub(),
getStorage: sinon.stub(),
getAppSourceStorage: sinon.stub(),
getRocketChatLogger: sinon.stub(),
triggerEvent: sinon.stub(),
},
};
const apiMock = {
call: sinon.stub(),
nodeList: sinon.stub(),
};
const isRunningMsMock = sinon.stub();
const serviceMocks = {
'@rocket.chat/apps': { Apps: AppsMock },
'@rocket.chat/core-services': {
api: apiMock,
ServiceClassInternal: class {
onEvent = sinon.stub();
},
},
'../../lib/isRunningMs': { isRunningMs: isRunningMsMock },
'../../lib/logger/system': { SystemLogger: { error: sinon.stub() } },
};
const { AppsEngineService, AppsEngineNoNodesFoundError } = proxyquire
.noCallThru()
.load('../../../../../server/services/apps-engine/service', serviceMocks);
describe('AppsEngineService', () => {
let service: IAppsEngineService;
it('should instantiate properly', () => {
expect(new AppsEngineService()).to.be.instanceOf(AppsEngineService);
});
describe('#getAppsStatusInNode - part 1', () => {
it('should error if api is not available', async () => {
isRunningMsMock.returns(true);
const service = new AppsEngineService();
await expect(service.getAppsStatusInNodes()).to.be.rejectedWith('AppsEngineService is not initialized');
});
});
beforeEach(() => {
service = new AppsEngineService();
(service as any).api = apiMock;
});
afterEach(() => {
apiMock.call.reset();
apiMock.nodeList.reset();
AppsMock.self.isInitialized.reset();
AppsMock.self.getManager.reset();
AppsMock.self.getStorage.reset();
AppsMock.self.getAppSourceStorage.reset();
AppsMock.self.getRocketChatLogger.reset();
AppsMock.self.triggerEvent.reset();
isRunningMsMock.reset();
});
describe('#isInitialized', () => {
it('should return true when Apps is initialized', () => {
AppsMock.self.isInitialized.returns(true);
expect(service.isInitialized()).to.be.true;
});
it('should return false when Apps is not initialized', () => {
AppsMock.self.isInitialized.returns(false);
expect(service.isInitialized()).to.be.false;
});
});
describe('#getApps', () => {
it('should return app info from manager', async () => {
const mockApps = [{ getInfo: () => ({ id: 'app1' }) }];
const mockManager = { get: sinon.stub().resolves(mockApps) };
AppsMock.self.getManager.returns(mockManager);
const result = await service.getApps({});
expect(result).to.deep.equal([{ id: 'app1' }]);
});
it('should return undefined when manager is not available', async () => {
AppsMock.self.getManager.returns(undefined);
const result = await service.getApps({});
expect(result).to.be.undefined;
});
});
describe('#getAppsStatusLocal', () => {
it('should return app status reports', async () => {
const mockApps = [
{
getStatus: sinon.stub().resolves('enabled'),
getID: sinon.stub().returns('app1'),
},
];
const mockManager = { get: sinon.stub().resolves(mockApps) };
AppsMock.self.getManager.returns(mockManager);
const result = await service.getAppsStatusLocal();
expect(result).to.deep.equal([
{
status: 'enabled',
appId: 'app1',
},
]);
});
it('should return empty array when manager is not available', async () => {
AppsMock.self.getManager.returns(undefined);
const result = await service.getAppsStatusLocal();
expect(result).to.deep.equal([]);
});
});
describe('#getAppStorageItemById', () => {
it('should return storage item for existing app', async () => {
const mockStorageItem = { id: 'app1' };
const mockApp = {
getStorageItem: sinon.stub().returns(mockStorageItem),
};
const mockManager = { getOneById: sinon.stub().returns(mockApp) };
AppsMock.self.getManager.returns(mockManager);
const result = await service.getAppStorageItemById('app1');
expect(result).to.equal(mockStorageItem);
});
it('should return undefined for non-existent app', async () => {
const mockManager = { getOneById: sinon.stub().returns(undefined) };
AppsMock.self.getManager.returns(mockManager);
const result = await service.getAppStorageItemById('non-existent');
expect(result).to.be.undefined;
});
});
describe('#getAppsStatusInNode - part 2', () => {
it('should throw error when not in microservices mode', async () => {
isRunningMsMock.returns(false);
await expect(service.getAppsStatusInNodes()).to.be.rejectedWith(
'Getting apps status in cluster is only available in microservices mode',
);
});
it('should throw error when not enough apps-engine nodes are available', async () => {
isRunningMsMock.returns(true);
apiMock.nodeList.resolves([{ id: 'node1', local: true }]);
apiMock.call.resolves([{ name: 'apps-engine', nodes: ['node1'] }]);
await expect(service.getAppsStatusInNodes()).to.be.rejectedWith(AppsEngineNoNodesFoundError);
});
it('should return status from all nodes', async () => {
isRunningMsMock.returns(true);
apiMock.nodeList.resolves([{ id: 'node1', local: true }]);
apiMock.call
.onCall(0)
.resolves([{ name: 'apps-engine', nodes: ['node1', 'node2', 'node3'] }])
.onCall(1)
.resolves([
{ status: 'enabled', appId: 'app1' },
{ status: 'enabled', appId: 'app2' },
])
.onCall(2)
.resolves([
{ status: 'enabled', appId: 'app1' },
{ status: 'enabled', appId: 'app2' },
])
.onCall(3)
.resolves([
{ status: 'initialized', appId: 'app1' },
{ status: 'enabled', appId: 'app2' },
]);
const result = await service.getAppsStatusInNodes();
expect(result).to.deep.equal({
app1: [
{ instanceId: 'node1', isLocal: true, status: 'enabled' },
{ instanceId: 'node2', isLocal: false, status: 'enabled' },
{ instanceId: 'node3', isLocal: false, status: 'initialized' },
],
app2: [
{ instanceId: 'node1', isLocal: true, status: 'enabled' },
{ instanceId: 'node2', isLocal: false, status: 'enabled' },
{ instanceId: 'node3', isLocal: false, status: 'enabled' },
],
});
});
it('should throw error when failed to get status from a node', async () => {
isRunningMsMock.returns(true);
apiMock.nodeList.resolves([{ id: 'node1', local: true }]);
apiMock.call
.onFirstCall()
.resolves([{ name: 'apps-engine', nodes: ['node1', 'node2'] }])
.onSecondCall()
.resolves([
{ status: 'enabled', appId: 'app1' },
{ status: 'enabled', appId: 'app2' },
])
.onThirdCall()
.resolves(undefined);
await expect(service.getAppsStatusInNodes()).to.be.rejectedWith('Failed to get apps status from node node2');
});
});
});

View File

@@ -0,0 +1,119 @@
import type { IUser } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { beforeEach, describe, it } from 'mocha';
import p from 'proxyquire';
import sinon from 'sinon';
const sandbox = sinon.createSandbox();
const modelsMock = {
Subscriptions: {
findOneBannedSubscription: sandbox.stub(),
countByRoomIdAndUserId: sandbox.stub(),
},
Rooms: {
findOneById: sandbox.stub(),
},
TeamMember: {
findOneByUserIdAndTeamId: sandbox.stub(),
},
Team: {
findOneById: sandbox.stub(),
},
Users: {
findOneById: sandbox.stub(),
},
};
const coreServicesMock = {
Authorization: {
hasPermission: sandbox.stub(),
canAccessRoom: sandbox.stub(),
},
License: {
hasModule: sandbox.stub(),
},
Abac: {
canAccessObject: sandbox.stub(),
},
Settings: {
get: sandbox.stub(),
},
};
const canAccessRoomLivechatMock = sandbox.stub();
const { canAccessRoom } = p.noCallThru().load('../../../../../server/services/authorization/canAccessRoom.ts', {
'@rocket.chat/models': modelsMock,
'@rocket.chat/core-services': coreServicesMock,
'./canAccessRoomLivechat': { canAccessRoomLivechat: canAccessRoomLivechatMock },
});
describe('canAccessRoom', () => {
beforeEach(() => {
sandbox.reset();
// sane defaults: everything denies access unless a test says otherwise
modelsMock.Subscriptions.findOneBannedSubscription.resolves(null);
modelsMock.Subscriptions.countByRoomIdAndUserId.resolves(0);
modelsMock.Rooms.findOneById.resolves(null);
modelsMock.TeamMember.findOneByUserIdAndTeamId.resolves(null);
modelsMock.Team.findOneById.resolves(null);
modelsMock.Users.findOneById.resolves(null);
coreServicesMock.Authorization.hasPermission.resolves(false);
coreServicesMock.Authorization.canAccessRoom.resolves(false);
coreServicesMock.License.hasModule.resolves(false);
coreServicesMock.Abac.canAccessObject.resolves(false);
coreServicesMock.Settings.get.resolves(false);
canAccessRoomLivechatMock.resolves(false);
});
describe('user hydration', () => {
it('should hydrate a partial user before running the validators', async () => {
const fullUser = { _id: 'user-id', username: 'john.doe', roles: ['user'] };
modelsMock.Users.findOneById.resolves(fullUser);
coreServicesMock.Authorization.hasPermission.resolves(true);
const result = await canAccessRoom({ _id: 'room-id', t: 'c' }, { _id: 'user-id' });
expect(result).to.be.true;
expect(modelsMock.Users.findOneById.calledOnceWith('user-id')).to.be.true;
expect(coreServicesMock.Authorization.hasPermission.calledWith(fullUser, 'view-c-room')).to.be.true;
});
it('should throw when the partial user cannot be found', async () => {
await expect(canAccessRoom({ _id: 'room-id', t: 'c' }, { _id: 'user-id' })).to.be.rejectedWith('User not found');
});
it('should not hydrate a full user', async () => {
await canAccessRoom({ _id: 'room-id', t: 'c' }, { _id: 'user-id', username: 'john.doe' } as IUser);
expect(modelsMock.Users.findOneById.notCalled).to.be.true;
});
it('should not hydrate an undefined user', async () => {
await canAccessRoom({ _id: 'room-id', t: 'c' }, undefined);
expect(modelsMock.Users.findOneById.notCalled).to.be.true;
});
it('should not hydrate a user whose _id is undefined, and treat it as anonymous instead of throwing when anonymous read is on', async () => {
coreServicesMock.Settings.get.resolves(true);
const result = await canAccessRoom({ _id: 'room-id', t: 'c' }, { _id: undefined });
expect(modelsMock.Users.findOneById.notCalled).to.be.true;
expect(coreServicesMock.Settings.get.calledWith('Accounts_AllowAnonymousRead')).to.be.true;
expect(result).to.be.true;
});
it('should deny access to a user whose _id is undefined when anonymous read is off', async () => {
coreServicesMock.Settings.get.resolves(false);
const result = await canAccessRoom({ _id: 'room-id', t: 'c' }, { _id: undefined });
expect(modelsMock.Users.findOneById.notCalled).to.be.true;
expect(result).to.be.false;
});
});
});

View File

@@ -0,0 +1,149 @@
import type { BannerPlatform, IBanner, IBannerDismiss } from '@rocket.chat/core-typings';
import { registerModel, BaseRaw } from '@rocket.chat/models';
import { expect } from 'chai';
import { afterEach, before, describe, it } from 'mocha';
import type { FindCursor, FindOptions } from 'mongodb';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const { BannerService } = proxyquire.noCallThru().load('../../../../../server/services/banner/service', {});
class BannerModel extends BaseRaw<any> {
findActiveByRoleOrId(
_roles: string[],
_platform: BannerPlatform,
_bannerId?: string,
_options?: FindOptions<IBanner>,
): FindCursor<IBanner> {
return {} as unknown as FindCursor<IBanner>;
}
}
class BannerDismissModel extends BaseRaw<any> {
findByUserIdAndBannerId(): FindCursor<Pick<IBannerDismiss, 'bannerId'>> {
return {} as unknown as FindCursor<Pick<IBannerDismiss, 'bannerId'>>;
}
}
class UserModel extends BaseRaw<any> {
override findOneById(): Promise<any> {
return Promise.resolve({});
}
}
function findCursorFactory<T>(items: T[]): FindCursor<T> {
return {
toArray: () => Promise.resolve(items),
} as unknown as FindCursor<T>;
}
const bannersModel = new BannerModel({ collection: () => ({}) } as unknown as any, 'banner');
const bannerDismissModel = new BannerDismissModel({ collection: () => ({}) } as unknown as any, 'banner_dismiss');
const userModel = new UserModel({ collection: () => ({}) } as unknown as any, 'user');
describe('Banners service', () => {
before(() => {
registerModel('IBannersModel', bannersModel);
registerModel('IBannersDismissModel', bannerDismissModel);
registerModel('IUsersModel', userModel);
});
afterEach(() => sinon.restore());
it('should be defined', () => {
const service = new BannerService();
expect(service).to.be.an('object');
});
describe('getBannersForUser', () => {
const FAKE_BANNER: IBanner = {
_id: 'fake-id',
view: {
appId: 'fake-app-id',
viewId: 'fake-view-id',
},
} as IBanner;
const A_SECOND_FAKE_BANNER: IBanner = {
_id: 'fake-id-2',
surface: 'modal',
view: {},
} as IBanner;
it('should return the banners a user has access to', async () => {
const service = new BannerService();
const findActiveByRoleOrIdMock = sinon.replace(
bannersModel,
'findActiveByRoleOrId',
sinon.fake.returns(findCursorFactory<IBanner>([FAKE_BANNER])),
);
const findByUserIdAndBannerIdMock = sinon.replace(
bannerDismissModel,
'findByUserIdAndBannerId',
sinon.fake.returns(findCursorFactory<Pick<IBannerDismiss, 'bannerId'>>([])),
);
sinon.replace(userModel, 'findOneById', sinon.fake.returns(Promise.resolve({})));
const banners = await service.getBannersForUser('a-fake-id', 'web');
expect(findActiveByRoleOrIdMock.callCount).to.be.equal(1);
expect(findByUserIdAndBannerIdMock.callCount).to.be.equal(1);
expect(banners).to.be.an('array');
expect(banners).to.have.lengthOf(1);
expect(banners[0].view.viewId).to.be.equal(FAKE_BANNER.view.viewId);
expect(banners[0].surface).to.be.equal('banner');
});
it('should return all the banners that were not dismissed', async () => {
const service = new BannerService();
const findActiveByRoleOrIdMock = sinon.replace(
bannersModel,
'findActiveByRoleOrId',
sinon.fake.returns(findCursorFactory<IBanner>([FAKE_BANNER])),
);
const findByUserIdAndBannerIdMock = sinon.replace(
bannerDismissModel,
'findByUserIdAndBannerId',
sinon.fake.returns(findCursorFactory<Pick<IBannerDismiss, 'bannerId'>>([{ bannerId: FAKE_BANNER._id }])),
);
sinon.replace(userModel, 'findOneById', sinon.fake.returns(Promise.resolve({})));
const banners = await service.getBannersForUser('a-fake-id', 'web');
expect(findActiveByRoleOrIdMock.callCount).to.be.equal(1);
expect(findByUserIdAndBannerIdMock.callCount).to.be.equal(1);
expect(banners).to.be.an('array');
expect(banners).to.have.lengthOf(0);
});
it('should use the _id as viewId if not set', async () => {
const service = new BannerService();
const findActiveByRoleOrIdMock = sinon.replace(
bannersModel,
'findActiveByRoleOrId',
sinon.fake.returns(findCursorFactory<IBanner>([A_SECOND_FAKE_BANNER])),
);
const findByUserIdAndBannerIdMock = sinon.replace(
bannerDismissModel,
'findByUserIdAndBannerId',
sinon.fake.returns(findCursorFactory<Pick<IBannerDismiss, 'bannerId'>>([])),
);
sinon.replace(userModel, 'findOneById', sinon.fake.returns(Promise.resolve({})));
const banners = await service.getBannersForUser('a-fake-id', 'web');
expect(findActiveByRoleOrIdMock.callCount).to.be.equal(1);
expect(findByUserIdAndBannerIdMock.callCount).to.be.equal(1);
expect(banners).to.be.an('array');
expect(banners).to.have.lengthOf(1);
expect(banners[0].view.viewId).to.be.equal(A_SECOND_FAKE_BANNER._id);
expect(banners[0].surface).to.be.equal('modal');
});
});
});

View File

@@ -0,0 +1,32 @@
import type { AgendaCronJobs } from '@rocket.chat/cron';
// #TODO: Move this to a package and write unit tests there ensuring that the behavior of the mock and the real class match 1:1
export class MockedCronJobs {
public jobNames = new Set<string>();
private _started = false;
public get started(): boolean {
return this._started;
}
start: AgendaCronJobs['start'] = async () => {
this._started = true;
};
add: AgendaCronJobs['add'] = async (name) => {
this.jobNames.add(name);
};
addAtTimestamp: AgendaCronJobs['addAtTimestamp'] = async (name) => {
this.jobNames.add(name);
};
remove: AgendaCronJobs['remove'] = async (name) => {
this.jobNames.delete(name);
};
has: AgendaCronJobs['has'] = async (jobName) => {
return this.jobNames.has(jobName);
};
}

View File

@@ -0,0 +1,636 @@
import { api } from '@rocket.chat/core-services';
import { expect } from 'chai';
import { describe, it, beforeEach, afterEach } from 'mocha';
import type { DeleteResult, UpdateResult } from 'mongodb';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
import { testPrivateMethod, createFreshServiceInstance } from '../utils';
import { MockedCronJobs } from './mocks/cronJobs';
const settingsMock = new Map<string, any>();
const cronJobsMock = new MockedCronJobs();
const CalendarEventMock = {
insertOne: sinon.stub(),
findOne: sinon.stub(),
findByUserIdAndDate: sinon.stub(),
updateEvent: sinon.stub(),
deleteOne: sinon.stub(),
findNextNotificationDate: sinon.stub(),
findEventsToNotify: sinon.stub(),
flagNotificationSent: sinon.stub(),
findOneByExternalIdAndUserId: sinon.stub(),
findNextFutureEvent: sinon.stub(),
findOverlappingEvents: sinon.stub(),
};
const PresenceMock = {
setActiveState: sinon.stub().resolves(),
endActiveState: sinon.stub().resolves(),
};
const UsersMock = {
findOneById: sinon.stub().resolves({ language: 'en' }),
};
const getUserPreferenceMock = sinon.stub();
const serviceMocks = {
'../../settings': { settings: settingsMock },
'@rocket.chat/core-services': {
api,
ServiceClassInternal: class {},
Presence: PresenceMock,
},
'@rocket.chat/cron': { cronJobs: cronJobsMock },
'@rocket.chat/models': { CalendarEvent: CalendarEventMock, Users: UsersMock },
'../../lib/utils/lib/getUserPreference': { getUserPreference: getUserPreferenceMock },
'../../lib/i18n': { i18n: { t: sinon.stub().returns('Outlook: In a meeting') } },
};
const { CalendarService } = proxyquire.noCallThru().load('../../../../../server/services/calendar/service', serviceMocks);
describe('CalendarService', () => {
let sandbox: sinon.SinonSandbox;
let service: InstanceType<typeof CalendarService>;
const fakeUserId = 'user123';
const fakeEventId = 'event456';
const fakeExternalId = 'external789';
const fakeStartTime = new Date('2025-01-01T10:00:00Z');
const fakeEndTime = new Date('2025-01-01T11:00:00Z');
const fakeSubject = 'Test Meeting';
const fakeDescription = 'This is a test meeting';
const fakeMeetingUrl = 'https://meet.test/123';
beforeEach(() => {
sandbox = sinon.createSandbox();
service = new CalendarService();
stubServiceMethods();
setupCalendarEventMocks();
setupPresenceMocks();
setupOtherMocks();
});
function stubServiceMethods() {
const proto = Object.getPrototypeOf(service);
sandbox.stub(proto, 'parseDescriptionForMeetingUrl').resolves(fakeMeetingUrl);
sandbox.stub(proto, 'findImportedEvent').callsFake(async (externalId, uid) => {
return CalendarEventMock.findOneByExternalIdAndUserId(externalId, uid);
});
sandbox.stub(proto, 'sendEventNotification').resolves();
sandbox.stub(proto, 'sendCurrentNotifications').resolves();
sandbox.stub(proto, 'doSetupNextNotification').resolves();
sandbox.stub(proto, 'doSetupNextStatusChange').resolves();
sandbox.stub(service, 'setupNextNotification').resolves();
sandbox.stub(service, 'setupNextStatusChange').resolves();
}
function setupCalendarEventMocks() {
const freshMocks = {
insertOne: sinon.stub().resolves({ insertedId: fakeEventId }),
findOne: sinon.stub().resolves(null),
findByUserIdAndDate: sinon.stub().returns({
toArray: sinon.stub().resolves([]),
}),
updateEvent: sinon.stub().resolves({ modifiedCount: 1, matchedCount: 1 } as UpdateResult),
deleteOne: sinon.stub().resolves({ deletedCount: 1 } as DeleteResult),
findNextNotificationDate: sinon.stub().resolves(null),
findEventsToNotify: sinon.stub().returns({
toArray: sinon.stub().resolves([]),
}),
flagNotificationSent: sinon.stub().resolves(),
findOneByExternalIdAndUserId: sinon.stub().resolves(null),
findNextFutureEvent: sinon.stub().resolves(null),
findOverlappingEvents: sinon.stub().returns({
next: sinon.stub().resolves(null),
toArray: sinon.stub().resolves([]),
}),
};
Object.assign(CalendarEventMock, freshMocks);
}
function setupPresenceMocks() {
PresenceMock.setActiveState.resetHistory();
PresenceMock.endActiveState.resetHistory();
UsersMock.findOneById.resetHistory();
UsersMock.findOneById.resolves({ language: 'en' });
}
function setupOtherMocks() {
sandbox.stub(api, 'broadcast').resolves();
settingsMock.clear();
settingsMock.set(
'Calendar_MeetingUrl_Regex',
'(?:[?&]callUrl=([^\n&<]+))|(?:(?:%3F)|(?:%26))callUrl(?:%3D)((?:(?:[^\n&<](?!%26)))+[^\n&<]?)',
);
settingsMock.set('Calendar_BusyStatus_Enabled', true);
cronJobsMock.jobNames.clear();
getUserPreferenceMock.reset();
getUserPreferenceMock.resolves(true);
}
afterEach(() => {
sandbox.restore();
});
describe('#create', () => {
it('should create a new calendar event', async () => {
const eventData = {
uid: fakeUserId,
startTime: fakeStartTime,
endTime: fakeEndTime,
subject: fakeSubject,
description: fakeDescription,
meetingUrl: fakeMeetingUrl,
reminderMinutesBeforeStart: 5,
};
const result = await service.create(eventData);
expect(result).to.equal(fakeEventId);
expect(CalendarEventMock.insertOne.callCount).to.equal(1);
expect(CalendarEventMock.insertOne.firstCall.args[0]).to.include({
uid: fakeUserId,
startTime: fakeStartTime,
subject: fakeSubject,
description: fakeDescription,
meetingUrl: fakeMeetingUrl,
reminderMinutesBeforeStart: 5,
notificationSent: false,
});
sinon.assert.calledOnce(service.setupNextStatusChange);
});
});
describe('#import', () => {
it('should create a new event if externalId is not provided', async () => {
const eventData = {
uid: fakeUserId,
startTime: fakeStartTime,
subject: fakeSubject,
description: fakeDescription,
};
await service.import(eventData);
sinon.assert.calledOnce(CalendarEventMock.insertOne);
sinon.assert.calledOnce(service.setupNextStatusChange);
});
it('should update existing event if found by externalId', async () => {
const eventData = {
uid: fakeUserId,
startTime: fakeStartTime,
subject: fakeSubject,
description: fakeDescription,
externalId: fakeExternalId,
};
CalendarEventMock.findOneByExternalIdAndUserId.resolves({
_id: fakeEventId,
uid: fakeUserId,
externalId: fakeExternalId,
});
await service.import(eventData);
sinon.assert.calledWith(CalendarEventMock.findOneByExternalIdAndUserId, fakeExternalId, fakeUserId);
sinon.assert.calledOnce(CalendarEventMock.updateEvent);
sinon.assert.notCalled(CalendarEventMock.insertOne);
});
});
describe('#update', () => {
it('should update an existing event', async () => {
const fakeEvent = {
_id: fakeEventId,
uid: fakeUserId,
startTime: fakeStartTime,
endTime: fakeEndTime,
subject: fakeSubject,
};
CalendarEventMock.findOne.resolves(fakeEvent);
const updateData = {
subject: 'Updated Subject',
description: 'Updated Description',
};
await service.update(fakeEventId, updateData);
sinon.assert.calledWith(CalendarEventMock.updateEvent, fakeEventId, sinon.match.has('subject', 'Updated Subject'));
});
it('should reschedule the status change when start/end times change', async () => {
const fakeEvent = {
_id: fakeEventId,
uid: fakeUserId,
startTime: fakeStartTime,
endTime: fakeEndTime,
subject: fakeSubject,
};
CalendarEventMock.findOne.resolves(fakeEvent);
const newStartTime = new Date('2025-01-02T10:00:00Z');
const newEndTime = new Date('2025-01-02T11:00:00Z');
await service.update(fakeEventId, {
startTime: newStartTime,
endTime: newEndTime,
});
sinon.assert.calledOnce(service.setupNextStatusChange);
});
});
describe('#delete', () => {
it('should delete an event', async () => {
const fakeEvent = {
_id: fakeEventId,
uid: fakeUserId,
startTime: fakeStartTime,
subject: fakeSubject,
};
CalendarEventMock.findOne.resolves(fakeEvent);
await service.delete(fakeEventId);
sinon.assert.calledWith(CalendarEventMock.deleteOne, { _id: fakeEventId });
});
it('should lower the busy expiry to the remaining active event when deleting an overlapping in-progress event', async () => {
const t = Date.now();
CalendarEventMock.findOne.resolves({
_id: fakeEventId,
uid: fakeUserId,
startTime: new Date(t - 60 * 1000),
endTime: new Date(t + 120 * 60 * 1000),
});
CalendarEventMock.findOverlappingEvents.returns({
next: sinon.stub().resolves(null),
toArray: sinon.stub().resolves([{ _id: 'remaining', endTime: new Date(t + 30 * 60 * 1000) }]),
});
await service.delete(fakeEventId);
sinon.assert.calledOnce(PresenceMock.setActiveState);
sinon.assert.notCalled(PresenceMock.endActiveState);
const [uid, state] = PresenceMock.setActiveState.firstCall.args;
expect(uid).to.equal(fakeUserId);
expect(state.statusExpiresAt.getTime()).to.equal(t + 30 * 60 * 1000);
});
it('should end the busy state when deleting the only active in-progress event', async () => {
const t = Date.now();
CalendarEventMock.findOne.resolves({
_id: fakeEventId,
uid: fakeUserId,
startTime: new Date(t - 60 * 1000),
endTime: new Date(t + 60 * 60 * 1000),
});
CalendarEventMock.findOverlappingEvents.returns({
next: sinon.stub().resolves(null),
toArray: sinon.stub().resolves([]),
});
await service.delete(fakeEventId);
sinon.assert.calledOnceWithExactly(PresenceMock.endActiveState, fakeUserId, 'calendar');
sinon.assert.notCalled(PresenceMock.setActiveState);
});
it('should not touch presence when deleting an event that is not in progress', async () => {
const t = Date.now();
CalendarEventMock.findOne.resolves({
_id: fakeEventId,
uid: fakeUserId,
startTime: new Date(t + 60 * 60 * 1000),
endTime: new Date(t + 120 * 60 * 1000),
});
await service.delete(fakeEventId);
sinon.assert.notCalled(PresenceMock.endActiveState);
sinon.assert.notCalled(PresenceMock.setActiveState);
});
});
describe('#setupNextNotification', () => {
it('should call doSetupNextNotification internally', async () => {
const serviceExports = proxyquire.noCallThru().load('../../../../../server/services/calendar/service', serviceMocks);
const testService = createFreshServiceInstance<InstanceType<typeof CalendarService>>(serviceExports);
const localSandbox = sinon.createSandbox();
try {
const doSetupStub = localSandbox.stub(Object.getPrototypeOf(testService), 'doSetupNextNotification').resolves();
await testService.setupNextNotification();
sinon.assert.calledOnceWithExactly(doSetupStub, false);
} finally {
localSandbox.restore();
}
});
});
describe('Private: parseDescriptionForMeetingUrl', () => {
it('should extract URL from description with default pattern', async () => {
await testPrivateMethod(service, 'parseDescriptionForMeetingUrl', async (method) => {
const testDescription = 'Join at https://meet.example.com?callUrl=https://special-meeting.com/123';
const result = await method(testDescription);
expect(result).to.equal('https://special-meeting.com/123');
});
});
});
describe('Private: doSetupNextNotification', () => {
it('should schedule notifications at the next date', async () => {
await testPrivateMethod(service, 'doSetupNextNotification', async (method) => {
const nextDate = new Date('2025-01-01T10:00:00Z');
CalendarEventMock.findNextNotificationDate.resolves(nextDate);
await method(false);
expect(cronJobsMock.jobNames.has('calendar-reminders')).to.true;
});
});
});
describe('Private: doSetupNextStatusChange', () => {
it('should not run when busy status setting is disabled', async () => {
await testPrivateMethod(service, 'doSetupNextStatusChange', async (method) => {
settingsMock.set('Calendar_BusyStatus_Enabled', false);
const originalHas = cronJobsMock.has;
const originalRemove = cronJobsMock.remove;
const originalAddAtTimestamp = cronJobsMock.addAtTimestamp;
const hasStub = sinon.stub().resolves(true);
const removeStub = sinon.stub().resolves();
const addAtTimestampStub = sinon.stub().resolves();
cronJobsMock.has = hasStub;
cronJobsMock.remove = removeStub;
cronJobsMock.addAtTimestamp = addAtTimestampStub;
try {
await method();
sinon.assert.calledWith(hasStub, 'calendar-status-scheduler');
sinon.assert.calledWith(removeStub, 'calendar-status-scheduler');
sinon.assert.notCalled(addAtTimestampStub);
} finally {
cronJobsMock.has = originalHas;
cronJobsMock.remove = originalRemove;
cronJobsMock.addAtTimestamp = originalAddAtTimestamp;
}
});
});
it('should schedule a job at the next event start time', async () => {
await testPrivateMethod(service, 'doSetupNextStatusChange', async (method) => {
settingsMock.set('Calendar_BusyStatus_Enabled', true);
const futureEvent = {
_id: 'future123',
uid: fakeUserId,
startTime: new Date(Date.now() + 60000),
endTime: new Date(Date.now() + 3660000),
};
CalendarEventMock.findNextFutureEvent.resolves(futureEvent);
const originalHas = cronJobsMock.has;
const originalRemove = cronJobsMock.remove;
const originalAddAtTimestamp = cronJobsMock.addAtTimestamp;
const hasStub = sinon.stub().resolves(false);
const removeStub = sinon.stub().resolves();
const addAtTimestampStub = sinon.stub().resolves();
cronJobsMock.has = hasStub;
cronJobsMock.remove = removeStub;
cronJobsMock.addAtTimestamp = addAtTimestampStub;
try {
await method();
sinon.assert.calledOnce(addAtTimestampStub);
sinon.assert.calledWith(addAtTimestampStub, 'calendar-status-scheduler', futureEvent.startTime, sinon.match.func);
} finally {
cronJobsMock.has = originalHas;
cronJobsMock.remove = originalRemove;
cronJobsMock.addAtTimestamp = originalAddAtTimestamp;
}
});
});
it('should not schedule a job when there are no future events', async () => {
await testPrivateMethod(service, 'doSetupNextStatusChange', async (method) => {
settingsMock.set('Calendar_BusyStatus_Enabled', true);
CalendarEventMock.findNextFutureEvent.resolves(null);
const originalHas = cronJobsMock.has;
const originalRemove = cronJobsMock.remove;
const originalAddAtTimestamp = cronJobsMock.addAtTimestamp;
const hasStub = sinon.stub().resolves(false);
const removeStub = sinon.stub().resolves();
const addAtTimestampStub = sinon.stub().resolves();
cronJobsMock.has = hasStub;
cronJobsMock.remove = removeStub;
cronJobsMock.addAtTimestamp = addAtTimestampStub;
try {
await method();
sinon.assert.notCalled(addAtTimestampStub);
} finally {
cronJobsMock.has = originalHas;
cronJobsMock.remove = originalRemove;
cronJobsMock.addAtTimestamp = originalAddAtTimestamp;
}
});
});
});
describe('Private: processEventStart', () => {
// await directly: testPrivateMethod doesn't await its callback, so async assertions wouldn't fail
const callProcessEventStart = (event: unknown) => (service as any).processEventStart(event);
it('should set statusExpiresAt to the latest endTime among overlapping active events', async () => {
const now = Date.now();
const event = {
_id: fakeEventId,
uid: fakeUserId,
startTime: new Date(now - 1000),
endTime: new Date(now + 60 * 60 * 1000),
};
CalendarEventMock.findOverlappingEvents.returns({
next: sinon.stub().resolves(null),
toArray: sinon.stub().resolves([
{ _id: 'later', endTime: new Date(now + 120 * 60 * 1000) },
{ _id: 'earlier', endTime: new Date(now + 30 * 60 * 1000) },
]),
});
await callProcessEventStart(event);
sinon.assert.calledOnce(PresenceMock.setActiveState);
const [uid, state] = PresenceMock.setActiveState.firstCall.args;
expect(uid).to.equal(fakeUserId);
expect(state.statusSource).to.equal('external');
expect(state.statusExpiresAt.getTime()).to.equal(now + 120 * 60 * 1000);
expect(state.statusId).to.equal('calendar');
expect(state.statusText).to.equal('Outlook: In a meeting');
});
it('should use the event own endTime when there are no overlapping events', async () => {
const now = Date.now();
const endTime = new Date(now + 60 * 60 * 1000);
const event = {
_id: fakeEventId,
uid: fakeUserId,
startTime: new Date(now - 1000),
endTime,
};
await callProcessEventStart(event);
sinon.assert.calledOnce(PresenceMock.setActiveState);
const [, state] = PresenceMock.setActiveState.firstCall.args;
expect(state.statusExpiresAt.getTime()).to.equal(endTime.getTime());
});
it('should not apply busy status when the event has no endTime', async () => {
const event = {
_id: fakeEventId,
uid: fakeUserId,
startTime: new Date(),
};
await callProcessEventStart(event);
sinon.assert.notCalled(PresenceMock.setActiveState);
});
});
describe('Private: reconcileInProgressEvent', () => {
const callReconcile = (eventId: string) => (service as any).reconcileInProgressEvent(eventId);
const now = () => Date.now();
it('applies the busy claim for an in-progress event using its endTime', async () => {
const t = now();
CalendarEventMock.findOne.resolves({
_id: fakeEventId,
uid: fakeUserId,
startTime: new Date(t - 60 * 1000),
endTime: new Date(t + 60 * 60 * 1000),
});
await callReconcile(fakeEventId);
sinon.assert.calledOnce(PresenceMock.setActiveState);
sinon.assert.notCalled(PresenceMock.endActiveState);
const [uid, state] = PresenceMock.setActiveState.firstCall.args;
expect(uid).to.equal(fakeUserId);
expect(state.statusSource).to.equal('external');
expect(state.statusExpiresAt.getTime()).to.equal(t + 60 * 60 * 1000);
});
it('extends the busy claim to the latest end among overlapping in-progress events', async () => {
const t = now();
CalendarEventMock.findOne.resolves({
_id: fakeEventId,
uid: fakeUserId,
startTime: new Date(t - 60 * 1000),
endTime: new Date(t + 30 * 60 * 1000),
});
CalendarEventMock.findOverlappingEvents.returns({
next: sinon.stub().resolves(null),
toArray: sinon.stub().resolves([{ _id: 'longer', endTime: new Date(t + 90 * 60 * 1000) }]),
});
await callReconcile(fakeEventId);
sinon.assert.calledOnce(PresenceMock.setActiveState);
const [, state] = PresenceMock.setActiveState.firstCall.args;
expect(state.statusExpiresAt.getTime()).to.equal(t + 90 * 60 * 1000);
});
it('does nothing for an event that has not started yet', async () => {
const t = now();
CalendarEventMock.findOne.resolves({
_id: fakeEventId,
uid: fakeUserId,
startTime: new Date(t + 60 * 60 * 1000),
endTime: new Date(t + 120 * 60 * 1000),
});
await callReconcile(fakeEventId);
sinon.assert.notCalled(PresenceMock.setActiveState);
sinon.assert.notCalled(PresenceMock.endActiveState);
});
it('does nothing for an event that has already ended', async () => {
const t = now();
CalendarEventMock.findOne.resolves({
_id: fakeEventId,
uid: fakeUserId,
startTime: new Date(t - 120 * 60 * 1000),
endTime: new Date(t - 60 * 60 * 1000),
});
await callReconcile(fakeEventId);
sinon.assert.notCalled(PresenceMock.setActiveState);
sinon.assert.notCalled(PresenceMock.endActiveState);
});
it('does nothing for a non-busy event', async () => {
const t = now();
CalendarEventMock.findOne.resolves({
_id: fakeEventId,
uid: fakeUserId,
startTime: new Date(t - 60 * 1000),
endTime: new Date(t + 60 * 60 * 1000),
busy: false,
});
await callReconcile(fakeEventId);
sinon.assert.notCalled(PresenceMock.setActiveState);
sinon.assert.notCalled(PresenceMock.endActiveState);
});
it('does nothing when busy status is disabled', async () => {
settingsMock.set('Calendar_BusyStatus_Enabled', false);
const t = now();
CalendarEventMock.findOne.resolves({
_id: fakeEventId,
uid: fakeUserId,
startTime: new Date(t - 60 * 1000),
endTime: new Date(t + 60 * 60 * 1000),
});
await callReconcile(fakeEventId);
sinon.assert.notCalled(PresenceMock.setActiveState);
sinon.assert.notCalled(PresenceMock.endActiveState);
});
});
});

View File

@@ -0,0 +1,22 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import proxyquire from 'proxyquire';
const { getShiftedTime } = proxyquire.noCallThru().load('../../../../../../server/services/calendar/utils/getShiftedTime', {});
describe('#getShiftedTime', () => {
it('should shift time forward by minutes', () => {
const date = new Date('2025-01-01T10:00:00Z');
const result = getShiftedTime(date, 30);
expect(result.getTime()).to.equal(new Date('2025-01-01T10:30:00Z').getTime());
expect(date.getTime()).to.equal(new Date('2025-01-01T10:00:00Z').getTime());
});
it('should shift time backward by negative minutes', () => {
const date = new Date('2025-01-01T10:00:00Z');
const result = getShiftedTime(date, -15);
expect(result.getTime()).to.equal(new Date('2025-01-01T09:45:00Z').getTime());
});
});

View File

@@ -0,0 +1,126 @@
import { expect } from 'chai';
import { describe, it, beforeEach, afterEach } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
import type { IInstanceService } from '../../../../../ee/server/sdk/types/IInstanceService';
const ServiceBrokerMock = {
call: sinon.stub(),
};
const AppsMock = {
getAppsStatusLocal: sinon.stub(),
};
const serviceMocks = {
'@rocket.chat/core-services': {
ServiceClassInternal: class {
onEvent = sinon.stub();
onSettingChanged = sinon.stub();
},
Apps: AppsMock,
},
'moleculer': {
ServiceBroker: sinon.stub().returns(ServiceBrokerMock),
Serializers: {
Base: class {},
},
},
};
const { InstanceService } = proxyquire
.noPreserveCache()
.noCallThru()
.load('../../../../../ee/server/local-services/instance/service', serviceMocks);
describe('InstanceService', () => {
let service: IInstanceService;
beforeEach(() => {
service = new InstanceService();
(service as any).broker = ServiceBrokerMock;
});
afterEach(() => {
ServiceBrokerMock.call.reset();
AppsMock.getAppsStatusLocal.reset();
});
describe('#getInstances', () => {
it('should return list of instances', async () => {
const mockInstances = [{ id: 'node1' }];
ServiceBrokerMock.call.resolves(mockInstances);
const instances = await service.getInstances();
expect(instances).to.deep.equal(mockInstances);
expect(ServiceBrokerMock.call.calledWith('$node.list', { onlyAvailable: true })).to.be.true;
});
it('should handle empty instance list', async () => {
ServiceBrokerMock.call.resolves([]);
const instances = await service.getInstances();
expect(instances).to.deep.equal([]);
expect(ServiceBrokerMock.call.calledWith('$node.list', { onlyAvailable: true })).to.be.true;
});
});
describe('#getAppsStatusInInstances', () => {
it('should return app status from all instances', async () => {
const mockInstances = [
{ id: 'node1', local: true },
{ id: 'node2', local: false },
{ id: 'node3', local: false },
];
ServiceBrokerMock.call
.onCall(0)
.resolves(mockInstances)
.onCall(1)
.resolves([{ status: 'enabled', appId: 'app1' }])
.onCall(2)
.resolves([{ status: 'disabled', appId: 'app2' }])
.onCall(3)
.resolves([{ status: 'enabled', appId: 'app3' }]);
const result = await service.getAppsStatusInInstances();
expect(result).to.deep.equal({
app1: [{ instanceId: 'node1', isLocal: true, status: 'enabled' }],
app2: [{ instanceId: 'node2', isLocal: false, status: 'disabled' }],
app3: [{ instanceId: 'node3', isLocal: false, status: 'enabled' }],
});
expect(ServiceBrokerMock.call.callCount).to.be.equal(4);
});
it('should handle empty app status response', async () => {
const mockInstances = [
{ id: 'node1', local: true },
{ id: 'node2', local: false },
];
ServiceBrokerMock.call.onFirstCall().resolves(mockInstances).onSecondCall().resolves([]).onThirdCall().resolves([]);
const result = await service.getAppsStatusInInstances();
expect(result).to.deep.equal({});
expect(ServiceBrokerMock.call.calledThrice).to.be.true;
});
it('should handle undefined app status response', async () => {
const mockInstances = [
{ id: 'node1', local: true },
{ id: 'node2', local: false },
];
ServiceBrokerMock.call.onFirstCall().resolves(mockInstances).onSecondCall().resolves(undefined);
await expect(service.getAppsStatusInInstances()).to.be.rejectedWith(`Failed to get apps status from instance node1`);
expect(ServiceBrokerMock.call.calledThrice).to.be.true;
});
});
});

View File

@@ -0,0 +1,127 @@
import { expect } from 'chai';
import { BeforeSaveBadWords } from '../../../../../../server/services/messages/hooks/BeforeSaveBadWords';
const createMessage = (msg?: string) => ({
_id: 'random',
rid: 'GENERAL',
ts: new Date(),
u: {
_id: 'userId',
username: 'username',
},
_updatedAt: new Date(),
msg: msg as string,
});
describe('Filter bad words before saving message', () => {
it('should return same message if bad words not configured', async () => {
const badWords = new BeforeSaveBadWords();
const message = createMessage('hell');
const result = await badWords.filterBadWords({ message });
return expect(result.msg).to.be.equal('hell');
});
it("should return same message if no 'msg' property provided", async () => {
const badWords = new BeforeSaveBadWords();
const message = createMessage();
const result = await badWords.filterBadWords({ message });
return expect(result.msg).to.be.equal(undefined);
});
it('should return filter bad words from message when configured', async () => {
const badWords = new BeforeSaveBadWords();
await badWords.configure();
const message = createMessage('hell');
const result = await badWords.filterBadWords({ message });
return expect(result.msg).to.equal('****');
});
it('should return same message if bad words disabled after configured', async () => {
const badWords = new BeforeSaveBadWords();
await badWords.configure();
badWords.disable();
const message = createMessage('hell');
const result = await badWords.filterBadWords({ message });
return expect(result.msg).to.be.equal('hell');
});
it('should filter custom bad words', async () => {
const badWords = new BeforeSaveBadWords();
await badWords.configure('good');
const message = createMessage('good');
const result = await badWords.filterBadWords({ message });
return expect(result.msg).to.be.equal('****');
});
it('should allow custom good words', async () => {
const badWords = new BeforeSaveBadWords();
await badWords.configure(undefined, 'hell');
const message = createMessage('hell');
const result = await badWords.filterBadWords({ message });
return expect(result.msg).to.be.equal('hell');
});
it('should filter non ascii bad words', async () => {
const badWords = new BeforeSaveBadWords();
await badWords.configure('バカ');
const message = createMessage('hell is バカ');
const result = await badWords.filterBadWords({ message });
return expect(result.msg).to.be.equal('**** is **');
});
it('should filter just the non ascii bad words', async () => {
const badWords = new BeforeSaveBadWords();
await badWords.configure('バカ');
const message = createMessage('バカ');
const result = await badWords.filterBadWords({ message });
return expect(result.msg).to.be.equal('**');
});
it('should filter non ascii bad words with punctuation', async () => {
const badWords = new BeforeSaveBadWords();
await badWords.configure('バカ');
const message = createMessage('バカ.');
const result = await badWords.filterBadWords({ message });
return expect(result.msg).to.be.equal('**.');
});
it('should not filter non ascii bad words, if part of another word ', async () => {
const badWords = new BeforeSaveBadWords();
await badWords.configure('バカ');
const message = createMessage('TESTバカTEST');
const result = await badWords.filterBadWords({ message });
return expect(result.msg).to.be.equal('TESTバカTEST');
});
});

View File

@@ -0,0 +1,104 @@
import { api, MeteorError } from '@rocket.chat/core-services';
import { expect } from 'chai';
import { before, describe } from 'mocha';
import { BeforeSaveCheckMAC } from '../../../../../../server/services/messages/hooks/BeforeSaveCheckMAC';
import { BrokerMocked } from '../../../../../mocks/server/BrokerMocked';
const createMessage = (msg?: string, extra: any = {}) => ({
_id: 'random',
rid: 'GENERAL',
ts: new Date(),
u: {
_id: 'userId',
username: 'username',
},
_updatedAt: new Date(),
msg: msg as string,
...extra,
});
const createRoom = (extra: any = {}): any => ({
_id: 'GENERAL',
t: 'c',
u: {
_id: 'userId',
username: 'username',
name: 'name',
},
msgs: 1,
usersCount: 1,
_updatedAt: new Date(),
...extra,
});
const broker = new BrokerMocked();
describe('Check MAC', () => {
before(() => {
api.setBroker(broker as any);
});
it('should do nothing if not omnichannel room', async () => {
const checkMAC = new BeforeSaveCheckMAC();
const empty = await checkMAC.isWithinLimits({
message: createMessage('hey'),
room: createRoom(),
});
expect(empty).to.not.have.value;
});
it('should do nothing if message from visitor', async () => {
const checkMAC = new BeforeSaveCheckMAC();
const empty = await checkMAC.isWithinLimits({
message: createMessage('hey', { token: 'hash' }),
room: createRoom({ t: 'l' }),
});
expect(empty).to.not.have.value;
});
it('should do nothing if it is a system message', async () => {
const checkMAC = new BeforeSaveCheckMAC();
const empty = await checkMAC.isWithinLimits({
message: createMessage('hey', { t: 'uj' }),
room: createRoom({ t: 'l' }),
});
expect(empty).to.not.have.value;
});
it('should throw an error if not within limits', () => {
const checkMAC = new BeforeSaveCheckMAC();
broker.mockServices({
'omnichannel.isWithinMACLimit': async () => false,
});
void expect(
checkMAC.isWithinLimits({
message: createMessage('hey'),
room: createRoom({ t: 'l' }),
}),
).to.be.rejectedWith(MeteorError, 'error-mac-limit-reached');
});
it('should work if MAC within limits', async () => {
const checkMAC = new BeforeSaveCheckMAC();
broker.mockServices({
'omnichannel.isWithinMACLimit': async () => true,
});
const empty = await checkMAC.isWithinLimits({
message: createMessage('hey'),
room: createRoom({ t: 'l' }),
});
expect(empty).to.not.have.value;
});
});

View File

@@ -0,0 +1,734 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { BeforeSaveJumpToMessage } from '../../../../../../server/services/messages/hooks/BeforeSaveJumpToMessage';
const createMessage = (msg?: string, extra: Partial<IMessage> = {}) => ({
_id: 'random',
rid: 'GENERAL',
ts: new Date(),
u: {
_id: 'userId',
username: 'username',
},
_updatedAt: new Date(),
msg: msg as string,
...extra,
});
const createUser = (username?: string) => ({
_id: 'userId',
username,
name: 'name',
language: 'en',
});
const createRoom = (extra: any = {}): any => ({
_id: 'GENERAL',
t: 'c',
u: {
_id: 'userId',
username: 'username',
name: 'name',
},
msgs: 1,
usersCount: 1,
_updatedAt: new Date(),
...extra,
});
const countDeep = (msg: any, deep = 1): number => {
if (!msg) {
return deep - 1;
}
if (Array.isArray(msg?.attachments) && msg.attachments.length > 0) {
return msg.attachments.reduce((count: number, att: any) => Math.max(countDeep(att, deep + 1), count), 0);
}
return deep - 1;
};
describe('Create attachments for message URLs', () => {
it('should return message without attatchment and URLs if no URL provided', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => [createMessage('linked message', { _id: 'linked' })],
getRooms: async () => [createRoom()],
canAccessRoom: async () => true,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey'),
user: createUser(),
config: {
chainLimit: 10,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
return expect(message).to.not.have.property('urls');
});
it('should do nothing if URL is not from SiteUrl', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => [createMessage('linked message', { _id: 'linked' })],
getRooms: async () => [createRoom()],
canAccessRoom: async () => true,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey', {
urls: [
{
url: 'https://google.com',
meta: {},
},
],
}),
user: createUser(),
config: {
chainLimit: 10,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
expect(message).to.have.property('urls').of.length(1);
expect(message).to.not.have.property('attachments');
});
it('should do nothing if URL is from SiteUrl but not have a query string', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => [createMessage('linked message', { _id: 'linked' })],
getRooms: async () => [createRoom()],
canAccessRoom: async () => true,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey', {
urls: [
{
url: 'https://open.rocket.chat',
meta: {},
},
],
}),
user: createUser(),
config: {
chainLimit: 10,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
expect(message).to.have.property('urls').of.length(1);
expect(message).to.not.have.property('attachments');
});
it('should do nothing if URL is from SiteUrl but not have a msgId query string', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => [createMessage('linked message', { _id: 'linked' })],
getRooms: async () => [createRoom()],
canAccessRoom: async () => true,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey', {
urls: [
{
url: 'https://open.rocket.chat/?token=value',
meta: {},
},
],
}),
user: createUser(),
config: {
chainLimit: 10,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
expect(message).to.have.property('urls').of.length(1);
expect(message).to.not.have.property('attachments');
});
it('should do nothing if it do not find a msg from the URL', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => [],
getRooms: async () => [createRoom()],
canAccessRoom: async () => true,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey', {
urls: [
{
url: 'https://open.rocket.chat/?msg=value',
meta: {},
},
],
}),
user: createUser(),
config: {
chainLimit: 10,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
expect(message).to.have.property('urls').of.length(1);
expect(message).to.not.have.property('attachments');
});
it('should do nothing if it cannot find the room of the message from the URL', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => [createMessage('linked message', { _id: 'linked' })],
getRooms: async () => [],
canAccessRoom: async () => true,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey', {
urls: [
{
url: 'https://open.rocket.chat/?msg=value',
meta: {},
},
],
}),
user: createUser(),
config: {
chainLimit: 10,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
expect(message).to.have.property('urls').of.length(1);
expect(message).to.not.have.property('attachments');
});
it('should do nothing if user dont have access to the room of the message from the URL', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => [createMessage('linked message', { _id: 'linked' })],
getRooms: async () => [createRoom()],
canAccessRoom: async () => false,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey', {
urls: [
{
url: 'https://open.rocket.chat/?msg=value',
meta: {},
},
],
}),
user: createUser(),
config: {
chainLimit: 10,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
expect(message).to.have.property('urls').of.length(1);
expect(message).to.not.have.property('attachments');
});
it('should not duplicate quote attachment from the message if message_link is the same as the URL', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => [createMessage('linked message', { _id: 'linked' })],
getRooms: async () => [createRoom()],
canAccessRoom: async () => true,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey', {
urls: [
{
url: 'https://open.rocket.chat/linked?msg=linked',
meta: {},
},
],
attachments: [
{
text: 'old attachment',
author_name: 'username',
author_icon: 'url',
message_link: 'https://open.rocket.chat/linked?msg=linked',
ts: new Date(),
},
],
}),
user: createUser(),
config: {
chainLimit: 10,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
expect(message).to.have.property('urls').and.to.have.lengthOf(1);
const [url] = message.urls ?? [];
expect(url).to.include({
url: 'https://open.rocket.chat/linked?msg=linked',
ignoreParse: true,
});
expect(message).to.have.property('attachments').and.to.have.lengthOf(1);
const [attachment] = message.attachments ?? [];
expect(attachment).to.have.property('text', 'linked message');
});
it('should remove existing quote attachments provided in the message if they are not in the urls field', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => [createMessage('linked message', { _id: 'linked' })],
getRooms: async () => [createRoom()],
canAccessRoom: async () => true,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey', {
urls: [],
attachments: [
{
text: 'old attachment',
author_name: 'username',
author_icon: 'url',
message_link: 'https://open.rocket.chat/linked?msg=linked',
ts: new Date(),
},
],
}),
user: createUser(),
config: {
chainLimit: 10,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
expect(message).to.have.property('attachments').and.to.have.lengthOf(0);
});
it('should not consider attachments with undefined message_link as quotes', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => [createMessage('linked message', { _id: 'linked' })],
getRooms: async () => [createRoom()],
canAccessRoom: async () => true,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey', {
urls: [],
attachments: [
{
text: 'old attachment',
author_name: 'username',
author_icon: 'url',
message_link: undefined,
ts: new Date(),
},
],
}),
user: createUser(),
config: {
chainLimit: 10,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
expect(message).to.have.property('attachments').and.to.have.lengthOf(1);
const [attachment] = message.attachments ?? [];
expect(attachment).to.have.property('text', 'old attachment');
});
it('should return an attachment with the message content if a message URL is provided', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => [createMessage('linked message', { _id: 'linked' })],
getRooms: async () => [createRoom()],
canAccessRoom: async () => true,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey', {
urls: [
{
url: 'https://open.rocket.chat/linked?msg=linked',
meta: {},
},
],
}),
user: createUser(),
config: {
chainLimit: 10,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
expect(message).to.have.property('urls').and.to.have.lengthOf(1);
const [url] = message.urls ?? [];
expect(url).to.include({
url: 'https://open.rocket.chat/linked?msg=linked',
ignoreParse: true,
});
expect(message).to.have.property('attachments').and.to.have.lengthOf(1);
const [attachment] = message.attachments ?? [];
expect(attachment).to.have.property('text', 'linked message');
});
it('should respect chain limit config', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => [
createMessage('linked message', {
_id: 'linked',
attachments: [
{
text: 'chained 1',
author_name: 'username',
author_icon: 'url',
ts: new Date(),
message_link: 'https://open.rocket.chat/linked?msg=linkedMsgId',
attachments: [
{
text: 'chained 2',
author_name: 'username',
author_icon: 'url',
message_link: 'https://open.rocket.chat/linked?msg=linkedMsgId',
ts: new Date(),
attachments: [
{
text: 'chained 3',
author_name: 'username',
author_icon: 'url',
message_link: 'https://open.rocket.chat/linked?msg=linkedMsgId',
ts: new Date(),
},
],
},
],
},
],
}),
],
getRooms: async () => [createRoom()],
canAccessRoom: async () => true,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey', {
urls: [
{
url: 'https://open.rocket.chat/linked?msg=linked',
meta: {},
},
],
}),
user: createUser(),
config: {
chainLimit: 3,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
expect(message).to.have.property('urls').and.to.have.lengthOf(1);
expect(message).to.have.property('attachments').and.to.have.lengthOf(1);
const deep = countDeep(message);
expect(deep).to.be.eq(3);
});
it('should respect chain limit config but keep the file attachments of last chained message', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => [
createMessage('linked message', {
_id: 'linked',
attachments: [
{
text: 'chained 1',
author_name: 'username',
author_icon: 'url',
ts: new Date(),
message_link: 'https://open.rocket.chat/linked?msg=linkedMsgId',
attachments: [
{
text: 'chained 2',
author_name: 'username',
author_icon: 'url',
message_link: 'https://open.rocket.chat/linked?msg=linkedMsgId',
ts: new Date(),
attachments: [
{
ts: new Date(),
title: 'file.png',
title_link: 'title-link',
title_link_download: true,
image_dimensions: {
width: 480,
height: 269,
},
image_preview: 'image-preview',
image_url: 'image-url',
image_type: 'image/png',
image_size: 68016,
type: 'file',
description: 'chained 3 - file',
descriptionMd: [
{
type: 'PARAGRAPH',
value: [
{
type: 'PLAIN_TEXT',
value: 'chained 3 - file',
},
],
},
],
},
],
},
],
},
],
}),
],
getRooms: async () => [createRoom()],
canAccessRoom: async () => true,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey', {
urls: [
{
url: 'https://open.rocket.chat/linked?msg=linked',
meta: {},
},
],
}),
user: createUser(),
config: {
chainLimit: 3,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
expect(message).to.have.property('urls').and.to.have.lengthOf(1);
expect(message).to.have.property('attachments').and.to.have.lengthOf(1);
const deep = countDeep(message);
expect(deep).to.be.eq(4, 'The chain limit should be respected, but the file attachments of the last chained message should be kept.');
});
it('should create the attachment if cannot access room but message has a livechat token', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => [createMessage('linked message', { _id: 'linked' })],
getRooms: async () => [createRoom({ t: 'l', v: { token: 'livechatToken' } })],
canAccessRoom: async () => false,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey', {
urls: [
{
url: 'https://open.rocket.chat/linked?msg=linked',
meta: {},
},
],
token: 'livechatToken',
}),
user: createUser(),
config: {
chainLimit: 10,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
expect(message).to.have.property('urls').and.to.have.lengthOf(1);
const [url] = message.urls ?? [];
expect(url).to.include({
url: 'https://open.rocket.chat/linked?msg=linked',
ignoreParse: true,
});
expect(message).to.have.property('attachments').and.to.have.lengthOf(1);
const [attachment] = message.attachments ?? [];
expect(attachment).to.have.property('text', 'linked message');
});
it('should do nothing if cannot access room but message has a livechat token but is not from the room does not have a token', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => [createMessage('linked message', { _id: 'linked' })],
getRooms: async () => [createRoom({ t: 'l' })],
canAccessRoom: async () => false,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey', {
urls: [
{
url: 'https://open.rocket.chat/linked?msg=linked',
meta: {},
},
],
token: 'another-token',
}),
user: createUser(),
config: {
chainLimit: 10,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
expect(message).to.have.property('urls').and.to.have.lengthOf(1);
expect(message).to.not.have.property('attachments');
});
it('should remove the clean up the attachments of the quoted message property if chain limit < 2', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => [
createMessage('linked message', {
_id: 'linkedMsgId',
attachments: [
{
text: 'chained 1',
author_name: 'username',
author_icon: 'url',
message_link: 'https://open.rocket.chat/linked?msg=linkedMsgId1',
ts: new Date(),
},
],
}),
],
getRooms: async () => [createRoom()],
canAccessRoom: async () => true,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey', {
urls: [
{
url: 'https://open.rocket.chat/linked?msg=linkedMsgId',
meta: {},
},
],
}),
user: createUser(),
config: {
chainLimit: 1,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
expect(message).to.have.property('urls').and.to.have.lengthOf(1);
expect(message).to.have.property('attachments').and.to.have.lengthOf(1);
const deep = countDeep(message);
expect(deep).to.be.eq(1);
const [attachment] = message.attachments ?? [];
expect(attachment).to.have.property('attachments').and.to.have.lengthOf(0);
expect(attachment).to.include({
text: 'linked message',
author_name: 'username',
author_icon: 'url',
message_link: 'https://open.rocket.chat/linked?msg=linkedMsgId',
});
});
it('should work for multiple URLs', async () => {
const jumpToMessage = new BeforeSaveJumpToMessage({
getMessages: async () => {
return [
createMessage('first message', {
_id: 'msg1',
}),
createMessage('second message', {
_id: 'msg2',
}),
];
},
getRooms: async () => [createRoom()],
canAccessRoom: async () => true,
getUserAvatarURL: () => 'url',
});
const message = await jumpToMessage.createAttachmentForMessageURLs({
message: createMessage('hey', {
urls: [
{
url: 'https://open.rocket.chat/linked?msg=msg1',
meta: {},
},
{
url: 'https://open.rocket.chat/linked?msg=msg2',
meta: {},
},
],
}),
user: createUser(),
config: {
chainLimit: 1,
siteUrl: 'https://open.rocket.chat',
useRealName: true,
},
});
expect(message).to.have.property('urls').and.to.have.lengthOf(2);
expect(message).to.have.property('attachments').and.to.have.lengthOf(2);
const deep = countDeep(message);
expect(deep).to.be.eq(1);
const [att1, att2] = message.attachments ?? [];
expect(att1).to.include({
text: 'first message',
message_link: 'https://open.rocket.chat/linked?msg=msg1',
});
expect(att2).to.include({
text: 'second message',
message_link: 'https://open.rocket.chat/linked?msg=msg2',
});
});
});

View File

@@ -0,0 +1,110 @@
import { expect } from 'chai';
import { beforeEach } from 'mocha';
import { BeforeSaveMarkdownParser } from '../../../../../../server/services/messages/hooks/BeforeSaveMarkdownParser';
const createMessage = (msg?: string, extra: any = {}) => ({
_id: 'random',
rid: 'GENERAL',
ts: new Date(),
u: {
_id: 'userId',
username: 'username',
},
_updatedAt: new Date(),
msg: msg as string,
...extra,
});
describe('Markdown parser', () => {
beforeEach(() => {
delete process.env.MESSAGE_MAX_PARSE_LENGTH;
});
afterEach(() => {
delete process.env.MESSAGE_MAX_PARSE_LENGTH;
});
it('should do nothing if markdown parser is disabled', async () => {
const markdownParser = new BeforeSaveMarkdownParser(false);
const message = await markdownParser.parseMarkdown({
message: createMessage('hey'),
config: {},
});
expect(message).to.not.have.property('md');
});
it('should do nothing for E2E messages', async () => {
const markdownParser = new BeforeSaveMarkdownParser(true);
const message = await markdownParser.parseMarkdown({
message: createMessage('hey', { t: 'e2e' }),
config: {},
});
expect(message).to.not.have.property('md');
});
it('should skip parsing when msg exceeds MESSAGE_MAX_PARSE_LENGTH', async () => {
process.env.MESSAGE_MAX_PARSE_LENGTH = '10';
const markdownParser = new BeforeSaveMarkdownParser(true);
const message = await markdownParser.parseMarkdown({
message: createMessage('a'.repeat(11)),
config: {},
});
expect(message).to.not.have.property('md');
});
it('should parse normally when msg is within MESSAGE_MAX_PARSE_LENGTH', async () => {
process.env.MESSAGE_MAX_PARSE_LENGTH = '100';
const markdownParser = new BeforeSaveMarkdownParser(true);
const message = await markdownParser.parseMarkdown({
message: createMessage('short msg'),
config: {},
});
expect(message).to.have.property('md');
});
it('should parse normally when MESSAGE_MAX_PARSE_LENGTH is 0', async () => {
process.env.MESSAGE_MAX_PARSE_LENGTH = '0';
const markdownParser = new BeforeSaveMarkdownParser(true);
const message = await markdownParser.parseMarkdown({
message: createMessage('short msg'),
config: {},
});
expect(message).to.have.property('md');
});
it('should parse markdown on the first attachment only', async () => {
const markdownParser = new BeforeSaveMarkdownParser(true);
const message = await markdownParser.parseMarkdown({
message: createMessage('hey', {
attachments: [
{
description: 'hey ho',
},
{
description: 'lets go',
},
],
}),
config: {},
});
expect(message).to.have.property('md');
const [attachment1, attachment2] = message.attachments || [];
expect(attachment1).to.have.property('descriptionMd');
expect(attachment2).to.not.have.property('descriptionMd');
});
});

View File

@@ -0,0 +1,114 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
const Authorization = {
hasPermission: async (_uid: string, _permission: string, _room?: string): Promise<boolean> => true,
};
class MeteorError extends Error {}
const { BeforeSavePreventMention } = proxyquire
.noCallThru()
.load('../../../../../../server/services/messages/hooks/BeforeSavePreventMention', {
'@rocket.chat/core-services': {
Authorization,
MeteorError,
},
'../../../lib/i18n': {
i18n: {
t: (s: any) => s,
},
},
});
describe('Prevent mention on messages', () => {
it('should return void if message has no mentions', async () => {
const preventMention = new BeforeSavePreventMention();
return expect(
preventMention.preventMention({
message: { rid: 'GENERAL', msg: 'hey', u: { _id: 'random' } },
user: { _id: 'userId', language: 'en' },
mention: 'all',
permission: 'mention-all',
}),
).to.eventually.be.true;
});
it("should return void if message doesnt't have @all mention", () => {
const preventMention = new BeforeSavePreventMention();
return expect(
preventMention.preventMention({
message: { rid: 'GENERAL', msg: 'hey', mentions: [{ _id: 'here' }], u: { _id: 'random' } },
user: { _id: 'userId', language: 'en' },
mention: 'all',
permission: 'mention-all',
}),
).to.eventually.be.true;
});
it("should return void if message doesnt't have @here mention", () => {
const preventMention = new BeforeSavePreventMention();
return expect(
preventMention.preventMention({
message: { rid: 'GENERAL', msg: 'hey', mentions: [{ _id: 'all' }], u: { _id: 'random' } },
user: { _id: 'userId', language: 'en' },
mention: 'all',
permission: 'mention-here',
}),
).to.eventually.be.true;
});
it('should return true if user has required permission', () => {
const preventMention = new BeforeSavePreventMention();
Authorization.hasPermission = async () => true;
return expect(
preventMention.preventMention({
message: { rid: 'GENERAL', msg: 'hey', mentions: [{ _id: 'all' }], u: { _id: 'random' } },
user: { _id: 'userId', language: 'en' },
mention: 'all',
permission: 'mention-all',
}),
).to.eventually.be.true;
});
it('should return void if user has required permission on room scope', () => {
const preventMention = new BeforeSavePreventMention();
Authorization.hasPermission = async (_uid, _permission, room) => {
if (room) {
return true;
}
return false;
};
return expect(
preventMention.preventMention({
message: { rid: 'GENERAL', msg: 'hey', mentions: [{ _id: 'all' }], u: { _id: 'random' } },
user: { _id: 'userId', language: 'en' },
mention: 'all',
permission: 'mention-all',
}),
).to.eventually.be.true;
});
it("should throw if user doesn't have required permissions", () => {
Authorization.hasPermission = async (_uid, _permission, _room) => false;
const preventMention = new BeforeSavePreventMention();
return expect(
preventMention.preventMention({
message: { rid: 'GENERAL', msg: 'hey', mentions: [{ _id: 'all' }], u: { _id: 'random' } },
user: { _id: 'userId', language: 'en' },
mention: 'all',
permission: 'mention-all',
}),
).to.be.rejectedWith(MeteorError);
});
});

View File

@@ -0,0 +1,92 @@
import { expect } from 'chai';
import { BeforeSaveSpotify } from '../../../../../../server/services/messages/hooks/BeforeSaveSpotify';
const createMessage = (msg?: string, extra: any = {}) => ({
_id: 'random',
rid: 'GENERAL',
ts: new Date(),
u: {
_id: 'userId',
username: 'username',
},
_updatedAt: new Date(),
msg: msg as string,
...extra,
});
describe('Convert Spotify syntax to URLs', () => {
it('should return no URLs if no Spotify syntax provided', async () => {
const spotify = new BeforeSaveSpotify();
const message = await spotify.convertSpotifyLinks({
message: createMessage('hey'),
});
return expect(message).to.not.have.property('urls');
});
it('should return no URLs if an undefined message is provided', async () => {
const spotify = new BeforeSaveSpotify();
const message = await spotify.convertSpotifyLinks({
message: createMessage(),
});
return expect(message).to.not.have.property('urls');
});
it('should not return a Spotify URL if some Spotify syntax is provided within a code block', async () => {
const spotify = new BeforeSaveSpotify();
const message = await spotify.convertSpotifyLinks({
message: createMessage('test\n```\nspotify:track:1q6IK1l4qpYykOaWaLJkWG\n```'),
});
return expect(message).to.not.have.property('urls');
});
it('should not return a Spotify URL if some Spotify syntax is provided within a inline code', async () => {
const spotify = new BeforeSaveSpotify();
const message = await spotify.convertSpotifyLinks({
message: createMessage('test `spotify:track:1q6IK1l4qpYykOaWaLJkWG` ok'),
});
return expect(message).to.not.have.property('urls');
});
it('should return a Spotify URL if some Spotify syntax is provided', async () => {
const spotify = new BeforeSaveSpotify();
const message = await spotify.convertSpotifyLinks({
message: createMessage('spotify:track:1q6IK1l4qpYykOaWaLJkWG'),
});
expect(message).to.have.property('urls').and.to.have.lengthOf(1);
const [url] = message.urls ?? [];
expect(url).to.have.property('url', 'https://open.spotify.com/track/1q6IK1l4qpYykOaWaLJkWG');
expect(url).to.have.property('source', 'spotify:track:1q6IK1l4qpYykOaWaLJkWG');
});
it('should append a Spotify URL when Spotify syntax is provided with already existing URLs', async () => {
const spotify = new BeforeSaveSpotify();
const message = await spotify.convertSpotifyLinks({
message: createMessage('spotify:track:1q6IK1l4qpYykOaWaLJkWG', {
urls: [{ url: 'https://rocket.chat' }],
}),
});
expect(message).to.have.property('urls').and.to.have.lengthOf(2);
const [url1, url2] = message.urls ?? [];
expect(url1).to.have.property('url', 'https://rocket.chat');
expect(url2).to.have.property('url', 'https://open.spotify.com/track/1q6IK1l4qpYykOaWaLJkWG');
expect(url2).to.have.property('source', 'spotify:track:1q6IK1l4qpYykOaWaLJkWG');
});
});

View File

@@ -0,0 +1,103 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import p from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
'NpsVote': {},
'Nps': {
findOne: sinon.stub(),
save: sinon.stub(),
},
'@noCallThru': true,
};
const servicesMock = {
Banner: {
create: sinon.stub(),
},
Settings: {
get: sinon.stub(),
},
};
const getbannerforadminsMock = sinon.stub();
const { NPSService } = p('../../../../../server/services/nps/service.ts', {
'@rocket.chat/models': modelsMock,
'@rocket.chat/core-services': servicesMock,
'./sendNpsResults': { 'sendNpsResults': sinon.stub(), '@noCallThru': true },
'../../lib/logger/system': { 'SystemLogger': { error: sinon.stub() }, '@noCallThru': true },
'./notification': { 'notifyAdmins': sinon.stub(), 'getBannerForAdmins': getbannerforadminsMock, '@noCallThru': true },
});
describe('NPS Service', () => {
it('should instantiate properly', () => {
expect(new NPSService()).to.be.an('object');
});
describe('@create', () => {
beforeEach(() => {
servicesMock.Settings.get.reset();
modelsMock.Nps.findOne.reset();
modelsMock.Nps.save.reset();
servicesMock.Banner.create.reset();
getbannerforadminsMock.reset();
});
it('should fail when user opted out of nps', async () => {
servicesMock.Settings.get.withArgs('NPS_survey_enabled').resolves(false);
await expect(new NPSService().create({})).to.be.rejectedWith('Server opted-out for NPS surveys');
});
it('should fail when nps expireDate is less than nps startAt', async () => {
servicesMock.Settings.get.withArgs('NPS_survey_enabled').resolves(true);
modelsMock.Nps.findOne.resolves(null);
await expect(new NPSService().create({ expireAt: new Date('2020-01-01'), startAt: new Date('2020-01-02') })).to.be.rejectedWith(
'NPS already expired',
);
});
it('should fail when expireDate is less than current date', async () => {
servicesMock.Settings.get.withArgs('NPS_survey_enabled').resolves(true);
modelsMock.Nps.findOne.resolves(null);
await expect(new NPSService().create({ expireAt: new Date('2020-01-02'), startAt: new Date('2020-01-01') })).to.be.rejectedWith(
'NPS already expired',
);
});
it('should try to create a banner when theres no nps saved', async () => {
servicesMock.Settings.get.withArgs('NPS_survey_enabled').resolves(true);
modelsMock.Nps.findOne.resolves(null);
const today = new Date();
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
await new NPSService().create({
expireAt: tomorrow,
startAt: today,
createdBy: { _id: 'tomorrow', username: 'tomorrow' },
npsId: 'test',
});
expect(getbannerforadminsMock.called).to.be.true;
expect(getbannerforadminsMock.calledWith(tomorrow)).to.be.true;
expect(modelsMock.Nps.save.called).to.be.true;
expect(
modelsMock.Nps.save.calledWith(
sinon.match({
expireAt: tomorrow,
startAt: today,
status: 'open',
_id: 'test',
createdBy: { _id: 'tomorrow', username: 'tomorrow' },
}),
),
).to.be.true;
});
it('should fail if theres an error when saving the Nps', async () => {
servicesMock.Settings.get.withArgs('NPS_survey_enabled').resolves(true);
modelsMock.Nps.findOne.resolves({ _id: 'test' });
modelsMock.Nps.save.rejects();
await expect(new NPSService().create({})).to.be.rejectedWith('Error creating NPS');
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,243 @@
/* eslint-disable new-cap */
import { expect } from 'chai';
import sinon from 'sinon';
import { ChartData } from '../../../../../server/services/omnichannel-analytics/ChartData';
describe('ChartData Analytics', () => {
describe('isActionAllowed', () => {
it('should return false if no action is provided', () => {
const chart = new ChartData({} as any);
expect(chart.isActionAllowed(undefined)).to.be.false;
});
it('should return false if an invalid action is provided', () => {
const chart = new ChartData({} as any);
expect(chart.isActionAllowed('invalid_action')).to.be.false;
});
it('should return true if a valid action is provided', () => {
const chart = new ChartData({} as any);
expect(chart.isActionAllowed('Total_conversations')).to.be.true;
});
});
describe('callAction', () => {
it('should call the correct action', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDateWithMessages: () => [],
getAnalyticsMetricsBetweenDate: () => [],
getTotalConversationsBetweenDate: () => 0,
} as any);
expect(await chart.callAction('Total_conversations', {} as any)).to.be.equal(0);
});
it('should throw an error if the action is invalid', async () => {
const chart = new ChartData({} as any);
try {
// @ts-expect-error - Invalid action
await chart.callAction('invalid_action', {} as any);
} catch (e: any) {
expect(e.message).to.be.equal('Invalid action');
}
});
it('should call the correct action with the correct parameters', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDateWithMessages: () => [],
getAnalyticsMetricsBetweenDate: () => [],
getTotalConversationsBetweenDate: () => 0,
} as any);
const spy = sinon.spy(chart, 'Total_conversations');
const spy2 = sinon.spy(chart, 'Avg_chat_duration');
const spy3 = sinon.spy(chart, 'Total_messages');
const spy4 = sinon.spy(chart, 'Avg_first_response_time');
const spy5 = sinon.spy(chart, 'Avg_reaction_time');
await chart.callAction('Total_conversations', {} as any);
expect(spy.calledOnce).to.be.true;
await chart.callAction('Avg_chat_duration', {} as any);
expect(spy2.calledOnce).to.be.true;
await chart.callAction('Total_messages', {} as any);
expect(spy3.calledOnce).to.be.true;
await chart.callAction('Avg_first_response_time', {} as any);
expect(spy4.calledOnce).to.be.true;
await chart.callAction('Avg_reaction_time', {} as any);
expect(spy5.calledOnce).to.be.true;
});
});
describe('Total_conversations', () => {
it('should return the total number of conversations', async () => {
const chart = new ChartData({
getTotalConversationsBetweenDate: async () => 10,
} as any);
expect(await chart.Total_conversations({} as any)).to.be.equal(10);
});
});
describe('Avg_chat_duration', () => {
it('should return the average chat duration', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [{ metrics: { chatDuration: 10 } }],
} as any);
expect(await chart.Avg_chat_duration({} as any)).to.be.equal(10);
});
it('should properly calculate with more conversations', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [{ metrics: { chatDuration: 10 } }, { metrics: { chatDuration: 20 } }],
} as any);
expect(await chart.Avg_chat_duration({} as any)).to.be.equal(15);
});
it('should return 0 if no conversations are found', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [],
} as any);
expect(await chart.Avg_chat_duration({} as any)).to.be.equal(0);
});
it('should ignore conversations without chatDuration', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [{ metrics: { chatDuration: 10 } }, { metrics: {} }],
} as any);
expect(await chart.Avg_chat_duration({} as any)).to.be.equal(10);
});
});
describe('Total_messages', () => {
it('should return the total number of messages', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDateWithMessages: () => [{ msgs: 10 }],
} as any);
expect(await chart.Total_messages({} as any)).to.be.equal(10);
});
it('should properly calculate with more conversations', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDateWithMessages: () => [{ msgs: 10 }, { msgs: 20 }],
} as any);
expect(await chart.Total_messages({} as any)).to.be.equal(30);
});
it('should return 0 if no conversations are found', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDateWithMessages: () => [],
} as any);
expect(await chart.Total_messages({} as any)).to.be.equal(0);
});
it('should ignore conversations without msgs', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDateWithMessages: () => [{ msgs: 10 }, {}],
} as any);
expect(await chart.Total_messages({} as any)).to.be.equal(10);
});
});
describe('Avg_first_response_time', () => {
it('should return the average first response time', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { ft: 10 } } }],
} as any);
expect(await chart.Avg_first_response_time({} as any)).to.be.equal(10);
});
it('should properly calculate with more conversations', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { ft: 10 } } }, { metrics: { response: { ft: 20 } } }],
} as any);
expect(await chart.Avg_first_response_time({} as any)).to.be.equal(15);
});
it('should return 0 if no conversations are found', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [],
} as any);
expect(await chart.Avg_first_response_time({} as any)).to.be.equal(0);
});
it('should ignore conversations without response', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { ft: 10 } } }, { metrics: {} }],
} as any);
expect(await chart.Avg_first_response_time({} as any)).to.be.equal(10);
});
});
describe('Best_first_response_time', () => {
it('should return the best first response time', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { ft: 10 } } }],
} as any);
expect(await chart.Best_first_response_time({} as any)).to.be.equal(10);
});
it('should properly calculate with more conversations', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { ft: 10 } } }, { metrics: { response: { ft: 20 } } }],
} as any);
expect(await chart.Best_first_response_time({} as any)).to.be.equal(10);
});
it('should return 0 if no conversations are found', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [],
} as any);
expect(await chart.Best_first_response_time({} as any)).to.be.equal(0);
});
it('should ignore conversations without response', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { ft: 10 } } }, { metrics: {} }],
} as any);
expect(await chart.Best_first_response_time({} as any)).to.be.equal(10);
});
});
describe('Avg_response_time', () => {
it('should return the average response time', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { avg: 10 } } }],
} as any);
expect(await chart.Avg_response_time({} as any)).to.be.equal(10);
});
it('should properly calculate with more conversations', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { avg: 10 } } }, { metrics: { response: { avg: 20 } } }],
} as any);
expect(await chart.Avg_response_time({} as any)).to.be.equal(15);
});
it('should return 0 if no conversations are found', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [],
} as any);
expect(await chart.Avg_response_time({} as any)).to.be.equal(0);
});
it('should ignore conversations without response', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { avg: 10 } } }, { metrics: {} }],
} as any);
expect(await chart.Avg_response_time({} as any)).to.be.equal(10);
});
});
describe('Avg_reaction_time', () => {
it('should return the average reaction time', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [{ metrics: { reaction: { ft: 10 } } }],
} as any);
expect(await chart.Avg_reaction_time({} as any)).to.be.equal(10);
});
it('should properly calculate with more conversations', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [{ metrics: { reaction: { ft: 10 } } }, { metrics: { reaction: { ft: 20 } } }],
} as any);
expect(await chart.Avg_reaction_time({} as any)).to.be.equal(15);
});
it('should return 0 if no conversations are found', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [],
} as any);
expect(await chart.Avg_reaction_time({} as any)).to.be.equal(0);
});
it('should ignore conversations without reaction', async () => {
const chart = new ChartData({
getAnalyticsMetricsBetweenDate: () => [{ metrics: { reaction: { ft: 10 } } }, { metrics: {} }],
} as any);
expect(await chart.Avg_reaction_time({} as any)).to.be.equal(10);
});
});
});

View File

@@ -0,0 +1,351 @@
/* eslint-disable new-cap */
import { expect } from 'chai';
import moment from 'moment-timezone';
import sinon from 'sinon';
import { conversations } from './mockData';
import { OverviewData } from '../../../../../server/services/omnichannel-analytics/OverviewData';
const analytics = (date: { gte: Date; lte: Date }) => {
// filter the mockData array with the date param with moment
return conversations.filter((c) => moment(c.ts).isBetween(date.gte, date.lte, undefined, '[]'));
};
describe('OverviewData Analytics', () => {
describe('isActionAllowed', () => {
it('should return false if no action is provided', () => {
const overview = new OverviewData({} as any);
expect(overview.isActionAllowed(undefined)).to.be.false;
});
it('should return false if an invalid action is provided', () => {
const overview = new OverviewData({} as any);
expect(overview.isActionAllowed('invalid_action')).to.be.false;
});
it('should return true if a valid action is provided', () => {
const overview = new OverviewData({} as any);
expect(overview.isActionAllowed('Conversations')).to.be.true;
});
});
describe('callAction', () => {
it('should fail if the action is invalid', async () => {
const overview = new OverviewData({} as any);
try {
// @ts-expect-error - Invalid action
await overview.callAction('invalid_action', {} as any);
} catch (e: any) {
expect(e.message).to.be.equal('Invalid action');
}
});
it('should call the correct action with the correct parameters', async () => {
const overview = new OverviewData({
getAnalyticsBetweenDate: () => [],
getOnHoldConversationsBetweenDate: () => 0,
getAnalyticsMetricsBetweenDate: () => [],
} as any);
const spy = sinon.spy(overview, 'Conversations');
const spy2 = sinon.spy(overview, 'Productivity');
await overview.callAction('Conversations', moment(), moment(), '', 'UTC');
expect(spy.calledOnce).to.be.true;
await overview.callAction('Productivity', moment(), moment(), '', 'UTC', (v: string): string => v);
expect(spy2.calledOnce).to.be.true;
});
});
describe('getKeyHavingMaxValue', () => {
it('should return the key with the max value', () => {
const overview = new OverviewData({} as any);
const map = new Map();
map.set('a', 1);
map.set('b', 2);
map.set('c', 3);
expect(overview.getKeyHavingMaxValue(map, 'd')).to.be.equal('c');
});
it('should return the default key if the map is empty', () => {
const overview = new OverviewData({} as any);
const map = new Map();
expect(overview.getKeyHavingMaxValue(map, 'd')).to.be.equal('d');
});
});
describe('getAllMapKeysSize', () => {
it('should return the sum of all map keys', () => {
const overview = new OverviewData({} as any);
const map = new Map();
map.set('a', 1);
map.set('b', 2);
map.set('c', 3);
expect(overview.sumAllMapKeys(map)).to.be.equal(6);
});
it('should return 0 if the map is empty', () => {
const overview = new OverviewData({} as any);
const map = new Map();
expect(overview.sumAllMapKeys(map)).to.be.equal(0);
});
});
describe('getBusiestDay', () => {
it('should return the day with the most messages', () => {
const overview = new OverviewData({} as any);
const map = new Map();
map.set(
'Monday',
new Map([
['1', 1],
['2', 2],
]),
);
map.set(
'Tuesday',
new Map([
['13', 1],
['15', 2],
]),
);
map.set(
'Sunday',
new Map([
['12', 2],
['23', 2],
]),
);
expect(overview.getBusiestDay(map)).to.be.equal('Sunday');
});
it('should return the first day with the most messages if theres a tie', () => {
const overview = new OverviewData({} as any);
const map = new Map();
map.set(
'Monday',
new Map([
['1', 1],
['2', 2],
]),
);
map.set(
'Tuesday',
new Map([
['13', 1],
['15', 2],
]),
);
map.set(
'Sunday',
new Map([
['12', 1],
['23', 2],
]),
);
expect(overview.getBusiestDay(map)).to.be.equal('Monday');
});
it('should return the default key if the map is empty', () => {
const overview = new OverviewData({} as any);
const map = new Map();
expect(overview.getBusiestDay(map)).to.be.equal('-');
});
});
describe('sumAllMapKeys', () => {
it('should return the sum of all map keys', () => {
const overview = new OverviewData({} as any);
const map = new Map();
map.set('a', 1);
map.set('b', 2);
map.set('c', 3);
expect(overview.sumAllMapKeys(map)).to.be.equal(6);
});
it('should return 0 if the map is empty', () => {
const overview = new OverviewData({} as any);
const map = new Map();
expect(overview.sumAllMapKeys(map)).to.be.equal(0);
});
});
describe('Conversations', () => {
it('should return all values as 0 when theres no data', async () => {
const overview = new OverviewData({
getAnalyticsBetweenDate: () => [],
getOnHoldConversationsBetweenDate: () => 0,
} as any);
const result = await overview.Conversations(moment(), moment(), '', 'UTC', (v: string): string => v, {});
expect(result).to.be.deep.equal([
{ title: 'Total_conversations', value: 0 },
{ title: 'Open_conversations', value: 0 },
{ title: 'On_Hold_conversations', value: 0 },
{ title: 'Total_messages', value: 0 },
{ title: 'Busiest_day', value: '-' },
{ title: 'Conversations_per_day', value: '0.00' },
{ title: 'Busiest_time', value: '-' },
]);
});
it('should return all values as 0 when theres data but not on the period we pass', async () => {
const overview = new OverviewData({
getAnalyticsBetweenDate: () => analytics({ gte: moment().set('month', 9).toDate(), lte: moment().set('month', 9).toDate() }),
getOnHoldConversationsBetweenDate: () => 0,
} as any);
const result = await overview.Conversations(moment(), moment(), '', 'UTC', (v: string): string => v, {});
expect(result).to.be.deep.equal([
{ title: 'Total_conversations', value: 0 },
{ title: 'Open_conversations', value: 0 },
{ title: 'On_Hold_conversations', value: 0 },
{ title: 'Total_messages', value: 0 },
{ title: 'Busiest_day', value: '-' },
{ title: 'Conversations_per_day', value: '0.00' },
{ title: 'Busiest_time', value: '-' },
]);
});
it('should return the correct values when theres data on the period we pass', async () => {
const overview = new OverviewData({
getAnalyticsBetweenDate: (date: { gte: Date; lte: Date }) => analytics(date),
getOnHoldConversationsBetweenDate: () => 1,
} as any);
// Fixed date to assure we get the same data
const result = await overview.Conversations(
moment.utc().set('month', 10).set('year', 2023).set('date', 12).startOf('day'),
moment.utc().set('month', 10).set('year', 2023).set('date', 12).endOf('day'),
'',
'UTC',
(v: string): string => v,
{},
);
expect(result).to.be.deep.equal([
{ title: 'Total_conversations', value: 1 },
{ title: 'Open_conversations', value: 0 },
{ title: 'On_Hold_conversations', value: 1 },
{ title: 'Total_messages', value: 93 },
{ title: 'Busiest_day', value: 'Sunday' },
{ title: 'Conversations_per_day', value: '1.00' },
{ title: 'Busiest_time', value: '11AM - 12PM' },
]);
});
it('should only return conversation metrics related to the provided period, and not consider previous or following days', async () => {
const overview = new OverviewData({
getAnalyticsBetweenDate: (date: { gte: Date; lte: Date }) => analytics(date),
getOnHoldConversationsBetweenDate: () => 1,
} as any);
// choosing this specific date since the day before and after are not empty
const targetDate = moment.utc().set('month', 10).set('year', 2023).set('date', 23);
// Fixed date to assure we get the same data
const result = await overview.Conversations(targetDate.startOf('day'), targetDate.endOf('day'), '', 'UTC');
expect(result).to.be.deep.equal([
{ title: 'Total_conversations', value: 1 },
{ title: 'Open_conversations', value: 0 },
{ title: 'On_Hold_conversations', value: 1 },
{ title: 'Total_messages', value: 14 },
{ title: 'Busiest_day', value: 'Thursday' },
{ title: 'Conversations_per_day', value: '1.00' },
{ title: 'Busiest_time', value: '7AM - 8AM' },
]);
});
it('should return all values as 0 when there is no data in the provided period, but there is data in the previous and following days', async () => {
const overview = new OverviewData({
getAnalyticsBetweenDate: (date: { gte: Date; lte: Date }) => analytics(date),
getOnHoldConversationsBetweenDate: () => 0,
} as any);
// choosing this specific date since the day before and after are not empty
const targetDate = moment.utc().set('month', 10).set('year', 2023).set('date', 13);
const result = await overview.Conversations(targetDate.startOf('day'), targetDate.endOf('day'), '', 'UTC');
expect(result).to.be.deep.equal([
{ title: 'Total_conversations', value: 0 },
{ title: 'Open_conversations', value: 0 },
{ title: 'On_Hold_conversations', value: 0 },
{ title: 'Total_messages', value: 0 },
{ title: 'Busiest_day', value: '-' },
{ title: 'Conversations_per_day', value: '0.00' },
{ title: 'Busiest_time', value: '-' },
]);
});
});
describe('Productivity', () => {
it('should return all values as 0 when theres no data', async () => {
const overview = new OverviewData({
getAnalyticsMetricsBetweenDate: () => ({
forEach: () => [],
}),
} as any);
const result = await overview.Productivity(moment(), moment(), '', 'UTC', (v: string): string => v, {});
expect(result).to.be.deep.equal([
{ title: 'Avg_response_time', value: '00:00:00' },
{ title: 'Avg_first_response_time', value: '00:00:00' },
{ title: 'Avg_reaction_time', value: '00:00:00' },
]);
});
it('should return all values as 0 when theres data but not on the period we pass', async () => {
const overview = new OverviewData({
getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lte: Date }) => analytics(date),
} as any);
const result = await overview.Productivity(
moment().set('month', 9),
moment().set('month', 9),
'',
'UTC',
(v: string): string => v,
{},
);
expect(result).to.be.deep.equal([
{ title: 'Avg_response_time', value: '00:00:00' },
{ title: 'Avg_first_response_time', value: '00:00:00' },
{ title: 'Avg_reaction_time', value: '00:00:00' },
]);
});
it('should return the correct values when theres data on the period we pass', async () => {
const overview = new OverviewData({
getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lte: Date }) => analytics(date),
} as any);
const result = await overview.Productivity(
moment().set('month', 10).set('year', 2023).startOf('month'),
moment().set('month', 10).set('year', 2023).endOf('month'),
'',
'UTC',
);
expect(result).to.be.deep.equal([
{ title: 'Avg_response_time', value: '00:00:07' },
{ title: 'Avg_first_response_time', value: '00:00:10' },
{ title: 'Avg_reaction_time', value: '00:00:49' },
]);
});
it('should only return productivity metrics related to the provided period, and not consider previous or following days', async () => {
const overview = new OverviewData({
getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lte: Date }) => analytics(date),
} as any);
// choosing this specific date since the day before and after are not empty
const targetDate = moment().set('month', 10).set('year', 2023).set('date', 25);
const result = await overview.Productivity(targetDate.startOf('day'), targetDate.clone().endOf('day'), '', 'UTC');
expect(result).to.be.deep.equal([
{ title: 'Avg_response_time', value: '00:00:01' },
{ title: 'Avg_first_response_time', value: '00:00:04' },
{ title: 'Avg_reaction_time', value: '00:02:03' },
]);
});
it('should return all values as 0 when there is no data in the provided period, but there is data in the previous and following days', async () => {
const overview = new OverviewData({
getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lte: Date }) => analytics(date),
} as any);
// choosing this specific date since the day before and after are not empty
const targetDate = moment.utc().set('month', 10).set('year', 2023).set('date', 13);
const result = await overview.Productivity(targetDate.startOf('day'), targetDate.endOf('day'), '', 'UTC');
expect(result).to.be.deep.equal([
{ title: 'Avg_response_time', value: '00:00:00' },
{ title: 'Avg_first_response_time', value: '00:00:00' },
{ title: 'Avg_reaction_time', value: '00:00:00' },
]);
});
});
});

View File

@@ -0,0 +1,38 @@
export const conversations = [
{ ts: new Date('2023-11-07T14:37:40.081Z'), msgs: 21 },
{
ts: new Date('2023-11-14T02:10:38.971Z'),
msgs: 48,
metrics: {
response: { avg: 10, ft: 4 },
reaction: { ft: 10 },
},
},
{ ts: new Date('2023-11-04T20:33:27.172Z'), msgs: 82 },
{ ts: new Date('2023-11-12T11:37:17.716Z'), msgs: 93 },
{ ts: new Date('2023-11-30T17:27:00.057Z'), msgs: 65 },
{
ts: new Date('2023-11-28T01:13:44.980Z'),
msgs: 45,
metrics: {
response: { avg: 10.233, ft: 23.1 },
reaction: { ft: 13.2343333 },
},
},
{ ts: new Date('2023-11-19T04:05:23.717Z'), msgs: 11 },
{ ts: new Date('2023-11-15T09:16:21.890Z'), msgs: 17 },
{ ts: new Date('2023-11-26T03:53:19.443Z'), msgs: 37 },
{ ts: new Date('2023-11-24T13:45:01.650Z'), msgs: 96 },
{
ts: new Date('2023-11-25T07:46:38.990Z'),
msgs: 38,
metrics: {
response: { avg: 1.34344417, ft: 4.19383484 },
reaction: { ft: 123.040949 },
},
},
{ ts: new Date('2023-11-21T07:27:38.990Z'), msgs: 38 },
{ ts: new Date('2023-11-29T07:37:38.990Z'), msgs: 38 },
{ ts: new Date('2023-11-22T07:47:38.990Z'), msgs: 34 },
{ ts: new Date('2023-11-23T07:57:38.990Z'), msgs: 14 },
];

View File

@@ -0,0 +1,283 @@
import crypto from 'crypto';
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const settingsStub = {
get: sinon.stub(),
};
const twilioStub = {
validateRequest: sinon.stub(),
isRequestFromTwilio: sinon.stub(),
};
const { Twilio } = proxyquire.noCallThru().load('../../../../../../server/services/omnichannel-integrations/providers/twilio.ts', {
'../../../settings': { settings: settingsStub },
'../../../lib/utils/restrictions': { fileUploadIsValidContentType: sinon.stub() },
'../../../lib/i18n': { i18n: sinon.stub() },
'../../../lib/logger/system': { SystemLogger: { error: sinon.stub() } },
});
/**
* Get a valid Twilio signature for a request
*
* @param {String} authToken your Twilio AuthToken
* @param {String} url your webhook URL
* @param {Object} params the included request parameters
*/
function getSignature(authToken: string, url: string, params: Record<string, any>): string {
// get all request parameters
const data = Object.keys(params)
// sort them
.sort()
// concatenate them to a string
.reduce((acc, key) => acc + key + params[key], url);
return (
crypto
// sign the string with sha1 using your AuthToken
.createHmac('sha1', authToken)
.update(Buffer.from(data, 'utf-8'))
// base64 encode it
.digest('base64')
);
}
describe('Twilio Request Validation', () => {
beforeEach(() => {
settingsStub.get.reset();
twilioStub.validateRequest.reset();
twilioStub.isRequestFromTwilio.reset();
});
it('should not validate a request when process.env.TEST_MODE is true', async () => {
process.env.TEST_MODE = 'true';
const twilio = new Twilio();
const request = {
headers: {
'x-twilio-signature': 'test',
},
};
expect(await twilio.validateRequest(request)).to.be.true;
});
it('should validate a request when process.env.TEST_MODE is false', async () => {
process.env.TEST_MODE = 'false';
settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test');
settingsStub.get.withArgs('Site_Url').returns('https://example.com');
const twilio = new Twilio();
const requestBody = {
To: 'test',
From: 'test',
Body: 'test',
};
const request = {
headers: {
get: (param: string) => {
const headers: Record<string, any> = {
'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody),
};
return headers[param];
},
},
};
expect(await twilio.validateRequest(request, requestBody)).to.be.true;
});
it('should validate a request when query string is present', async () => {
process.env.TEST_MODE = 'false';
settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test');
settingsStub.get.withArgs('Site_Url').returns('https://example.com/');
const twilio = new Twilio();
const requestBody = {
To: 'test',
From: 'test',
Body: 'test',
};
const request = {
url: 'https://example.com/api/v1/livechat/sms-incoming/twilio?department=1',
headers: {
get: (param: string) => {
const headers: Record<string, any> = {
'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio?department=1', requestBody),
};
return headers[param];
},
},
};
expect(await twilio.validateRequest(request, requestBody)).to.be.true;
});
it('should reject a request where signature doesnt match', async () => {
settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test');
settingsStub.get.withArgs('Site_Url').returns('https://example.com');
const twilio = new Twilio();
const requestBody = {
To: 'test',
From: 'test',
Body: 'test',
};
const request = {
headers: {
get: (param: string) => {
const headers: Record<string, any> = {
'x-twilio-signature': getSignature('anotherAuthToken', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody),
};
return headers[param];
},
},
};
expect(await twilio.validateRequest(request, requestBody)).to.be.false;
});
it('should reject a request where signature is missing', async () => {
settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test');
settingsStub.get.withArgs('Site_Url').returns('https://example.com');
const twilio = new Twilio();
const requestBody = {
To: 'test',
From: 'test',
Body: 'test',
};
const request = {
headers: {
get: () => null,
},
};
expect(await twilio.validateRequest(request, requestBody)).to.be.false;
});
it('should reject a request where the signature doesnt correspond body', async () => {
settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test');
settingsStub.get.withArgs('Site_Url').returns('https://example.com');
const twilio = new Twilio();
const requestBody = {
To: 'test',
From: 'test',
Body: 'test',
};
const request = {
headers: {
get: (param: string) => {
const headers: Record<string, any> = {
'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', {}),
};
return headers[param];
},
},
};
expect(await twilio.validateRequest(request, requestBody)).to.be.false;
});
it('should return false if URL is not provided', async () => {
process.env.TEST_MODE = 'false';
settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test');
settingsStub.get.withArgs('Site_Url').returns('');
const twilio = new Twilio();
const requestBody = {
To: 'test',
From: 'test',
Body: 'test',
};
const request = {
headers: {
get: (param: string) => {
const headers: Record<string, any> = {
'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody),
};
return headers[param];
},
},
};
expect(await twilio.validateRequest(request, requestBody)).to.be.false;
});
it('should return false if authToken is not provided', async () => {
process.env.TEST_MODE = 'false';
settingsStub.get.withArgs('SMS_Twilio_authToken').returns('');
settingsStub.get.withArgs('Site_Url').returns('https://example.com');
const twilio = new Twilio();
const requestBody = {
To: 'test',
From: 'test',
Body: 'test',
};
const request = {
headers: {
get: (param: string) => {
const headers: Record<string, any> = {
'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody),
};
return headers[param];
},
},
};
expect(await twilio.validateRequest(request, requestBody)).to.be.false;
});
// Twilio signature will always use the workspace public URL, which may difer from the request URL in some circumstances.
it('should use siteURL instead of request.url hostname', async () => {
process.env.TEST_MODE = 'false';
settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test');
settingsStub.get.withArgs('Site_Url').returns('https://ngrok.barn.com');
const twilio = new Twilio();
const requestBody = {
To: 'test',
From: 'test',
Body: 'test',
};
const request = {
url: 'https://example.com/api/v1/livechat/sms-incoming/twilio',
headers: {
get: (param: string) => {
const headers: Record<string, any> = {
'x-twilio-signature': getSignature('test', 'https://ngrok.barn.com/api/v1/livechat/sms-incoming/twilio', requestBody),
};
return headers[param];
},
},
};
expect(await twilio.validateRequest(request, requestBody)).to.be.true;
});
});

View File

@@ -0,0 +1,441 @@
import { expect } from 'chai';
import { beforeEach, describe, after, it } from 'mocha';
import p from 'proxyquire';
import Sinon from 'sinon';
const dispatchAgentDelegated = Sinon.stub();
const getConfig = Sinon.stub();
const delegateInquiry = Sinon.stub();
const libSettings = { getInquirySortMechanismSetting: Sinon.stub().returns('timestamp') };
const settings = {
get: Sinon.stub(),
};
const queueLogger = {
info: Sinon.stub(),
debug: Sinon.stub(),
error: Sinon.stub(),
};
const mockedInquiry = {
_id: 'inquiryId',
rid: 'rid',
department: 'department1',
ts: new Date(),
};
const models = {
LivechatInquiry: {
unlockAll: Sinon.stub(),
findNextAndLock: Sinon.stub(),
getDistinctQueuedDepartments: Sinon.stub(),
unlock: Sinon.stub(),
removeByRoomId: Sinon.stub(),
takeInquiry: Sinon.stub(),
},
LivechatRooms: {
findOneById: Sinon.stub(),
},
};
const license = {
shouldPreventAction: Sinon.stub(),
};
const { OmnichannelQueue } = p.noCallThru().load('../../../../../server/services/omnichannel/queue', {
'../../lib/omnichannel/Helper': {
dispatchAgentDelegated,
},
'../../lib/omnichannel/RoutingManager': {
RoutingManager: {
getConfig,
delegateInquiry,
},
},
'../../lib/omnichannel/settings': libSettings,
'../../settings': { settings },
'./logger': { queueLogger },
'@rocket.chat/models': models,
'@rocket.chat/license': { License: license },
'../../lib/metrics': {
metrics: {
timeToQueueProcessingByQueue: { observe: Sinon.stub() },
timeToQueueProcessingByQueueHistogram: { observe: Sinon.stub() },
totalItemsProcessedByQueue: { inc: Sinon.stub() },
totalItemsProcessedByReconciliationQueue: { inc: Sinon.stub() },
totalItemsFailedByQueue: { inc: Sinon.stub() },
},
},
});
describe('Omnichannel Queue processor', () => {
describe('isRunning', () => {
it('should return the running status', () => {
const queue = new OmnichannelQueue();
expect(queue.isRunning()).to.be.false;
});
it('should return the running status', () => {
const queue = new OmnichannelQueue();
queue.running = true;
expect(queue.isRunning()).to.be.true;
});
});
describe('delay', () => {
after(() => {
settings.get.reset();
});
it('should return 5000 if setting is not set', () => {
settings.get.returns(undefined);
const queue = new OmnichannelQueue();
expect(queue.delay()).to.be.equal(5000);
});
it('should return the right value if setting has a value above 1', () => {
settings.get.returns(10);
const queue = new OmnichannelQueue();
expect(queue.delay()).to.be.equal(10000);
});
});
describe('getActiveQueues', () => {
after(() => {
models.LivechatInquiry.getDistinctQueuedDepartments.reset();
});
it('should return empty array when there are no active queues', async () => {
models.LivechatInquiry.getDistinctQueuedDepartments.resolves([]);
const queue = new OmnichannelQueue();
expect(await queue.getActiveQueues()).to.be.eql([]);
});
it('should return [department1] when department1 is an active queue', async () => {
models.LivechatInquiry.getDistinctQueuedDepartments.resolves([{ _id: 'department1' }]);
const queue = new OmnichannelQueue();
expect(await queue.getActiveQueues()).to.be.eql(['department1']);
});
it('should return [null, department1] when department1 is an active queue and there are elements on public queue', async () => {
models.LivechatInquiry.getDistinctQueuedDepartments.resolves([{ _id: 'department1' }, { _id: null }]);
const queue = new OmnichannelQueue();
expect(await queue.getActiveQueues()).to.be.eql(['department1', null]);
});
});
describe('checkQueue', () => {
let clock: any;
beforeEach(() => {
models.LivechatInquiry.findNextAndLock.resetHistory();
models.LivechatInquiry.takeInquiry.resetHistory();
models.LivechatInquiry.unlock.resetHistory();
queueLogger.error.resetHistory();
queueLogger.info.resetHistory();
clock = Sinon.useFakeTimers();
});
afterEach(() => {
clock.restore();
});
after(() => {
models.LivechatInquiry.findNextAndLock.reset();
models.LivechatInquiry.takeInquiry.reset();
models.LivechatInquiry.unlock.reset();
queueLogger.error.reset();
queueLogger.info.reset();
clock.reset();
});
it('should return undefined when the queue is empty', async () => {
models.LivechatInquiry.findNextAndLock.returns(null);
const queue = new OmnichannelQueue();
queue.execute = Sinon.stub();
expect(await queue.checkQueue()).to.be.undefined;
});
it('should try to process the inquiry when there is one', async () => {
models.LivechatInquiry.findNextAndLock.returns(mockedInquiry);
const queue = new OmnichannelQueue();
queue.processWaitingQueue = Sinon.stub().throws('error');
queue.execute = Sinon.stub();
await queue.checkQueue();
expect(models.LivechatInquiry.findNextAndLock.calledOnce).to.be.true;
expect(queue.processWaitingQueue.calledOnce).to.be.true;
});
it('should call unlock when the inquiry could not be processed', async () => {
models.LivechatInquiry.findNextAndLock.returns(mockedInquiry);
const queue = new OmnichannelQueue();
queue.processWaitingQueue = Sinon.stub().returns(false);
queue.execute = Sinon.stub();
await queue.checkQueue();
expect(queue.processWaitingQueue.calledOnce).to.be.true;
expect(models.LivechatInquiry.unlock.calledOnce).to.be.true;
});
it('should unlock the inquiry when it was processed succesfully', async () => {
models.LivechatInquiry.findNextAndLock.returns(mockedInquiry);
const queue = new OmnichannelQueue();
queue.processWaitingQueue = Sinon.stub().returns(true);
queue.execute = Sinon.stub();
await queue.checkQueue();
expect(queue.processWaitingQueue.calledOnce).to.be.true;
expect(models.LivechatInquiry.unlock.calledOnce).to.be.true;
});
it('should print a log when there was an error processing inquiry', async () => {
models.LivechatInquiry.findNextAndLock.throws('error');
const queue = new OmnichannelQueue();
queue.execute = Sinon.stub();
await queue.checkQueue();
expect(queueLogger.error.calledOnce).to.be.true;
});
});
describe('shouldStart', () => {
beforeEach(() => {
settings.get.resetHistory();
getConfig.resetHistory();
});
after(() => {
settings.get.reset();
getConfig.reset();
});
it('should call stop if Livechat is not enabled', async () => {
settings.get.returns(false);
const queue = new OmnichannelQueue();
queue.stop = Sinon.stub();
await queue.shouldStart();
expect(queue.stop.calledOnce).to.be.true;
});
it('should call start if routing algorithm supports auto assignment', async () => {
settings.get.returns(true);
getConfig.returns({ autoAssignAgent: true });
const queue = new OmnichannelQueue();
queue.start = Sinon.stub();
await queue.shouldStart();
expect(queue.start.calledOnce).to.be.true;
expect(queue.start.calledAfter(getConfig)).to.be.true;
});
it('should call stop if routing algorithm does not support auto assignment', async () => {
settings.get.returns(true);
getConfig.returns({ autoAssignAgent: false });
const queue = new OmnichannelQueue();
queue.stop = Sinon.stub();
await queue.shouldStart();
expect(queue.stop.calledOnce).to.be.true;
expect(queue.stop.calledAfter(getConfig)).to.be.true;
});
});
describe('reconciliation', () => {
beforeEach(() => {
models.LivechatInquiry.removeByRoomId.resetHistory();
models.LivechatInquiry.takeInquiry.resetHistory();
});
it('should remove inquiries from rooms that do not exist', async () => {
const queue = new OmnichannelQueue();
await queue.reconciliation('missing', { roomId: 'rid', inquiryId: 'inquiryId' });
expect(models.LivechatInquiry.removeByRoomId.calledOnce).to.be.true;
});
it('should take an inquiry if the room was taken', async () => {
const queue = new OmnichannelQueue();
await queue.reconciliation('taken', { roomId: 'rid', inquiryId: 'inquiryId' });
expect(models.LivechatInquiry.takeInquiry.calledOnce).to.be.true;
});
it('should remove inquiries from rooms that were closed', async () => {
const queue = new OmnichannelQueue();
await queue.reconciliation('closed', { roomId: 'rid', inquiryId: 'inquiryId' });
expect(models.LivechatInquiry.removeByRoomId.calledOnce).to.be.true;
});
it('should return true for any other case', async () => {
const queue = new OmnichannelQueue();
expect(await queue.reconciliation('random', { roomId: 'rid', inquiryId: 'inquiryId' })).to.be.true;
expect(models.LivechatInquiry.removeByRoomId.notCalled).to.be.true;
expect(models.LivechatInquiry.takeInquiry.notCalled).to.be.true;
});
});
describe('processWaitingQueue', () => {
let clock: any;
beforeEach(() => {
models.LivechatRooms.findOneById.reset();
models.LivechatInquiry.takeInquiry.resetHistory();
models.LivechatInquiry.removeByRoomId.resetHistory();
delegateInquiry.resetHistory();
queueLogger.debug.resetHistory();
clock = Sinon.useFakeTimers();
});
afterEach(() => {
clock.restore();
});
after(() => {
models.LivechatRooms.findOneById.reset();
models.LivechatInquiry.takeInquiry.reset();
delegateInquiry.reset();
queueLogger.debug.reset();
clock.reset();
});
it('should process the public queue when department is undefined', async () => {
const queue = new OmnichannelQueue();
expect(await queue.processWaitingQueue(undefined, mockedInquiry)).to.be.true;
expect(queueLogger.debug.calledWith('Processing inquiry inquiryId from queue Public'));
expect(models.LivechatRooms.findOneById.calledOnce).to.be.true;
});
it('should call removeInquiry when findOneById returns null', async () => {
models.LivechatRooms.findOneById.returns(null);
const queue = new OmnichannelQueue();
expect(await queue.processWaitingQueue('department1', mockedInquiry)).to.be.true;
expect(
queueLogger.debug.calledWith({
msg: 'Room from inquiry missing. Removing inquiry',
roomId: 'rid',
inquiryId: 'inquiryId',
step: 'reconciliation',
}),
).to.be.true;
expect(models.LivechatInquiry.removeByRoomId.calledOnce).to.be.true;
});
it('should call takeInquiry when findOneById returns a room thats already being served', async () => {
models.LivechatRooms.findOneById.returns({ _id: 'rid', servedBy: { some: 'thing' } });
const queue = new OmnichannelQueue();
queue.reconciliation = Sinon.stub().returns(true);
expect(await queue.processWaitingQueue('department1', mockedInquiry)).to.be.true;
expect(queue.reconciliation.calledOnce).to.be.true;
});
it('should call removeInquiry when findOneById returns a room that was closed', async () => {
models.LivechatRooms.findOneById.returns({ _id: 'rid', closedAt: new Date() });
const queue = new OmnichannelQueue();
queue.reconciliation = Sinon.stub().returns(true);
expect(await queue.processWaitingQueue('department1', mockedInquiry)).to.be.true;
expect(queue.reconciliation.calledOnce).to.be.true;
});
it('should call delegateInquiry when prechecks are met and return false if inquiry was not served', async () => {
models.LivechatRooms.findOneById.returns({ _id: 'rid' });
delegateInquiry.returns({});
const queue = new OmnichannelQueue();
expect(await queue.processWaitingQueue('department1', mockedInquiry)).to.be.false;
expect(delegateInquiry.calledOnce).to.be.true;
});
it('should call delegateInquiry and return true if inquiry was served', async () => {
models.LivechatRooms.findOneById.returns({ _id: 'rid' });
delegateInquiry.returns({ _id: 'rid', servedBy: { _id: 'agentId' } });
const queue = new OmnichannelQueue();
expect(await queue.processWaitingQueue('department1', mockedInquiry)).to.be.true;
expect(delegateInquiry.calledOnce).to.be.true;
});
it('should call dispatchAgentDelegated if inquiry was served (after 1s)', async () => {
models.LivechatRooms.findOneById.returns({ _id: 'rid' });
delegateInquiry.returns({ _id: 'rid', servedBy: { _id: 'agentId' } });
const queue = new OmnichannelQueue();
expect(await queue.processWaitingQueue('department1', mockedInquiry)).to.be.true;
expect(delegateInquiry.calledOnce).to.be.true;
clock.tick(1000);
expect(dispatchAgentDelegated.calledOnce).to.be.true;
});
});
describe('execute', () => {
beforeEach(() => {
license.shouldPreventAction.reset();
queueLogger.debug.reset();
});
after(() => {
license.shouldPreventAction.reset();
queueLogger.debug.reset();
});
it('should return undefined if service is not running', async () => {
const queue = new OmnichannelQueue();
queue.running = false;
expect(await queue.execute()).to.be.undefined;
});
it('should return undefined if license is over mac limits', async () => {
license.shouldPreventAction.returns(true);
const queue = new OmnichannelQueue();
queue.running = true;
expect(await queue.execute()).to.be.undefined;
expect(license.shouldPreventAction.calledOnce).to.be.true;
expect(queue.running).to.be.false;
});
it('should try to process a queue if license is not over mac limits', async () => {
license.shouldPreventAction.returns(false);
const queue = new OmnichannelQueue();
queue.running = true;
queue.getActiveQueues = Sinon.stub().resolves([null]);
await queue.execute();
expect(queue.getActiveQueues.calledOnce).to.be.true;
});
});
describe('start', () => {
beforeEach(() => {
queueLogger.info.resetHistory();
queueLogger.debug.resetHistory();
});
after(() => {
queueLogger.info.reset();
queueLogger.debug.reset();
});
it('should do nothing if queue is already running', async () => {
const queue = new OmnichannelQueue();
queue.running = true;
queue.execute = Sinon.stub();
await queue.start();
expect(queue.execute.notCalled).to.be.true;
});
it('should fetch active queues and set running to true', async () => {
const queue = new OmnichannelQueue();
queue.running = false;
queue.getActiveQueues = Sinon.stub().returns(['department1']);
queue.execute = Sinon.stub();
await queue.start();
expect(queue.running).to.be.true;
expect(queue.getActiveQueues.calledOnce).to.be.true;
expect(queueLogger.info.calledOnce).to.be.true;
expect(queueLogger.info.calledWith('Service started')).to.be.true;
expect(queue.execute.calledOnce).to.be.true;
});
});
describe('stop', () => {
beforeEach(() => {
models.LivechatInquiry.unlockAll.reset();
queueLogger.info.resetHistory();
});
after(() => {
models.LivechatInquiry.unlockAll.reset();
queueLogger.info.reset();
});
it('should unlock all inquiries and set running to false', async () => {
const queue = new OmnichannelQueue();
queue.running = true;
await queue.stop();
expect(queue.running).to.be.false;
expect(models.LivechatInquiry.unlockAll.calledOnce).to.be.true;
expect(queueLogger.info.calledOnce).to.be.true;
expect(queueLogger.info.calledWith('Service stopped')).to.be.true;
});
});
});

View File

@@ -0,0 +1,128 @@
import { expect } from 'chai';
import { getNameForDMs } from '../../../../../server/services/room/getNameForDMs';
describe('getNameForDMs', () => {
it('should return empty object when members array is empty', () => {
const result = getNameForDMs([]);
expect(result).to.deep.equal({});
});
it('should return own name for single member', () => {
const members = [{ _id: 'user1', name: 'John Doe', username: 'john' }];
const result = getNameForDMs(members);
expect(result).to.deep.equal({
user1: {
fname: 'John Doe',
name: 'john',
},
});
});
it('should return name map for two members using name field', () => {
const members = [
{ _id: 'user1', name: 'John Doe', username: 'john' },
{ _id: 'user2', name: 'Jane Smith', username: 'jane' },
];
const result = getNameForDMs(members);
expect(result).to.deep.equal({
user1: {
fname: 'Jane Smith',
name: 'jane',
},
user2: {
fname: 'John Doe',
name: 'john',
},
});
});
it('should fallback to username when name is not available', () => {
const members = [
{ _id: 'user1', name: '', username: 'john' },
{ _id: 'user2', name: '', username: 'jane' },
];
const result = getNameForDMs(members);
expect(result).to.deep.equal({
user1: {
fname: 'jane',
name: 'jane',
},
user2: {
fname: 'john',
name: 'john',
},
});
});
it('should handle multiple members and sort them alphabetically', () => {
const members = [
{ _id: 'user3', name: 'Charlie Brown', username: 'charlie' },
{ _id: 'user1', name: 'Alice Wonder', username: 'alice' },
{ _id: 'user2', name: 'Bob Builder', username: 'bob' },
];
const result = getNameForDMs(members);
expect(result).to.deep.equal({
user1: {
fname: 'Bob Builder, Charlie Brown',
name: 'bob, charlie',
},
user2: {
fname: 'Alice Wonder, Charlie Brown',
name: 'alice, charlie',
},
user3: {
fname: 'Alice Wonder, Bob Builder',
name: 'alice, bob',
},
});
});
it('should handle mix of members with and without names', () => {
const members = [
{ _id: 'user1', name: 'Alice Wonder', username: 'alice' },
{ _id: 'user2', name: '', username: 'bob' },
];
const result = getNameForDMs(members);
expect(result).to.deep.equal({
user1: {
fname: 'bob',
name: 'bob',
},
user2: {
fname: 'Alice Wonder',
name: 'alice',
},
});
});
it('should sort by username when names are empty', () => {
const members = [
{ _id: 'user1', name: '', username: 'zebra' },
{ _id: 'user2', name: '', username: 'alpha' },
];
const result = getNameForDMs(members);
expect(result).to.deep.equal({
user1: {
fname: 'alpha',
name: 'alpha',
},
user2: {
fname: 'zebra',
name: 'zebra',
},
});
});
});

View File

@@ -0,0 +1,150 @@
import { expect } from 'chai';
import { beforeEach, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const Rooms = {
findDefaultRoomsForTeam: sinon.stub(),
findOneById: sinon.stub(),
unsetTeamId: sinon.stub(),
};
const Users = {
findActiveByIds: sinon.stub(),
};
const Message = {
saveSystemMessage: sinon.stub(),
};
const addUserToRoom = sinon.stub();
const { TeamService } = proxyquire.noCallThru().load('../../../../../server/services/team/service', {
'@rocket.chat/core-services': {
Room: {},
Authorization: {},
Message,
ServiceClassInternal: class {},
api: {},
},
'@rocket.chat/models': {
Team: {},
Rooms,
Subscriptions: {},
Users,
TeamMember: {},
},
'@rocket.chat/tools': {
escapeRegExp: (value: string) => value,
},
'../../lib/rooms/settings': {
saveRoomName: sinon.stub(),
},
'../../lib/rooms/settings/saveRoomType': {
saveRoomType: sinon.stub(),
},
'../../lib/rooms/addUserToRoom': {
addUserToRoom,
},
'../../lib/users/checkUsernameAvailability': {
checkUsernameAvailability: sinon.stub(),
},
'../../lib/rooms/getRoomsWithSingleOwner': {
getSubscribedRoomsForUserWithDetails: sinon.stub(),
},
'../../lib/rooms/removeUserFromRoom': {
removeUserFromRoom: sinon.stub(),
},
'../../lib/notifyListener': {
notifyOnSubscriptionChangedByRoomIdAndUserId: sinon.stub(),
notifyOnRoomChangedById: sinon.stub(),
},
'../../settings': {
settings: { get: sinon.stub() },
},
});
const service = new TeamService();
describe('Team service', () => {
beforeEach(() => {
addUserToRoom.reset();
Rooms.findDefaultRoomsForTeam.reset();
Rooms.findOneById.reset();
Rooms.unsetTeamId.reset();
Users.findActiveByIds.reset();
Message.saveSystemMessage.reset();
});
it('should wait for default room membership operations to finish', async function () {
this.timeout(15000);
addUserToRoom.onFirstCall().resolves(true);
addUserToRoom.onSecondCall().returns(
new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, 20);
}),
);
Rooms.findDefaultRoomsForTeam.returns({
toArray: () => Promise.resolve([{ _id: 'default-room' }]),
});
Users.findActiveByIds.returns({
toArray: () =>
Promise.resolve([
{ _id: 'user-1', username: 'user-1' },
{ _id: 'user-2', username: 'user-2' },
]),
});
await service.addMembersToDefaultRooms({ _id: 'inviter', username: 'inviter' }, 'team-id', [
{ userId: 'user-1' },
{ userId: 'user-2' },
]);
expect(addUserToRoom.callCount).to.equal(2);
});
it('should propagate errors from default room membership operations', async function () {
this.timeout(15000);
addUserToRoom.rejects(new Error('room-add-failed'));
Rooms.findDefaultRoomsForTeam.returns({
toArray: () => Promise.resolve([{ _id: 'default-room' }]),
});
Users.findActiveByIds.returns({
toArray: () => Promise.resolve([{ _id: 'user-1', username: 'user-1' }]),
});
await expect(
service.addMembersToDefaultRooms({ _id: 'inviter', username: 'inviter' }, 'team-id', [{ userId: 'user-1' }]),
).to.be.rejectedWith('room-add-failed');
expect(addUserToRoom.callCount).to.equal(1);
});
describe('unsetTeamIdOfRooms', () => {
const user = { _id: 'user-1', username: 'user-1', name: 'User One' };
const team = { _id: 'team-id', roomId: 'team-room' };
it('should announce the conversion on the main room and move the remaining rooms back to the workspace', async () => {
Rooms.findOneById.resolves({ _id: 'team-room', name: 'team-room-name' });
await service.unsetTeamIdOfRooms(user, team);
expect(Message.saveSystemMessage.calledOnceWith('user-converted-to-channel', 'team-room', 'team-room-name', user)).to.be.true;
expect(Rooms.unsetTeamId.calledOnceWith('team-id')).to.be.true;
});
it('should move the remaining rooms back to the workspace when the main room no longer exists', async () => {
Rooms.findOneById.resolves(null);
await service.unsetTeamIdOfRooms(user, team);
expect(Message.saveSystemMessage.called).to.be.false;
expect(Rooms.unsetTeamId.calledOnceWith('team-id')).to.be.true;
});
});
});

View File

@@ -0,0 +1,68 @@
import { registerModel, BaseRaw } from '@rocket.chat/models';
import { expect } from 'chai';
import { afterEach, before, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const maxTokens = {
getMaxLoginTokens: () => 2,
};
const { UserService } = proxyquire.noCallThru().load('../../../../../server/services/user/service', {
'../../lib/getMaxLoginTokens': maxTokens,
});
class UsersModel extends BaseRaw<any> {
async findAllResumeTokensByUserId(): Promise<Array<{ tokens: Array<{ when: Date }> }>> {
return [];
}
async removeOlderResumeTokensByUserId(_uid: string, _when: Date) {
// no op
}
}
const usersModel = new UsersModel({ collection: () => ({}) } as unknown as any, 'user');
describe('User service', () => {
before(() => {
registerModel('IUsersModel', usersModel);
});
afterEach(() => sinon.restore());
it('should do nothing if user has less login tokens than the limit', async () => {
const service = new UserService();
sinon.replace(usersModel, 'findAllResumeTokensByUserId', sinon.fake.returns(Promise.resolve([{ tokens: [{ when: new Date() }] }])));
const updateFake = sinon.replace(usersModel, 'removeOlderResumeTokensByUserId', sinon.fake());
const result = await service.ensureLoginTokensLimit('uid');
expect(result).to.be.undefined;
expect(updateFake.callCount).to.be.equal(0);
});
it('should remove the oldest tokens if over the limit', async () => {
const service = new UserService();
const firstOld = new Date('2023-01-03');
// the query return the tokens in order so we should do the same here
const tokens = [
{ when: new Date('2023-01-01') },
{ when: new Date('2023-01-02') },
{ when: firstOld },
{ when: new Date('2023-01-04') },
];
sinon.replace(usersModel, 'findAllResumeTokensByUserId', sinon.fake.returns(Promise.resolve([{ tokens }])));
const updateFake = sinon.replace(usersModel, 'removeOlderResumeTokensByUserId', sinon.fake());
const result = await service.ensureLoginTokensLimit('uid');
expect(result).to.be.undefined;
expect(updateFake.callCount).to.be.equal(1);
expect(updateFake.lastCall.lastArg).to.be.equal(firstOld);
});
});

View File

@@ -0,0 +1,28 @@
import sinon from 'sinon';
export async function testPrivateMethod<T extends (...args: any[]) => any>(
service: any,
methodName: string,
testFn: (method: T) => Promise<void> | void,
): Promise<void> {
const proto = Object.getPrototypeOf(service);
const originalMethod = proto[methodName];
const isStubbed = originalMethod && 'restore' in originalMethod;
if (isStubbed) {
(originalMethod as sinon.SinonStub).restore();
}
const method = proto[methodName];
void testFn(method.bind(service));
if (isStubbed) {
sinon.stub(proto, methodName).callsFake(originalMethod);
}
}
export function createFreshServiceInstance<T>(moduleExports: any, serviceName?: string): T {
const ServiceClass = serviceName ? moduleExports[serviceName] : Object.values(moduleExports)[0];
return new ServiceClass();
}