const { expect } = require('chai') const sinon = require('sinon') const Errors = require('../../../../app/src/Features/Errors/Errors') const SandboxedModule = require('sandboxed-module') const { ObjectId } = require('mongodb') const MODULE_PATH = '../../../../app/src/Features/Project/ProjectEntityUpdateHandler' describe('ProjectEntityUpdateHandler', function () { const projectId = '4eecb1c1bffa66588e0000a1' const projectHistoryId = '123456' const docId = '4eecb1c1bffa66588e0000a2' const fileId = '4eecaffcbffa66588e000009' const folderId = '4eecaffcbffa66588e000008' const newFileId = '4eecaffcbffa66588e000099' const userId = 1234 beforeEach(function () { this.project = { _id: projectId, name: 'project name', overleaf: { history: { id: projectHistoryId, }, }, } this.fileUrl = 'filestore.example.com/file' this.user = { _id: new ObjectId() } this.DocModel = class Doc { constructor(options) { this.name = options.name this.lines = options.lines this._id = docId this.rev = options.rev ?? 0 } } this.FileModel = class File { constructor(options) { this.name = options.name // use a new id for replacement files if (this.name === 'dummy-upload-filename') { this._id = newFileId } else { this._id = fileId } this.rev = 0 if (options.linkedFileData != null) { this.linkedFileData = options.linkedFileData } if (options.hash != null) { this.hash = options.hash } } } this.docName = 'doc-name' this.docLines = ['1234', 'abc'] this.doc = { _id: new ObjectId(), name: this.docName } this.fileName = 'something.jpg' this.fileSystemPath = 'somehintg' this.file = { _id: new ObjectId(), name: this.fileName, rev: 2 } this.linkedFileData = { provider: 'url' } this.source = 'editor' this.callback = sinon.stub() this.DocstoreManager = { getDoc: sinon.stub(), isDocDeleted: sinon.stub(), updateDoc: sinon.stub(), deleteDoc: sinon.stub(), } this.DocumentUpdaterHandler = { flushDocToMongo: sinon.stub().yields(), updateProjectStructure: sinon.stub().yields(), setDocument: sinon.stub(), resyncProjectHistory: sinon.stub().yields(), deleteDoc: sinon.stub().yields(), } this.fs = { unlink: sinon.stub().yields(), } this.LockManager = { runWithLock: sinon.spy((namespace, id, runner, callback) => runner(callback) ), withTimeout: sinon.stub().returns(this.LockManager), } this.ProjectModel = { updateOne: sinon.stub(), } this.ProjectGetter = { getProject: sinon.stub(), getProjectWithoutDocLines: sinon.stub(), } this.ProjectLocator = { findElement: sinon.stub(), findElementByPath: sinon.stub(), } this.ProjectUpdater = { markAsUpdated: sinon.stub(), } this.ProjectEntityHandler = { getDoc: sinon.stub(), getDocPathByProjectIdAndDocId: sinon.stub(), getAllEntitiesFromProject: sinon.stub(), } this.ProjectEntityMongoUpdateHandler = { addDoc: sinon.stub(), addFile: sinon.stub(), addFolder: sinon.stub(), _confirmFolder: sinon.stub(), _putElement: sinon.stub(), _insertDeletedFileReference: sinon.stub(), replaceFileWithNew: sinon.stub(), mkdirp: sinon.stub(), moveEntity: sinon.stub(), renameEntity: sinon.stub().yields(), deleteEntity: sinon.stub(), replaceDocWithFile: sinon.stub(), replaceFileWithDoc: sinon.stub(), } this.TpdsUpdateSender = { addFile: sinon.stub().yields(), addDoc: sinon.stub(), deleteEntity: sinon.stub().yields(), moveEntity: sinon.stub(), promises: { moveEntity: sinon.stub().resolves(), }, } this.FileStoreHandler = { copyFile: sinon.stub(), uploadFileFromDisk: sinon.stub(), deleteFile: sinon.stub(), _buildUrl: sinon .stub() .callsFake( (projectId, fileId) => `www.filestore.test/${projectId}/${fileId}` ), } this.FileWriter = { writeLinesToDisk: sinon.stub(), } this.EditorRealTimeController = { emitToRoom: sinon.stub(), } this.ProjectEntityUpdateHandler = SandboxedModule.require(MODULE_PATH, { requires: { '@overleaf/settings': { validRootDocExtensions: ['tex'] }, fs: this.fs, '../../models/Doc': { Doc: this.DocModel }, '../Docstore/DocstoreManager': this.DocstoreManager, '../../Features/DocumentUpdater/DocumentUpdaterHandler': this.DocumentUpdaterHandler, '../../models/File': { File: this.FileModel }, '../FileStore/FileStoreHandler': this.FileStoreHandler, '../../infrastructure/LockManager': this.LockManager, '../../models/Project': { Project: this.ProjectModel }, './ProjectGetter': this.ProjectGetter, './ProjectLocator': this.ProjectLocator, './ProjectUpdateHandler': this.ProjectUpdater, './ProjectEntityHandler': this.ProjectEntityHandler, './ProjectEntityMongoUpdateHandler': this.ProjectEntityMongoUpdateHandler, '../ThirdPartyDataStore/TpdsUpdateSender': this.TpdsUpdateSender, '../Editor/EditorRealTimeController': this.EditorRealTimeController, '../../infrastructure/FileWriter': this.FileWriter, }, }) }) describe('updateDocLines', function () { beforeEach(function () { this.path = '/somewhere/something.tex' this.doc = { _id: docId, } this.version = 42 this.ranges = { mock: 'ranges' } this.lastUpdatedAt = new Date().getTime() this.lastUpdatedBy = 'fake-last-updater-id' this.parentFolder = { _id: ObjectId() } this.DocstoreManager.isDocDeleted.yields(null, false) this.ProjectGetter.getProject.yields(null, this.project) this.ProjectLocator.findElement.yields( null, this.doc, { fileSystem: this.path, }, this.parentFolder ) this.TpdsUpdateSender.addDoc.yields() }) describe('when the doc has been modified', function () { beforeEach(function () { this.DocstoreManager.updateDoc.yields(null, true, (this.rev = 5)) this.ProjectEntityUpdateHandler.updateDocLines( projectId, docId, this.docLines, this.version, this.ranges, this.lastUpdatedAt, this.lastUpdatedBy, this.callback ) }) it('should get the project with very few fields', function () { this.ProjectGetter.getProject .calledWith(projectId, { name: true, rootFolder: true, }) .should.equal(true) }) it('should find the doc', function () { this.ProjectLocator.findElement .calledWith({ project: this.project, type: 'docs', element_id: docId, }) .should.equal(true) }) it('should update the doc in the docstore', function () { this.DocstoreManager.updateDoc .calledWith( projectId, docId, this.docLines, this.version, this.ranges ) .should.equal(true) }) it('should mark the project as updated', function () { sinon.assert.calledWith( this.ProjectUpdater.markAsUpdated, projectId, this.lastUpdatedAt, this.lastUpdatedBy ) }) it('should send the doc the to the TPDS', function () { this.TpdsUpdateSender.addDoc.should.have.been.calledWith({ projectId, projectName: this.project.name, docId, rev: this.rev, path: this.path, folderId: this.parentFolder._id, }) }) it('should call the callback', function () { this.callback.called.should.equal(true) }) }) describe('when the doc has not been modified', function () { beforeEach(function () { this.DocstoreManager.updateDoc.yields(null, false, (this.rev = 5)) this.ProjectEntityUpdateHandler.updateDocLines( projectId, docId, this.docLines, this.version, this.ranges, this.lastUpdatedAt, this.lastUpdatedBy, this.callback ) }) it('should not mark the project as updated', function () { this.ProjectUpdater.markAsUpdated.called.should.equal(false) }) it('should not send the doc the to the TPDS', function () { this.TpdsUpdateSender.addDoc.called.should.equal(false) }) it('should call the callback', function () { this.callback.called.should.equal(true) }) }) describe('when the doc has been deleted', function () { beforeEach(function () { this.ProjectGetter.getProject.yields(null, this.project) this.ProjectLocator.findElement.yields(new Errors.NotFoundError()) this.DocstoreManager.isDocDeleted.yields(null, true) this.DocstoreManager.updateDoc.yields() this.ProjectEntityUpdateHandler.updateDocLines( projectId, docId, this.docLines, this.version, this.ranges, this.lastUpdatedAt, this.lastUpdatedBy, this.callback ) }) it('should update the doc in the docstore', function () { this.DocstoreManager.updateDoc .calledWith( projectId, docId, this.docLines, this.version, this.ranges ) .should.equal(true) }) it('should not mark the project as updated', function () { this.ProjectUpdater.markAsUpdated.called.should.equal(false) }) it('should not send the doc the to the TPDS', function () { this.TpdsUpdateSender.addDoc.called.should.equal(false) }) it('should call the callback', function () { this.callback.called.should.equal(true) }) }) describe('when projects and docs collection are de-synced', function () { beforeEach(function () { this.ProjectGetter.getProject.yields(null, this.project) // The doc is not in the file-tree, but also not marked as deleted. // This should not happen, but web should handle it. this.ProjectLocator.findElement.yields(new Errors.NotFoundError()) this.DocstoreManager.isDocDeleted.yields(null, false) this.DocstoreManager.updateDoc.yields() this.ProjectEntityUpdateHandler.updateDocLines( projectId, docId, this.docLines, this.version, this.ranges, this.lastUpdatedAt, this.lastUpdatedBy, this.callback ) }) it('should update the doc in the docstore', function () { this.DocstoreManager.updateDoc .calledWith( projectId, docId, this.docLines, this.version, this.ranges ) .should.equal(true) }) it('should not mark the project as updated', function () { this.ProjectUpdater.markAsUpdated.called.should.equal(false) }) it('should not send the doc the to the TPDS', function () { this.TpdsUpdateSender.addDoc.called.should.equal(false) }) it('should call the callback', function () { this.callback.called.should.equal(true) }) }) describe('when the doc is not related to the project', function () { beforeEach(function () { this.ProjectGetter.getProject.yields(null, this.project) this.ProjectLocator.findElement.yields(new Errors.NotFoundError()) this.DocstoreManager.isDocDeleted.yields(new Errors.NotFoundError()) this.ProjectEntityUpdateHandler.updateDocLines( projectId, docId, this.docLines, this.version, this.ranges, this.lastUpdatedAt, this.lastUpdatedBy, this.callback ) }) it('should return a not found error', function () { this.callback .calledWith(sinon.match.instanceOf(Errors.NotFoundError)) .should.equal(true) }) it('should not update the doc', function () { this.DocstoreManager.updateDoc.called.should.equal(false) }) it('should not send the doc the to the TPDS', function () { this.TpdsUpdateSender.addDoc.called.should.equal(false) }) }) describe('when the project is not found', function () { beforeEach(function () { this.ProjectGetter.getProject.yields(new Errors.NotFoundError()) this.ProjectEntityUpdateHandler.updateDocLines( projectId, docId, this.docLines, this.version, this.ranges, this.lastUpdatedAt, this.lastUpdatedBy, this.callback ) }) it('should return a not found error', function () { this.callback .calledWith(sinon.match.instanceOf(Errors.NotFoundError)) .should.equal(true) }) it('should not update the doc', function () { this.DocstoreManager.updateDoc.called.should.equal(false) }) it('should not send the doc the to the TPDS', function () { this.TpdsUpdateSender.addDoc.called.should.equal(false) }) }) }) describe('setRootDoc', function () { beforeEach(function () { this.rootDocId = 'root-doc-id-123123' }) it('should call Project.updateOne when the doc exists and has a valid extension', function () { this.ProjectEntityHandler.getDocPathByProjectIdAndDocId.yields( null, `/main.tex` ) this.ProjectEntityUpdateHandler.setRootDoc( projectId, this.rootDocId, () => {} ) this.ProjectModel.updateOne .calledWith({ _id: projectId }, { rootDoc_id: this.rootDocId }) .should.equal(true) }) it("should not call Project.updateOne when the doc doesn't exist", function () { this.ProjectEntityHandler.getDocPathByProjectIdAndDocId.yields( Errors.NotFoundError ) this.ProjectEntityUpdateHandler.setRootDoc( projectId, this.rootDocId, () => {} ) this.ProjectModel.updateOne .calledWith({ _id: projectId }, { rootDoc_id: this.rootDocId }) .should.equal(false) }) it('should call the callback with an UnsupportedFileTypeError when the doc has an unaccepted file extension', function () { this.ProjectEntityHandler.getDocPathByProjectIdAndDocId.yields( null, `/foo/bar.baz` ) this.ProjectEntityUpdateHandler.setRootDoc( projectId, this.rootDocId, this.callback ) expect(this.callback.firstCall.args[0]).to.be.an.instanceof( Errors.UnsupportedFileTypeError ) }) }) describe('unsetRootDoc', function () { it('should call Project.updateOne', function () { this.ProjectEntityUpdateHandler.unsetRootDoc(projectId) this.ProjectModel.updateOne .calledWith({ _id: projectId }, { $unset: { rootDoc_id: true } }) .should.equal(true) }) }) describe('addDoc', function () { describe('adding a doc', function () { beforeEach(function () { this.path = '/path/to/doc' this.rev = 5 this.newDoc = new this.DocModel({ name: this.docName, lines: undefined, _id: docId, rev: this.rev, }) this.DocstoreManager.updateDoc.yields(null, false, this.rev) this.TpdsUpdateSender.addDoc.yields() this.ProjectEntityMongoUpdateHandler.addDoc.yields( null, { path: { fileSystem: this.path } }, this.project ) this.ProjectEntityUpdateHandler.addDoc( projectId, docId, this.docName, this.docLines, userId, this.source, this.callback ) }) it('creates the doc without history', function () { this.DocstoreManager.updateDoc .calledWith(projectId, docId, this.docLines, 0, {}) .should.equal(true) }) it('sends the change in project structure to the doc updater', function () { const newDocs = [ { doc: this.newDoc, path: this.path, docLines: this.docLines.join('\n'), }, ] this.DocumentUpdaterHandler.updateProjectStructure.should.have.been.calledWith( projectId, projectHistoryId, userId, { newDocs, newProject: this.project, }, this.source ) }) }) describe('adding a doc with an invalid name', function () { beforeEach(function () { this.path = '/path/to/doc' this.newDoc = { _id: docId } this.ProjectEntityUpdateHandler.addDoc( projectId, folderId, `*${this.docName}`, this.docLines, userId, this.source, this.callback ) }) it('returns an error', function () { const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) this.callback.calledWithMatch(errorMatcher).should.equal(true) }) }) }) describe('addFile', function () { describe('adding a file', function () { beforeEach(function () { this.path = '/path/to/file' this.newFile = { _id: fileId, rev: 0, name: this.fileName, linkedFileData: this.linkedFileData, } this.FileStoreHandler.uploadFileFromDisk.yields( null, this.fileUrl, this.newFile ) this.TpdsUpdateSender.addFile.yields() this.ProjectEntityMongoUpdateHandler.addFile.yields( null, { path: { fileSystem: this.path } }, this.project ) this.ProjectEntityUpdateHandler.addFile( projectId, folderId, this.fileName, this.fileSystemPath, this.linkedFileData, userId, this.source, this.callback ) }) it('updates the file in the filestore', function () { this.FileStoreHandler.uploadFileFromDisk .calledWith( projectId, { name: this.fileName, linkedFileData: this.linkedFileData }, this.fileSystemPath ) .should.equal(true) }) it('updates the file in mongo', function () { const fileMatcher = sinon.match(file => { return file.name === this.fileName }) this.ProjectEntityMongoUpdateHandler.addFile .calledWithMatch(projectId, folderId, fileMatcher) .should.equal(true) }) it('notifies the tpds', function () { this.TpdsUpdateSender.addFile .calledWith({ projectId, projectName: this.project.name, fileId, rev: 0, path: this.path, folderId, }) .should.equal(true) }) it('should mark the project as updated', function () { const args = this.ProjectUpdater.markAsUpdated.args[0] args[0].should.equal(projectId) args[1].should.exist args[2].should.equal(userId) }) it('sends the change in project structure to the doc updater', function () { const newFiles = [ { file: this.newFile, path: this.path, url: this.fileUrl, }, ] this.DocumentUpdaterHandler.updateProjectStructure .calledWith( projectId, projectHistoryId, userId, { newFiles, newProject: this.project, }, this.source ) .should.equal(true) }) }) describe('adding a file with an invalid name', function () { beforeEach(function () { this.path = '/path/to/file' this.newFile = { _id: fileId, rev: 0, name: this.fileName, linkedFileData: this.linkedFileData, } this.TpdsUpdateSender.addFile.yields() this.ProjectEntityMongoUpdateHandler.addFile.yields( null, { path: { fileSystem: this.path } }, this.project ) this.ProjectEntityUpdateHandler.addFile( projectId, folderId, `*${this.fileName}`, this.fileSystemPath, this.linkedFileData, userId, this.source, this.callback ) }) it('returns an error', function () { const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) this.callback.calledWithMatch(errorMatcher).should.equal(true) }) }) }) describe('upsertDoc', function () { describe('upserting into an invalid folder', function () { beforeEach(function () { this.ProjectLocator.findElement.yields() this.ProjectEntityUpdateHandler.upsertDoc( projectId, folderId, this.docName, this.docLines, this.source, userId, this.callback ) }) it('returns an error', function () { const errorMatcher = sinon.match.instanceOf(Error) this.callback.calledWithMatch(errorMatcher).should.equal(true) }) }) describe('updating an existing doc', function () { beforeEach(function () { this.existingDoc = { _id: docId, name: this.docName } this.existingFile = { _id: fileId, name: this.fileName } this.folder = { _id: folderId, docs: [this.existingDoc], fileRefs: [this.existingFile], } this.ProjectLocator.findElement.yields(null, this.folder) this.DocumentUpdaterHandler.setDocument.yields() this.ProjectEntityUpdateHandler.upsertDoc( projectId, folderId, this.docName, this.docLines, this.source, userId, this.callback ) }) it('tries to find the folder', function () { this.ProjectLocator.findElement .calledWith({ project_id: projectId, element_id: folderId, type: 'folder', }) .should.equal(true) }) it('updates the doc contents', function () { this.DocumentUpdaterHandler.setDocument .calledWith( projectId, this.existingDoc._id, userId, this.docLines, this.source ) .should.equal(true) }) it('returns the doc', function () { this.callback.calledWith(null, this.existingDoc, false) }) }) describe('creating a new doc', function () { beforeEach(function () { this.folder = { _id: folderId, docs: [], fileRefs: [] } this.newDoc = { _id: docId } this.ProjectLocator.findElement.yields(null, this.folder) this.ProjectEntityUpdateHandler.addDocWithRanges = { withoutLock: sinon.stub().yields(null, this.newDoc), } this.ProjectEntityUpdateHandler.upsertDoc( projectId, folderId, this.docName, this.docLines, this.source, userId, this.callback ) }) it('tries to find the folder', function () { this.ProjectLocator.findElement .calledWith({ project_id: projectId, element_id: folderId, type: 'folder', }) .should.equal(true) }) it('adds the doc', function () { this.ProjectEntityUpdateHandler.addDocWithRanges.withoutLock .calledWith( projectId, folderId, this.docName, this.docLines, {}, userId, this.source ) .should.equal(true) }) it('returns the doc', function () { this.callback.calledWith(null, this.newDoc, true) }) }) describe('upserting a new doc with an invalid name', function () { beforeEach(function () { this.folder = { _id: folderId, docs: [], fileRefs: [] } this.newDoc = { _id: docId } this.ProjectLocator.findElement.yields(null, this.folder) this.ProjectEntityUpdateHandler.addDocWithRanges = { withoutLock: sinon.stub().yields(null, this.newDoc), } this.ProjectEntityUpdateHandler.upsertDoc( projectId, folderId, `*${this.docName}`, this.docLines, this.source, userId, this.callback ) }) it('returns an error', function () { const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) this.callback.calledWithMatch(errorMatcher).should.equal(true) }) }) describe('upserting a doc on top of a file', function () { beforeEach(function () { this.newProject = { name: 'new project', overleaf: { history: { id: projectHistoryId } }, } this.existingFile = { _id: fileId, name: 'foo.tex', rev: 12 } this.folder = { _id: folderId, docs: [], fileRefs: [this.existingFile] } this.newDoc = { _id: docId } this.docLines = ['line one', 'line two'] this.folderPath = '/path/to/folder' this.filePath = '/path/to/folder/foo.tex' this.ProjectLocator.findElement .withArgs({ project_id: projectId, element_id: this.folder._id, type: 'folder', }) .yields(null, this.folder, { fileSystem: this.folderPath, }) this.DocstoreManager.updateDoc.yields() this.ProjectEntityMongoUpdateHandler.replaceFileWithDoc.yields( null, this.newProject ) this.TpdsUpdateSender.addDoc.yields() this.ProjectEntityUpdateHandler.upsertDoc( projectId, folderId, 'foo.tex', this.docLines, this.source, userId, this.callback ) }) it('notifies docstore of the new doc', function () { expect(this.DocstoreManager.updateDoc).to.have.been.calledWith( projectId, this.newDoc._id, this.docLines ) }) it('adds the new doc and removes the file in one go', function () { expect( this.ProjectEntityMongoUpdateHandler.replaceFileWithDoc ).to.have.been.calledWithMatch( projectId, this.existingFile._id, this.newDoc ) }) it('sends the doc to TPDS', function () { expect(this.TpdsUpdateSender.addDoc).to.have.been.calledWith({ projectId, docId: this.newDoc._id, path: this.filePath, projectName: this.newProject.name, rev: this.existingFile.rev + 1, folderId, }) }) it('sends the updates to the doc updater', function () { const oldFiles = [ { file: this.existingFile, path: this.filePath, }, ] const newDocs = [ { doc: sinon.match(this.newDoc), path: this.filePath, docLines: this.docLines.join('\n'), }, ] expect( this.DocumentUpdaterHandler.updateProjectStructure ).to.have.been.calledWith( projectId, projectHistoryId, userId, { oldFiles, newDocs, newProject: this.newProject, }, this.source ) }) it('should notify everyone of the file deletion', function () { expect( this.EditorRealTimeController.emitToRoom ).to.have.been.calledWith( projectId, 'removeEntity', this.existingFile._id, 'convertFileToDoc' ) }) }) }) describe('upsertFile', function () { beforeEach(function () { this.FileStoreHandler.uploadFileFromDisk.yields( null, this.fileUrl, this.file ) }) describe('upserting into an invalid folder', function () { beforeEach(function () { this.ProjectLocator.findElement.yields() this.ProjectEntityUpdateHandler.upsertFile( projectId, folderId, this.fileName, this.fileSystemPath, this.linkedFileData, userId, this.source, this.callback ) }) it('returns an error', function () { const errorMatcher = sinon.match.instanceOf(Error) this.callback.calledWithMatch(errorMatcher).should.equal(true) }) }) describe('updating an existing file', function () { beforeEach(function () { this.existingFile = { _id: fileId, name: this.fileName, rev: 1 } this.newFile = { _id: ObjectId(), name: this.fileName, rev: 3 } this.folder = { _id: folderId, fileRefs: [this.existingFile], docs: [] } this.ProjectLocator.findElement.yields(null, this.folder) this.newProject = 'new-project-stub' this.ProjectEntityMongoUpdateHandler.replaceFileWithNew.yields( null, this.existingFile, this.project, { fileSystem: this.fileSystemPath }, this.newProject, this.newFile ) this.ProjectEntityUpdateHandler.upsertFile( projectId, folderId, this.fileName, this.fileSystemPath, this.linkedFileData, userId, this.source, this.callback ) }) it('uploads a new version of the file', function () { this.FileStoreHandler.uploadFileFromDisk.should.have.been.calledWith( projectId, { name: this.fileName, linkedFileData: this.linkedFileData, }, this.fileSystemPath ) }) it('replaces the file in mongo', function () { this.ProjectEntityMongoUpdateHandler.replaceFileWithNew.should.have.been.calledWith( projectId, this.existingFile._id, this.file ) }) it('notifies the tpds', function () { this.TpdsUpdateSender.addFile.should.have.been.calledWith({ projectId, projectName: this.project.name, fileId: this.newFile._id, rev: this.newFile.rev, path: this.fileSystemPath, folderId, }) }) it('should mark the project as updated', function () { const args = this.ProjectUpdater.markAsUpdated.args[0] args[0].should.equal(projectId) args[1].should.exist args[2].should.equal(userId) }) it('updates the project structure in the doc updater', function () { const oldFiles = [ { file: this.existingFile, path: this.fileSystemPath, }, ] const newFiles = [ { file: this.newFile, path: this.fileSystemPath, url: this.fileUrl, }, ] this.DocumentUpdaterHandler.updateProjectStructure.should.have.been.calledWith( projectId, projectHistoryId, userId, { oldFiles, newFiles, newProject: this.newProject, }, this.source ) }) it('returns the file', function () { this.callback.calledWith(null, this.existingFile, false) }) }) describe('creating a new file', function () { beforeEach(function () { this.folder = { _id: folderId, fileRefs: [], docs: [] } this.newFile = { _id: fileId } this.ProjectLocator.findElement.yields(null, this.folder) this.FileStoreHandler.uploadFileFromDisk.yields( null, this.fileUrl, this.newFile ) this.ProjectEntityUpdateHandler.addFile = { mainTask: sinon.stub().yields(null, this.newFile), } this.ProjectEntityUpdateHandler.upsertFile( projectId, folderId, this.fileName, this.fileSystemPath, this.linkedFileData, userId, this.source, this.callback ) }) it('tries to find the folder', function () { this.ProjectLocator.findElement .calledWith({ project_id: projectId, element_id: folderId, type: 'folder', }) .should.equal(true) }) it('adds the file', function () { expect( this.ProjectEntityUpdateHandler.addFile.mainTask ).to.have.been.calledWith( projectId, folderId, this.fileName, this.fileSystemPath, this.linkedFileData, userId, this.newFile, this.fileUrl, this.source ) }) it('returns the file', function () { this.callback.calledWith(null, this.newFile, true) }) }) describe('upserting a new file with an invalid name', function () { beforeEach(function () { this.folder = { _id: folderId, fileRefs: [] } this.newFile = { _id: fileId } this.ProjectLocator.findElement.yields(null, this.folder) this.ProjectEntityUpdateHandler.addFile = { mainTask: sinon.stub().yields(null, this.newFile), } this.ProjectEntityUpdateHandler.upsertFile( projectId, folderId, `*${this.fileName}`, this.fileSystemPath, this.linkedFileData, userId, this.source, this.callback ) }) it('returns an error', function () { const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) this.callback.calledWithMatch(errorMatcher).should.equal(true) }) }) describe('upserting file on top of a doc', function () { beforeEach(function (done) { this.path = '/path/to/doc' this.existingDoc = { _id: new ObjectId(), name: this.fileName } this.folder = { _id: folderId, fileRefs: [], docs: [this.existingDoc], } this.ProjectLocator.findElement .withArgs({ project_id: this.project._id.toString(), element_id: folderId, type: 'folder', }) .yields(null, this.folder) this.ProjectLocator.findElement .withArgs({ project_id: this.project._id.toString(), element_id: this.existingDoc._id, type: 'doc', }) .yields( null, this.existingDoc, { fileSystem: this.path }, this.folder ) this.newFileUrl = 'new-file-url' this.newFile = { _id: newFileId, name: 'dummy-upload-filename', rev: 0, linkedFileData: this.linkedFileData, } this.newProject = { name: 'new project', overleaf: { history: { id: projectHistoryId } }, } this.FileStoreHandler.uploadFileFromDisk.yields( null, this.newFileUrl, this.newFile ) this.ProjectEntityMongoUpdateHandler.replaceDocWithFile.yields( null, this.newProject ) this.ProjectEntityUpdateHandler.upsertFile( projectId, folderId, this.fileName, this.fileSystemPath, this.linkedFileData, userId, this.source, done ) }) it('replaces the existing doc with a file', function () { expect( this.ProjectEntityMongoUpdateHandler.replaceDocWithFile ).to.have.been.calledWith(projectId, this.existingDoc._id, this.newFile) }) it('updates the doc structure', function () { const oldDocs = [ { doc: this.existingDoc, path: this.path, }, ] const newFiles = [ { file: this.newFile, path: this.path, url: this.newFileUrl, }, ] const updates = { oldDocs, newFiles, newProject: this.newProject, } expect( this.DocumentUpdaterHandler.updateProjectStructure ).to.have.been.calledWith( projectId, projectHistoryId, userId, updates, this.source ) }) it('tells everyone in the room the doc is removed', function () { expect( this.EditorRealTimeController.emitToRoom ).to.have.been.calledWith( projectId, 'removeEntity', this.existingDoc._id, 'convertDocToFile' ) }) }) }) describe('upsertDocWithPath', function () { describe('upserting a doc', function () { beforeEach(function () { this.path = '/folder/doc.tex' this.newFolders = ['mock-a', 'mock-b'] this.folder = { _id: folderId } this.doc = { _id: docId } this.isNewDoc = true this.ProjectEntityUpdateHandler.mkdirp = { withoutLock: sinon.stub().yields(null, this.newFolders, this.folder), } this.ProjectEntityUpdateHandler.upsertDoc = { withoutLock: sinon.stub().yields(null, this.doc, this.isNewDoc), } this.ProjectEntityUpdateHandler.upsertDocWithPath( projectId, this.path, this.docLines, this.source, userId, this.callback ) }) it('creates any necessary folders', function () { this.ProjectEntityUpdateHandler.mkdirp.withoutLock .calledWith(projectId, '/folder') .should.equal(true) }) it('upserts the doc', function () { this.ProjectEntityUpdateHandler.upsertDoc.withoutLock .calledWith( projectId, this.folder._id, 'doc.tex', this.docLines, this.source, userId ) .should.equal(true) }) it('calls the callback', function () { this.callback .calledWith( null, this.doc, this.isNewDoc, this.newFolders, this.folder ) .should.equal(true) }) }) describe('upserting a doc with an invalid path', function () { beforeEach(function () { this.path = '/*folder/doc.tex' this.newFolders = ['mock-a', 'mock-b'] this.folder = { _id: folderId } this.doc = { _id: docId } this.isNewDoc = true this.ProjectEntityUpdateHandler.mkdirp = { withoutLock: sinon.stub().yields(null, this.newFolders, this.folder), } this.ProjectEntityUpdateHandler.upsertDoc = { withoutLock: sinon.stub().yields(null, this.doc, this.isNewDoc), } this.ProjectEntityUpdateHandler.upsertDocWithPath( projectId, this.path, this.docLines, this.source, userId, this.callback ) }) it('returns an error', function () { const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) this.callback.calledWithMatch(errorMatcher).should.equal(true) }) }) describe('upserting a doc with an invalid name', function () { beforeEach(function () { this.path = '/folder/*doc.tex' this.newFolders = ['mock-a', 'mock-b'] this.folder = { _id: folderId } this.doc = { _id: docId } this.isNewDoc = true this.ProjectEntityUpdateHandler.mkdirp = { withoutLock: sinon.stub().yields(null, this.newFolders, this.folder), } this.ProjectEntityUpdateHandler.upsertDoc = { withoutLock: sinon.stub().yields(null, this.doc, this.isNewDoc), } this.ProjectEntityUpdateHandler.upsertDocWithPath( projectId, this.path, this.docLines, this.source, userId, this.callback ) }) it('returns an error', function () { const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) this.callback.calledWithMatch(errorMatcher).should.equal(true) }) }) }) describe('upsertFileWithPath', function () { describe('upserting a file', function () { beforeEach(function () { this.path = '/folder/file.png' this.newFolders = ['mock-a', 'mock-b'] this.folder = { _id: folderId } this.file = { _id: fileId } this.isNewFile = true this.FileStoreHandler.uploadFileFromDisk.yields( null, this.fileUrl, this.newFile ) this.ProjectEntityUpdateHandler.mkdirp = { withoutLock: sinon.stub().yields(null, this.newFolders, this.folder), } this.ProjectEntityUpdateHandler.upsertFile = { mainTask: sinon.stub().yields(null, this.file, this.isNewFile), } this.ProjectEntityUpdateHandler.upsertFileWithPath( projectId, this.path, this.fileSystemPath, this.linkedFileData, userId, this.source, this.callback ) }) it('creates any necessary folders', function () { this.ProjectEntityUpdateHandler.mkdirp.withoutLock .calledWith(projectId, '/folder') .should.equal(true) }) it('upserts the file', function () { this.ProjectEntityUpdateHandler.upsertFile.mainTask .calledWith( projectId, this.folder._id, 'file.png', this.fileSystemPath, this.linkedFileData, userId, this.newFile, this.fileUrl, this.source ) .should.equal(true) }) it('calls the callback', function () { this.callback .calledWith( null, this.file, this.isNewFile, undefined, this.newFolders, this.folder ) .should.equal(true) }) }) describe('upserting a file with an invalid path', function () { beforeEach(function () { this.path = '/*folder/file.png' this.newFolders = ['mock-a', 'mock-b'] this.folder = { _id: folderId } this.file = { _id: fileId } this.isNewFile = true this.ProjectEntityUpdateHandler.mkdirp = { withoutLock: sinon.stub().yields(null, this.newFolders, this.folder), } this.ProjectEntityUpdateHandler.upsertFile = { mainTask: sinon.stub().yields(null, this.file, this.isNewFile), } this.ProjectEntityUpdateHandler.upsertFileWithPath( projectId, this.path, this.fileSystemPath, this.linkedFileData, userId, this.source, this.callback ) }) it('returns an error', function () { const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) this.callback.calledWithMatch(errorMatcher).should.equal(true) }) }) describe('upserting a file with an invalid name', function () { beforeEach(function () { this.path = '/folder/*file.png' this.newFolders = ['mock-a', 'mock-b'] this.folder = { _id: folderId } this.file = { _id: fileId } this.isNewFile = true this.ProjectEntityUpdateHandler.mkdirp = { withoutLock: sinon.stub().yields(null, this.newFolders, this.folder), } this.ProjectEntityUpdateHandler.upsertFile = { mainTask: sinon.stub().yields(null, this.file, this.isNewFile), } this.ProjectEntityUpdateHandler.upsertFileWithPath( projectId, this.path, this.fileSystemPath, this.linkedFileData, userId, this.source, this.callback ) }) it('returns an error', function () { const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) this.callback.calledWithMatch(errorMatcher).should.equal(true) }) }) }) describe('deleteEntity', function () { beforeEach(function () { this.path = '/path/to/doc.tex' this.doc = { _id: docId } this.projectBeforeDeletion = { _id: projectId, name: 'project' } this.newProject = 'new-project' this.ProjectEntityMongoUpdateHandler.deleteEntity.yields( null, this.doc, { fileSystem: this.path }, this.projectBeforeDeletion, this.newProject ) this.ProjectEntityUpdateHandler._cleanUpEntity = sinon .stub() .yields(null, [{ type: 'doc', entity: this.doc, path: this.path }]) this.ProjectEntityUpdateHandler.deleteEntity( projectId, docId, 'doc', userId, this.source, this.callback ) }) it('deletes the entity in mongo', function () { this.ProjectEntityMongoUpdateHandler.deleteEntity .calledWith(projectId, docId, 'doc') .should.equal(true) }) it('cleans up the doc in the docstore', function () { this.ProjectEntityUpdateHandler._cleanUpEntity .calledWith( this.projectBeforeDeletion, this.newProject, this.doc, 'doc', this.path, userId, this.source ) .should.equal(true) }) it('it notifies the tpds', function () { this.TpdsUpdateSender.deleteEntity.should.have.been.calledWith({ projectId, path: this.path, projectName: this.projectBeforeDeletion.name, entityId: docId, entityType: 'doc', subtreeEntityIds: [this.doc._id], }) }) it('retuns the entity_id', function () { this.callback.calledWith(null, docId).should.equal(true) }) }) describe('deleteEntityWithPath', function () { describe('when the entity exists', function () { beforeEach(function () { this.doc = { _id: docId } this.ProjectLocator.findElementByPath.yields(null, this.doc, 'doc') this.ProjectEntityUpdateHandler.deleteEntity = { withoutLock: sinon.stub().yields(), } this.path = '/path/to/doc.tex' this.ProjectEntityUpdateHandler.deleteEntityWithPath( projectId, this.path, userId, this.source, this.callback ) }) it('finds the entity', function () { this.ProjectLocator.findElementByPath .calledWith({ project_id: projectId, path: this.path, exactCaseMatch: true, }) .should.equal(true) }) it('deletes the entity', function () { this.ProjectEntityUpdateHandler.deleteEntity.withoutLock .calledWith( projectId, this.doc._id, 'doc', userId, this.source, this.callback ) .should.equal(true) }) }) describe('when the entity does not exist', function () { beforeEach(function () { this.ProjectLocator.findElementByPath.yields() this.path = '/doc.tex' this.ProjectEntityUpdateHandler.deleteEntityWithPath( projectId, this.path, userId, this.source, this.callback ) }) it('returns an error', function () { this.callback .calledWith(sinon.match.instanceOf(Errors.NotFoundError)) .should.equal(true) }) }) }) describe('mkdirp', function () { beforeEach(function () { this.docPath = '/folder/doc.tex' this.ProjectEntityMongoUpdateHandler.mkdirp.yields() this.ProjectEntityUpdateHandler.mkdirp( projectId, this.docPath, this.callback ) }) it('calls ProjectEntityMongoUpdateHandler', function () { this.ProjectEntityMongoUpdateHandler.mkdirp .calledWith(projectId, this.docPath) .should.equal(true) }) }) describe('mkdirpWithExactCase', function () { beforeEach(function () { this.docPath = '/folder/doc.tex' this.ProjectEntityMongoUpdateHandler.mkdirp.yields() this.ProjectEntityUpdateHandler.mkdirpWithExactCase( projectId, this.docPath, this.callback ) }) it('calls ProjectEntityMongoUpdateHandler', function () { this.ProjectEntityMongoUpdateHandler.mkdirp .calledWith(projectId, this.docPath, { exactCaseMatch: true }) .should.equal(true) }) }) describe('addFolder', function () { describe('adding a folder', function () { beforeEach(function () { this.parentFolderId = '123asdf' this.folderName = 'new-folder' this.ProjectEntityMongoUpdateHandler.addFolder.yields() this.ProjectEntityUpdateHandler.addFolder( projectId, this.parentFolderId, this.folderName, this.callback ) }) it('calls ProjectEntityMongoUpdateHandler', function () { this.ProjectEntityMongoUpdateHandler.addFolder .calledWith(projectId, this.parentFolderId, this.folderName) .should.equal(true) }) }) describe('adding a folder with an invalid name', function () { beforeEach(function () { this.parentFolderId = '123asdf' this.folderName = '*new-folder' this.ProjectEntityMongoUpdateHandler.addFolder.yields() this.ProjectEntityUpdateHandler.addFolder( projectId, this.parentFolderId, this.folderName, this.callback ) }) it('returns an error', function () { const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) this.callback.calledWithMatch(errorMatcher).should.equal(true) }) }) }) describe('moveEntity', function () { beforeEach(function () { this.project_name = 'project name' this.startPath = '/a.tex' this.endPath = '/folder/b.tex' this.rev = 2 this.changes = { newDocs: ['old-doc'], newFiles: ['old-file'] } this.ProjectEntityMongoUpdateHandler.moveEntity.yields( null, this.project, this.startPath, this.endPath, this.rev, this.changes ) this.ProjectEntityUpdateHandler.moveEntity( projectId, docId, folderId, 'doc', userId, this.source, this.callback ) }) it('moves the entity in mongo', function () { this.ProjectEntityMongoUpdateHandler.moveEntity .calledWith(projectId, docId, folderId, 'doc') .should.equal(true) }) it('notifies tpds', function () { this.TpdsUpdateSender.promises.moveEntity .calledWith({ projectId, projectName: this.project_name, startPath: this.startPath, endPath: this.endPath, rev: this.rev, entityId: docId, entityType: 'doc', folderId, }) .should.equal(true) }) it('sends the changes in project structure to the doc updater', function () { this.DocumentUpdaterHandler.updateProjectStructure .calledWith( projectId, projectHistoryId, userId, this.changes, this.source, this.callback ) .should.equal(true) }) }) describe('renameEntity', function () { describe('renaming an entity', function () { beforeEach(function () { this.project_name = 'project name' this.startPath = '/folder/a.tex' this.endPath = '/folder/b.tex' this.rev = 2 this.changes = { newDocs: ['old-doc'], newFiles: ['old-file'] } this.newDocName = 'b.tex' this.ProjectEntityMongoUpdateHandler.renameEntity.yields( null, this.project, this.startPath, this.endPath, this.rev, this.changes ) this.ProjectEntityUpdateHandler.renameEntity( projectId, docId, 'doc', this.newDocName, userId, this.source, this.callback ) }) it('moves the entity in mongo', function () { this.ProjectEntityMongoUpdateHandler.renameEntity .calledWith(projectId, docId, 'doc', this.newDocName) .should.equal(true) }) it('notifies tpds', function () { this.TpdsUpdateSender.promises.moveEntity .calledWith({ projectId, projectName: this.project_name, startPath: this.startPath, endPath: this.endPath, rev: this.rev, entityId: docId, entityType: 'doc', folderId: null, }) .should.equal(true) }) it('sends the changes in project structure to the doc updater', function () { this.DocumentUpdaterHandler.updateProjectStructure .calledWith( projectId, projectHistoryId, userId, this.changes, this.source, this.callback ) .should.equal(true) }) }) describe('renaming an entity to an invalid name', function () { beforeEach(function () { this.project_name = 'project name' this.startPath = '/folder/a.tex' this.endPath = '/folder/b.tex' this.rev = 2 this.changes = { newDocs: ['old-doc'], newFiles: ['old-file'] } this.newDocName = '*b.tex' this.ProjectEntityMongoUpdateHandler.renameEntity.yields( null, this.project, this.startPath, this.endPath, this.rev, this.changes ) this.ProjectEntityUpdateHandler.renameEntity( projectId, docId, 'doc', this.newDocName, userId, this.source, this.callback ) }) it('returns an error', function () { const errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) this.callback.calledWithMatch(errorMatcher).should.equal(true) }) }) }) describe('resyncProjectHistory', function () { describe('a deleted project', function () { beforeEach(function () { this.ProjectGetter.getProject.yields() this.ProjectEntityUpdateHandler.resyncProjectHistory( projectId, this.callback ) }) it('should return an error', function () { expect(this.callback).to.have.been.calledWith( sinon.match .instanceOf(Errors.ProjectHistoryDisabledError) .and( sinon.match.has( 'message', `project history not enabled for ${projectId}` ) ) ) }) }) describe('a project without project-history enabled', function () { beforeEach(function () { this.project.overleaf = {} this.ProjectGetter.getProject.yields(null, this.project) this.ProjectEntityUpdateHandler.resyncProjectHistory( projectId, this.callback ) }) it('should return an error', function () { expect(this.callback).to.have.been.calledWith( sinon.match .instanceOf(Errors.ProjectHistoryDisabledError) .and( sinon.match.has( 'message', `project history not enabled for ${projectId}` ) ) ) }) }) describe('a project with project-history enabled', function () { beforeEach(function () { this.ProjectGetter.getProject.yields(null, this.project) const docs = [ { doc: { _id: docId, name: 'main.tex' }, path: 'main.tex' }, ] const files = [ { file: { _id: fileId, name: 'universe.png', hash: '123456' }, path: 'universe.png', }, ] const folders = [] this.ProjectEntityHandler.getAllEntitiesFromProject.returns({ docs, files, folders, }) this.ProjectEntityUpdateHandler.resyncProjectHistory( projectId, this.callback ) }) it('gets the project', function () { this.ProjectGetter.getProject.calledWith(projectId).should.equal(true) }) it('gets the entities for the project', function () { this.ProjectEntityHandler.getAllEntitiesFromProject .calledWith(this.project) .should.equal(true) }) it('uses an extended timeout', function () { this.LockManager.withTimeout.calledWith(6 * 60).should.equal(true) }) it('tells the doc updater to sync the project', function () { const docs = [ { doc: docId, path: 'main.tex', }, ] const files = [ { file: fileId, path: 'universe.png', url: `www.filestore.test/${projectId}/${fileId}`, _hash: '123456', }, ] this.DocumentUpdaterHandler.resyncProjectHistory .calledWith(projectId, projectHistoryId, docs, files) .should.equal(true) }) it('calls the callback', function () { this.callback.called.should.equal(true) }) }) describe('a project with duplicate filenames', function () { beforeEach(function (done) { this.ProjectGetter.getProject.yields(null, this.project) this.docs = [ { doc: { _id: 'doc1', name: 'main.tex' }, path: 'main.tex' }, { doc: { _id: 'doc2', name: 'duplicate.tex' }, path: 'a/b/c/duplicate.tex', }, { doc: { _id: 'doc3', name: 'duplicate.tex' }, path: 'a/b/c/duplicate.tex', }, { doc: { _id: 'doc4', name: 'another dupe (22)' }, path: 'another dupe (22)', }, { doc: { _id: 'doc5', name: 'duplicate.tex' }, path: 'a/b/c/duplicate.tex', }, ] this.files = [ { file: { _id: 'file1', name: 'image.jpg', hash: 'hash1' }, path: 'image.jpg', }, { file: { _id: 'file2', name: 'duplicate.jpg', hash: 'hash2' }, path: 'duplicate.jpg', }, { file: { _id: 'file3', name: 'duplicate.jpg', hash: 'hash3' }, path: 'duplicate.jpg', }, { file: { _id: 'file4', name: 'another dupe (22)', hash: 'hash4' }, path: 'another dupe (22)', }, ] this.ProjectEntityHandler.getAllEntitiesFromProject.returns({ docs: this.docs, files: this.files, folders: [], }) this.ProjectEntityUpdateHandler.resyncProjectHistory(projectId, done) }) it('renames the duplicate files', function () { const renameEntity = this.ProjectEntityMongoUpdateHandler.renameEntity expect(renameEntity).to.have.callCount(4) expect(renameEntity).to.have.been.calledWith( projectId, 'doc3', 'doc', 'duplicate.tex (1)' ) expect(renameEntity).to.have.been.calledWith( projectId, 'doc5', 'doc', 'duplicate.tex (2)' ) expect(renameEntity).to.have.been.calledWith( projectId, 'file3', 'file', 'duplicate.jpg (1)' ) expect(renameEntity).to.have.been.calledWith( projectId, 'file4', 'file', 'another dupe (23)' ) }) it('tells the doc updater to resync the project', function () { const docs = [ { doc: 'doc1', path: 'main.tex' }, { doc: 'doc2', path: 'a/b/c/duplicate.tex' }, { doc: 'doc3', path: 'a/b/c/duplicate.tex (1)' }, { doc: 'doc4', path: 'another dupe (22)' }, { doc: 'doc5', path: 'a/b/c/duplicate.tex (2)' }, ] const urlPrefix = `www.filestore.test/${projectId}` const files = [ { file: 'file1', path: 'image.jpg', url: `${urlPrefix}/file1`, _hash: 'hash1', }, { file: 'file2', path: 'duplicate.jpg', url: `${urlPrefix}/file2`, _hash: 'hash2', }, { file: 'file3', path: 'duplicate.jpg (1)', url: `${urlPrefix}/file3`, _hash: 'hash3', }, { file: 'file4', path: 'another dupe (23)', url: `${urlPrefix}/file4`, _hash: 'hash4', }, ] expect( this.DocumentUpdaterHandler.resyncProjectHistory ).to.have.been.calledWith(projectId, projectHistoryId, docs, files) }) }) describe('a project with bad filenames', function () { beforeEach(function (done) { this.ProjectGetter.getProject.yields(null, this.project) this.docs = [ { doc: { _id: 'doc1', name: '/d/e/f/test.tex' }, path: 'a/b/c/d/e/f/test.tex', }, { doc: { _id: 'doc2', name: '' }, path: 'a', }, ] this.files = [ { file: { _id: 'file1', name: 'A*.png', hash: 'hash1' }, path: 'A*.png', }, { file: { _id: 'file2', name: 'A_.png', hash: 'hash2' }, path: 'A_.png', }, ] this.ProjectEntityHandler.getAllEntitiesFromProject.returns({ docs: this.docs, files: this.files, folders: [], }) this.ProjectEntityUpdateHandler.resyncProjectHistory(projectId, done) }) it('renames the files', function () { const renameEntity = this.ProjectEntityMongoUpdateHandler.renameEntity expect(renameEntity).to.have.callCount(4) expect(renameEntity).to.have.been.calledWith( projectId, 'doc1', 'doc', '_d_e_f_test.tex' ) expect(renameEntity).to.have.been.calledWith( projectId, 'doc2', 'doc', 'untitled' ) expect(renameEntity).to.have.been.calledWith( projectId, 'file1', 'file', 'A_.png' ) expect(renameEntity).to.have.been.calledWith( projectId, 'file2', 'file', 'A_.png (1)' ) }) it('tells the doc updater to resync the project', function () { const docs = [ { doc: 'doc1', path: 'a/b/c/_d_e_f_test.tex' }, { doc: 'doc2', path: 'a/untitled' }, ] const urlPrefix = `www.filestore.test/${projectId}` const files = [ { file: 'file1', path: 'A_.png', url: `${urlPrefix}/file1`, _hash: 'hash1', }, { file: 'file2', path: 'A_.png (1)', url: `${urlPrefix}/file2`, _hash: 'hash2', }, ] expect( this.DocumentUpdaterHandler.resyncProjectHistory ).to.have.been.calledWith(projectId, projectHistoryId, docs, files) }) }) describe('a project with a bad folder name', function () { beforeEach(function (done) { this.ProjectGetter.getProject.yields(null, this.project) const folders = [ { folder: { _id: 'folder1', name: 'good' }, path: 'good', }, { folder: { _id: 'folder2', name: 'bad*' }, path: 'bad*', }, ] const docs = [ { doc: { _id: 'doc1', name: 'doc1.tex' }, path: 'good/doc1.tex', }, { doc: { _id: 'doc2', name: 'duplicate.tex' }, path: 'bad*/doc2.tex', }, ] const files = [] this.ProjectEntityHandler.getAllEntitiesFromProject.returns({ docs, files, folders, }) this.ProjectEntityUpdateHandler.resyncProjectHistory(projectId, done) }) it('renames the folder', function () { const renameEntity = this.ProjectEntityMongoUpdateHandler.renameEntity expect(renameEntity).to.have.callCount(1) expect(renameEntity).to.have.been.calledWith( projectId, 'folder2', 'folder', 'bad_' ) }) it('tells the doc updater to resync the project', function () { const docs = [ { doc: 'doc1', path: 'good/doc1.tex' }, { doc: 'doc2', path: 'bad_/doc2.tex' }, ] const files = [] expect( this.DocumentUpdaterHandler.resyncProjectHistory ).to.have.been.calledWith(projectId, projectHistoryId, docs, files) }) }) describe('a project with duplicate names between a folder and a doc', function () { beforeEach(function (done) { this.ProjectGetter.getProject.yields(null, this.project) const folders = [ { folder: { _id: 'folder1', name: 'chapters' }, path: 'chapters', }, ] const docs = [ { doc: { _id: 'doc1', name: 'chapters' }, path: 'chapters', }, { doc: { _id: 'doc2', name: 'chapter1.tex' }, path: 'chapters/chapter1.tex', }, ] const files = [] this.ProjectEntityHandler.getAllEntitiesFromProject.returns({ docs, files, folders, }) this.ProjectEntityUpdateHandler.resyncProjectHistory(projectId, done) }) it('renames the doc', function () { const renameEntity = this.ProjectEntityMongoUpdateHandler.renameEntity expect(renameEntity).to.have.callCount(1) expect(renameEntity).to.have.been.calledWith( projectId, 'doc1', 'doc', 'chapters (1)' ) }) it('tells the doc updater to resync the project', function () { const docs = [ { doc: 'doc1', path: 'chapters (1)' }, { doc: 'doc2', path: 'chapters/chapter1.tex' }, ] const files = [] expect( this.DocumentUpdaterHandler.resyncProjectHistory ).to.have.been.calledWith(projectId, projectHistoryId, docs, files) }) }) }) describe('_cleanUpEntity', function () { beforeEach(function () { this.entityId = '4eecaffcbffa66588e000009' this.FileStoreHandler.deleteFile.yields() this.ProjectEntityUpdateHandler.unsetRootDoc = sinon.stub().yields() this.ProjectEntityMongoUpdateHandler._insertDeletedFileReference.yields() }) describe('a file', function () { beforeEach(function (done) { this.path = '/file/system/path.png' this.entity = { _id: this.entityId } this.newProject = 'new-project' this.ProjectEntityUpdateHandler._cleanUpEntity( this.project, this.newProject, this.entity, 'file', this.path, userId, this.source, (err, subtreeListing) => { if (err) { return done(err) } this.subtreeListing = subtreeListing done() } ) }) it('should insert the file into the deletedFiles collection', function () { this.ProjectEntityMongoUpdateHandler._insertDeletedFileReference .calledWith(this.project._id, this.entity) .should.equal(true) }) it('should not delete the file from FileStoreHandler', function () { this.FileStoreHandler.deleteFile .calledWith(projectId, this.entityId) .should.equal(false) }) it('should not attempt to delete from the document updater', function () { this.DocumentUpdaterHandler.deleteDoc.called.should.equal(false) }) it('should send the update to the doc updater', function () { const oldFiles = [{ file: this.entity, path: this.path }] this.DocumentUpdaterHandler.updateProjectStructure.should.have.been.calledWith( projectId, projectHistoryId, userId, { oldFiles, oldDocs: [], newProject: this.newProject, }, this.source ) }) it('should return a subtree listing containing only the file', function () { expect(this.subtreeListing).to.deep.equal([ { type: 'file', entity: this.entity, path: this.path }, ]) }) }) describe('a doc', function () { beforeEach(function (done) { this.path = '/file/system/path.tex' this.ProjectEntityUpdateHandler._cleanUpDoc = sinon.stub().yields() this.entity = { _id: this.entityId } this.newProject = 'new-project' this.ProjectEntityUpdateHandler._cleanUpEntity( this.project, this.newProject, this.entity, 'doc', this.path, userId, this.source, (err, subtreeListing) => { if (err) { return done(err) } this.subtreeListing = subtreeListing done() } ) }) it('should clean up the doc', function () { this.ProjectEntityUpdateHandler._cleanUpDoc .calledWith(this.project, this.entity, this.path, userId) .should.equal(true) }) it('should send the update to the doc updater', function () { const oldDocs = [{ doc: this.entity, path: this.path }] this.DocumentUpdaterHandler.updateProjectStructure.should.have.been.calledWith( projectId, projectHistoryId, userId, { oldDocs, oldFiles: [], newProject: this.newProject, }, this.source ) }) it('should return a subtree listing containing only the doc', function () { expect(this.subtreeListing).to.deep.equal([ { type: 'doc', entity: this.entity, path: this.path }, ]) }) }) describe('a folder', function () { beforeEach(function (done) { this.folder = { folders: [ { name: 'subfolder', fileRefs: [ (this.file1 = { _id: 'file-id-1', name: 'file-name-1' }), ], docs: [(this.doc1 = { _id: 'doc-id-1', name: 'doc-name-1' })], folders: [], }, ], fileRefs: [(this.file2 = { _id: 'file-id-2', name: 'file-name-2' })], docs: [(this.doc2 = { _id: 'doc-id-2', name: 'doc-name-2' })], } this.ProjectEntityUpdateHandler._cleanUpDoc = sinon.stub().yields() this.ProjectEntityUpdateHandler._cleanUpFile = sinon.stub().yields() const path = '/folder' this.newProject = 'new-project' this.ProjectEntityUpdateHandler._cleanUpEntity( this.project, this.newProject, this.folder, 'folder', path, userId, this.source, (err, subtreeListing) => { if (err) { return done(err) } this.subtreeListing = subtreeListing done() } ) }) it('should clean up all sub files', function () { this.ProjectEntityUpdateHandler._cleanUpFile .calledWith( this.project, this.file1, '/folder/subfolder/file-name-1', userId ) .should.equal(true) this.ProjectEntityUpdateHandler._cleanUpFile .calledWith(this.project, this.file2, '/folder/file-name-2', userId) .should.equal(true) }) it('should clean up all sub docs', function () { this.ProjectEntityUpdateHandler._cleanUpDoc .calledWith( this.project, this.doc1, '/folder/subfolder/doc-name-1', userId ) .should.equal(true) this.ProjectEntityUpdateHandler._cleanUpDoc .calledWith(this.project, this.doc2, '/folder/doc-name-2', userId) .should.equal(true) }) it('should should send one update to the doc updater for all docs and files', function () { const oldFiles = [ { file: this.file2, path: '/folder/file-name-2' }, { file: this.file1, path: '/folder/subfolder/file-name-1' }, ] const oldDocs = [ { doc: this.doc2, path: '/folder/doc-name-2' }, { doc: this.doc1, path: '/folder/subfolder/doc-name-1' }, ] this.DocumentUpdaterHandler.updateProjectStructure .calledWith( projectId, projectHistoryId, userId, { oldFiles, oldDocs, newProject: this.newProject, }, this.source ) .should.equal(true) }) it('should return a subtree listing containing all sub-entities', function () { expect(this.subtreeListing).to.have.deep.members([ { type: 'folder', entity: this.folder, path: '/folder' }, { type: 'folder', entity: this.folder.folders[0], path: '/folder/subfolder', }, { type: 'file', entity: this.file1, path: '/folder/subfolder/file-name-1', }, { type: 'doc', entity: this.doc1, path: '/folder/subfolder/doc-name-1', }, { type: 'file', entity: this.file2, path: '/folder/file-name-2' }, { type: 'doc', entity: this.doc2, path: '/folder/doc-name-2' }, ]) }) }) }) describe('_cleanUpDoc', function () { beforeEach(function () { this.doc = { _id: ObjectId(), name: 'test.tex', } this.path = '/path/to/doc' this.ProjectEntityUpdateHandler.unsetRootDoc = sinon.stub().yields() this.DocstoreManager.deleteDoc.yields() }) describe('when the doc is the root doc', function () { beforeEach(function () { this.project.rootDoc_id = this.doc._id this.ProjectEntityUpdateHandler._cleanUpDoc( this.project, this.doc, this.path, userId, this.callback ) }) it('should unset the root doc', function () { this.ProjectEntityUpdateHandler.unsetRootDoc .calledWith(projectId) .should.equal(true) }) it('should delete the doc in the doc updater', function () { this.DocumentUpdaterHandler.deleteDoc .calledWith(projectId, this.doc._id.toString()) .should.equal(true) }) it('should delete the doc in the doc store', function () { this.DocstoreManager.deleteDoc .calledWith(projectId, this.doc._id.toString(), 'test.tex') .should.equal(true) }) it('should call the callback', function () { this.callback.called.should.equal(true) }) }) describe('when the doc is not the root doc', function () { beforeEach(function () { this.project.rootDoc_id = ObjectId() this.ProjectEntityUpdateHandler._cleanUpDoc( this.project, this.doc, this.path, userId, this.callback ) }) it('should not unset the root doc', function () { this.ProjectEntityUpdateHandler.unsetRootDoc.called.should.equal(false) }) it('should call the callback', function () { this.callback.called.should.equal(true) }) }) }) describe('convertDocToFile', function () { beforeEach(function () { this.docPath = '/folder/doc.tex' this.docLines = ['line one', 'line two'] this.tmpFilePath = '/tmp/file' this.fileStoreUrl = 'http://filestore/file' this.folder = { _id: new ObjectId() } this.rev = 3 this.ProjectLocator.findElement .withArgs({ project_id: this.project._id, element_id: this.doc._id, type: 'doc', }) .yields(null, this.doc, { fileSystem: this.path }, this.folder) this.ProjectLocator.findElement .withArgs({ project_id: this.project._id.toString(), element_id: this.file._id, type: 'file', }) .yields(null, this.file, this.docPath, this.folder) this.DocstoreManager.getDoc .withArgs(this.project._id, this.doc._id) .yields(null, this.docLines, this.rev) this.FileWriter.writeLinesToDisk.yields(null, this.tmpFilePath) this.FileStoreHandler.uploadFileFromDisk.yields( null, this.fileStoreUrl, this.file ) this.ProjectEntityMongoUpdateHandler.replaceDocWithFile.yields( null, this.project ) }) describe('successfully', function () { beforeEach(function (done) { this.ProjectEntityUpdateHandler.convertDocToFile( this.project._id, this.doc._id, this.user._id, this.source, done ) }) it('deletes the document in doc updater', function () { expect(this.DocumentUpdaterHandler.deleteDoc).to.have.been.calledWith( this.project._id, this.doc._id ) }) it('uploads the file to filestore', function () { expect( this.FileStoreHandler.uploadFileFromDisk ).to.have.been.calledWith( this.project._id, { name: this.doc.name, rev: this.rev + 1 }, this.tmpFilePath ) }) it('cleans up the temporary file', function () { expect(this.fs.unlink).to.have.been.calledWith(this.tmpFilePath) }) it('replaces the doc with the file', function () { expect( this.ProjectEntityMongoUpdateHandler.replaceDocWithFile ).to.have.been.calledWith(this.project._id, this.doc._id, this.file) }) it('notifies document updater of changes', function () { expect( this.DocumentUpdaterHandler.updateProjectStructure ).to.have.been.calledWith( this.project._id, this.project.overleaf.history.id, this.user._id, { oldDocs: [{ doc: this.doc, path: this.path }], newFiles: [ { file: this.file, path: this.path, url: this.fileStoreUrl }, ], newProject: this.project, }, this.source ) }) it('should notify real-time of the doc deletion', function () { expect( this.EditorRealTimeController.emitToRoom ).to.have.been.calledWith( this.project._id, 'removeEntity', this.doc._id, 'convertDocToFile' ) }) it('should notify real-time of the file creation', function () { expect( this.EditorRealTimeController.emitToRoom ).to.have.been.calledWith( this.project._id, 'reciveNewFile', this.folder._id, this.file, 'convertDocToFile', null ) }) }) describe('when the doc has ranges', function () { it('should throw a DocHasRangesError', function (done) { this.ranges = { comments: [{ id: 123 }] } this.DocstoreManager.getDoc .withArgs(this.project._id, this.doc._id) .yields(null, this.docLines, 'rev', 'version', this.ranges) this.ProjectEntityUpdateHandler.convertDocToFile( this.project._id, this.doc._id, this.user._id, this.source, err => { expect(err).to.be.instanceof(Errors.DocHasRangesError) done() } ) }) }) }) })