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,35 @@
import { expect } from 'chai';
import { describe, it, before, after } from 'mocha';
import { addServerUrlToIndex } from '../../../../../server/lib/omnichannel/Assets';
describe('addServerUrlToIndex', () => {
before(() => {
(global as any).__meteor_runtime_config__ = { ROOT_URL: 'http://localhost:3000' };
});
after(() => {
delete (global as any).__meteor_runtime_config__;
});
it('should do nothing if "file" has no <body> tag', () => {
const file = addServerUrlToIndex('file');
expect(file).to.equal('file');
});
it('should replace <body> with an script tag containing the SERVER_URL inside', () => {
const file = addServerUrlToIndex('<html><body></body></html>');
expect(file).to.equal("<html><body><script> SERVER_URL = 'http://localhost:3000'; </script></body></html>");
});
it('should remove any trailing / from the SERVER_URL', () => {
(global as any).__meteor_runtime_config__ = { ROOT_URL: 'http://localhost:3000/' };
const file = addServerUrlToIndex('<html><body></body></html>');
expect(file).to.equal("<html><body><script> SERVER_URL = 'http://localhost:3000'; </script></body></html>");
});
it('should use the value from the global variable always', () => {
const file = addServerUrlToIndex('<html><body></body></html>');
expect(file).to.equal("<html><body><script> SERVER_URL = 'http://localhost:3000'; </script></body></html>");
(global as any).__meteor_runtime_config__ = { ROOT_URL: 'http://kodiak.rocket.chat/' };
const file2 = addServerUrlToIndex('<html><body></body></html>');
expect(file2).to.equal("<html><body><script> SERVER_URL = 'http://kodiak.rocket.chat'; </script></body></html>");
});
});

View File

@@ -0,0 +1,203 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const formatStub = sinon.stub().returns('00:00');
const isSameStub = sinon.stub().returns(true);
const isDSTStub = sinon.stub().returns(true);
const momentStub = sinon.stub().returns({
utc: () => ({
tz: () => ({
format: formatStub,
isSame: isSameStub,
isDST: isDSTStub,
}),
}),
});
const findActiveBusinessHoursStub = sinon.stub().returns([]);
const LivechatBusinessHoursStub = {
findActiveBusinessHours: findActiveBusinessHoursStub,
};
const saveBusinessHourStub = sinon.stub();
const loggerStub = sinon.stub();
const { BusinessHourManager } = proxyquire.noCallThru().load('../../../../../../server/lib/omnichannel/business-hour/BusinessHourManager', {
'../../../settings': {},
'../../callbacks': {},
'./AbstractBusinessHour': {},
'moment-timezone': momentStub,
'@rocket.chat/models': {
LivechatBusinessHours: LivechatBusinessHoursStub,
},
'../logger': {
businessHourLogger: {
error: loggerStub,
},
},
});
const cronAddStub = sinon.stub();
const cronRemoveStub = sinon.stub();
describe('[OC] BusinessHourManager', () => {
afterEach(() => sinon.restore());
describe('hasDaylightSavingTimeChanged()', () => {
const manager = new BusinessHourManager({} as any);
it('should return false if the provided timezone is equal to the current one (No changes in the timezone)', () => {
formatStub.returns('00:00');
expect(manager.hasDaylightSavingTimeChanged({ name: 'test', utc: '00:00' })).to.be.false;
});
it('should return false if the provided timezone is different to the current one, the current time is in DST but there is no difference between times (current, stored)', () => {
isDSTStub.returns(true);
formatStub.returns('01:00');
isSameStub.returns(true);
expect(manager.hasDaylightSavingTimeChanged({ name: 'test', utc: '00:00' })).to.be.false;
});
it('should return false if the provided timezone is different to the current one, the current time is NOT in DST but there is no difference between times (current, stored)', () => {
isDSTStub.returns(false);
formatStub.returns('01:00');
isSameStub.returns(true);
expect(manager.hasDaylightSavingTimeChanged({ name: 'test', utc: '00:00' })).to.be.false;
});
it('should return true if the provided timezone is different to the current one, the current time is in DST and the current time is different than the stored one', () => {
isDSTStub.returns(true);
formatStub.returns('01:00');
isSameStub.returns(false);
expect(manager.hasDaylightSavingTimeChanged({ name: 'test', utc: '00:00' })).to.be.true;
});
it('should return true if the provided timezone is different to the current one, the current time is NOT in DST and the current time is different than the stored one', () => {
isDSTStub.returns(false);
formatStub.returns('01:00');
isSameStub.returns(false);
expect(manager.hasDaylightSavingTimeChanged({ name: 'test', utc: '00:00' })).to.be.true;
});
});
describe('startDaylightSavingTimeVerifier()', () => {
const cronStub = {
add: cronAddStub,
remove: cronRemoveStub,
};
const manager = new BusinessHourManager(cronStub);
manager.registerBusinessHourBehavior({
onStartBusinessHours: () => {},
onDisableBusinessHours: () => {},
});
afterEach(() => {
sinon.restore();
loggerStub.resetHistory();
});
it('should call the verifier when the manager starts', async () => {
sinon.stub(manager, 'createCronJobsForWorkHours');
sinon.stub(manager, 'setupCallbacks');
sinon.stub(manager, 'cleanupDisabledDepartmentReferences');
sinon.stub(manager, 'startDaylightSavingTimeVerifier');
await manager.startManager();
expect(manager.startDaylightSavingTimeVerifier.called).to.be.true;
});
it('should register the cron job for the DST verifier when the manager starts', async () => {
sinon.stub(manager, 'createCronJobsForWorkHours');
sinon.stub(manager, 'setupCallbacks');
sinon.stub(manager, 'cleanupDisabledDepartmentReferences');
sinon.stub(manager, 'startDaylightSavingTimeVerifier');
await manager.startManager();
expect(cronAddStub.called).to.be.true;
});
it('should remove the cron job for the DST verifier when the manager stops', async () => {
sinon.stub(manager, 'removeCronJobs');
sinon.stub(manager, 'clearCronJobsCache');
sinon.stub(manager, 'removeCallbacks');
sinon.stub(manager, 'startDaylightSavingTimeVerifier');
await manager.stopManager();
expect(cronRemoveStub.called).to.be.true;
});
it('should remove and add back the cron job for the DST verifier when the manager restarts', async () => {
sinon.stub(manager, 'createCronJobsForWorkHours');
sinon.stub(manager, 'setupCallbacks');
sinon.stub(manager, 'cleanupDisabledDepartmentReferences');
sinon.stub(manager, 'startDaylightSavingTimeVerifier');
sinon.stub(manager, 'removeCronJobs');
sinon.stub(manager, 'clearCronJobsCache');
sinon.stub(manager, 'removeCallbacks');
await manager.restartManager();
expect(cronRemoveStub.called).to.be.true;
expect(cronAddStub.called).to.be.true;
});
it('should NOT save business hours when there is no active business hours', async () => {
sinon.stub(manager, 'getBusinessHourType').returns({ saveBusinessHour: saveBusinessHourStub });
findActiveBusinessHoursStub.resolves([]);
await manager.startDaylightSavingTimeVerifier();
expect(saveBusinessHourStub.called).to.be.false;
});
it('should NOT save business hours when there is no timezone with DST changes', async () => {
sinon.stub(manager, 'getBusinessHourType').returns({ saveBusinessHour: saveBusinessHourStub });
sinon.stub(manager, 'hasDaylightSavingTimeChanged').returns(false);
findActiveBusinessHoursStub.resolves([{ timezone: 'test' }]);
await manager.startDaylightSavingTimeVerifier();
expect(saveBusinessHourStub.called).to.be.false;
});
it('should save business hours AND recreate the cron jobs for the work hours when there is a timezone with DST changes', async () => {
sinon.stub(manager, 'getBusinessHourType').returns({ saveBusinessHour: saveBusinessHourStub });
sinon.stub(manager, 'createCronJobsForWorkHours');
sinon.stub(manager, 'hasDaylightSavingTimeChanged').returns(true);
findActiveBusinessHoursStub.resolves([
{ timezone: { name: 'timezoneName' }, workHours: [{ start: { time: 'startTime' }, finish: { time: 'finishTime' } }] },
]);
await manager.startDaylightSavingTimeVerifier();
expect(
saveBusinessHourStub.calledWith({
timezone: { name: 'timezoneName' },
timezoneName: 'timezoneName',
workHours: [{ start: 'startTime', finish: 'finishTime' }],
}),
).to.be.true;
expect(manager.createCronJobsForWorkHours.called).to.be.true;
});
it('should log any error on the updating process', async () => {
sinon.stub(manager, 'getBusinessHourType').returns({ saveBusinessHour: saveBusinessHourStub });
sinon.stub(manager, 'createCronJobsForWorkHours');
sinon.stub(manager, 'hasDaylightSavingTimeChanged').returns(true);
findActiveBusinessHoursStub.resolves([
{ timezone: { name: 'timezoneName' }, workHours: [{ start: { time: 'startTime' }, finish: { time: 'finishTime' } }] },
{ timezone: { name: 'timezoneName2' }, workHours: [{ start: { time: 'startTime' }, finish: { time: 'finishTime' } }] },
]);
const error = new Error('update error');
saveBusinessHourStub.onSecondCall().rejects(error);
await manager.startDaylightSavingTimeVerifier();
expect(loggerStub.calledWith(sinon.match({ msg: 'Failed to update business hours with new timezone' }))).to.be.true;
expect(manager.createCronJobsForWorkHours.called).to.be.true;
});
it('should NOT throw any error even if the business hour has an invalid type', async () => {
sinon.stub(manager, 'getBusinessHourType').returns({ saveBusinessHour: saveBusinessHourStub });
sinon.stub(manager, 'createCronJobsForWorkHours');
sinon.stub(manager, 'hasDaylightSavingTimeChanged').returns(true);
findActiveBusinessHoursStub.resolves([
{
type: 'invalid',
timezone: { name: 'timezoneName' },
workHours: [{ start: { time: 'startTime' }, finish: { time: 'finishTime' } }],
},
]);
await expect(manager.startDaylightSavingTimeVerifier()).not.to.be.rejected;
expect(loggerStub.called).to.be.false;
});
});
});

View File

@@ -0,0 +1,123 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
import { createFakeRoom, createFakeSubscription, createFakeUser } from '../../../../mocks/data';
const subscriptionsStub = {
findOneByRoomIdAndUserId: sinon.stub(),
removeByRoomId: sinon.stub(),
countByRoomId: sinon.stub(),
};
const livechatRoomsStub = {
findOneById: sinon.stub(),
};
const livechatStub = {
closeRoom: sinon.stub(),
};
const hasPermissionStub = sinon.stub();
const { closeLivechatRoom } = proxyquire.noCallThru().load('../../../../../server/lib/omnichannel/closeLivechatRoom.ts', {
'./closeRoom': livechatStub,
'../authorization/hasPermission': {
hasPermissionAsync: hasPermissionStub,
},
'@rocket.chat/models': {
Subscriptions: subscriptionsStub,
LivechatRooms: livechatRoomsStub,
},
});
describe('closeLivechatRoom', () => {
const user = createFakeUser();
const room = createFakeRoom({ t: 'l', open: true });
const subscription = createFakeSubscription({ rid: room._id, u: { username: user.username, _id: user._id } });
beforeEach(() => {
subscriptionsStub.findOneByRoomIdAndUserId.reset();
subscriptionsStub.removeByRoomId.reset();
subscriptionsStub.countByRoomId.reset();
livechatRoomsStub.findOneById.reset();
livechatStub.closeRoom.reset();
hasPermissionStub.reset();
});
it('should not perform any operation when an invalid room id is provided', async () => {
livechatRoomsStub.findOneById.resolves(null);
hasPermissionStub.resolves(true);
await expect(closeLivechatRoom(user, room._id, {})).to.be.rejectedWith('error-invalid-room');
expect(livechatStub.closeRoom.notCalled).to.be.true;
expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true;
expect(subscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.be.true;
expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true;
});
it('should not perform any operation when the caller is not subscribed to an open room and does not have the permission to close others rooms', async () => {
livechatRoomsStub.findOneById.resolves(room);
subscriptionsStub.findOneByRoomIdAndUserId.resolves(null);
hasPermissionStub.resolves(false);
await expect(closeLivechatRoom(user, room._id, {})).to.be.rejectedWith('error-not-authorized');
expect(livechatStub.closeRoom.notCalled).to.be.true;
expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true;
expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true;
expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true;
});
it('should close the room when the caller is not subscribed to it but has the permission to close others rooms', async () => {
livechatRoomsStub.findOneById.resolves(room);
subscriptionsStub.findOneByRoomIdAndUserId.resolves(null);
hasPermissionStub.resolves(true);
await closeLivechatRoom(user, room._id, {});
expect(livechatStub.closeRoom.calledOnceWith(sinon.match({ room, user }))).to.be.true;
expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true;
expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true;
expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true;
});
it('should close the room when the caller is subscribed to it and does not have the permission to close others rooms', async () => {
livechatRoomsStub.findOneById.resolves(room);
subscriptionsStub.findOneByRoomIdAndUserId.resolves(subscription);
hasPermissionStub.resolves(false);
await closeLivechatRoom(user, room._id, {});
expect(livechatStub.closeRoom.calledOnceWith(sinon.match({ room, user }))).to.be.true;
expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true;
expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true;
expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true;
});
it('should call Livechat.closeRoom directly when forceClose is true even if room is in invalid state', async () => {
// Here we're using `open: false` as a way to simulate an invalid state. This should not happen in production. A room like this is effectively a "bad egg"
livechatRoomsStub.findOneById.resolves({ ...room, open: false });
subscriptionsStub.findOneByRoomIdAndUserId.resolves(subscription);
hasPermissionStub.resolves(true);
await closeLivechatRoom(user, room._id, { forceClose: true });
expect(
livechatStub.closeRoom.calledOnceWith(
sinon.match({ room: { ...room, open: false }, user, options: sinon.match({ clientAction: true }), forceClose: true }),
),
).to.be.true;
expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true;
expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true;
});
it('should throw an error if forceClose is false and room is already closed', async () => {
livechatRoomsStub.findOneById.resolves({ ...room, open: false });
subscriptionsStub.findOneByRoomIdAndUserId.resolves(subscription);
hasPermissionStub.resolves(true);
await expect(closeLivechatRoom(user, room._id, { forceClose: false })).to.be.rejectedWith('error-room-already-closed');
expect(livechatStub.closeRoom.notCalled).to.be.true;
expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true;
expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true;
});
});

View File

@@ -0,0 +1,100 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const mockUsers = {
acquireAgentLock: sinon.stub(),
releaseAgentLock: sinon.stub(),
};
const mockSettings = {
get: sinon.stub(),
};
const mockHasRoleAsync = sinon.stub();
const { conditionalLockAgent } = proxyquire.noCallThru().load('../../../../../server/lib/omnichannel/conditionalLockAgent', {
'@rocket.chat/models': { Users: mockUsers },
'../authorization/hasRole': { hasRoleAsync: mockHasRoleAsync },
'../../settings': { settings: mockSettings },
});
describe('conditionalLockAgent', () => {
beforeEach(() => {
mockUsers.acquireAgentLock.reset();
mockUsers.releaseAgentLock.reset();
mockSettings.get.reset();
mockHasRoleAsync.reset();
});
describe('when waiting_queue is enabled', () => {
beforeEach(() => {
mockSettings.get.withArgs('Livechat_waiting_queue').returns(true);
mockHasRoleAsync.resolves(false);
});
it('should return acquired: true when lock is successfully acquired', async () => {
mockUsers.acquireAgentLock.resolves(true);
const result = await conditionalLockAgent('agent1');
expect(result.acquired).to.equal(true);
expect(result.required).to.equal(true);
expect(mockUsers.acquireAgentLock.calledOnce).to.equal(true);
});
it('should return acquired: false when lock is already held by another process', async () => {
mockUsers.acquireAgentLock.resolves(false);
const result = await conditionalLockAgent('agent1');
expect(result.acquired).to.equal(false);
expect(result.required).to.equal(true);
expect(mockUsers.acquireAgentLock.calledOnce).to.equal(true);
});
it('should call releaseAgentLock when unlock is called', async () => {
mockUsers.acquireAgentLock.resolves(true);
mockUsers.releaseAgentLock.resolves(true);
const result = await conditionalLockAgent('agent1');
await result.unlock();
expect(mockUsers.releaseAgentLock.calledOnce).to.equal(true);
expect(mockUsers.releaseAgentLock.firstCall.args[0]).to.equal('agent1');
expect(mockUsers.releaseAgentLock.firstCall.args[1]).to.be.instanceOf(Date);
});
it('should skip lock acquisition for bot agents', async () => {
mockHasRoleAsync.resolves(true);
const result = await conditionalLockAgent('agent1');
expect(result.acquired).to.equal(false);
expect(result.required).to.equal(false);
expect(mockUsers.acquireAgentLock.called).to.equal(false);
});
});
describe('when waiting_queue is disabled', () => {
beforeEach(() => {
mockSettings.get.withArgs('Livechat_waiting_queue').returns(false);
});
it('should return acquired: false and required: false without calling acquireAgentLock', async () => {
const result = await conditionalLockAgent('agent1');
expect(result.acquired).to.equal(false);
expect(result.required).to.equal(false);
expect(mockUsers.acquireAgentLock.called).to.equal(false);
expect(mockHasRoleAsync.called).to.equal(false);
});
it('should have a no-op unlock function', async () => {
const result = await conditionalLockAgent('agent1');
await result.unlock(); // should not throw an error
expect(mockUsers.releaseAgentLock.called).to.equal(false);
});
});
});

View File

@@ -0,0 +1,106 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
LivechatContacts: {
updateById: sinon.stub(),
},
};
const { updateContactsCustomFields } = proxyquire.noCallThru().load('../../../../../server/lib/omnichannel/custom-fields.ts', {
'@rocket.chat/models': modelsMock,
});
describe('[Custom Fields] updateContactsCustomFields', () => {
beforeEach(() => {
modelsMock.LivechatContacts.updateById.reset();
});
it('should do nothing if validCustomFields param is empty', async () => {
const contact = { _id: 'contactId', customFields: {} } as any;
await updateContactsCustomFields(contact, []);
expect(modelsMock.LivechatContacts.updateById.called).to.be.false;
});
it('should add a custom field from the validCustomFields param', async () => {
const contact = { _id: 'contactId', customFields: {} } as any;
const validCustomFields = [{ key: 'field1', value: 'value1', overwrite: true }];
await updateContactsCustomFields(contact, validCustomFields);
expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true;
expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId');
expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({
$set: { 'customFields.field1': 'value1' },
});
});
it('should add multiple custom fields from the validCustomFields param', async () => {
const contact = { _id: 'contactId', customFields: {} } as any;
const validCustomFields = [
{ key: 'field1', value: 'value1', overwrite: true },
{ key: 'field2', value: 'value2', overwrite: true },
];
await updateContactsCustomFields(contact, validCustomFields);
expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true;
expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId');
expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({
$set: { 'customFields.field1': 'value1', 'customFields.field2': 'value2' },
});
});
it('should add custom field to conflictingFields when the contact already has the field and overwrite is false', async () => {
const contact = { _id: 'contactId', customFields: { field1: 'existingValue' } } as any;
const validCustomFields = [{ key: 'field1', value: 'newValue', overwrite: false }];
await updateContactsCustomFields(contact, validCustomFields);
expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true;
expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId');
expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({
$set: { conflictingFields: [{ field: 'customFields.field1', value: 'newValue' }] },
});
});
it('should correctly add custom field and conflicting field from validCustomFields array', async () => {
const contact = { _id: 'contactId', customFields: { field1: 'existingValue' } } as any;
const validCustomFields = [
{ key: 'field1', value: 'newValue', overwrite: false },
{ key: 'field2', value: 'value2', overwrite: true },
];
await updateContactsCustomFields(contact, validCustomFields);
expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true;
expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId');
expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({
$set: { 'customFields.field2': 'value2', 'conflictingFields': [{ field: 'customFields.field1', value: 'newValue' }] },
});
});
it('should overwrite an existing field when field is on validCustomFields array & overwrite is true', async () => {
const contact = { _id: 'contactId', customFields: { field1: 'existingValue' } } as any;
const validCustomFields = [
{ key: 'field1', value: 'newValue', overwrite: true },
{ key: 'field2', value: 'value2', overwrite: true },
];
await updateContactsCustomFields(contact, validCustomFields);
expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true;
expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId');
expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({
$set: { 'customFields.field1': 'newValue', 'customFields.field2': 'value2' },
});
});
it('should update all custom fields from the validCustomFields array without issues', async () => {
const contact = { _id: 'contactId', customFields: { field1: 'existingValue' } } as any;
const validCustomFields = [
{ key: 'field1', value: 'newValue', overwrite: true },
{ key: 'field2', value: 'value2', overwrite: true },
{ key: 'field3', value: 'value3', overwrite: true },
{ key: 'field4', value: 'value4', overwrite: true },
{ key: 'field5', value: 'value5', overwrite: true },
];
await updateContactsCustomFields(contact, validCustomFields);
expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true;
expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId');
expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({
$set: {
'customFields.field1': 'newValue',
'customFields.field2': 'value2',
'customFields.field3': 'value3',
'customFields.field4': 'value4',
'customFields.field5': 'value5',
},
});
});
});

View File

@@ -0,0 +1,105 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
LivechatContacts: {
findOneEnabledById: sinon.stub(),
disableByContactId: sinon.stub(),
},
LivechatRooms: {
checkContactOpenRooms: sinon.stub(),
},
};
const settingsMock = {
get: sinon.stub(),
};
const removeGuestMock = { removeGuest: sinon.stub() };
const { disableContactById } = proxyquire.noCallThru().load('../../../../../server/lib/omnichannel/contacts/disableContact.ts', {
'@rocket.chat/models': modelsMock,
'../guests': removeGuestMock,
'../../../settings': { settings: settingsMock },
});
describe('disableContact', () => {
const contact = {
_id: 'contact-id',
channels: [
{
visitor: {
visitorId: 'visitor-id',
},
},
],
};
beforeEach(() => {
modelsMock.LivechatContacts.findOneEnabledById.reset();
modelsMock.LivechatRooms.checkContactOpenRooms.reset();
modelsMock.LivechatContacts.disableByContactId.reset();
settingsMock.get.reset();
removeGuestMock.removeGuest.reset();
});
it('should disable the contact', async () => {
settingsMock.get.withArgs('Livechat_Allow_collect_and_store_HTTP_header_informations').returns(true);
modelsMock.LivechatContacts.findOneEnabledById.resolves(contact);
modelsMock.LivechatRooms.checkContactOpenRooms.resolves(null);
removeGuestMock.removeGuest.resolves();
modelsMock.LivechatContacts.disableByContactId.resolves();
await disableContactById(contact._id);
expect(modelsMock.LivechatContacts.findOneEnabledById.calledOnceWith(contact._id)).to.be.true;
expect(modelsMock.LivechatRooms.checkContactOpenRooms.calledOnceWith(contact._id)).to.be.true;
expect(removeGuestMock.removeGuest.calledOnceWith({ _id: 'visitor-id' })).to.be.true;
expect(modelsMock.LivechatContacts.disableByContactId.calledOnceWith(contact._id)).to.be.true;
});
it('should call removeGuest for each channel the contact has communicated from', async () => {
contact.channels.push({ visitor: { visitorId: 'visitor-id-2' } });
settingsMock.get.withArgs('Livechat_Allow_collect_and_store_HTTP_header_informations').returns(true);
modelsMock.LivechatContacts.findOneEnabledById.resolves(contact);
modelsMock.LivechatRooms.checkContactOpenRooms.resolves(null);
removeGuestMock.removeGuest.resolves();
modelsMock.LivechatContacts.disableByContactId.resolves();
await disableContactById(contact._id);
expect(modelsMock.LivechatContacts.findOneEnabledById.calledOnceWith(contact._id)).to.be.true;
expect(modelsMock.LivechatContacts.findOneEnabledById.calledOnceWith(contact._id)).to.be.true;
expect(modelsMock.LivechatRooms.checkContactOpenRooms.calledOnceWith(contact._id)).to.be.true;
expect(removeGuestMock.removeGuest.calledTwice).to.be.true;
expect(removeGuestMock.removeGuest.getCall(0).args[0]).to.deep.equal({ _id: 'visitor-id' });
expect(removeGuestMock.removeGuest.getCall(1).args[0]).to.deep.equal({ _id: 'visitor-id-2' });
expect(modelsMock.LivechatContacts.disableByContactId.calledOnceWith(contact._id)).to.be.true;
});
it('should throw error if contact is not found', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves(null);
await expect(disableContactById('nonexistent-contact-id')).to.be.rejectedWith('error-contact-not-found');
expect(modelsMock.LivechatContacts.findOneEnabledById.calledOnceWith('nonexistent-contact-id')).to.be.true;
expect(modelsMock.LivechatRooms.checkContactOpenRooms.notCalled).to.be.true;
expect(removeGuestMock.removeGuest.notCalled).to.be.true;
expect(modelsMock.LivechatContacts.disableByContactId.notCalled).to.be.true;
});
it('should throw error if contact has open rooms and GDPR is disabled', async () => {
settingsMock.get.withArgs('Livechat_Allow_collect_and_store_HTTP_header_informations').returns(false);
modelsMock.LivechatContacts.findOneEnabledById.resolves(contact);
modelsMock.LivechatRooms.checkContactOpenRooms.resolves({ _id: 'room-id' });
modelsMock.LivechatContacts.disableByContactId.resolves();
await expect(disableContactById(contact._id)).to.be.rejectedWith('error-contact-has-open-rooms');
expect(modelsMock.LivechatRooms.checkContactOpenRooms.calledOnceWith(contact._id)).to.be.true;
expect(removeGuestMock.removeGuest.notCalled).to.be.true;
expect(modelsMock.LivechatContacts.disableByContactId.notCalled).to.be.true;
});
});

View File

@@ -0,0 +1,35 @@
import { expect } from 'chai';
import p from 'proxyquire';
import sinon from 'sinon';
import { createFakeMessage } from '../../../../mocks/data';
const modelsMock = {
Users: {
isUserInRole: sinon.stub(),
},
};
const { isMessageFromBot } = p.noCallThru().load('../../../../../server/lib/omnichannel/isMessageFromBot', {
'@rocket.chat/models': modelsMock,
});
describe('isMessageFromBot', () => {
const mockMessage = createFakeMessage();
beforeEach(() => {
modelsMock.Users.isUserInRole.reset();
});
it('Should return true if user has bot role', async () => {
modelsMock.Users.isUserInRole.resolves(true);
const result = await isMessageFromBot(mockMessage);
expect(result).to.be.true;
});
it('Should return false if user does not have bot role', async () => {
modelsMock.Users.isUserInRole.resolves(false);
const result = await isMessageFromBot(mockMessage);
expect(result).to.be.false;
});
});

View File

@@ -0,0 +1,127 @@
import { expect } from 'chai';
import p from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
Users: {
findOneById: sinon.stub(),
},
LivechatVisitors: {
findOneById: sinon.stub(),
},
};
const settingsGetMock = {
get: sinon.stub(),
};
const { parseTranscriptRequest } = p.noCallThru().load('../../../../../server/lib/omnichannel/parseTranscriptRequest', {
'@rocket.chat/models': modelsMock,
'../../settings': { settings: settingsGetMock },
});
describe('parseTranscriptRequest', () => {
beforeEach(() => {
settingsGetMock.get.reset();
modelsMock.Users.findOneById.reset();
modelsMock.LivechatVisitors.findOneById.reset();
});
it('should return `options` param with no changes when Livechat_enable_transcript setting is true', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(true);
const options = await parseTranscriptRequest({} as any, {} as any);
expect(options).to.be.deep.equal({});
});
it('should return `options` param with no changes when send always is disabled', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false);
settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(false);
const options = await parseTranscriptRequest({} as any, {} as any);
expect(options).to.be.deep.equal({});
});
it('should return `options` param with no changes when visitor is not provided and its not found on db', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false);
settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true);
modelsMock.LivechatVisitors.findOneById.resolves(null);
const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any);
expect(options).to.be.deep.equal({});
});
it('should return `options` param with no changes when visitor is passed but no email is found', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false);
settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true);
modelsMock.LivechatVisitors.findOneById.resolves({} as any);
const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any, { _id: '123' } as any);
expect(options).to.be.deep.equal({});
});
it('should return `options` param with no changes when visitor is fetched from db, but no email is found', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false);
settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true);
modelsMock.LivechatVisitors.findOneById.resolves({} as any);
const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any);
expect(options).to.be.deep.equal({});
});
it('should return `options` param with no changes when no user is passed, room is not being served and rocketcat is not present', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false);
settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true);
modelsMock.Users.findOneById.resolves(null);
modelsMock.LivechatVisitors.findOneById.resolves({ visitorEmails: [{ address: 'abc@rocket.chat' }] } as any);
const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any);
expect(options).to.be.deep.equal({});
});
it('should return `options` param with `transcriptRequest` key attached when user is passed', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false);
settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true);
modelsMock.LivechatVisitors.findOneById.resolves({ visitorEmails: [{ address: 'abc@rocket.chat' }] } as any);
const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any, undefined, { _id: '123' } as any);
expect(modelsMock.LivechatVisitors.findOneById.getCall(0).firstArg).to.be.equal('123');
expect(options).to.have.property('emailTranscript').that.is.an('object');
expect(options.emailTranscript.requestData).to.have.property('email', 'abc@rocket.chat');
expect(options.emailTranscript.requestData).to.have.property('subject', '');
expect(options.emailTranscript.requestData.requestedBy).to.be.deep.equal({ _id: '123' });
});
it('should return `options` param with `transcriptRequest` key attached when no user is passed, but theres an agent serving the room', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false);
settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true);
modelsMock.Users.findOneById.resolves({ _id: '123', username: 'kevsxxx', name: 'Kev' } as any);
modelsMock.LivechatVisitors.findOneById.resolves({ visitorEmails: [{ address: 'abc@rocket.chat' }] } as any);
const options = await parseTranscriptRequest({ v: { _id: '123' }, servedBy: { _id: '123' } } as any, {} as any);
expect(modelsMock.Users.findOneById.getCall(0).firstArg).to.be.equal('123');
expect(options).to.have.property('emailTranscript').that.is.an('object');
expect(options.emailTranscript.requestData).to.have.property('email', 'abc@rocket.chat');
expect(options.emailTranscript.requestData).to.have.property('subject', '');
expect(options.emailTranscript.requestData.requestedBy).to.be.deep.equal({ _id: '123', username: 'kevsxxx', name: 'Kev' });
});
it('should return `options` param with `transcriptRequest` key attached when no user is passed, no agent is serving but rocket.cat is present', async () => {
settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false);
settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true);
modelsMock.Users.findOneById.resolves({ _id: 'rocket.cat', username: 'rocket.cat', name: 'Rocket Cat' } as any);
modelsMock.LivechatVisitors.findOneById.resolves({ visitorEmails: [{ address: 'abc@rocket.chat' }] } as any);
const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any);
expect(modelsMock.Users.findOneById.getCall(0).firstArg).to.be.equal('rocket.cat');
expect(options).to.have.property('emailTranscript').that.is.an('object');
expect(options.emailTranscript.requestData).to.have.property('email', 'abc@rocket.chat');
expect(options.emailTranscript.requestData).to.have.property('subject', '');
expect(options.emailTranscript.requestData.requestedBy).to.be.deep.equal({
_id: 'rocket.cat',
username: 'rocket.cat',
name: 'Rocket Cat',
});
});
});

View File

@@ -0,0 +1,234 @@
import { expect } from 'chai';
import p from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
LivechatRooms: {
findOneById: sinon.stub(),
},
Messages: {
findLivechatClosingMessage: sinon.stub(),
findVisibleByRoomIdNotContainingTypesBeforeTs: sinon.stub(),
},
Users: {
findOneById: sinon.stub(),
},
};
const checkMock = sinon.stub();
const mockLogger = class {
debug() {
return null;
}
error() {
return null;
}
warn() {
return null;
}
info() {
return null;
}
};
const mockSettingValues: Record<string, any> = {
Livechat_show_agent_info: true,
Language: 'en',
From_Email: 'test@rocket.chat',
};
const settingsMock = function (key: string) {
return mockSettingValues[key] || null;
};
const getTimezoneMock = sinon.stub();
const mailerMock = sinon.stub();
const tStub = sinon.stub();
const { sendTranscript } = p.noCallThru().load('../../../../../server/lib/omnichannel/sendTranscript', {
// mocked so the spec doesn't depend on whichever broker a previously-run spec installed
'@rocket.chat/core-services': {
Message: { saveSystemMessage: sinon.stub().resolves() },
Omnichannel: { isWithinMACLimit: sinon.stub().resolves(true) },
},
'@rocket.chat/models': modelsMock,
'@rocket.chat/logger': { Logger: mockLogger },
'meteor/meteor': {
Meteor: {
Error: globalThis.Error,
},
},
'meteor/check': { check: checkMock },
'../callbacks': {
callbacks: {
run: sinon.stub(),
},
},
'../i18n': { i18n: { t: tStub } },
'../notifications/email/api': { send: mailerMock },
'../../settings': { settings: { get: settingsMock } },
'../utils/lib/getTimezone': { getTimezone: getTimezoneMock },
// TODO: add tests for file handling on transcripts
'../media/file-upload': { FileUpload: {} },
});
describe('Send transcript', () => {
beforeEach(() => {
checkMock.reset();
modelsMock.LivechatRooms.findOneById.reset();
modelsMock.Messages.findLivechatClosingMessage.reset();
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.reset();
modelsMock.Users.findOneById.reset();
mailerMock.reset();
tStub.reset();
});
it('should throw error when rid or email are invalid params', async () => {
checkMock.throws(new Error('Invalid params'));
await expect(sendTranscript({})).to.be.rejectedWith(Error);
});
it('should throw error when visitor not found', async () => {
await expect(sendTranscript({ rid: 'rid', email: 'email', logger: mockLogger })).to.be.rejectedWith(Error);
});
it('should attempt to send an email when params are valid using default subject', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } });
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.returns([]);
tStub.returns('Conversation Transcript');
await sendTranscript({
rid: 'rid',
token: 'token',
email: 'email',
user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' },
});
expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true;
expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true;
expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true;
expect(
mailerMock.calledWith({
to: 'email',
from: 'test@rocket.chat',
subject: 'Conversation Transcript',
replyTo: 'test@rocket.chat',
html: '<div> <hr></div>',
}),
).to.be.true;
});
it('should use provided subject', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } });
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.returns([]);
await sendTranscript({
rid: 'rid',
token: 'token',
email: 'email',
subject: 'A custom subject',
user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' },
});
expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true;
expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true;
expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true;
expect(
mailerMock.calledWith({
to: 'email',
from: 'test@rocket.chat',
subject: 'A custom subject',
replyTo: 'test@rocket.chat',
html: '<div> <hr></div>',
}),
).to.be.true;
});
it('should use subject from setting (when configured) when no subject provided', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } });
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.returns([]);
mockSettingValues.Livechat_transcript_email_subject = 'A custom subject obtained from setting.get';
await sendTranscript({
rid: 'rid',
token: 'token',
email: 'email',
user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' },
});
expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true;
expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true;
expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true;
expect(
mailerMock.calledWith({
to: 'email',
from: 'test@rocket.chat',
subject: 'A custom subject obtained from setting.get',
replyTo: 'test@rocket.chat',
html: '<div> <hr></div>',
}),
).to.be.true;
});
it('should fail if room provided is invalid', async () => {
modelsMock.LivechatRooms.findOneById.resolves(null);
await expect(sendTranscript({ rid: 'rid', email: 'email', logger: mockLogger })).to.be.rejectedWith(Error);
});
it('should fail if room provided is of different type', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'c' });
await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error);
});
it('should fail if room is of valid type, but doesnt doesnt have `v` property', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l' });
await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error);
});
it('should fail if room is of valid type, has `v` prop, but it doesnt contain `token`', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { otherProp: 'xxx' } });
await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error);
});
it('should fail if room is of valid type, has `v.token`, but its different from the one on param (room from another visitor)', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'xxx' } });
await expect(sendTranscript({ rid: 'rid', email: 'email', token: 'xveasdf' })).to.be.rejectedWith(Error);
});
it('should throw an error when token is not the one on room.v', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'xxx' } });
await expect(sendTranscript({ rid: 'rid', email: 'email', token: 'xveasdf' })).to.be.rejectedWith(Error);
});
it('should work when token matches room.v', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token-123' } });
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.returns([]);
delete mockSettingValues.Livechat_transcript_email_subject;
tStub.returns('Conversation Transcript');
await sendTranscript({
rid: 'rid',
token: 'token-123',
email: 'email',
user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' },
});
expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true;
expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true;
expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true;
expect(
mailerMock.calledWith({
to: 'email',
from: 'test@rocket.chat',
subject: 'Conversation Transcript',
replyTo: 'test@rocket.chat',
html: '<div> <hr></div>',
}),
).to.be.true;
});
});

View File

@@ -0,0 +1,64 @@
import type { ILivechatCustomField } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { validateRequiredCustomFields } from '../../../../../server/lib/omnichannel/custom-fields';
describe('validateRequiredCustomFields', () => {
it('should throw an error if the required custom fields are not provided', async () => {
const customFields = ['customField1'];
const livechatCustomFields: ILivechatCustomField[] = [
{ _id: 'customField1', required: true, label: 'Custom Field 1', scope: 'visitor', visibility: 'public', _updatedAt: new Date() },
{ _id: 'customField2', required: true, label: 'Custom Field 2', scope: 'visitor', visibility: 'public', _updatedAt: new Date() },
];
try {
validateRequiredCustomFields(customFields, livechatCustomFields);
throw new Error('Expected error was not thrown');
} catch (error) {
if (error instanceof Error) {
expect(error.message).to.equal('Missing required custom fields: customField2');
} else {
throw error;
}
}
});
it('should not throw an error if all required custom fields are provided', async () => {
const customFields = ['customField1', 'customField2'];
const livechatCustomFields: ILivechatCustomField[] = [
{ _id: 'customField1', required: true, label: 'Custom Field 1', scope: 'visitor', visibility: 'public', _updatedAt: new Date() },
{ _id: 'customField2', required: true, label: 'Custom Field 2', scope: 'visitor', visibility: 'public', _updatedAt: new Date() },
];
validateRequiredCustomFields(customFields, livechatCustomFields);
});
it('should not throw an error if no custom fields are required', async () => {
const customFields = ['customField1'];
const livechatCustomFields: ILivechatCustomField[] = [
{ _id: 'customField1', required: false, label: 'Custom Field 1', scope: 'visitor', visibility: 'public', _updatedAt: new Date() },
{ _id: 'customField2', required: false, label: 'Custom Field 2', scope: 'visitor', visibility: 'public', _updatedAt: new Date() },
];
validateRequiredCustomFields(customFields, livechatCustomFields);
});
it('should throw an error if none of the required custom fields are provided', async () => {
const customFields: string[] = [];
const livechatCustomFields: ILivechatCustomField[] = [
{ _id: 'customField1', required: true, label: 'Custom Field 1', scope: 'visitor', visibility: 'public', _updatedAt: new Date() },
{ _id: 'customField2', required: true, label: 'Custom Field 2', scope: 'visitor', visibility: 'public', _updatedAt: new Date() },
];
try {
validateRequiredCustomFields(customFields, livechatCustomFields);
throw new Error('Expected error was not thrown');
} catch (error) {
if (error instanceof Error) {
expect(error.message).to.equal('Missing required custom fields: customField1, customField2');
} else {
throw error;
}
}
});
});

View File

@@ -0,0 +1,293 @@
import { expect } from 'chai';
import { describe, it, afterEach } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
type FetchResponse = {
status: number;
text: () => Promise<string>;
};
const mkResponse = (status: number, text = ''): FetchResponse => ({
status,
text: async () => text,
});
const MODULE_PATH = '../../../../../server/lib/omnichannel/webhooks';
function buildSubject(options?: {
fetchSequence?: Array<{ status: number; text?: string }>;
fetchOnce?: { status: number; text?: string };
settings?: Partial<Record<'Livechat_http_timeout' | 'Livechat_secret_token' | 'Livechat_webhookUrl', any>>;
}) {
const defaults = {
Livechat_http_timeout: 1000,
Livechat_secret_token: 'super-secret',
Livechat_webhookUrl: 'https://example.test/hook',
};
const settingsValues = { ...defaults, ...(options?.settings ?? {}) };
const settings = {
get: sinon.stub().callsFake((key: string) => settingsValues[key as keyof typeof settingsValues]),
};
const fetchStub = sinon.stub();
if (options?.fetchSequence && options.fetchSequence.length) {
options.fetchSequence.forEach((spec, i) => {
fetchStub.onCall(i).resolves(mkResponse(spec.status, spec.text));
});
} else if (options?.fetchOnce) {
fetchStub.resolves(mkResponse(options.fetchOnce.status, options.fetchOnce.text));
} else {
fetchStub.resolves(mkResponse(200, 'ok'));
}
const logger = {
debug: sinon.spy(),
error: sinon.spy(),
};
const metrics = {
totalLivechatWebhooksSuccess: { inc: sinon.spy() },
totalLivechatWebhooksSuccessTotal: { inc: sinon.spy() },
totalLivechatWebhooksFailures: { inc: sinon.spy() },
totalLivechatWebhooksFailuresTotal: { inc: sinon.spy() },
};
const { sendRequest } = proxyquire.noCallThru().load(MODULE_PATH, {
'@rocket.chat/server-fetch': { serverFetch: fetchStub },
'./logger': { webhooksLogger: logger },
'../metrics': { metrics },
'../../settings': { settings },
});
return {
sendRequest,
stubs: { fetchStub, logger, metrics, settings },
values: settingsValues,
};
}
describe('livechat/server/lib/webhooks sendRequest', () => {
let clock: sinon.SinonFakeTimers;
afterEach(() => {
sinon.restore();
if (clock) {
clock.restore();
}
});
it('sends a POST request with correct options and resolves on 200', async () => {
const postData = { type: 'TestEvent', a: 1 };
const secret = 'my-secret';
const timeout = 2500;
const webhookUrl = 'https://example.com/webhook';
const { sendRequest, stubs, values } = buildSubject({
fetchOnce: { status: 200, text: 'OK' },
settings: {
Livechat_secret_token: secret,
Livechat_http_timeout: timeout,
Livechat_webhookUrl: webhookUrl,
},
});
const cb = sinon.spy(async () => {
/* noop */
});
const result = await sendRequest(postData, 5, cb);
// fetch was called with correct URL and options
expect(stubs.fetchStub.calledOnce).to.be.true;
const [calledUrl, calledOpts] = stubs.fetchStub.getCall(0).args;
expect(calledUrl).to.equal(values.Livechat_webhookUrl);
expect(calledOpts).to.deep.include({
method: 'POST',
body: postData,
timeout: values.Livechat_http_timeout,
});
expect(calledOpts.headers).to.have.property('X-RocketChat-Livechat-Token', secret);
// success metrics and callback
expect(stubs.metrics.totalLivechatWebhooksSuccess.inc.calledOnce).to.be.true;
expect(stubs.metrics.totalLivechatWebhooksFailures.inc.notCalled).to.be.true;
expect(cb.calledOnce).to.be.true;
// result is the fetch response
expect(result).to.be.ok;
expect(result!.status).to.equal(200);
// debug logging invoked at least once
expect(stubs.logger.debug.called).to.be.true;
expect(stubs.logger.error.notCalled).to.be.true;
});
it('omits X-RocketChat-Livechat-Token header when secret token is falsy', async () => {
const { sendRequest, stubs } = buildSubject({
fetchOnce: { status: 200, text: 'OK' },
settings: {
Livechat_secret_token: '',
},
});
await sendRequest({ type: 'NoSecret' });
const [, calledOpts] = stubs.fetchStub.getCall(0).args;
expect(calledOpts.headers).to.not.have.property('X-RocketChat-Livechat-Token');
});
it('logs and does not retry on non-retryable status (e.g., 400)', async () => {
const { sendRequest, stubs } = buildSubject({
fetchOnce: { status: 400, text: 'Bad Request' },
});
const cb = sinon.spy(async () => {
/* noop */
});
const result = await sendRequest({ type: 'NonRetryable' }, 5, cb);
// Does not throw; returns undefined
expect(result).to.be.undefined;
// fetch called only once, no retry
expect(stubs.fetchStub.calledOnce).to.be.true;
// failure metric incremented
expect(stubs.metrics.totalLivechatWebhooksFailures.inc.calledOnce).to.be.true;
expect(stubs.metrics.totalLivechatWebhooksSuccess.inc.notCalled).to.be.true;
// callback not called on failure
expect(cb.notCalled).to.be.true;
// error log with status and response
expect(stubs.logger.error.called).to.be.true;
const logArg = stubs.logger.error.getCall(0).args[0];
expect(logArg).to.include.keys(['msg', 'status', 'response']);
expect(logArg.status).to.equal(400);
expect(logArg.response).to.equal('Bad Request');
});
it('retries once on retryable status (e.g., 500) and succeeds on next attempt', async () => {
clock = sinon.useFakeTimers();
const timeout = 1500;
const retryAfter = timeout * 4;
const { sendRequest, stubs } = buildSubject({
fetchSequence: [
{ status: 500, text: 'Server Error' },
{ status: 200, text: 'OK' },
],
settings: { Livechat_http_timeout: timeout },
});
const cb = sinon.spy(async () => {
/* noop */
});
// Call initial request; it will schedule a retry
await sendRequest({ type: 'RetryableOnce' }, 5, cb);
expect(stubs.fetchStub.callCount).to.equal(1);
expect(stubs.metrics.totalLivechatWebhooksFailures.inc.calledOnce).to.be.true;
// Advance clock to trigger the retry
// use tickAsync to ensure promise microtasks inside the timer are flushed
await clock.tickAsync(retryAfter);
// After retry, we should have a success
expect(stubs.fetchStub.callCount).to.equal(2);
expect(stubs.metrics.totalLivechatWebhooksSuccess.inc.calledOnce).to.be.true;
// callback called once for the successful attempt
expect(cb.calledOnce).to.be.true;
// no terminal error log
const errorMsgs = stubs.logger.error.getCalls().map((c: sinon.SinonSpyCall) => c.args[0]?.msg);
expect(errorMsgs.some((m: string) => typeof m === 'string' && m.includes('Max attempts'))).to.be.false;
});
it('stops after max attempts and logs final error for repeated retryable failures', async () => {
clock = sinon.useFakeTimers();
const timeout = 500;
const retryAfter = timeout * 4;
// Prepare 5 retryable failures (equal to attempts)
const failures = Array.from({ length: 5 }, () => ({ status: 500, text: 'Upstream down' }));
const { sendRequest, stubs } = buildSubject({
fetchSequence: failures,
settings: { Livechat_http_timeout: timeout },
});
const postData = { type: 'AlwaysFail' };
// Fire the initial call
await sendRequest(postData, 5);
// Process the 4 scheduled retries
for (let i = 0; i < 4; i++) {
await clock.tickAsync(retryAfter);
}
// fetch was attempted 5 times total
expect(stubs.fetchStub.callCount).to.equal(5);
// failure metric incremented for each failed attempt
expect(stubs.metrics.totalLivechatWebhooksFailures.inc.callCount).to.equal(5);
expect(stubs.metrics.totalLivechatWebhooksSuccess.inc.notCalled).to.be.true;
// terminal error logged when max attempts reached
expect(stubs.logger.error.called).to.be.true;
const msgs = stubs.logger.error.getCalls().map((c: sinon.SinonSpyCall) => c.args[0]?.msg);
expect(msgs.some((m: string) => typeof m === 'string' && m.includes('Max attempts'))).to.be.true;
});
it('passes the Response to the callback on success', async () => {
const responseText = 'Great Success';
const { sendRequest, stubs } = buildSubject({
fetchOnce: { status: 200, text: responseText },
});
const cb = sinon.spy(async (res: FetchResponse) => {
const txt = await res.text();
expect(txt).to.equal(responseText);
});
await sendRequest({ type: 'CbTest' }, 5, cb);
expect(cb.calledOnce).to.be.true;
expect(stubs.metrics.totalLivechatWebhooksSuccess.inc.calledOnce).to.be.true;
});
it('uses default attempts=5 and schedules retry with delay = timeout*4', async () => {
clock = sinon.useFakeTimers();
const timeout = 1234;
const retryAfter = timeout * 4;
const { sendRequest, stubs } = buildSubject({
fetchSequence: [
{ status: 500, text: 'fail' },
{ status: 200, text: 'ok' },
],
settings: { Livechat_http_timeout: timeout },
});
await sendRequest({ type: 'DelayCheck' });
// Before advancing time, only first attempt should have been made
expect(stubs.fetchStub.callCount).to.equal(1);
// Advance just shy of the retryAfter - no retry should happen
await clock.tickAsync(retryAfter - 1);
expect(stubs.fetchStub.callCount).to.equal(1);
// Advance the remaining 1ms - retry should fire
await clock.tickAsync(1);
expect(stubs.fetchStub.callCount).to.equal(2);
});
});