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:
430
apps/meteor/tests/unit/server/lib/import/CsvImporter.spec.ts
Normal file
430
apps/meteor/tests/unit/server/lib/import/CsvImporter.spec.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import AdmZip from 'adm-zip';
|
||||
import { expect } from 'chai';
|
||||
import { parse } from 'csv-parse/sync';
|
||||
import { after, before, beforeEach, describe, it } from 'mocha';
|
||||
import proxyquire from 'proxyquire';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import { ProgressStep } from '../../../../../app/importer/lib/ImporterProgressStep';
|
||||
|
||||
const converter = {
|
||||
clearImportData: sinon.stub(),
|
||||
addChannel: sinon.stub(),
|
||||
addUser: sinon.stub(),
|
||||
addMessage: sinon.stub(),
|
||||
addContact: sinon.stub(),
|
||||
};
|
||||
|
||||
const logger = {
|
||||
debug: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
error: sinon.stub(),
|
||||
};
|
||||
|
||||
const progressUpdated = sinon.stub();
|
||||
const updateProgress = sinon.stub();
|
||||
const updateRecord = sinon.stub();
|
||||
const incrementValueById = sinon.stub();
|
||||
const notifyOnSettingChanged = sinon.stub();
|
||||
const findOneByUsername = sinon.stub();
|
||||
const addImportIds = sinon.stub();
|
||||
|
||||
class MockImporter {
|
||||
protected AdmZip = AdmZip;
|
||||
|
||||
protected logger = logger;
|
||||
|
||||
protected converter = converter;
|
||||
|
||||
public importRecord: any;
|
||||
|
||||
public progress: any;
|
||||
|
||||
constructor(_info: any, importRecord: any) {
|
||||
this.importRecord = importRecord;
|
||||
this.progress = { step: ProgressStep.PREPARING_STARTED, count: { total: 0 } };
|
||||
}
|
||||
|
||||
async updateProgress(step: string) {
|
||||
updateProgress(step);
|
||||
this.progress.step = step;
|
||||
return this.progress;
|
||||
}
|
||||
|
||||
async updateRecord(fields: any) {
|
||||
updateRecord(fields);
|
||||
return this.importRecord;
|
||||
}
|
||||
|
||||
async addCountToTotal(count: number) {
|
||||
this.progress.count.total += count;
|
||||
return this.progress;
|
||||
}
|
||||
|
||||
getProgress() {
|
||||
return this.progress;
|
||||
}
|
||||
}
|
||||
|
||||
const { CsvImporter } = proxyquire.noCallThru().load('../../../../../server/lib/import/csv/CsvImporter', {
|
||||
'@rocket.chat/models': {
|
||||
Settings: { incrementValueById },
|
||||
Users: { findOneByUsername, addImportIds },
|
||||
},
|
||||
'csv-parse/lib/sync': { parse },
|
||||
'..': {
|
||||
Importer: MockImporter,
|
||||
ProgressStep,
|
||||
ImporterWebsocket: { progressUpdated },
|
||||
},
|
||||
'../../notifyListener': { notifyOnSettingChanged },
|
||||
});
|
||||
|
||||
const allStubs = [
|
||||
...Object.values(converter),
|
||||
...Object.values(logger),
|
||||
progressUpdated,
|
||||
updateProgress,
|
||||
updateRecord,
|
||||
incrementValueById,
|
||||
notifyOnSettingChanged,
|
||||
findOneByUsername,
|
||||
addImportIds,
|
||||
];
|
||||
|
||||
type ZipEntry = { name: string; content?: string };
|
||||
|
||||
describe('CsvImporter', () => {
|
||||
let tmpDir: string;
|
||||
let zipCount = 0;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rc-csv-importer-'));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
allStubs.forEach((stub) => stub.reset());
|
||||
|
||||
converter.clearImportData.resolves();
|
||||
converter.addChannel.resolves();
|
||||
converter.addUser.resolves();
|
||||
converter.addMessage.resolves();
|
||||
converter.addContact.resolves();
|
||||
incrementValueById.resolves(null);
|
||||
findOneByUsername.resolves(null);
|
||||
addImportIds.resolves();
|
||||
});
|
||||
|
||||
const writeZip = (entries: ZipEntry[]): string => {
|
||||
const zip = new AdmZip();
|
||||
for (const entry of entries) {
|
||||
zip.addFile(entry.name, Buffer.from(entry.content ?? ''));
|
||||
}
|
||||
|
||||
zipCount += 1;
|
||||
const filePath = path.join(tmpDir, `archive-${zipCount}.zip`);
|
||||
zip.writeZip(filePath);
|
||||
return filePath;
|
||||
};
|
||||
|
||||
const runImport = async (entries: ZipEntry[]) => {
|
||||
const importer = new CsvImporter({ key: 'csv', name: 'CSV' }, { _id: 'import-id' });
|
||||
const progress = await importer.prepareUsingLocalFile(writeZip(entries));
|
||||
return { importer, progress };
|
||||
};
|
||||
|
||||
const rates = () => progressUpdated.getCalls().map((call) => call.args[0].rate);
|
||||
|
||||
const CHANNELS_CSV = ' general , admin , Public , alice ; bob \nsecret,owner,Private,carol;;dave;';
|
||||
const USERS_CSV = ' alice , alice@example.com , Alice Smith \nbob,bob@example.com,Bob Jones';
|
||||
const CONTACTS_CSV = 'name,emails,phones\nJane Doe,jane@example.com,+5511999';
|
||||
const CHANNEL_MESSAGES_CSV = 'alice,1700000000000,hello there\nbob,1700000001000,hi';
|
||||
const DM_MESSAGES_CSV = 'alice,bob,1700000002000,dm from alice\nbob,alice,1700000003000,dm from bob';
|
||||
|
||||
const fullArchive = (): ZipEntry[] => [
|
||||
{ name: 'channels.csv', content: CHANNELS_CSV },
|
||||
{ name: 'users.csv', content: USERS_CSV },
|
||||
{ name: 'contacts.csv', content: CONTACTS_CSV },
|
||||
{ name: 'general/messages.csv', content: CHANNEL_MESSAGES_CSV },
|
||||
{ name: 'directmessages/messages.csv', content: DM_MESSAGES_CSV },
|
||||
];
|
||||
|
||||
describe('archive setup and progress', () => {
|
||||
it('should clear previously imported data before reading the archive', async () => {
|
||||
await runImport(fullArchive());
|
||||
|
||||
expect(converter.clearImportData.calledOnce).to.be.true;
|
||||
sinon.assert.callOrder(converter.clearImportData, converter.addUser);
|
||||
});
|
||||
|
||||
it('should not emit a progress update when the calculated rate did not increase', async () => {
|
||||
const entries = Array.from({ length: 1001 }, (_, index) => ({ name: `__MACOSX/ignored-${index}`, content: '' }));
|
||||
|
||||
await runImport(entries);
|
||||
|
||||
expect(rates().filter((rate) => rate === 0)).to.have.lengthOf(1);
|
||||
expect(progressUpdated.callCount).to.equal(1002);
|
||||
});
|
||||
|
||||
it('should log a progress update failure and keep importing', async () => {
|
||||
progressUpdated.onSecondCall().throws(new Error('streamer is down'));
|
||||
|
||||
const { progress } = await runImport(fullArchive());
|
||||
|
||||
expect(logger.error.calledWithMatch({ msg: 'Error while increasing CSV import progress' })).to.be.true;
|
||||
expect(converter.addUser.callCount).to.equal(2);
|
||||
expect(progress.step).to.not.equal(ProgressStep.ERROR);
|
||||
expect(rates()[rates().length - 1]).to.equal(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('archive filtering and room identification', () => {
|
||||
it('should ignore __MACOSX and directory entries, advancing the progress rate for every entry', async () => {
|
||||
await runImport([
|
||||
{ name: '__MACOSX/general/messages.csv', content: CHANNEL_MESSAGES_CSV },
|
||||
{ name: 'general/', content: '' },
|
||||
{ name: 'general/messages.csv', content: CHANNEL_MESSAGES_CSV },
|
||||
{ name: 'readme.txt', content: 'not a csv the importer knows about' },
|
||||
]);
|
||||
|
||||
expect(converter.addMessage.callCount).to.equal(2);
|
||||
expect(updateRecord.calledWithMatch({ messagesstatus: 'general/messages' })).to.be.true;
|
||||
expect(updateRecord.calledWithMatch({ messagesstatus: 'general/' })).to.be.false;
|
||||
expect(rates()).to.deep.equal([0, 25, 50, 75, 100, 100]);
|
||||
});
|
||||
|
||||
it('should reuse the same generated room id for every reference to a room name', async () => {
|
||||
await runImport([
|
||||
{ name: 'channels.csv', content: 'general,admin,public,alice' },
|
||||
{ name: 'general/first.csv', content: 'alice,1700000000000,one' },
|
||||
{ name: 'general/second.csv', content: 'bob,1700000001000,two' },
|
||||
]);
|
||||
|
||||
const channelId = converter.addChannel.firstCall.args[0].importIds[0];
|
||||
expect(channelId).to.be.a('string').and.not.empty;
|
||||
expect(converter.addMessage.getCalls().map((call) => call.args[0].rid)).to.deep.equal([channelId, channelId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel parsing', () => {
|
||||
it('should parse channels.csv case-insensitively, trimming every field and dropping empty members', async () => {
|
||||
await runImport([{ name: 'Channels.CSV', content: CHANNELS_CSV }]);
|
||||
|
||||
expect(updateProgress.calledWith(ProgressStep.PREPARING_CHANNELS)).to.be.true;
|
||||
expect(converter.addChannel.firstCall.args[0]).to.deep.include({
|
||||
name: 'general',
|
||||
u: { _id: 'admin' },
|
||||
users: ['alice', 'bob'],
|
||||
});
|
||||
expect(converter.addChannel.secondCall.args[0].users).to.deep.equal(['carol', 'dave']);
|
||||
expect(updateRecord.calledWithExactly({ 'count.channels': 2 })).to.be.true;
|
||||
});
|
||||
|
||||
it('should map private channels to p and every other channel to c', async () => {
|
||||
await runImport([{ name: 'channels.csv', content: CHANNELS_CSV }]);
|
||||
|
||||
expect(converter.addChannel.firstCall.args[0].t).to.equal('c');
|
||||
expect(converter.addChannel.secondCall.args[0].t).to.equal('p');
|
||||
});
|
||||
});
|
||||
|
||||
describe('user and contact parsing', () => {
|
||||
it('should parse users.csv case-insensitively into importer user records', async () => {
|
||||
await runImport([{ name: 'Users.CSV', content: USERS_CSV }]);
|
||||
|
||||
expect(updateProgress.calledWith(ProgressStep.PREPARING_USERS)).to.be.true;
|
||||
expect(converter.addUser.callCount).to.equal(2);
|
||||
expect(converter.addUser.firstCall.args[0]).to.deep.equal({
|
||||
type: 'user',
|
||||
importIds: ['alice'],
|
||||
emails: ['alice@example.com'],
|
||||
username: 'alice',
|
||||
name: 'Alice Smith',
|
||||
});
|
||||
expect(updateRecord.calledWithExactly({ 'count.users': 2 })).to.be.true;
|
||||
});
|
||||
|
||||
it('should delegate contacts.csv rows to the contact importer and count the accepted ones', async () => {
|
||||
await runImport([{ name: 'Contacts.CSV', content: `${CONTACTS_CSV}\n,,` }]);
|
||||
|
||||
expect(updateProgress.calledWith(ProgressStep.PREPARING_CONTACTS)).to.be.true;
|
||||
expect(converter.addContact.callCount).to.equal(1);
|
||||
expect(converter.addContact.firstCall.args[0]).to.deep.include({
|
||||
name: 'Jane Doe',
|
||||
emails: ['jane@example.com'],
|
||||
phones: ['+5511999'],
|
||||
});
|
||||
expect(updateRecord.calledWithExactly({ 'count.contacts': 1 })).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe('message parsing', () => {
|
||||
it('should move to the message preparation step only once for the whole archive', async () => {
|
||||
await runImport([
|
||||
{ name: 'general/first.csv', content: CHANNEL_MESSAGES_CSV },
|
||||
{ name: 'general/second.csv', content: CHANNEL_MESSAGES_CSV },
|
||||
]);
|
||||
|
||||
expect(updateProgress.withArgs(ProgressStep.PREPARING_MESSAGES).callCount).to.equal(1);
|
||||
});
|
||||
|
||||
it('should skip a message file with invalid csv syntax and warn about it', async () => {
|
||||
await runImport([
|
||||
{ name: 'general/broken.csv', content: 'alice,1700000000000,"unclosed' },
|
||||
{ name: 'general/valid.csv', content: CHANNEL_MESSAGES_CSV },
|
||||
]);
|
||||
|
||||
expect(logger.warn.calledWithMatch({ msg: 'The file contains invalid syntax', entryName: 'general/broken.csv' })).to.be.true;
|
||||
expect(converter.addMessage.callCount).to.equal(2);
|
||||
expect(updateRecord.calledWithMatch({ messagesstatus: 'general/broken' })).to.be.false;
|
||||
});
|
||||
|
||||
it('should convert a channel message with its room, author, timestamp and text', async () => {
|
||||
await runImport([{ name: 'general/messages.csv', content: CHANNEL_MESSAGES_CSV }]);
|
||||
|
||||
const message = converter.addMessage.firstCall.args[0];
|
||||
expect(Object.keys(message).sort()).to.deep.equal(['msg', 'rid', 'ts', 'u']);
|
||||
expect(message.rid).to.be.a('string').and.not.empty;
|
||||
expect(message).to.deep.include({
|
||||
u: { _id: 'alice' },
|
||||
ts: new Date(1700000000000),
|
||||
msg: 'hello there',
|
||||
});
|
||||
expect(converter.addMessage.secondCall.args[0]).to.deep.include({
|
||||
rid: message.rid,
|
||||
u: { _id: 'bob' },
|
||||
ts: new Date(1700000001000),
|
||||
msg: 'hi',
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert direct messages into a single room per participant pair, whatever the username order', async () => {
|
||||
await runImport([{ name: 'directmessages/messages.csv', content: DM_MESSAGES_CSV }]);
|
||||
|
||||
expect(converter.addChannel.calledOnce).to.be.true;
|
||||
expect(converter.addChannel.firstCall.args[0]).to.deep.equal({
|
||||
importIds: ['alice/bob'],
|
||||
users: ['alice', 'bob'],
|
||||
t: 'd',
|
||||
});
|
||||
expect(converter.addMessage.firstCall.args[0]).to.deep.equal({
|
||||
rid: 'alice/bob',
|
||||
u: { _id: 'alice' },
|
||||
ts: new Date(1700000002000),
|
||||
msg: 'dm from alice',
|
||||
});
|
||||
expect(converter.addMessage.getCalls().map((call) => call.args[0].rid)).to.deep.equal(['alice/bob', 'alice/bob']);
|
||||
});
|
||||
|
||||
it('should skip a direct message row that has no other participant', async () => {
|
||||
await runImport([{ name: 'directmessages/messages.csv', content: 'alice,,1700000002000,orphan' }]);
|
||||
|
||||
expect(converter.addChannel.called).to.be.false;
|
||||
expect(converter.addMessage.called).to.be.false;
|
||||
});
|
||||
|
||||
it('should accumulate the message count across files, clearing the file status after each one', async () => {
|
||||
await runImport([
|
||||
{ name: 'general/first.csv', content: CHANNEL_MESSAGES_CSV },
|
||||
{ name: 'general/second.csv', content: 'alice,1700000004000,third' },
|
||||
]);
|
||||
|
||||
expect(updateRecord.calledWithExactly({ 'count.messages': 2, 'messagesstatus': null })).to.be.true;
|
||||
expect(updateRecord.calledWithExactly({ 'count.messages': 3, 'messagesstatus': null })).to.be.true;
|
||||
|
||||
const statusCalls = updateRecord
|
||||
.getCalls()
|
||||
.filter((call) => 'messagesstatus' in call.args[0])
|
||||
.map((call) => call.args[0].messagesstatus);
|
||||
|
||||
expect(statusCalls).to.deep.equal(['general/first', null, 'general/second', null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('post-processing', () => {
|
||||
it('should increment the csv importer setting with the imported user count and notify the listeners', async () => {
|
||||
const updatedSetting = { _id: 'CSV_Importer_Count', value: 7 };
|
||||
incrementValueById.resolves(updatedSetting);
|
||||
|
||||
await runImport(fullArchive());
|
||||
|
||||
expect(incrementValueById.calledOnceWithExactly('CSV_Importer_Count', 2, { returnDocument: 'after' })).to.be.true;
|
||||
expect(notifyOnSettingChanged.calledOnceWithExactly(updatedSetting)).to.be.true;
|
||||
});
|
||||
|
||||
it('should not touch the csv importer setting when no user was parsed', async () => {
|
||||
await runImport([{ name: 'general/messages.csv', content: CHANNEL_MESSAGES_CSV }]);
|
||||
|
||||
expect(incrementValueById.called).to.be.false;
|
||||
});
|
||||
|
||||
it('should not notify the listeners when the setting update returned nothing', async () => {
|
||||
await runImport([{ name: 'users.csv', content: USERS_CSV }]);
|
||||
|
||||
expect(notifyOnSettingChanged.called).to.be.false;
|
||||
});
|
||||
|
||||
it('should add the import id to existing users that do not carry it yet', async () => {
|
||||
findOneByUsername.withArgs('alice').resolves({ _id: 'user-alice', importIds: ['other'] });
|
||||
findOneByUsername.withArgs('dana').resolves({ _id: 'user-dana' });
|
||||
|
||||
await runImport([{ name: 'general/messages.csv', content: 'alice,1700000000000,hello\ndana,1700000001000,hi' }]);
|
||||
|
||||
expect(addImportIds.calledTwice).to.be.true;
|
||||
expect(addImportIds.calledWithExactly('user-alice', 'alice')).to.be.true;
|
||||
expect(addImportIds.calledWithExactly('user-dana', 'dana')).to.be.true;
|
||||
});
|
||||
|
||||
it('should reconcile a direct message recipient that never sent a message', async () => {
|
||||
findOneByUsername.withArgs('bob').resolves({ _id: 'user-bob', importIds: [] });
|
||||
|
||||
await runImport([
|
||||
{ name: 'users.csv', content: ' alice , alice@example.com , Alice Smith ' },
|
||||
{ name: 'directmessages/messages.csv', content: 'alice,bob,1700000002000,dm from alice' },
|
||||
]);
|
||||
|
||||
expect(findOneByUsername.calledOnceWithExactly('bob')).to.be.true;
|
||||
expect(addImportIds.calledOnceWithExactly('user-bob', 'bob')).to.be.true;
|
||||
});
|
||||
|
||||
it('should leave unknown users and users that already carry the import id untouched', async () => {
|
||||
findOneByUsername.withArgs('carol').resolves({ _id: 'user-carol', importIds: ['carol'] });
|
||||
|
||||
await runImport([{ name: 'general/messages.csv', content: 'ghost,1700000000000,hello\ncarol,1700000001000,hi' }]);
|
||||
|
||||
expect(findOneByUsername.calledWithExactly('ghost')).to.be.true;
|
||||
expect(findOneByUsername.calledWithExactly('carol')).to.be.true;
|
||||
expect(addImportIds.called).to.be.false;
|
||||
});
|
||||
|
||||
it('should add every parsed record to the import total and return the importer progress', async () => {
|
||||
const { importer, progress } = await runImport(fullArchive());
|
||||
|
||||
expect(progress.count.total).to.equal(9);
|
||||
expect(progress).to.equal(importer.progress);
|
||||
});
|
||||
|
||||
it('should fail the import when the archive holds no valid record', async () => {
|
||||
const { progress } = await runImport([{ name: '__MACOSX/ignored', content: '' }]);
|
||||
|
||||
expect(logger.error.calledWithMatch({ msg: 'No valid record found in the import file.' })).to.be.true;
|
||||
expect(updateProgress.calledWith(ProgressStep.ERROR)).to.be.true;
|
||||
expect(progress.step).to.equal(ProgressStep.ERROR);
|
||||
});
|
||||
|
||||
it('should not fail the import when only contacts were found', async () => {
|
||||
const { progress } = await runImport([{ name: 'contacts.csv', content: CONTACTS_CSV }]);
|
||||
|
||||
expect(updateProgress.calledWith(ProgressStep.ERROR)).to.be.false;
|
||||
expect(progress.step).to.equal(ProgressStep.PREPARING_CONTACTS);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { describe, it, beforeEach } from 'mocha';
|
||||
import proxyquire from 'proxyquire';
|
||||
import sinon from 'sinon';
|
||||
|
||||
class MockIncomingMessage extends EventEmitter {
|
||||
statusCode: number;
|
||||
|
||||
headers: Record<string, string>;
|
||||
|
||||
constructor(statusCode: number, headers: Record<string, string> = {}) {
|
||||
super();
|
||||
this.statusCode = statusCode;
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
resume() {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
class MockClientRequest extends EventEmitter {
|
||||
// no-op
|
||||
}
|
||||
|
||||
const createMockHttpModule = (responseFactory: () => MockIncomingMessage, errorOnConnect = false) => ({
|
||||
get: (_url: string, callback: (res: MockIncomingMessage) => void) => {
|
||||
const req = new MockClientRequest();
|
||||
|
||||
if (errorOnConnect) {
|
||||
setImmediate(() => req.emit('error', new Error('Connection refused')));
|
||||
} else {
|
||||
setImmediate(() => {
|
||||
const res = responseFactory();
|
||||
callback(res);
|
||||
});
|
||||
}
|
||||
|
||||
return req;
|
||||
},
|
||||
});
|
||||
|
||||
const createPendingFileImporter = (httpMock: ReturnType<typeof createMockHttpModule>) => {
|
||||
const { PendingFileImporter } = proxyquire.noCallThru().load('../../../../../server/lib/import/pending-files/PendingFileImporter', {
|
||||
'node:http': httpMock,
|
||||
'node:https': httpMock,
|
||||
'@rocket.chat/core-services': { api: { broadcast: sinon.stub() } },
|
||||
'@rocket.chat/models': {
|
||||
Messages: {
|
||||
countAllImportedMessagesWithFilesToDownload: sinon.stub().resolves(0),
|
||||
findAllImportedMessagesWithFilesToDownload: sinon.stub().returns([]),
|
||||
setImportFileRocketChatAttachment: sinon.stub().resolves(),
|
||||
},
|
||||
Users: {
|
||||
findOneById: sinon.stub().resolves(null),
|
||||
},
|
||||
},
|
||||
'@rocket.chat/random': { Random: { id: () => 'random-id' } },
|
||||
'../../media/file-upload': { FileUpload: { getStore: sinon.stub(), getPath: sinon.stub() } },
|
||||
'../../../meteor-methods/messages/sendFileMessage': { parseFileIntoMessageAttachments: sinon.stub() },
|
||||
'..': {
|
||||
Importer: class MockImporter {
|
||||
logger = { debug: sinon.stub(), error: sinon.stub(), info: sinon.stub() };
|
||||
|
||||
progress = { count: { total: 0 } };
|
||||
|
||||
reportProgress() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
async updateProgress() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
async updateRecord() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
async addCountCompleted() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
async addCountError() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
getProgress() {
|
||||
return this.progress;
|
||||
}
|
||||
|
||||
isCursorNotFoundError() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
ProgressStep: {
|
||||
PREPARING_STARTED: 'preparing_started',
|
||||
IMPORTING_FILES: 'importing_files',
|
||||
DONE: 'done',
|
||||
ERROR: 'error',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const info = { key: 'pending-files', name: 'Pending Files' };
|
||||
const importRecord = { _id: 'test-import' };
|
||||
|
||||
return new PendingFileImporter(info, importRecord, {});
|
||||
};
|
||||
|
||||
describe('PendingFileImporter', () => {
|
||||
describe('downloadFile', () => {
|
||||
it('should resolve with buffer on successful 200 response', async () => {
|
||||
const responseData = Buffer.from('file content');
|
||||
const httpMock = createMockHttpModule(() => {
|
||||
const res = new MockIncomingMessage(200, { 'content-type': 'image/png' });
|
||||
setImmediate(() => {
|
||||
res.emit('data', responseData);
|
||||
res.emit('end');
|
||||
});
|
||||
return res;
|
||||
});
|
||||
|
||||
const importer = createPendingFileImporter(httpMock);
|
||||
const details: { type?: string } = {};
|
||||
|
||||
// Access private method through the instance
|
||||
const result = await (importer as any).downloadFile('https://example.com/file.png', details);
|
||||
|
||||
expect(result).to.deep.equal(responseData);
|
||||
expect(details.type).to.equal('image/png');
|
||||
});
|
||||
|
||||
it('should reject on non-2xx status codes (e.g., 302 redirect)', async () => {
|
||||
const httpMock = createMockHttpModule(() => {
|
||||
const res = new MockIncomingMessage(302, { location: 'https://example.com/login' });
|
||||
return res;
|
||||
});
|
||||
|
||||
const importer = createPendingFileImporter(httpMock);
|
||||
const details: { type?: string } = {};
|
||||
|
||||
try {
|
||||
await (importer as any).downloadFile('https://example.com/file.png', details);
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err: any) {
|
||||
expect(err.message).to.include('Unexpected response status 302');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject on 404 status code', async () => {
|
||||
const httpMock = createMockHttpModule(() => {
|
||||
const res = new MockIncomingMessage(404);
|
||||
return res;
|
||||
});
|
||||
|
||||
const importer = createPendingFileImporter(httpMock);
|
||||
const details: { type?: string } = {};
|
||||
|
||||
try {
|
||||
await (importer as any).downloadFile('https://example.com/file.png', details);
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err: any) {
|
||||
expect(err.message).to.include('Unexpected response status 404');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject on 500 server error', async () => {
|
||||
const httpMock = createMockHttpModule(() => {
|
||||
const res = new MockIncomingMessage(500);
|
||||
return res;
|
||||
});
|
||||
|
||||
const importer = createPendingFileImporter(httpMock);
|
||||
const details: { type?: string } = {};
|
||||
|
||||
try {
|
||||
await (importer as any).downloadFile('https://example.com/file.png', details);
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err: any) {
|
||||
expect(err.message).to.include('Unexpected response status 500');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject on connection errors', async () => {
|
||||
const httpMock = createMockHttpModule(
|
||||
() => new MockIncomingMessage(200),
|
||||
true, // errorOnConnect
|
||||
);
|
||||
|
||||
const importer = createPendingFileImporter(httpMock);
|
||||
const details: { type?: string } = {};
|
||||
|
||||
try {
|
||||
await (importer as any).downloadFile('https://example.com/file.png', details);
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err: any) {
|
||||
expect(err.message).to.equal('Connection refused');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject on stream errors during download', async () => {
|
||||
const httpMock = createMockHttpModule(() => {
|
||||
const res = new MockIncomingMessage(200, { 'content-type': 'image/png' });
|
||||
setImmediate(() => {
|
||||
res.emit('data', Buffer.from('partial'));
|
||||
res.emit('error', new Error('Stream error'));
|
||||
});
|
||||
return res;
|
||||
});
|
||||
|
||||
const importer = createPendingFileImporter(httpMock);
|
||||
const details: { type?: string } = {};
|
||||
|
||||
try {
|
||||
await (importer as any).downloadFile('https://example.com/file.png', details);
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err: any) {
|
||||
expect(err.message).to.equal('Stream error');
|
||||
}
|
||||
});
|
||||
|
||||
it('should not set content-type if details already has a type', async () => {
|
||||
const responseData = Buffer.from('file content');
|
||||
const httpMock = createMockHttpModule(() => {
|
||||
const res = new MockIncomingMessage(200, { 'content-type': 'text/html' });
|
||||
setImmediate(() => {
|
||||
res.emit('data', responseData);
|
||||
res.emit('end');
|
||||
});
|
||||
return res;
|
||||
});
|
||||
|
||||
const importer = createPendingFileImporter(httpMock);
|
||||
const details: { type?: string } = { type: 'image/png' };
|
||||
|
||||
await (importer as any).downloadFile('https://example.com/file.png', details);
|
||||
|
||||
expect(details.type).to.equal('image/png');
|
||||
});
|
||||
|
||||
it('should handle responses with undefined statusCode', async () => {
|
||||
const httpMock = createMockHttpModule(() => {
|
||||
const res = new MockIncomingMessage(undefined as any);
|
||||
return res;
|
||||
});
|
||||
|
||||
const importer = createPendingFileImporter(httpMock);
|
||||
const details: { type?: string } = {};
|
||||
|
||||
try {
|
||||
await (importer as any).downloadFile('https://example.com/file.png', details);
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err: any) {
|
||||
expect(err.message).to.include('Unexpected response status');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept status code 201', async () => {
|
||||
const responseData = Buffer.from('created');
|
||||
const httpMock = createMockHttpModule(() => {
|
||||
const res = new MockIncomingMessage(201);
|
||||
setImmediate(() => {
|
||||
res.emit('data', responseData);
|
||||
res.emit('end');
|
||||
});
|
||||
return res;
|
||||
});
|
||||
|
||||
const importer = createPendingFileImporter(httpMock);
|
||||
const details: { type?: string } = {};
|
||||
|
||||
const result = await (importer as any).downloadFile('https://example.com/file.png', details);
|
||||
expect(result).to.deep.equal(responseData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMessageAttachment', () => {
|
||||
let importer: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const httpMock = createMockHttpModule(() => new MockIncomingMessage(200));
|
||||
importer = createPendingFileImporter(httpMock);
|
||||
});
|
||||
|
||||
it('should create image attachment for image types', () => {
|
||||
const file = { _id: 'file-1', name: 'image.png', type: 'image/png', size: 1024 };
|
||||
const url = '/file-upload/file-1/image.png';
|
||||
|
||||
const attachment = importer.getMessageAttachment(file, url);
|
||||
|
||||
expect(attachment).to.deep.equal({
|
||||
title: 'image.png',
|
||||
title_link: url,
|
||||
image_url: url,
|
||||
image_type: 'image/png',
|
||||
image_size: 1024,
|
||||
image_dimensions: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create audio attachment for audio types', () => {
|
||||
const file = { _id: 'file-1', name: 'audio.mp3', type: 'audio/mpeg', size: 2048 };
|
||||
const url = '/file-upload/file-1/audio.mp3';
|
||||
|
||||
const attachment = importer.getMessageAttachment(file, url);
|
||||
|
||||
expect(attachment).to.deep.equal({
|
||||
title: 'audio.mp3',
|
||||
title_link: url,
|
||||
audio_url: url,
|
||||
audio_type: 'audio/mpeg',
|
||||
audio_size: 2048,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create video attachment for video types', () => {
|
||||
const file = { _id: 'file-1', name: 'video.mp4', type: 'video/mp4', size: 4096 };
|
||||
const url = '/file-upload/file-1/video.mp4';
|
||||
|
||||
const attachment = importer.getMessageAttachment(file, url);
|
||||
|
||||
expect(attachment).to.deep.equal({
|
||||
title: 'video.mp4',
|
||||
title_link: url,
|
||||
video_url: url,
|
||||
video_type: 'video/mp4',
|
||||
video_size: 4096,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create generic attachment for other types', () => {
|
||||
const file = { _id: 'file-1', name: 'doc.pdf', type: 'application/pdf', size: 8192 };
|
||||
const url = '/file-upload/file-1/doc.pdf';
|
||||
|
||||
const attachment = importer.getMessageAttachment(file, url);
|
||||
|
||||
expect(attachment).to.deep.equal({
|
||||
title: 'doc.pdf',
|
||||
title_link: url,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create generic attachment for files without type', () => {
|
||||
const file = { _id: 'file-1', name: 'unknown', size: 512 };
|
||||
const url = '/file-upload/file-1/unknown';
|
||||
|
||||
const attachment = importer.getMessageAttachment(file, url);
|
||||
|
||||
expect(attachment).to.deep.equal({
|
||||
title: 'unknown',
|
||||
title_link: url,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
298
apps/meteor/tests/unit/server/lib/import/SlackImporter.spec.ts
Normal file
298
apps/meteor/tests/unit/server/lib/import/SlackImporter.spec.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { expect } from 'chai';
|
||||
import { describe, it, beforeEach } from 'mocha';
|
||||
import proxyquire from 'proxyquire';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const addMessageStub = sinon.stub().resolves();
|
||||
const settingsStub = { get: sinon.stub() };
|
||||
|
||||
const { SlackImporter } = proxyquire.noCallThru().load('../../../../../server/lib/import/slack/SlackImporter', {
|
||||
'@rocket.chat/models': {
|
||||
'Messages': { find: sinon.stub().returns({ toArray: () => [] }) },
|
||||
'Settings': { findOneById: sinon.stub().resolves(null) },
|
||||
'ImportData': {},
|
||||
'@global': true,
|
||||
},
|
||||
'..': {
|
||||
Importer: class MockImporter {
|
||||
logger = { debug: sinon.stub(), error: sinon.stub(), info: sinon.stub(), warn: sinon.stub() };
|
||||
|
||||
progress = { count: { total: 0 } };
|
||||
|
||||
converter = {
|
||||
addMessage: addMessageStub,
|
||||
addUser: sinon.stub().resolves(),
|
||||
addChannel: sinon.stub().resolves(),
|
||||
};
|
||||
|
||||
_useUpsert = false;
|
||||
|
||||
reportProgress() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
async updateProgress() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
async updateRecord() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
getProgress() {
|
||||
return this.progress;
|
||||
}
|
||||
},
|
||||
ProgressStep: {
|
||||
PREPARING_STARTED: 'preparing_started',
|
||||
IMPORTING_USERS: 'importing_users',
|
||||
IMPORTING_CHANNELS: 'importing_channels',
|
||||
IMPORTING_MESSAGES: 'importing_messages',
|
||||
DONE: 'done',
|
||||
ERROR: 'error',
|
||||
},
|
||||
ImporterWebsocket: { progressUpdated: sinon.stub() },
|
||||
},
|
||||
'../../notifyListener': { notifyOnSettingChanged: sinon.stub() },
|
||||
'../../../../app/mentions/lib/MentionsParser': {
|
||||
MentionsParser: class {
|
||||
getUserMentions() {
|
||||
return [];
|
||||
}
|
||||
|
||||
getChannelMentions() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
},
|
||||
'../../../settings': { settings: settingsStub },
|
||||
'../../utils/getUserAvatarURL': { getUserAvatarURL: sinon.stub().returns('/avatar/user') },
|
||||
});
|
||||
|
||||
describe('SlackImporter', () => {
|
||||
let importer: any;
|
||||
|
||||
beforeEach(() => {
|
||||
addMessageStub.reset();
|
||||
settingsStub.get.reset();
|
||||
|
||||
const info = { key: 'slack', name: 'Slack' };
|
||||
const importRecord = { _id: 'test-import' };
|
||||
importer = new SlackImporter(info, importRecord, {});
|
||||
});
|
||||
|
||||
describe('processMessageSubType - file_share', () => {
|
||||
it('should create hidden file message with empty msg for file_share subtype', async () => {
|
||||
const message = {
|
||||
type: 'message',
|
||||
subtype: 'file_share',
|
||||
ts: '1234567890.000001',
|
||||
user: 'U12345',
|
||||
file: {
|
||||
id: 'F12345',
|
||||
name: 'test.png',
|
||||
url_private_download: 'https://files.slack.com/files/F12345/test.png?token=xoxe-123',
|
||||
size: 1024,
|
||||
is_external: false,
|
||||
},
|
||||
};
|
||||
|
||||
const slackChannelId = 'C12345';
|
||||
const newMessage = {
|
||||
_id: 'slack-C12345-1234567890-000001',
|
||||
rid: slackChannelId,
|
||||
ts: new Date(1234567890000),
|
||||
msg: '',
|
||||
u: { _id: 'U12345' },
|
||||
};
|
||||
|
||||
await importer.processMessageSubType(message, slackChannelId, newMessage, {});
|
||||
|
||||
expect(addMessageStub.calledOnce).to.be.true;
|
||||
|
||||
const fileMessage = addMessageStub.firstCall.args[0];
|
||||
expect(fileMessage._hidden).to.equal(true);
|
||||
expect(fileMessage.msg).to.equal('');
|
||||
expect(fileMessage._importFile).to.exist;
|
||||
expect(fileMessage._importFile.downloadUrl).to.equal('https://files.slack.com/files/F12345/test.png?token=xoxe-123');
|
||||
});
|
||||
|
||||
it('should not create file message if file has no download URL', async () => {
|
||||
const message = {
|
||||
type: 'message',
|
||||
subtype: 'file_share',
|
||||
ts: '1234567890.000001',
|
||||
user: 'U12345',
|
||||
file: {
|
||||
id: 'F12345',
|
||||
name: 'test.png',
|
||||
size: 1024,
|
||||
},
|
||||
};
|
||||
|
||||
const slackChannelId = 'C12345';
|
||||
const newMessage = {
|
||||
_id: 'slack-C12345-1234567890-000001',
|
||||
rid: slackChannelId,
|
||||
ts: new Date(1234567890000),
|
||||
msg: '',
|
||||
u: { _id: 'U12345' },
|
||||
};
|
||||
|
||||
await importer.processMessageSubType(message, slackChannelId, newMessage, {});
|
||||
|
||||
expect(addMessageStub.called).to.be.false;
|
||||
});
|
||||
|
||||
it('should include thread message id if file is shared in a thread', async () => {
|
||||
const message = {
|
||||
type: 'message',
|
||||
subtype: 'file_share',
|
||||
ts: '1234567890.000002',
|
||||
thread_ts: '1234567890.000001',
|
||||
user: 'U12345',
|
||||
file: {
|
||||
id: 'F12345',
|
||||
name: 'test.png',
|
||||
url_private_download: 'https://files.slack.com/files/F12345/test.png',
|
||||
size: 1024,
|
||||
},
|
||||
};
|
||||
|
||||
const slackChannelId = 'C12345';
|
||||
const newMessage = {
|
||||
_id: 'slack-C12345-1234567890-000002',
|
||||
rid: slackChannelId,
|
||||
ts: new Date(1234567890000),
|
||||
msg: '',
|
||||
u: { _id: 'U12345' },
|
||||
};
|
||||
|
||||
await importer.processMessageSubType(message, slackChannelId, newMessage, {});
|
||||
|
||||
expect(addMessageStub.calledOnce).to.be.true;
|
||||
|
||||
const fileMessage = addMessageStub.firstCall.args[0];
|
||||
expect(fileMessage.tmid).to.equal('slack-C12345-1234567890-000001');
|
||||
expect(fileMessage._hidden).to.equal(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareMessageObject - messages with files', () => {
|
||||
it('should create hidden file messages for messages with files array', async () => {
|
||||
const message = {
|
||||
type: 'message',
|
||||
ts: '1234567890.000001',
|
||||
user: 'U12345',
|
||||
text: 'Check out these files',
|
||||
files: [
|
||||
{
|
||||
id: 'F12345',
|
||||
name: 'image1.png',
|
||||
url_private_download: 'https://files.slack.com/files/F12345/image1.png',
|
||||
size: 1024,
|
||||
},
|
||||
{
|
||||
id: 'F12346',
|
||||
name: 'image2.png',
|
||||
url_private_download: 'https://files.slack.com/files/F12346/image2.png',
|
||||
size: 2048,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const slackChannelId = 'C12345';
|
||||
|
||||
await importer.prepareMessageObject(message, {}, slackChannelId);
|
||||
|
||||
const fileMessages = addMessageStub.getCalls().filter((call: any) => call.args[0]._importFile);
|
||||
expect(fileMessages.length).to.equal(2);
|
||||
|
||||
fileMessages.forEach((call: any) => {
|
||||
const fileMessage = call.args[0];
|
||||
expect(fileMessage._hidden).to.equal(true);
|
||||
expect(fileMessage.msg).to.equal('');
|
||||
expect(fileMessage._importFile).to.exist;
|
||||
});
|
||||
});
|
||||
|
||||
it('should create unique file message IDs for multiple files', async () => {
|
||||
const message = {
|
||||
type: 'message',
|
||||
ts: '1234567890.000001',
|
||||
user: 'U12345',
|
||||
text: 'Multiple files',
|
||||
files: [
|
||||
{ id: 'F1', name: 'file1.png', url_private_download: 'https://files.slack.com/F1', size: 100 },
|
||||
{ id: 'F2', name: 'file2.png', url_private_download: 'https://files.slack.com/F2', size: 200 },
|
||||
],
|
||||
};
|
||||
|
||||
await importer.prepareMessageObject(message, {}, 'C12345');
|
||||
|
||||
const fileMessages = addMessageStub.getCalls().filter((call: any) => call.args[0]._importFile);
|
||||
const ids = fileMessages.map((call: any) => call.args[0]._id);
|
||||
|
||||
expect(ids[0]).to.include('-file1');
|
||||
expect(ids[1]).to.include('-file2');
|
||||
expect(ids[0]).to.not.equal(ids[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertSlackFileToPendingFile', () => {
|
||||
it('should convert Slack file to pending file format', () => {
|
||||
const slackFile = {
|
||||
id: 'F12345',
|
||||
name: 'document.pdf',
|
||||
url_private_download: 'https://files.slack.com/files/F12345/document.pdf?token=xoxe-abc',
|
||||
size: 4096,
|
||||
is_external: false,
|
||||
mimetype: 'application/pdf',
|
||||
};
|
||||
|
||||
const pendingFile = importer.convertSlackFileToPendingFile(slackFile);
|
||||
|
||||
expect(pendingFile.id).to.equal('F12345');
|
||||
expect(pendingFile.name).to.equal('document.pdf');
|
||||
expect(pendingFile.downloadUrl).to.equal('https://files.slack.com/files/F12345/document.pdf?token=xoxe-abc');
|
||||
expect(pendingFile.size).to.equal(4096);
|
||||
expect(pendingFile.external).to.equal(false);
|
||||
expect(pendingFile.source).to.equal('slack');
|
||||
expect(pendingFile.original).to.deep.include(slackFile);
|
||||
});
|
||||
|
||||
it('should handle external files', () => {
|
||||
const slackFile = {
|
||||
id: 'F99999',
|
||||
name: 'external.pdf',
|
||||
url_private_download: 'https://external.com/file.pdf',
|
||||
size: 1000,
|
||||
is_external: true,
|
||||
};
|
||||
|
||||
const pendingFile = importer.convertSlackFileToPendingFile(slackFile);
|
||||
|
||||
expect(pendingFile.external).to.equal(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeSlackMessageId', () => {
|
||||
it('should generate base message ID without file index', () => {
|
||||
const id = importer.makeSlackMessageId('C12345', '1234567890.000001');
|
||||
|
||||
expect(id).to.equal('slack-C12345-1234567890-000001');
|
||||
});
|
||||
|
||||
it('should generate message ID with file index', () => {
|
||||
const id = importer.makeSlackMessageId('C12345', '1234567890.000001', '1');
|
||||
|
||||
expect(id).to.equal('slack-C12345-1234567890-000001-file1');
|
||||
});
|
||||
|
||||
it('should replace dots with dashes in timestamp', () => {
|
||||
const id = importer.makeSlackMessageId('CHANNEL', '123.456.789');
|
||||
|
||||
expect(id).to.equal('slack-CHANNEL-123-456-789');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { expect } from 'chai';
|
||||
import proxyquire from 'proxyquire';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const settingsStub = sinon.stub();
|
||||
const modelsMock = {
|
||||
Rooms: {
|
||||
findOneByImportId: sinon.stub(),
|
||||
},
|
||||
};
|
||||
const insertMessage = sinon.stub();
|
||||
|
||||
const { MessageConverter } = proxyquire.noCallThru().load('../../../../../server/lib/import/classes/converters/MessageConverter', {
|
||||
'../../../messages/insertMessage': {
|
||||
insertMessage,
|
||||
},
|
||||
'meteor/check': sinon.stub(),
|
||||
'meteor/meteor': sinon.stub(),
|
||||
'@rocket.chat/models': { ...modelsMock, '@global': true },
|
||||
});
|
||||
|
||||
describe('Message Converter', () => {
|
||||
beforeEach(() => {
|
||||
modelsMock.Rooms.findOneByImportId.reset();
|
||||
insertMessage.reset();
|
||||
settingsStub.reset();
|
||||
});
|
||||
|
||||
const messageToImport = {
|
||||
ts: Date.now(),
|
||||
u: {
|
||||
_id: 'rocket.cat',
|
||||
},
|
||||
rid: 'general',
|
||||
msg: 'testing',
|
||||
};
|
||||
|
||||
describe('[insertMessage]', () => {
|
||||
it('function should be called by the converter', async () => {
|
||||
const converter = new MessageConverter({ workInMemory: true });
|
||||
converter._cache.addRoom('general', 'general');
|
||||
|
||||
sinon.stub(converter, 'insertMessage');
|
||||
sinon.stub(converter, 'resetLastMessages');
|
||||
|
||||
await converter.addObject(messageToImport);
|
||||
await converter.convertData();
|
||||
|
||||
expect(converter.insertMessage.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(converter.insertMessage.getCall(0).args).to.be.an('array').that.is.not.empty;
|
||||
expect(converter.insertMessage.getCall(0).args[0]).to.be.deep.equal(messageToImport);
|
||||
});
|
||||
|
||||
it('should call insertMessage lib function to save the message', async () => {
|
||||
const converter = new MessageConverter({ workInMemory: true });
|
||||
converter._cache.addRoom('general', 'main');
|
||||
|
||||
await (converter as any).insertMessage(messageToImport);
|
||||
|
||||
expect(insertMessage.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(insertMessage.getCall(0).args).to.be.an('array').with.lengthOf(4);
|
||||
expect(insertMessage.getCall(0).args[0]).to.be.deep.equal({
|
||||
_id: 'rocket.cat',
|
||||
username: 'rocket.cat',
|
||||
});
|
||||
expect(insertMessage.getCall(0).args[1]).to.deep.include({
|
||||
ts: messageToImport.ts,
|
||||
msg: messageToImport.msg,
|
||||
rid: 'main',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('[buildMessageObject]', () => {
|
||||
it('should have the basic info', async () => {
|
||||
const converter = new MessageConverter({ workInMemory: true });
|
||||
|
||||
const converted = await converter.buildMessageObject(messageToImport, 'general', { _id: 'rocket.cat', username: 'rocket.cat' });
|
||||
|
||||
expect(converted)
|
||||
.to.be.an('object')
|
||||
.that.deep.includes({
|
||||
ts: messageToImport.ts,
|
||||
msg: messageToImport.msg,
|
||||
u: {
|
||||
_id: 'rocket.cat',
|
||||
username: 'rocket.cat',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not have properties with undefined values', async () => {
|
||||
const converter = new MessageConverter({ workInMemory: true });
|
||||
|
||||
const converted = await converter.buildMessageObject(messageToImport, 'general', { _id: 'rocket.cat', username: 'rocket.cat' });
|
||||
|
||||
Object.entries(converted).forEach(([key, value]) => {
|
||||
expect(value, `Property "${key}" should not be undefined`).to.not.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
// #TODO: Validate all message attributes
|
||||
|
||||
it('should include _hidden field when present', async () => {
|
||||
const converter = new MessageConverter({ workInMemory: true });
|
||||
|
||||
const hiddenMessage = {
|
||||
...messageToImport,
|
||||
_hidden: true,
|
||||
};
|
||||
|
||||
const converted = await converter.buildMessageObject(hiddenMessage, 'general', { _id: 'rocket.cat', username: 'rocket.cat' });
|
||||
|
||||
expect(converted).to.have.property('_hidden', true);
|
||||
});
|
||||
|
||||
it('should include _importFile field when present', async () => {
|
||||
const converter = new MessageConverter({ workInMemory: true });
|
||||
|
||||
const importFile = {
|
||||
id: 'F12345',
|
||||
name: 'test.png',
|
||||
downloadUrl: 'https://files.slack.com/F12345/test.png',
|
||||
size: 1024,
|
||||
source: 'slack',
|
||||
};
|
||||
|
||||
const fileMessage = {
|
||||
...messageToImport,
|
||||
_hidden: true,
|
||||
msg: '',
|
||||
_importFile: importFile,
|
||||
};
|
||||
|
||||
const converted = await converter.buildMessageObject(fileMessage, 'general', { _id: 'rocket.cat', username: 'rocket.cat' });
|
||||
|
||||
expect(converted).to.have.property('_hidden', true);
|
||||
expect(converted).to.have.property('msg', '');
|
||||
expect(converted).to.have.property('_importFile').that.deep.includes({
|
||||
id: 'F12345',
|
||||
name: 'test.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not include _hidden field when not present or false', async () => {
|
||||
const converter = new MessageConverter({ workInMemory: true });
|
||||
|
||||
const converted = await converter.buildMessageObject(messageToImport, 'general', { _id: 'rocket.cat', username: 'rocket.cat' });
|
||||
|
||||
expect(converted).to.not.have.property('_hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('callbacks', () => {
|
||||
it('beforeImportFn should be triggered', async () => {
|
||||
const beforeImportFn = sinon.stub();
|
||||
const converter = new MessageConverter({ workInMemory: true });
|
||||
converter._cache.addRoom('general', 'general');
|
||||
|
||||
sinon.stub(converter, 'insertMessage');
|
||||
sinon.stub(converter, 'resetLastMessages');
|
||||
|
||||
await converter.addObject(messageToImport);
|
||||
await converter.convertData({
|
||||
beforeImportFn,
|
||||
});
|
||||
|
||||
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
});
|
||||
|
||||
it('afterImportFn should be triggered', async () => {
|
||||
const afterImportFn = sinon.stub();
|
||||
const converter = new MessageConverter({ workInMemory: true });
|
||||
converter._cache.addRoom('general', 'general');
|
||||
|
||||
sinon.stub(converter, 'insertMessage');
|
||||
sinon.stub(converter, 'resetLastMessages');
|
||||
|
||||
await converter.addObject(messageToImport);
|
||||
await converter.convertData({
|
||||
afterImportFn,
|
||||
});
|
||||
|
||||
expect(converter.insertMessage.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
});
|
||||
|
||||
it('should skip record if beforeImportFn returns false', async () => {
|
||||
let recordId = null;
|
||||
const beforeImportFn = sinon.stub();
|
||||
const afterImportFn = sinon.stub();
|
||||
|
||||
beforeImportFn.callsFake((record) => {
|
||||
recordId = record._id;
|
||||
return false;
|
||||
});
|
||||
|
||||
const converter = new MessageConverter({ workInMemory: true });
|
||||
converter._cache.addRoom('general', 'general');
|
||||
|
||||
sinon.stub(converter, 'insertMessage');
|
||||
sinon.stub(converter, 'resetLastMessages');
|
||||
sinon.stub(converter, 'skipRecord');
|
||||
|
||||
await converter.addObject(messageToImport);
|
||||
await converter.convertData({
|
||||
beforeImportFn,
|
||||
afterImportFn,
|
||||
});
|
||||
|
||||
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(0);
|
||||
expect(converter.skipRecord.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(converter.skipRecord.getCall(0).args).to.be.an('array').that.is.deep.equal([recordId]);
|
||||
expect(converter.insertMessage.getCalls()).to.be.an('array').with.lengthOf(0);
|
||||
});
|
||||
|
||||
it('should not skip record if beforeImportFn returns true', async () => {
|
||||
const beforeImportFn = sinon.stub();
|
||||
const afterImportFn = sinon.stub();
|
||||
|
||||
beforeImportFn.callsFake(() => true);
|
||||
|
||||
const converter = new MessageConverter({ workInMemory: true });
|
||||
converter._cache.addRoom('general', 'general');
|
||||
|
||||
sinon.stub(converter, 'insertMessage');
|
||||
sinon.stub(converter, 'resetLastMessages');
|
||||
sinon.stub(converter, 'skipRecord');
|
||||
|
||||
await converter.addObject(messageToImport);
|
||||
await converter.convertData({
|
||||
beforeImportFn,
|
||||
afterImportFn,
|
||||
});
|
||||
|
||||
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(converter.skipRecord.getCalls()).to.be.an('array').with.lengthOf(0);
|
||||
expect(converter.insertMessage.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
});
|
||||
|
||||
it('onErrorFn should be triggered if mandatory attributes are missing', async () => {
|
||||
const converter = new MessageConverter({ workInMemory: true });
|
||||
converter._cache.addRoom('general', 'general');
|
||||
sinon.stub(converter, 'resetLastMessages');
|
||||
|
||||
const onErrorFn = sinon.stub();
|
||||
|
||||
sinon.stub(converter, 'saveError');
|
||||
|
||||
await converter.addObject({});
|
||||
await converter.convertData({ onErrorFn });
|
||||
|
||||
expect(onErrorFn.getCall(0)).to.not.be.null;
|
||||
expect(converter.saveError.getCall(0)).to.not.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
134
apps/meteor/tests/unit/server/lib/import/recordConverter.spec.ts
Normal file
134
apps/meteor/tests/unit/server/lib/import/recordConverter.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { IImportRecord, IImportRecordType } from '@rocket.chat/core-typings';
|
||||
import { expect } from 'chai';
|
||||
import proxyquire from 'proxyquire';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const settingsStub = sinon.stub();
|
||||
const modelsMock = {
|
||||
ImportData: {
|
||||
find: sinon.stub(),
|
||||
updateOne: sinon.stub(),
|
||||
col: {
|
||||
insertOne: sinon.stub(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { RecordConverter } = proxyquire.noCallThru().load('../../../../../server/lib/import/classes/converters/RecordConverter', {
|
||||
'meteor/check': sinon.stub(),
|
||||
'meteor/meteor': sinon.stub(),
|
||||
'@rocket.chat/models': { ...modelsMock, '@global': true },
|
||||
});
|
||||
|
||||
class TestConverter extends RecordConverter<IImportRecord> {
|
||||
constructor(workInMemory = true) {
|
||||
super({ workInMemory });
|
||||
}
|
||||
|
||||
protected getDataType(): IImportRecordType {
|
||||
return 'user';
|
||||
}
|
||||
}
|
||||
|
||||
describe('Record Converter', () => {
|
||||
const userToImport = {
|
||||
name: 'user1',
|
||||
emails: ['user1@domain.com'],
|
||||
importIds: ['importId1'],
|
||||
username: 'username1',
|
||||
};
|
||||
|
||||
describe('Working with Mongo Collection', () => {
|
||||
beforeEach(() => {
|
||||
modelsMock.ImportData.col.insertOne.reset();
|
||||
modelsMock.ImportData.find.reset();
|
||||
modelsMock.ImportData.updateOne.reset();
|
||||
modelsMock.ImportData.find.callsFake(() => ({ toArray: () => [] }));
|
||||
});
|
||||
|
||||
describe('Adding and Retrieving users', () => {
|
||||
it('should store objects in the collection', async () => {
|
||||
const converter = new TestConverter(false);
|
||||
|
||||
await converter.addObject(userToImport);
|
||||
expect(modelsMock.ImportData.col.insertOne.getCall(0)).to.not.be.null;
|
||||
});
|
||||
|
||||
it('should read objects from the collection', async () => {
|
||||
const converter = new TestConverter(false);
|
||||
await converter.addObject(userToImport);
|
||||
|
||||
await converter.getDataToImport();
|
||||
|
||||
expect(modelsMock.ImportData.find.getCall(0)).to.not.be.null;
|
||||
});
|
||||
|
||||
it('should flag skipped records on the document', async () => {
|
||||
const converter = new TestConverter(false);
|
||||
await (converter as any).skipRecord('skippedId');
|
||||
|
||||
expect(modelsMock.ImportData.updateOne.getCall(0)).to.not.be.null;
|
||||
expect(modelsMock.ImportData.updateOne.getCall(0).args).to.be.an('array').that.deep.contains({ _id: 'skippedId' });
|
||||
});
|
||||
|
||||
it('should store error information on the document', async () => {
|
||||
const converter = new TestConverter(false);
|
||||
await (converter as any).saveError('errorId', new Error());
|
||||
|
||||
expect(modelsMock.ImportData.updateOne.getCall(0)).to.not.be.null;
|
||||
expect(modelsMock.ImportData.updateOne.getCall(0).args).to.be.an('array').that.deep.contains({ _id: 'errorId' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Working in Memory', () => {
|
||||
beforeEach(() => {
|
||||
modelsMock.ImportData.col.insertOne.reset();
|
||||
modelsMock.ImportData.updateOne.reset();
|
||||
modelsMock.ImportData.find.reset();
|
||||
settingsStub.reset();
|
||||
});
|
||||
|
||||
describe('Adding and Retrieving users', () => {
|
||||
it('should not store objects in the collection', async () => {
|
||||
const converter = new TestConverter(true);
|
||||
|
||||
await converter.addObject(userToImport);
|
||||
expect(modelsMock.ImportData.col.insertOne.getCall(0)).to.be.null;
|
||||
});
|
||||
|
||||
it('should not try to read objects from the collection', async () => {
|
||||
const converter = new TestConverter(true);
|
||||
await converter.addObject(userToImport);
|
||||
|
||||
await converter.getDataToImport();
|
||||
|
||||
expect(modelsMock.ImportData.find.getCall(0)).to.be.null;
|
||||
});
|
||||
|
||||
it('should properly retrieve the data added to memory', async () => {
|
||||
const converter = new TestConverter(true);
|
||||
|
||||
await converter.addObject(userToImport);
|
||||
const dataToImport = await converter.getDataToImport();
|
||||
|
||||
expect(dataToImport.length).to.be.equal(1);
|
||||
expect(dataToImport[0].data).to.be.equal(userToImport);
|
||||
});
|
||||
|
||||
it('should not access the collection when flagging skipped records', async () => {
|
||||
const converter = new TestConverter(true);
|
||||
await (converter as any).skipRecord('skippedId');
|
||||
|
||||
expect(modelsMock.ImportData.updateOne.getCall(0)).to.be.null;
|
||||
});
|
||||
|
||||
it('should not access the collection when storing error information', async () => {
|
||||
const converter = new TestConverter(true);
|
||||
await (converter as any).saveError('errorId', new Error());
|
||||
|
||||
expect(modelsMock.ImportData.updateOne.getCall(0)).to.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
269
apps/meteor/tests/unit/server/lib/import/roomConverter.spec.ts
Normal file
269
apps/meteor/tests/unit/server/lib/import/roomConverter.spec.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { expect } from 'chai';
|
||||
import proxyquire from 'proxyquire';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const settingsStub = sinon.stub();
|
||||
const modelsMock = {
|
||||
Rooms: {
|
||||
archiveById: sinon.stub(),
|
||||
updateOne: sinon.stub(),
|
||||
findOneById: sinon.stub(),
|
||||
findDirectRoomContainingAllUsernames: sinon.stub(),
|
||||
findOneByNonValidatedName: sinon.stub(),
|
||||
},
|
||||
Subscriptions: {
|
||||
archiveByRoomId: sinon.stub(),
|
||||
},
|
||||
};
|
||||
const createDirectMessage = sinon.stub();
|
||||
const saveRoomSettings = sinon.stub();
|
||||
|
||||
const { RoomConverter } = proxyquire.noCallThru().load('../../../../../server/lib/import/classes/converters/RoomConverter', {
|
||||
'../../../../meteor-methods/messages/createDirectMessage': {
|
||||
createDirectMessage,
|
||||
},
|
||||
'../../../../meteor-methods/rooms/saveRoomSettings': {
|
||||
saveRoomSettings,
|
||||
},
|
||||
'../../../notifyListener': {
|
||||
notifyOnSubscriptionChangedByRoomId: sinon.stub(),
|
||||
},
|
||||
'../../../../meteor-methods/rooms/createChannel': {
|
||||
createChannelMethod: sinon.stub(),
|
||||
},
|
||||
'../../../../meteor-methods/rooms/createPrivateGroup': {
|
||||
createPrivateGroupMethod: sinon.stub(),
|
||||
},
|
||||
'meteor/check': sinon.stub(),
|
||||
'meteor/meteor': sinon.stub(),
|
||||
'@rocket.chat/models': { ...modelsMock, '@global': true },
|
||||
});
|
||||
|
||||
describe('Room Converter', () => {
|
||||
beforeEach(() => {
|
||||
modelsMock.Rooms.archiveById.reset();
|
||||
modelsMock.Rooms.updateOne.reset();
|
||||
modelsMock.Rooms.findOneById.reset();
|
||||
modelsMock.Rooms.findDirectRoomContainingAllUsernames.reset();
|
||||
modelsMock.Rooms.findOneByNonValidatedName.reset();
|
||||
modelsMock.Subscriptions.archiveByRoomId.reset();
|
||||
createDirectMessage.reset();
|
||||
saveRoomSettings.reset();
|
||||
settingsStub.reset();
|
||||
});
|
||||
|
||||
const roomToImport = {
|
||||
name: 'room1',
|
||||
importIds: ['importIdRoom1'],
|
||||
};
|
||||
|
||||
describe('[findExistingRoom]', () => {
|
||||
it('function should be called by the converter', async () => {
|
||||
const converter = new RoomConverter({ workInMemory: true });
|
||||
|
||||
sinon.stub(converter, 'findExistingRoom');
|
||||
sinon.stub(converter, 'insertOrUpdateRoom');
|
||||
|
||||
await converter.addObject(roomToImport);
|
||||
await converter.convertChannels('startedByUserId');
|
||||
|
||||
expect(converter.findExistingRoom.getCall(0)).to.not.be.null;
|
||||
});
|
||||
|
||||
it('should search by name', async () => {
|
||||
const converter = new RoomConverter({ workInMemory: true });
|
||||
|
||||
await converter.findExistingRoom(roomToImport);
|
||||
expect(modelsMock.Rooms.findOneByNonValidatedName.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(modelsMock.Rooms.findOneByNonValidatedName.getCall(0).args).to.be.an('array').that.contains('room1');
|
||||
});
|
||||
|
||||
it('should not search by name if there is none', async () => {
|
||||
const converter = new RoomConverter({ workInMemory: true });
|
||||
|
||||
await converter.findExistingRoom({});
|
||||
expect(modelsMock.Rooms.findOneByNonValidatedName.getCalls()).to.be.an('array').with.lengthOf(0);
|
||||
});
|
||||
|
||||
it('should search DMs by usernames', async () => {
|
||||
const converter = new RoomConverter({ workInMemory: true });
|
||||
converter._cache.addUser('importId1', 'userId1', 'username1');
|
||||
converter._cache.addUser('importId2', 'userId2', 'username2');
|
||||
|
||||
await converter.findExistingRoom({
|
||||
t: 'd',
|
||||
users: ['importId1', 'importId2'],
|
||||
importIds: ['importIdRoom1'],
|
||||
});
|
||||
|
||||
expect(modelsMock.Rooms.findDirectRoomContainingAllUsernames.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(modelsMock.Rooms.findDirectRoomContainingAllUsernames.getCall(0).args)
|
||||
.to.be.an('array')
|
||||
.that.deep.includes(['username1', 'username2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('[insertRoom]', () => {
|
||||
it('function should be called by the converter', async () => {
|
||||
const converter = new RoomConverter({ workInMemory: true });
|
||||
|
||||
sinon.stub(converter, 'findExistingRoom');
|
||||
sinon.stub(converter, 'insertRoom');
|
||||
sinon.stub(converter, 'updateRoom');
|
||||
|
||||
await converter.addObject(roomToImport);
|
||||
await converter.convertChannels('startedByUserId');
|
||||
|
||||
expect(converter.updateRoom.getCalls()).to.be.an('array').with.lengthOf(0);
|
||||
expect(converter.insertRoom.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(converter.insertRoom.getCall(0).args).to.be.an('array').that.is.not.empty;
|
||||
expect(converter.insertRoom.getCall(0).args[0]).to.be.deep.equal(roomToImport);
|
||||
});
|
||||
|
||||
it('function should not be called for existing rooms', async () => {
|
||||
const converter = new RoomConverter({ workInMemory: true });
|
||||
|
||||
sinon.stub(converter, 'findExistingRoom');
|
||||
converter.findExistingRoom.returns({ _id: 'oldId' });
|
||||
sinon.stub(converter, 'insertRoom');
|
||||
sinon.stub(converter, 'updateRoom');
|
||||
|
||||
await converter.addObject(roomToImport);
|
||||
await converter.convertChannels('startedByUserId');
|
||||
|
||||
expect(converter.insertRoom.getCall(0)).to.be.null;
|
||||
});
|
||||
|
||||
it('should call createDirectMessage to create DM rooms', async () => {
|
||||
const converter = new RoomConverter({ workInMemory: true });
|
||||
sinon.stub(converter, 'updateRoomId');
|
||||
|
||||
createDirectMessage.callsFake((_options, data) => {
|
||||
return {
|
||||
...data,
|
||||
_id: 'Id1',
|
||||
};
|
||||
});
|
||||
|
||||
converter._cache.addUser('importId1', 'userId1', 'username1');
|
||||
converter._cache.addUser('importId2', 'userId2', 'username2');
|
||||
|
||||
await (converter as any).insertRoom(
|
||||
{
|
||||
t: 'd',
|
||||
users: ['importId1', 'importId2'],
|
||||
importIds: ['importIdRoom1'],
|
||||
},
|
||||
'startedByUserId',
|
||||
);
|
||||
|
||||
expect(createDirectMessage.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(createDirectMessage.getCall(0).args).to.be.an('array').with.lengthOf(3).that.deep.includes(['username1', 'username2']);
|
||||
});
|
||||
|
||||
// #TODO: Validate all room types
|
||||
});
|
||||
|
||||
describe('callbacks', () => {
|
||||
it('beforeImportFn should be triggered', async () => {
|
||||
const beforeImportFn = sinon.stub();
|
||||
const converter = new RoomConverter({ workInMemory: true });
|
||||
|
||||
sinon.stub(converter, 'findExistingRoom');
|
||||
sinon.stub(converter, 'insertOrUpdateRoom');
|
||||
|
||||
await converter.addObject(roomToImport);
|
||||
await converter.convertChannels('startedByUserId', {
|
||||
beforeImportFn,
|
||||
});
|
||||
|
||||
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
});
|
||||
|
||||
it('afterImportFn should be triggered', async () => {
|
||||
const afterImportFn = sinon.stub();
|
||||
const converter = new RoomConverter({ workInMemory: true });
|
||||
|
||||
sinon.stub(converter, 'findExistingRoom');
|
||||
sinon.stub(converter, 'insertOrUpdateRoom');
|
||||
|
||||
await converter.addObject(roomToImport);
|
||||
await converter.convertChannels('startedByUserId', {
|
||||
afterImportFn,
|
||||
});
|
||||
|
||||
expect(converter.insertOrUpdateRoom.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
});
|
||||
|
||||
it('should skip record if beforeImportFn returns false', async () => {
|
||||
let recordId = null;
|
||||
const beforeImportFn = sinon.stub();
|
||||
const afterImportFn = sinon.stub();
|
||||
|
||||
beforeImportFn.callsFake((record) => {
|
||||
recordId = record._id;
|
||||
return false;
|
||||
});
|
||||
|
||||
const converter = new RoomConverter({ workInMemory: true });
|
||||
|
||||
sinon.stub(converter, 'findExistingRoom');
|
||||
sinon.stub(converter, 'insertOrUpdateRoom');
|
||||
sinon.stub(converter, 'skipRecord');
|
||||
|
||||
await converter.addObject(roomToImport);
|
||||
await converter.convertChannels('startedByUserId', {
|
||||
beforeImportFn,
|
||||
afterImportFn,
|
||||
});
|
||||
|
||||
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(0);
|
||||
expect(converter.skipRecord.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(converter.skipRecord.getCall(0).args).to.be.an('array').that.is.deep.equal([recordId]);
|
||||
expect(converter.insertOrUpdateRoom.getCalls()).to.be.an('array').with.lengthOf(0);
|
||||
});
|
||||
|
||||
it('should not skip record if beforeImportFn returns true', async () => {
|
||||
const beforeImportFn = sinon.stub();
|
||||
const afterImportFn = sinon.stub();
|
||||
|
||||
beforeImportFn.callsFake(() => true);
|
||||
|
||||
const converter = new RoomConverter({ workInMemory: true });
|
||||
|
||||
sinon.stub(converter, 'findExistingRoom');
|
||||
sinon.stub(converter, 'insertOrUpdateRoom');
|
||||
sinon.stub(converter, 'skipRecord');
|
||||
|
||||
await converter.addObject(roomToImport);
|
||||
await converter.convertChannels('startedByUserId', {
|
||||
beforeImportFn,
|
||||
afterImportFn,
|
||||
});
|
||||
|
||||
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(converter.skipRecord.getCalls()).to.be.an('array').with.lengthOf(0);
|
||||
expect(converter.insertOrUpdateRoom.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
});
|
||||
|
||||
it('onErrorFn should be triggered if there is no name and is not a DM', async () => {
|
||||
const converter = new RoomConverter({ workInMemory: true });
|
||||
|
||||
const onErrorFn = sinon.stub();
|
||||
|
||||
sinon.stub(converter, 'findExistingRoom');
|
||||
sinon.stub(converter, 'insertOrUpdateRoom');
|
||||
sinon.stub(converter, 'saveError');
|
||||
|
||||
await converter.addObject({});
|
||||
await converter.convertChannels('startedByUserId', { onErrorFn });
|
||||
|
||||
expect(converter.insertOrUpdateRoom.getCall(0)).to.be.null;
|
||||
expect(onErrorFn.getCall(0)).to.not.be.null;
|
||||
expect(converter.saveError.getCall(0)).to.not.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
625
apps/meteor/tests/unit/server/lib/import/userConverter.spec.ts
Normal file
625
apps/meteor/tests/unit/server/lib/import/userConverter.spec.ts
Normal file
@@ -0,0 +1,625 @@
|
||||
import { expect } from 'chai';
|
||||
import proxyquire from 'proxyquire';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const settingsStub = sinon.stub();
|
||||
const modelsMock = {
|
||||
Users: {
|
||||
findOneByEmailAddress: sinon.stub(),
|
||||
findOneByUsernameIgnoringCase: sinon.stub(),
|
||||
findOneById: sinon.stub(),
|
||||
},
|
||||
};
|
||||
const addUserToDefaultChannels = sinon.stub();
|
||||
const generateUsernameSuggestion = sinon.stub();
|
||||
const insertUserDoc = sinon.stub();
|
||||
const callbacks = {
|
||||
run: sinon.stub(),
|
||||
};
|
||||
const bcryptHash = sinon.stub();
|
||||
const sha = sinon.stub();
|
||||
const generateTempPassword = sinon.stub();
|
||||
|
||||
const { UserConverter } = proxyquire.noCallThru().load('../../../../../server/lib/import/classes/converters/UserConverter', {
|
||||
'../../../callbacks': {
|
||||
callbacks,
|
||||
},
|
||||
'../../../rooms/addUserToDefaultChannels': {
|
||||
addUserToDefaultChannels,
|
||||
},
|
||||
'../../../users/getUsernameSuggestion': {
|
||||
generateUsernameSuggestion,
|
||||
},
|
||||
'../../../users/saveUserIdentity': {
|
||||
saveUserIdentity: sinon.stub(),
|
||||
},
|
||||
'../../../users/setUserActiveStatus': {
|
||||
setUserActiveStatus: sinon.stub(),
|
||||
},
|
||||
'../../../notifyListener': {
|
||||
notifyOnUserChange: sinon.stub(),
|
||||
},
|
||||
'./generateTempPassword': {
|
||||
generateTempPassword,
|
||||
'@global': true,
|
||||
},
|
||||
'bcrypt': {
|
||||
'hash': bcryptHash,
|
||||
'@global': true,
|
||||
},
|
||||
'meteor/check': sinon.stub(),
|
||||
'meteor/meteor': sinon.stub(),
|
||||
'@rocket.chat/sha256': {
|
||||
SHA256: sha,
|
||||
},
|
||||
'meteor/accounts-base': {
|
||||
Accounts: {
|
||||
insertUserDoc,
|
||||
_bcryptRounds: () => 10,
|
||||
},
|
||||
},
|
||||
'@rocket.chat/models': { ...modelsMock, '@global': true },
|
||||
});
|
||||
|
||||
describe('User Converter', () => {
|
||||
beforeEach(() => {
|
||||
modelsMock.Users.findOneByEmailAddress.reset();
|
||||
modelsMock.Users.findOneByUsernameIgnoringCase.reset();
|
||||
modelsMock.Users.findOneById.reset();
|
||||
callbacks.run.reset();
|
||||
insertUserDoc.reset();
|
||||
addUserToDefaultChannels.reset();
|
||||
generateUsernameSuggestion.reset();
|
||||
settingsStub.reset();
|
||||
});
|
||||
|
||||
const userToImport = {
|
||||
name: 'user1',
|
||||
emails: ['user1@domain.com'],
|
||||
importIds: ['importId1'],
|
||||
username: 'username1',
|
||||
};
|
||||
|
||||
describe('[findExistingUser]', () => {
|
||||
it('function should be called by the converter', async () => {
|
||||
const converter = new UserConverter({ workInMemory: true });
|
||||
const findExistingUser = sinon.stub(converter, 'findExistingUser');
|
||||
|
||||
findExistingUser.throws();
|
||||
|
||||
await converter.addObject(userToImport);
|
||||
await converter.convertData();
|
||||
|
||||
expect(findExistingUser.getCall(0)).to.not.be.null;
|
||||
expect(callbacks.run.getCall(0)).to.not.be.null;
|
||||
expect(callbacks.run.getCall(0).args).to.be.deep.equal(['afterUserImport', { inserted: [], updated: [], skipped: 0, failed: 1 }]);
|
||||
});
|
||||
|
||||
it('should search by email address', async () => {
|
||||
const converter = new UserConverter({ workInMemory: true });
|
||||
|
||||
await converter.findExistingUser(userToImport);
|
||||
expect(modelsMock.Users.findOneByEmailAddress.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(modelsMock.Users.findOneByEmailAddress.getCall(0).args).to.be.an('array').that.contains('user1@domain.com');
|
||||
});
|
||||
|
||||
it('should search by username', async () => {
|
||||
const converter = new UserConverter({ workInMemory: true });
|
||||
|
||||
await converter.findExistingUser(userToImport);
|
||||
expect(modelsMock.Users.findOneByUsernameIgnoringCase.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(modelsMock.Users.findOneByUsernameIgnoringCase.getCall(0).args).to.be.an('array').that.contains('username1');
|
||||
});
|
||||
|
||||
it('should not search by username if an user is found by email', async () => {
|
||||
const converter = new UserConverter({ workInMemory: true });
|
||||
|
||||
modelsMock.Users.findOneByEmailAddress.resolves(userToImport);
|
||||
|
||||
await converter.findExistingUser(userToImport);
|
||||
expect(modelsMock.Users.findOneByUsernameIgnoringCase.getCall(0)).to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
describe('[buildNewUserObject]', () => {
|
||||
const mappedUser = (expectedData: Record<string, any>) => ({
|
||||
type: 'user',
|
||||
services: {
|
||||
password: {
|
||||
bcrypt: 'hashed=tempPassword',
|
||||
},
|
||||
},
|
||||
...expectedData,
|
||||
});
|
||||
|
||||
const converter = new UserConverter({ workInMemory: true });
|
||||
const hashPassword = sinon.stub(converter, 'hashPassword');
|
||||
|
||||
generateTempPassword.returns('tempPassword');
|
||||
hashPassword.callsFake(async (pass) => `hashed=${pass}`);
|
||||
bcryptHash.callsFake((pass: string) => `hashed=${pass}`);
|
||||
sha.callsFake((pass: string) => pass);
|
||||
|
||||
it('should map an empty object', async () => {
|
||||
expect(
|
||||
await converter.buildNewUserObject({
|
||||
emails: [],
|
||||
importIds: [],
|
||||
}),
|
||||
).to.be.deep.equal(mappedUser({}));
|
||||
});
|
||||
|
||||
it('should map the name and username', async () => {
|
||||
expect(
|
||||
await converter.buildNewUserObject({
|
||||
emails: [],
|
||||
importIds: [],
|
||||
name: 'name1',
|
||||
username: 'username1',
|
||||
}),
|
||||
).to.be.deep.equal(
|
||||
mappedUser({
|
||||
username: 'username1',
|
||||
name: 'name1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should map optional fields', async () => {
|
||||
expect(
|
||||
await converter.buildNewUserObject({
|
||||
emails: [],
|
||||
importIds: [],
|
||||
statusText: 'statusText1',
|
||||
bio: 'bio1',
|
||||
avatarUrl: 'avatarUrl',
|
||||
utcOffset: 3,
|
||||
}),
|
||||
).to.be.deep.equal(
|
||||
mappedUser({
|
||||
statusText: 'statusText1',
|
||||
bio: 'bio1',
|
||||
_pendingAvatarUrl: 'avatarUrl',
|
||||
utcOffset: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should map custom fields', async () => {
|
||||
expect(
|
||||
await (converter as any).buildNewUserObject({
|
||||
emails: [],
|
||||
importIds: [],
|
||||
customFields: {
|
||||
age: 32,
|
||||
nickname: 'stitch',
|
||||
},
|
||||
}),
|
||||
).to.be.deep.equal(
|
||||
mappedUser({
|
||||
customFields: {
|
||||
age: 32,
|
||||
nickname: 'stitch',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not map roles', async () => {
|
||||
expect(
|
||||
await converter.buildNewUserObject({
|
||||
emails: [],
|
||||
importIds: [],
|
||||
roles: ['role1'],
|
||||
}),
|
||||
).to.be.deep.equal(mappedUser({}));
|
||||
});
|
||||
|
||||
it('should map identifiers', async () => {
|
||||
expect(
|
||||
await converter.buildNewUserObject({
|
||||
name: 'user1',
|
||||
emails: ['user1@domain.com'],
|
||||
importIds: ['importId1'],
|
||||
username: 'username1',
|
||||
}),
|
||||
).to.be.deep.equal(
|
||||
mappedUser({
|
||||
username: 'username1',
|
||||
name: 'user1',
|
||||
importIds: ['importId1'],
|
||||
emails: [{ address: 'user1@domain.com', verified: false }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should map password', async () => {
|
||||
expect(
|
||||
await converter.buildNewUserObject({
|
||||
emails: [],
|
||||
importIds: [],
|
||||
password: 'batata',
|
||||
}),
|
||||
).to.be.deep.equal(
|
||||
mappedUser({
|
||||
services: {
|
||||
password: {
|
||||
bcrypt: 'hashed=batata',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should map ldap service data', async () => {
|
||||
expect(
|
||||
await converter.buildNewUserObject({
|
||||
emails: [],
|
||||
importIds: [],
|
||||
services: {
|
||||
ldap: {
|
||||
id: 'id',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).to.be.deep.equal(
|
||||
mappedUser({
|
||||
services: {
|
||||
ldap: {
|
||||
id: 'id',
|
||||
},
|
||||
},
|
||||
ldap: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should map deleted users', async () => {
|
||||
expect(
|
||||
await converter.buildNewUserObject({
|
||||
emails: [],
|
||||
importIds: [],
|
||||
deleted: true,
|
||||
}),
|
||||
).to.be.deep.equal(
|
||||
mappedUser({
|
||||
active: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should map restored users', async () => {
|
||||
expect(
|
||||
await converter.buildNewUserObject({
|
||||
emails: [],
|
||||
importIds: [],
|
||||
deleted: false,
|
||||
}),
|
||||
).to.be.deep.equal(
|
||||
mappedUser({
|
||||
active: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should map user type', async () => {
|
||||
expect(
|
||||
await converter.buildNewUserObject({
|
||||
emails: [],
|
||||
importIds: [],
|
||||
type: 'user',
|
||||
}),
|
||||
).to.be.deep.equal(mappedUser({}));
|
||||
});
|
||||
|
||||
it('should map bot type', async () => {
|
||||
expect(
|
||||
await converter.buildNewUserObject({
|
||||
emails: [],
|
||||
importIds: [],
|
||||
type: 'bot',
|
||||
}),
|
||||
).to.be.deep.equal(
|
||||
mappedUser({
|
||||
type: 'bot',
|
||||
services: {
|
||||
password: {
|
||||
bcrypt: 'hashed=tempPassword',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('[insertUser]', () => {
|
||||
it('function should be called by the converter', async () => {
|
||||
const converter = new UserConverter({ workInMemory: true, skipDefaultChannels: true });
|
||||
|
||||
modelsMock.Users.findOneByEmailAddress.resolves(null);
|
||||
modelsMock.Users.findOneByUsernameIgnoringCase.resolves(null);
|
||||
|
||||
sinon.stub(converter, 'insertUser');
|
||||
sinon.stub(converter, 'updateUser');
|
||||
|
||||
await converter.addObject(userToImport);
|
||||
await converter.convertData();
|
||||
|
||||
expect(converter.updateUser.getCalls()).to.be.an('array').with.lengthOf(0);
|
||||
expect(converter.insertUser.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(converter.insertUser.getCall(0).args).to.be.an('array').that.is.not.empty;
|
||||
expect(converter.insertUser.getCall(0).args[0]).to.be.deep.equal(userToImport);
|
||||
expect(addUserToDefaultChannels.getCalls()).to.be.an('array').with.lengthOf(0);
|
||||
});
|
||||
|
||||
it('function should not be called when skipNewUsers = true', async () => {
|
||||
const converter = new UserConverter({ workInMemory: true, skipNewUsers: true });
|
||||
|
||||
sinon.stub(converter, 'findExistingUser');
|
||||
sinon.stub(converter, 'insertUser');
|
||||
sinon.stub(converter, 'updateUser');
|
||||
sinon.stub(converter, 'skipMemoryRecord');
|
||||
|
||||
await converter.addObject(userToImport);
|
||||
await converter.convertData();
|
||||
|
||||
expect(converter.insertUser.getCall(0)).to.be.null;
|
||||
expect(converter.skipMemoryRecord.getCall(0)).to.not.be.null;
|
||||
expect(callbacks.run.getCall(0)).to.not.be.null;
|
||||
expect(callbacks.run.getCall(0).args).to.be.deep.equal(['afterUserImport', { inserted: [], updated: [], skipped: 1, failed: 0 }]);
|
||||
});
|
||||
|
||||
it('function should not be called for existing users', async () => {
|
||||
const converter = new UserConverter({ workInMemory: true });
|
||||
|
||||
sinon.stub(converter, 'findExistingUser');
|
||||
converter.findExistingUser.returns({ _id: 'oldId' });
|
||||
sinon.stub(converter, 'insertUser');
|
||||
sinon.stub(converter, 'updateUser');
|
||||
|
||||
await converter.addObject(userToImport);
|
||||
await converter.convertData();
|
||||
|
||||
expect(converter.insertUser.getCall(0)).to.be.null;
|
||||
expect(callbacks.run.getCall(0)).to.not.be.null;
|
||||
expect(callbacks.run.getCall(0).args).to.be.deep.equal([
|
||||
'afterUserImport',
|
||||
{ inserted: [], updated: ['oldId'], skipped: 0, failed: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('addUserToDefaultChannels should be called by the converter on successful insert', async () => {
|
||||
const converter = new UserConverter({ workInMemory: true, skipDefaultChannels: false });
|
||||
|
||||
modelsMock.Users.findOneByEmailAddress.resolves(null);
|
||||
modelsMock.Users.findOneByUsernameIgnoringCase.resolves(null);
|
||||
modelsMock.Users.findOneById.withArgs('newId').returns({ newUser: true });
|
||||
|
||||
sinon.stub(converter, 'insertUser');
|
||||
|
||||
converter.insertUser.callsFake(() => 'newId');
|
||||
|
||||
await converter.addObject(userToImport);
|
||||
await converter.convertData();
|
||||
|
||||
expect(converter.insertUser.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(converter.insertUser.getCall(0).args).to.be.an('array').that.is.not.empty;
|
||||
expect(converter.insertUser.getCall(0).args[0]).to.be.deep.equal(userToImport);
|
||||
expect(addUserToDefaultChannels.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(addUserToDefaultChannels.getCall(0).args).to.be.an('array').that.deep.contains({ newUser: true });
|
||||
});
|
||||
|
||||
it('should call insertUserDoc with the mapped data and roles', async () => {
|
||||
const converter = new UserConverter({ workInMemory: true });
|
||||
let insertedUser = null;
|
||||
|
||||
insertUserDoc.callsFake((_options, data) => {
|
||||
insertedUser = {
|
||||
...data,
|
||||
_id: 'Id1',
|
||||
};
|
||||
return 'Id1';
|
||||
});
|
||||
|
||||
modelsMock.Users.findOneById.withArgs('Id1').resolves(insertedUser);
|
||||
|
||||
await (converter as any).insertUser({ ...userToImport, roles: ['role1', 'role2'] });
|
||||
|
||||
expect(insertUserDoc.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(insertUserDoc.getCall(0).args).to.be.an('array').with.lengthOf(2);
|
||||
|
||||
const usedParams = insertUserDoc.getCall(0).args[1];
|
||||
expect(usedParams).to.deep.include({
|
||||
type: 'user',
|
||||
username: 'username1',
|
||||
name: 'user1',
|
||||
importIds: ['importId1'],
|
||||
emails: [{ address: 'user1@domain.com', verified: false }],
|
||||
globalRoles: ['role1', 'role2'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('[updateUser]', () => {
|
||||
it('function should be called by the converter', async () => {
|
||||
const converter = new UserConverter({ workInMemory: true });
|
||||
|
||||
sinon.stub(converter, 'findExistingUser');
|
||||
converter.findExistingUser.returns({ _id: 'oldId' });
|
||||
sinon.stub(converter, 'insertUser');
|
||||
sinon.stub(converter, 'updateUser');
|
||||
|
||||
await converter.addObject(userToImport);
|
||||
await converter.convertData();
|
||||
|
||||
expect(converter.insertUser.getCalls()).to.be.an('array').with.lengthOf(0);
|
||||
expect(converter.updateUser.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(converter.updateUser.getCall(0).args).to.be.an('array').that.is.not.empty;
|
||||
expect(converter.updateUser.getCall(0).args[1]).to.be.deep.equal(userToImport);
|
||||
});
|
||||
|
||||
it('function should not be called when skipExistingUsers = true', async () => {
|
||||
const converter = new UserConverter({ workInMemory: true, skipExistingUsers: true });
|
||||
|
||||
sinon.stub(converter, 'findExistingUser');
|
||||
converter.findExistingUser.returns({ _id: 'oldId' });
|
||||
sinon.stub(converter, 'insertUser');
|
||||
sinon.stub(converter, 'updateUser');
|
||||
sinon.stub(converter, 'skipMemoryRecord');
|
||||
|
||||
await converter.addObject(userToImport);
|
||||
await converter.convertData();
|
||||
|
||||
expect(converter.updateUser.getCall(0)).to.be.null;
|
||||
expect(converter.skipMemoryRecord.getCall(0)).to.not.be.null;
|
||||
expect(callbacks.run.getCall(0)).to.not.be.null;
|
||||
expect(callbacks.run.getCall(0).args).to.be.deep.equal(['afterUserImport', { inserted: [], updated: [], skipped: 1, failed: 0 }]);
|
||||
});
|
||||
|
||||
it('function should not be called for new users', async () => {
|
||||
const converter = new UserConverter({ workInMemory: true });
|
||||
|
||||
sinon.stub(converter, 'findExistingUser');
|
||||
sinon.stub(converter, 'insertUser');
|
||||
sinon.stub(converter, 'updateUser');
|
||||
|
||||
await converter.addObject(userToImport);
|
||||
await converter.convertData();
|
||||
|
||||
expect(converter.updateUser.getCall(0)).to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
// #TODO: Validate batch conversions
|
||||
|
||||
describe('callbacks', () => {
|
||||
it('beforeImportFn should be triggered', async () => {
|
||||
const beforeImportFn = sinon.stub();
|
||||
|
||||
beforeImportFn.callsFake(() => true);
|
||||
|
||||
const converter = new UserConverter({ workInMemory: true, skipDefaultChannels: true });
|
||||
|
||||
sinon.stub(converter, 'findExistingUser');
|
||||
sinon.stub(converter, 'insertUser');
|
||||
sinon.stub(converter, 'updateUser');
|
||||
|
||||
await converter.addObject(userToImport);
|
||||
await converter.convertData({
|
||||
beforeImportFn,
|
||||
});
|
||||
|
||||
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(converter.insertUser.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
});
|
||||
|
||||
it('afterImportFn should be triggered', async () => {
|
||||
const afterImportFn = sinon.stub();
|
||||
const converter = new UserConverter({ workInMemory: true, skipDefaultChannels: true });
|
||||
|
||||
sinon.stub(converter, 'findExistingUser');
|
||||
sinon.stub(converter, 'insertUser');
|
||||
sinon.stub(converter, 'updateUser');
|
||||
|
||||
await converter.addObject(userToImport);
|
||||
await converter.convertData({
|
||||
afterImportFn,
|
||||
});
|
||||
|
||||
expect(converter.insertUser.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
});
|
||||
|
||||
it('should skip record if beforeImportFn returns false', async () => {
|
||||
let recordId = null;
|
||||
const beforeImportFn = sinon.stub();
|
||||
const afterImportFn = sinon.stub();
|
||||
|
||||
beforeImportFn.callsFake((record) => {
|
||||
recordId = record._id;
|
||||
return false;
|
||||
});
|
||||
|
||||
const converter = new UserConverter({ workInMemory: true, skipDefaultChannels: true });
|
||||
|
||||
sinon.stub(converter, 'findExistingUser');
|
||||
sinon.stub(converter, 'insertUser');
|
||||
sinon.stub(converter, 'updateUser');
|
||||
sinon.stub(converter, 'skipMemoryRecord');
|
||||
|
||||
await converter.addObject(userToImport);
|
||||
await converter.convertData({
|
||||
beforeImportFn,
|
||||
afterImportFn,
|
||||
});
|
||||
|
||||
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(converter.skipMemoryRecord.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(0);
|
||||
expect(converter.skipMemoryRecord.getCall(0).args).to.be.an('array').that.is.deep.equal([recordId]);
|
||||
|
||||
expect(callbacks.run.getCall(0)).to.not.be.null;
|
||||
expect(callbacks.run.getCall(0).args).to.be.deep.equal(['afterUserImport', { inserted: [], updated: [], skipped: 1, failed: 0 }]);
|
||||
});
|
||||
|
||||
it('should not skip record if beforeImportFn returns true', async () => {
|
||||
let userId = null;
|
||||
const beforeImportFn = sinon.stub();
|
||||
const afterImportFn = sinon.stub();
|
||||
|
||||
beforeImportFn.callsFake(() => true);
|
||||
|
||||
afterImportFn.callsFake((record) => {
|
||||
userId = record.data._id;
|
||||
});
|
||||
|
||||
const converter = new UserConverter({ workInMemory: true, skipDefaultChannels: true });
|
||||
|
||||
sinon.stub(converter, 'findExistingUser');
|
||||
sinon.stub(converter, 'insertUser');
|
||||
sinon.stub(converter, 'updateUser');
|
||||
|
||||
await converter.addObject(userToImport);
|
||||
await converter.convertData({
|
||||
beforeImportFn,
|
||||
afterImportFn,
|
||||
});
|
||||
|
||||
expect(beforeImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(converter.insertUser.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
expect(afterImportFn.getCalls()).to.be.an('array').with.lengthOf(1);
|
||||
|
||||
expect(callbacks.run.getCall(0)).to.not.be.null;
|
||||
expect(callbacks.run.getCall(0).args).to.be.deep.equal([
|
||||
'afterUserImport',
|
||||
{ inserted: [userId], updated: [], skipped: 0, failed: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('onErrorFn should be triggered if there is no email and no username', async () => {
|
||||
const converter = new UserConverter({ workInMemory: true, skipDefaultChannels: true });
|
||||
|
||||
const onErrorFn = sinon.stub();
|
||||
|
||||
sinon.stub(converter, 'findExistingUser');
|
||||
sinon.stub(converter, 'insertUser');
|
||||
sinon.stub(converter, 'updateUser');
|
||||
sinon.stub(converter, 'saveError');
|
||||
|
||||
await converter.addObject({
|
||||
name: 'user1',
|
||||
emails: [],
|
||||
importIds: [],
|
||||
});
|
||||
await converter.convertData({ onErrorFn });
|
||||
|
||||
expect(converter.insertUser.getCall(0)).to.be.null;
|
||||
expect(callbacks.run.getCall(0)).to.not.be.null;
|
||||
expect(callbacks.run.getCall(0).args).to.be.deep.equal(['afterUserImport', { inserted: [], updated: [], skipped: 0, failed: 1 }]);
|
||||
expect(onErrorFn.getCall(0)).to.not.be.null;
|
||||
expect(converter.saveError.getCall(0)).to.not.be.null;
|
||||
});
|
||||
|
||||
// #TODO: Validate afterBatchFn
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user