snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
149
apps/meteor/tests/unit/server/services/banner/service.tests.ts
Normal file
149
apps/meteor/tests/unit/server/services/banner/service.tests.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
636
apps/meteor/tests/unit/server/services/calendar/service.tests.ts
Normal file
636
apps/meteor/tests/unit/server/services/calendar/service.tests.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
126
apps/meteor/tests/unit/server/services/instance/service.tests.ts
Normal file
126
apps/meteor/tests/unit/server/services/instance/service.tests.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
103
apps/meteor/tests/unit/server/services/nps/spec.tests.ts
Normal file
103
apps/meteor/tests/unit/server/services/nps/spec.tests.ts
Normal 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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
150
apps/meteor/tests/unit/server/services/team/service.tests.ts
Normal file
150
apps/meteor/tests/unit/server/services/team/service.tests.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
68
apps/meteor/tests/unit/server/services/user/service.tests.ts
Normal file
68
apps/meteor/tests/unit/server/services/user/service.tests.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
28
apps/meteor/tests/unit/server/services/utils.ts
Normal file
28
apps/meteor/tests/unit/server/services/utils.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user