Files
overleaf-cep/services/web/test/unit/src/Project/ProjectEntityUpdateHandler.test.mjs
T
Brian Gough 60860aa202 Merge pull request #33576 from overleaf/bg-jpa-convert-document-to-file
Modify convertDocToFile to bypass docstore

GitOrigin-RevId: 3ec789034a369d39d223450462394c8f303caa07
2026-05-19 08:04:13 +00:00

3284 lines
93 KiB
JavaScript

import { vi, expect } from 'vitest'
import sinon from 'sinon'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
import mongodb from 'mongodb-legacy'
vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
)
const { ObjectId } = 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(async function (ctx) {
ctx.project = {
_id: projectId,
name: 'project name',
overleaf: {
history: {
id: projectHistoryId,
},
},
}
ctx.user = { _id: new ObjectId() }
ctx.DocModel = class Doc {
constructor(options) {
this.name = options.name
this.lines = options.lines
this._id = docId
this.rev = options.rev ?? 0
}
}
ctx.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
}
}
}
ctx.docName = 'doc-name'
ctx.docLines = ['1234', 'abc']
ctx.doc = { _id: new ObjectId(), name: ctx.docName }
ctx.fileName = 'something.jpg'
ctx.fileSystemPath = 'somehintg'
ctx.file = { _id: new ObjectId(), name: ctx.fileName, rev: 2 }
ctx.linkedFileData = { provider: 'url' }
ctx.source = 'editor'
ctx.callback = sinon.stub()
ctx.DocstoreManager = {
promises: {
getDoc: sinon.stub(),
isDocDeleted: sinon.stub(),
updateDoc: sinon.stub(),
deleteDoc: sinon.stub(),
},
}
ctx.DocumentUpdaterHandler = {
promises: {
flushDocToMongo: sinon.stub().resolves(),
flushProjectToMongo: sinon.stub().resolves(),
updateProjectStructure: sinon.stub().resolves(),
getDocument: sinon.stub(),
setDocument: sinon.stub(),
resyncProjectHistory: sinon.stub().resolves(),
deleteDoc: sinon.stub().resolves(),
},
}
ctx.fs = {
promises: {
unlink: sinon.stub().resolves(),
},
}
ctx.LockManager = {
promises: {
runWithLock: sinon.spy((namespace, id, runner, callback) =>
runner(callback)
),
},
withTimeout: sinon.stub().returns(ctx.LockManager),
}
ctx.ProjectModel = {
updateOne: sinon.stub().returns({ exec: sinon.stub().resolves() }),
}
ctx.ProjectGetter = {
promises: {
getProject: sinon.stub(),
},
}
ctx.ProjectLocator = {
promises: {
findElement: sinon.stub(),
findElementByPath: sinon.stub(),
},
}
ctx.ProjectUpdater = {
promises: {
markAsUpdated: sinon.stub().resolves(),
},
}
ctx.ProjectEntityHandler = {
getAllEntitiesFromProject: sinon.stub(),
promises: {
getDoc: sinon.stub(),
getDocPathByProjectIdAndDocId: sinon.stub(),
},
}
ctx.ProjectEntityMongoUpdateHandler = {
promises: {
addDoc: sinon.stub(),
addFile: sinon.stub(),
addFolder: sinon.stub(),
_confirmFolder: sinon.stub(),
_putElement: sinon.stub(),
replaceFileWithNew: sinon.stub(),
mkdirp: sinon.stub(),
moveEntity: sinon.stub(),
renameEntity: sinon.stub().resolves({}),
deleteEntity: sinon.stub(),
replaceDocWithFile: sinon.stub(),
replaceFileWithDoc: sinon.stub(),
},
}
ctx.TpdsUpdateSender = {
promises: {
addFile: sinon.stub().resolves(),
addDoc: sinon.stub(),
deleteEntity: sinon.stub().resolves(),
moveEntity: sinon.stub().resolves(),
},
}
ctx.FileStoreHandler = {
promises: {
uploadFileFromDisk: sinon.stub(),
},
}
ctx.FileWriter = {
promises: {
writeLinesToDisk: sinon.stub(),
},
}
ctx.EditorRealTimeController = {
emitToRoom: sinon.stub(),
}
ctx.ProjectOptionsHandler = {
setHistoryRangesSupport: sinon.stub().resolves(),
}
vi.doMock('@overleaf/settings', () => ({
default: { validRootDocExtensions: ['tex'] },
}))
vi.doMock('fs', () => ({
default: ctx.fs,
}))
vi.doMock('../../../../app/src/models/Doc', () => ({
Doc: ctx.DocModel,
}))
vi.doMock('../../../../app/src/Features/Docstore/DocstoreManager', () => ({
default: ctx.DocstoreManager,
}))
vi.doMock(
'../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler',
() => ({
default: ctx.DocumentUpdaterHandler,
})
)
vi.doMock('../../../../app/src/models/File', () => ({
File: ctx.FileModel,
}))
vi.doMock(
'../../../../app/src/Features/FileStore/FileStoreHandler',
() => ({
default: ctx.FileStoreHandler,
})
)
vi.doMock('../../../../app/src/infrastructure/LockManager', () => ({
default: ctx.LockManager,
}))
vi.doMock('../../../../app/src/models/Project', () => ({
Project: ctx.ProjectModel,
}))
vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
default: ctx.ProjectGetter,
}))
vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({
default: ctx.ProjectLocator,
}))
vi.doMock(
'../../../../app/src/Features/Project/ProjectUpdateHandler',
() => ({
default: ctx.ProjectUpdater,
})
)
vi.doMock(
'../../../../app/src/Features/Project/ProjectEntityHandler',
() => ({
default: ctx.ProjectEntityHandler,
})
)
vi.doMock(
'../../../../app/src/Features/Project/ProjectEntityMongoUpdateHandler',
() => ({
default: ctx.ProjectEntityMongoUpdateHandler,
})
)
vi.doMock(
'../../../../app/src/Features/Project/ProjectOptionsHandler',
() => ({
default: ctx.ProjectOptionsHandler,
})
)
vi.doMock(
'../../../../app/src/Features/ThirdPartyDataStore/TpdsUpdateSender',
() => ({
default: ctx.TpdsUpdateSender,
})
)
vi.doMock(
'../../../../app/src/Features/Editor/EditorRealTimeController',
() => ({
default: ctx.EditorRealTimeController,
})
)
vi.doMock('../../../../app/src/infrastructure/FileWriter', () => ({
default: ctx.FileWriter,
}))
ctx.ProjectEntityUpdateHandler = (await import(MODULE_PATH)).default
})
describe('updateDocLines', function () {
beforeEach(function (ctx) {
ctx.path = '/somewhere/something.tex'
ctx.doc = {
_id: docId,
}
ctx.version = 42
ctx.ranges = { mock: 'ranges' }
ctx.lastUpdatedAt = new Date().getTime()
ctx.lastUpdatedBy = 'fake-last-updater-id'
ctx.parentFolder = { _id: new ObjectId() }
ctx.DocstoreManager.promises.isDocDeleted.resolves(false)
ctx.ProjectGetter.promises.getProject.resolves(ctx.project)
ctx.ProjectLocator.promises.findElement.resolves({
element: ctx.doc,
path: {
fileSystem: ctx.path,
},
folder: ctx.parentFolder,
})
ctx.TpdsUpdateSender.promises.addDoc.resolves()
})
describe('when the doc has been modified', function () {
beforeEach(async function (ctx) {
ctx.DocstoreManager.promises.updateDoc.resolves({
modified: true,
rev: (ctx.rev = 5),
})
await ctx.ProjectEntityUpdateHandler.promises.updateDocLines(
projectId,
docId,
ctx.docLines,
ctx.version,
ctx.ranges,
ctx.lastUpdatedAt,
ctx.lastUpdatedBy
)
})
it('should get the project with very few fields', function (ctx) {
ctx.ProjectGetter.promises.getProject
.calledWith(projectId, {
name: true,
rootFolder: true,
})
.should.equal(true)
})
it('should find the doc', function (ctx) {
ctx.ProjectLocator.promises.findElement
.calledWith({
project: ctx.project,
type: 'docs',
element_id: docId,
})
.should.equal(true)
})
it('should update the doc in the docstore', function (ctx) {
ctx.DocstoreManager.promises.updateDoc
.calledWith(projectId, docId, ctx.docLines, ctx.version, ctx.ranges)
.should.equal(true)
})
it('should mark the project as updated', function (ctx) {
sinon.assert.calledWith(
ctx.ProjectUpdater.promises.markAsUpdated,
projectId,
ctx.lastUpdatedAt,
ctx.lastUpdatedBy
)
})
it('should send the doc to the TPDS', function (ctx) {
ctx.TpdsUpdateSender.promises.addDoc.should.have.been.calledWith({
projectId,
projectName: ctx.project.name,
docId,
rev: ctx.rev,
path: ctx.path,
folderId: ctx.parentFolder._id,
})
})
})
describe('when the doc has not been modified', function () {
beforeEach(async function (ctx) {
ctx.DocstoreManager.promises.updateDoc.resolves({
modified: false,
rev: (ctx.rev = 5),
})
await ctx.ProjectEntityUpdateHandler.promises.updateDocLines(
projectId,
docId,
ctx.docLines,
ctx.version,
ctx.ranges,
ctx.lastUpdatedAt,
ctx.lastUpdatedBy
)
})
it('should not mark the project as updated', function (ctx) {
ctx.ProjectUpdater.promises.markAsUpdated.called.should.equal(false)
})
it('should not send the doc the to the TPDS', function (ctx) {
ctx.TpdsUpdateSender.promises.addDoc.called.should.equal(false)
})
})
describe('when the doc has been deleted', function () {
beforeEach(async function (ctx) {
ctx.ProjectGetter.promises.getProject.resolves(ctx.project)
ctx.ProjectLocator.promises.findElement.rejects(
new Errors.NotFoundError()
)
ctx.DocstoreManager.promises.isDocDeleted.resolves(true)
ctx.DocstoreManager.promises.updateDoc.resolves({})
await ctx.ProjectEntityUpdateHandler.promises.updateDocLines(
projectId,
docId,
ctx.docLines,
ctx.version,
ctx.ranges,
ctx.lastUpdatedAt,
ctx.lastUpdatedBy
)
})
it('should update the doc in the docstore', function (ctx) {
ctx.DocstoreManager.promises.updateDoc
.calledWith(projectId, docId, ctx.docLines, ctx.version, ctx.ranges)
.should.equal(true)
})
it('should not mark the project as updated', function (ctx) {
ctx.ProjectUpdater.promises.markAsUpdated.called.should.equal(false)
})
it('should not send the doc the to the TPDS', function (ctx) {
ctx.TpdsUpdateSender.promises.addDoc.called.should.equal(false)
})
})
describe('when projects and docs collection are de-synced', function () {
beforeEach(async function (ctx) {
ctx.ProjectGetter.promises.getProject.resolves(ctx.project)
// The doc is not in the file-tree, but also not marked as deleted.
// This should not happen, but web should handle it.
ctx.ProjectLocator.promises.findElement.rejects(
new Errors.NotFoundError()
)
ctx.DocstoreManager.promises.isDocDeleted.resolves(false)
ctx.DocstoreManager.promises.updateDoc.resolves({})
await ctx.ProjectEntityUpdateHandler.promises.updateDocLines(
projectId,
docId,
ctx.docLines,
ctx.version,
ctx.ranges,
ctx.lastUpdatedAt,
ctx.lastUpdatedBy
)
})
it('should update the doc in the docstore', function (ctx) {
ctx.DocstoreManager.promises.updateDoc
.calledWith(projectId, docId, ctx.docLines, ctx.version, ctx.ranges)
.should.equal(true)
})
it('should not mark the project as updated', function (ctx) {
ctx.ProjectUpdater.promises.markAsUpdated.called.should.equal(false)
})
it('should not send the doc the to the TPDS', function (ctx) {
ctx.TpdsUpdateSender.promises.addDoc.called.should.equal(false)
})
})
describe('when the doc is not related to the project', function () {
let updateDocLinesPromise
beforeEach(function (ctx) {
ctx.ProjectGetter.promises.getProject.resolves(ctx.project)
ctx.ProjectLocator.promises.findElement.rejects(
new Errors.NotFoundError()
)
ctx.DocstoreManager.promises.isDocDeleted.rejects(
new Errors.NotFoundError()
)
updateDocLinesPromise =
ctx.ProjectEntityUpdateHandler.promises.updateDocLines(
projectId,
docId,
ctx.docLines,
ctx.version,
ctx.ranges,
ctx.lastUpdatedAt,
ctx.lastUpdatedBy
)
})
it('should return a not found error', async function () {
let error
try {
await updateDocLinesPromise
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Errors.NotFoundError)
})
it('should not update the doc', async function (ctx) {
let error
try {
await updateDocLinesPromise
} catch (err) {
error = err
}
expect(error).to.exist
ctx.DocstoreManager.promises.updateDoc.called.should.equal(false)
})
it('should not send the doc the to the TPDS', async function (ctx) {
let error
try {
await updateDocLinesPromise
} catch (err) {
error = err
}
expect(error).to.exist
ctx.TpdsUpdateSender.promises.addDoc.called.should.equal(false)
})
})
describe('when the project is not found', function () {
let error
beforeEach(async function (ctx) {
ctx.ProjectGetter.promises.getProject.rejects(
new Errors.NotFoundError()
)
try {
await ctx.ProjectEntityUpdateHandler.promises.updateDocLines(
projectId,
docId,
ctx.docLines,
ctx.version,
ctx.ranges,
ctx.lastUpdatedAt,
ctx.lastUpdatedBy
)
} catch (err) {
error = err
}
})
it('should return a not found error', async function () {
expect(error).to.be.instanceOf(Errors.NotFoundError)
})
it('should not update the doc', async function (ctx) {
ctx.DocstoreManager.promises.updateDoc.called.should.equal(false)
})
it('should not send the doc the to the TPDS', function (ctx) {
ctx.TpdsUpdateSender.promises.addDoc.called.should.equal(false)
})
})
})
describe('setRootDoc', function () {
beforeEach(function (ctx) {
ctx.rootDocId = 'root-doc-id-123123'
})
it('should call Project.updateOne when the doc exists and has a valid extension', async function (ctx) {
ctx.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId.resolves(
`/main.tex`
)
await ctx.ProjectEntityUpdateHandler.promises.setRootDoc(
projectId,
ctx.rootDocId
)
ctx.ProjectModel.updateOne
.calledWith({ _id: projectId }, { rootDoc_id: ctx.rootDocId })
.should.equal(true)
})
it("should not call Project.updateOne when the doc doesn't exist", async function (ctx) {
ctx.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId.rejects(
Errors.NotFoundError
)
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.setRootDoc(
projectId,
ctx.rootDocId
)
} catch (err) {
error = err
}
expect(error).to.exist
ctx.ProjectModel.updateOne
.calledWith({ _id: projectId }, { rootDoc_id: ctx.rootDocId })
.should.equal(false)
})
it('should call the callback with an UnsupportedFileTypeError when the doc has an unaccepted file extension', function (ctx) {
ctx.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId.resolves(
`/foo/bar.baz`
)
ctx.ProjectEntityUpdateHandler.setRootDoc(
projectId,
ctx.rootDocId,
error => {
expect(error).to.be.an.instanceof(Errors.UnsupportedFileTypeError)
}
)
})
})
describe('unsetRootDoc', function () {
it('should call Project.updateOne', async function (ctx) {
await ctx.ProjectEntityUpdateHandler.promises.unsetRootDoc(projectId)
ctx.ProjectModel.updateOne
.calledWith({ _id: projectId }, { $unset: { rootDoc_id: true } })
.should.equal(true)
})
})
describe('addDoc', function () {
describe('adding a doc', function () {
beforeEach(async function (ctx) {
ctx.path = '/path/to/doc'
ctx.rev = 5
ctx.newDoc = new ctx.DocModel({
name: ctx.docName,
lines: undefined,
_id: docId,
rev: ctx.rev,
})
ctx.DocstoreManager.promises.updateDoc.resolves({
lines: false,
rev: ctx.rev,
})
ctx.TpdsUpdateSender.promises.addDoc.resolves()
ctx.ProjectEntityMongoUpdateHandler.promises.addDoc.resolves({
result: { path: { fileSystem: ctx.path } },
project: ctx.project,
})
await ctx.ProjectEntityUpdateHandler.promises.addDoc(
projectId,
docId,
ctx.docName,
ctx.docLines,
userId,
ctx.source
)
})
it('creates the doc without history', function (ctx) {
ctx.DocstoreManager.promises.updateDoc
.calledWith(projectId, docId, ctx.docLines, 0, {})
.should.equal(true)
})
it('sends the change in project structure to the doc updater', function (ctx) {
const newDocs = [
{
doc: ctx.newDoc,
path: ctx.path,
docLines: ctx.docLines.join('\n'),
ranges: {},
},
]
ctx.DocumentUpdaterHandler.promises.updateProjectStructure.should.have.been.calledWith(
projectId,
projectHistoryId,
userId,
{
newDocs,
newProject: ctx.project,
},
ctx.source
)
})
})
describe('adding a doc with an invalid name', function () {
beforeEach(function (ctx) {
ctx.path = '/path/to/doc'
ctx.newDoc = { _id: docId }
})
it('returns an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.addDoc(
projectId,
folderId,
`*${ctx.docName}`,
ctx.docLines,
userId,
ctx.source
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
})
describe('addFile', function () {
describe('adding a file', function () {
beforeEach(async function (ctx) {
ctx.path = '/path/to/file'
ctx.newFile = {
_id: fileId,
hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
rev: 0,
name: ctx.fileName,
linkedFileData: ctx.linkedFileData,
}
ctx.FileStoreHandler.promises.uploadFileFromDisk.resolves({
fileRef: ctx.newFile,
createdBlob: true,
})
ctx.TpdsUpdateSender.promises.addFile.resolves()
ctx.ProjectEntityMongoUpdateHandler.promises.addFile.resolves({
result: { path: { fileSystem: ctx.path } },
project: ctx.project,
})
await ctx.ProjectEntityUpdateHandler.promises.addFile(
projectId,
folderId,
ctx.fileName,
ctx.fileSystemPath,
ctx.linkedFileData,
userId,
ctx.source
)
})
it('updates the file in the filestore', function (ctx) {
ctx.FileStoreHandler.promises.uploadFileFromDisk
.calledWith(
projectId,
{ name: ctx.fileName, linkedFileData: ctx.linkedFileData },
ctx.fileSystemPath
)
.should.equal(true)
})
it('updates the file in mongo', function (ctx) {
const fileMatcher = sinon.match(file => {
return file.name === ctx.fileName
})
ctx.ProjectEntityMongoUpdateHandler.promises.addFile
.calledWithMatch(projectId, folderId, fileMatcher)
.should.equal(true)
})
it('notifies the tpds', function (ctx) {
ctx.TpdsUpdateSender.promises.addFile
.calledWith({
projectId,
historyId: ctx.project.overleaf.history.id,
projectName: ctx.project.name,
fileId,
hash: ctx.newFile.hash,
rev: 0,
path: ctx.path,
folderId,
})
.should.equal(true)
})
it('sends the change in project structure to the doc updater', function (ctx) {
const newFiles = [
{
file: ctx.newFile,
path: ctx.path,
createdBlob: true,
},
]
ctx.DocumentUpdaterHandler.promises.updateProjectStructure.should.have.been.calledWith(
projectId,
projectHistoryId,
userId,
{
newFiles,
newProject: ctx.project,
},
ctx.source
)
})
})
describe('adding a file with an invalid name', function () {
beforeEach(function (ctx) {
ctx.path = '/path/to/file'
ctx.newFile = {
_id: fileId,
hash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
rev: 0,
name: ctx.fileName,
linkedFileData: ctx.linkedFileData,
}
ctx.TpdsUpdateSender.promises.addFile.resolves()
ctx.ProjectEntityMongoUpdateHandler.promises.addFile.resolves({
result: { path: { fileSystem: ctx.path } },
project: ctx.project,
})
})
it('returns an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.addFile(
projectId,
folderId,
`*${ctx.fileName}`,
ctx.fileSystemPath,
ctx.linkedFileData,
userId,
ctx.source
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
})
describe('upsertDoc', function () {
describe('upserting into an invalid folder', function () {
beforeEach(function (ctx) {
ctx.ProjectLocator.promises.findElement.resolves({ element: null })
})
it('returns an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.upsertDoc(
projectId,
folderId,
ctx.docName,
ctx.docLines,
ctx.source,
userId
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Error)
})
})
describe('updating an existing doc', function () {
let upsertDocResponse
beforeEach(async function (ctx) {
ctx.existingDoc = { _id: docId, name: ctx.docName }
ctx.existingFile = {
_id: fileId,
name: ctx.fileName,
hash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
}
ctx.folder = {
_id: folderId,
docs: [ctx.existingDoc],
fileRefs: [ctx.existingFile],
}
ctx.ProjectLocator.promises.findElement.resolves({
element: ctx.folder,
})
ctx.DocumentUpdaterHandler.promises.setDocument.resolves()
upsertDocResponse =
await ctx.ProjectEntityUpdateHandler.promises.upsertDoc(
projectId,
folderId,
ctx.docName,
ctx.docLines,
ctx.source,
userId
)
})
it('tries to find the folder', function (ctx) {
ctx.ProjectLocator.promises.findElement
.calledWith({
project_id: projectId,
element_id: folderId,
type: 'folder',
})
.should.equal(true)
})
it('updates the doc contents', function (ctx) {
ctx.DocumentUpdaterHandler.promises.setDocument
.calledWith(
projectId,
ctx.existingDoc._id,
userId,
ctx.docLines,
ctx.source
)
.should.equal(true)
})
it('returns the doc', function (ctx) {
expect(upsertDocResponse.isNew).to.equal(false)
expect(upsertDocResponse.doc).to.eql(ctx.existingDoc)
})
})
describe('creating a new doc', function () {
let upsertDocResponse
beforeEach(async function (ctx) {
ctx.folder = { _id: folderId, docs: [], fileRefs: [] }
ctx.newDoc = { _id: docId }
ctx.ProjectLocator.promises.findElement.resolves({
element: ctx.folder,
})
ctx.ProjectEntityUpdateHandler.promises.addDocWithRanges = {
withoutLock: sinon.stub().resolves({ doc: ctx.newDoc }),
}
upsertDocResponse =
await ctx.ProjectEntityUpdateHandler.promises.upsertDoc(
projectId,
folderId,
ctx.docName,
ctx.docLines,
ctx.source,
userId
)
})
it('tries to find the folder', function (ctx) {
ctx.ProjectLocator.promises.findElement
.calledWith({
project_id: projectId,
element_id: folderId,
type: 'folder',
})
.should.equal(true)
})
it('adds the doc', function (ctx) {
ctx.ProjectEntityUpdateHandler.promises.addDocWithRanges.withoutLock.should.have.been.calledWith(
projectId,
folderId,
ctx.docName,
ctx.docLines,
{},
userId,
ctx.source
)
})
it('returns the doc', function (ctx) {
expect(upsertDocResponse.isNew).to.equal(true)
expect(upsertDocResponse.doc).to.equal(ctx.newDoc)
})
})
describe('upserting a new doc with an invalid name', function () {
beforeEach(function (ctx) {
ctx.folder = { _id: folderId, docs: [], fileRefs: [] }
ctx.newDoc = { _id: docId }
ctx.ProjectLocator.promises.findElement.resolves({
element: ctx.folder,
})
ctx.ProjectEntityUpdateHandler.promises.addDocWithRanges = {
withoutLock: sinon.stub().resolves({ doc: ctx.newDoc }),
}
})
it('returns an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.upsertDoc(
projectId,
folderId,
`*${ctx.docName}`,
ctx.docLines,
ctx.source,
userId
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
describe('upserting a doc on top of a file', function () {
beforeEach(async function (ctx) {
ctx.newProject = {
name: 'new project',
overleaf: { history: { id: projectHistoryId } },
}
ctx.existingFile = { _id: fileId, name: 'foo.tex', rev: 12 }
ctx.folder = { _id: folderId, docs: [], fileRefs: [ctx.existingFile] }
ctx.newDoc = { _id: docId }
ctx.docLines = ['line one', 'line two']
ctx.folderPath = '/path/to/folder'
ctx.filePath = '/path/to/folder/foo.tex'
ctx.ProjectLocator.promises.findElement
.withArgs({
project_id: projectId,
element_id: ctx.folder._id,
type: 'folder',
})
.resolves({
element: ctx.folder,
path: {
fileSystem: ctx.folderPath,
},
})
ctx.DocstoreManager.promises.updateDoc.resolves({ rev: null })
ctx.ProjectEntityMongoUpdateHandler.promises.replaceFileWithDoc.resolves(
ctx.newProject
)
ctx.TpdsUpdateSender.promises.addDoc.resolves()
await ctx.ProjectEntityUpdateHandler.promises.upsertDoc(
projectId,
folderId,
'foo.tex',
ctx.docLines,
ctx.source,
userId
)
})
it('notifies docstore of the new doc', function (ctx) {
expect(ctx.DocstoreManager.promises.updateDoc).to.have.been.calledWith(
projectId,
ctx.newDoc._id,
ctx.docLines
)
})
it('adds the new doc and removes the file in one go', function (ctx) {
expect(
ctx.ProjectEntityMongoUpdateHandler.promises.replaceFileWithDoc
).to.have.been.calledWithMatch(
projectId,
ctx.existingFile._id,
ctx.newDoc
)
})
it('sends the doc to TPDS', function (ctx) {
expect(ctx.TpdsUpdateSender.promises.addDoc).to.have.been.calledWith({
projectId,
docId: ctx.newDoc._id,
path: ctx.filePath,
projectName: ctx.newProject.name,
rev: ctx.existingFile.rev + 1,
folderId,
})
})
it('sends the updates to the doc updater', function (ctx) {
const oldFiles = [
{
file: ctx.existingFile,
path: ctx.filePath,
},
]
const newDocs = [
{
doc: sinon.match(ctx.newDoc),
path: ctx.filePath,
docLines: ctx.docLines.join('\n'),
},
]
expect(
ctx.DocumentUpdaterHandler.promises.updateProjectStructure
).to.have.been.calledWith(
projectId,
projectHistoryId,
userId,
{
oldFiles,
newDocs,
newProject: ctx.newProject,
},
ctx.source
)
})
it('should notify everyone of the file deletion', function (ctx) {
expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
projectId,
'removeEntity',
ctx.existingFile._id,
'convertFileToDoc'
)
})
})
})
describe('upsertFile', function () {
beforeEach(function (ctx) {
ctx.FileStoreHandler.promises.uploadFileFromDisk.resolves({
fileRef: ctx.file,
createdBlob: true,
})
})
describe('upserting into an invalid folder', function () {
beforeEach(function (ctx) {
ctx.ProjectLocator.promises.findElement.resolves({ element: null })
})
it('returns an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.upsertFile(
projectId,
folderId,
ctx.fileName,
ctx.fileSystemPath,
ctx.linkedFileData,
userId,
ctx.source
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Error)
})
})
describe('updating an existing file', function () {
let upsertFileResult
beforeEach(async function (ctx) {
ctx.existingFile = { _id: fileId, name: ctx.fileName, rev: 1 }
ctx.newFile = {
_id: new ObjectId(),
name: ctx.fileName,
rev: 3,
hash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
}
ctx.folder = { _id: folderId, fileRefs: [ctx.existingFile], docs: [] }
ctx.ProjectLocator.promises.findElement.resolves({
element: ctx.folder,
})
ctx.newProject = 'new-project-stub'
ctx.ProjectEntityMongoUpdateHandler.promises.replaceFileWithNew.resolves(
{
oldFileRef: ctx.existingFile,
project: ctx.project,
path: { fileSystem: ctx.fileSystemPath },
newProject: ctx.newProject,
newFileRef: ctx.newFile,
}
)
upsertFileResult =
await ctx.ProjectEntityUpdateHandler.promises.upsertFile(
projectId,
folderId,
ctx.fileName,
ctx.fileSystemPath,
ctx.linkedFileData,
userId,
ctx.source
)
})
it('uploads a new version of the file', function (ctx) {
ctx.FileStoreHandler.promises.uploadFileFromDisk.should.have.been.calledWith(
projectId,
{
name: ctx.fileName,
linkedFileData: ctx.linkedFileData,
},
ctx.fileSystemPath
)
})
it('replaces the file in mongo', function (ctx) {
ctx.ProjectEntityMongoUpdateHandler.promises.replaceFileWithNew.should.have.been.calledWith(
projectId,
ctx.existingFile._id,
ctx.file,
userId
)
})
it('notifies the tpds', function (ctx) {
ctx.TpdsUpdateSender.promises.addFile.should.have.been.calledWith({
projectId,
historyId: ctx.project.overleaf.history.id,
projectName: ctx.project.name,
fileId: ctx.newFile._id,
hash: ctx.newFile.hash,
rev: ctx.newFile.rev,
path: ctx.fileSystemPath,
folderId,
})
})
it('updates the project structure in the doc updater', function (ctx) {
const oldFiles = [
{
file: ctx.existingFile,
path: ctx.fileSystemPath,
},
]
const newFiles = [
{
file: ctx.newFile,
path: ctx.fileSystemPath,
createdBlob: true,
},
]
ctx.DocumentUpdaterHandler.promises.updateProjectStructure.should.have.been.calledWith(
projectId,
projectHistoryId,
userId,
{
oldFiles,
newFiles,
newProject: ctx.newProject,
},
ctx.source
)
})
it('returns the file', function (ctx) {
expect(upsertFileResult.isNew).to.be.false
expect(upsertFileResult.fileRef.toString()).to.eql(
ctx.existingFile.toString()
)
})
})
describe('creating a new file', function () {
let upsertFileResult
beforeEach(async function (ctx) {
ctx.folder = { _id: folderId, fileRefs: [], docs: [] }
ctx.newFile = {
_id: fileId,
hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
}
ctx.ProjectLocator.promises.findElement.resolves({
element: ctx.folder,
})
ctx.FileStoreHandler.promises.uploadFileFromDisk.resolves({
fileRef: ctx.newFile,
createdBlob: true,
})
ctx.ProjectEntityUpdateHandler.promises.addFile = {
mainTask: sinon.stub().resolves(ctx.newFile),
}
upsertFileResult =
await ctx.ProjectEntityUpdateHandler.promises.upsertFile(
projectId,
folderId,
ctx.fileName,
ctx.fileSystemPath,
ctx.linkedFileData,
userId,
ctx.source
)
})
it('tries to find the folder', function (ctx) {
ctx.ProjectLocator.promises.findElement.should.have.been.calledWith({
project_id: projectId,
element_id: folderId,
type: 'folder',
})
})
it('adds the file', function (ctx) {
expect(
ctx.ProjectEntityUpdateHandler.promises.addFile.mainTask
).to.have.been.calledWith({
projectId,
folderId,
userId,
fileRef: ctx.newFile,
source: ctx.source,
createdBlob: true,
})
})
it('returns the file', function (ctx) {
expect(upsertFileResult.fileRef).to.eql(ctx.newFile)
expect(upsertFileResult.isNew).to.be.true
})
})
describe('upserting a new file with an invalid name', function () {
beforeEach(function (ctx) {
ctx.folder = { _id: folderId, fileRefs: [] }
ctx.newFile = { _id: fileId }
ctx.ProjectLocator.promises.findElement.resolves({
element: ctx.folder,
})
ctx.ProjectEntityUpdateHandler.promises.addFile = {
mainTask: sinon.stub().resolves(ctx.newFile),
}
})
it('returns an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.upsertFile(
projectId,
folderId,
`*${ctx.fileName}`,
ctx.fileSystemPath,
ctx.linkedFileData,
userId,
ctx.source
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
describe('upserting file on top of a doc', function () {
beforeEach(async function (ctx) {
ctx.path = '/path/to/doc'
ctx.existingDoc = { _id: new ObjectId(), name: ctx.fileName }
ctx.folder = {
_id: folderId,
fileRefs: [],
docs: [ctx.existingDoc],
}
ctx.ProjectLocator.promises.findElement
.withArgs({
project_id: ctx.project._id.toString(),
element_id: folderId,
type: 'folder',
})
.resolves({ element: ctx.folder })
ctx.ProjectLocator.promises.findElement
.withArgs({
project_id: ctx.project._id.toString(),
element_id: ctx.existingDoc._id,
type: 'doc',
})
.resolves({
element: ctx.existingDoc,
path: { fileSystem: ctx.path },
folder: ctx.folder,
})
ctx.newFile = {
_id: newFileId,
name: 'dummy-upload-filename',
rev: 0,
linkedFileData: ctx.linkedFileData,
}
ctx.newProject = {
name: 'new project',
overleaf: { history: { id: projectHistoryId } },
}
ctx.FileStoreHandler.promises.uploadFileFromDisk.resolves({
fileRef: ctx.newFile,
createdBlob: true,
})
ctx.ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile.resolves(
ctx.newProject
)
await ctx.ProjectEntityUpdateHandler.promises.upsertFile(
projectId,
folderId,
ctx.fileName,
ctx.fileSystemPath,
ctx.linkedFileData,
userId,
ctx.source
)
})
it('replaces the existing doc with a file', function (ctx) {
expect(
ctx.ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile
).to.have.been.calledWith(
projectId,
ctx.existingDoc._id,
ctx.newFile,
userId
)
})
it('updates the doc structure', function (ctx) {
const oldDocs = [
{
doc: ctx.existingDoc,
path: ctx.path,
},
]
const newFiles = [
{
file: ctx.newFile,
path: ctx.path,
createdBlob: true,
},
]
const updates = {
oldDocs,
newFiles,
newProject: ctx.newProject,
}
expect(
ctx.DocumentUpdaterHandler.promises.updateProjectStructure
).to.have.been.calledWith(
projectId,
projectHistoryId,
userId,
updates,
ctx.source
)
})
it('tells everyone in the room the doc is removed', function (ctx) {
expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
projectId,
'removeEntity',
ctx.existingDoc._id,
'convertDocToFile'
)
})
})
})
describe('upsertDocWithPath', function () {
describe('upserting a doc', function () {
let upsertDocWithPathResult
beforeEach(async function (ctx) {
ctx.path = '/folder/doc.tex'
ctx.newFolders = ['mock-a', 'mock-b']
ctx.folder = { _id: folderId }
ctx.doc = { _id: docId }
ctx.isNewDoc = true
ctx.ProjectEntityUpdateHandler.promises.mkdirp = {
withoutLock: sinon
.stub()
.resolves({ newFolders: ctx.newFolders, folder: ctx.folder }),
}
ctx.ProjectEntityUpdateHandler.promises.upsertDoc = {
withoutLock: sinon
.stub()
.resolves({ doc: ctx.doc, isNew: ctx.isNewDoc }),
}
upsertDocWithPathResult =
await ctx.ProjectEntityUpdateHandler.promises.upsertDocWithPath(
projectId,
ctx.path,
ctx.docLines,
ctx.source,
userId
)
})
it('creates any necessary folders', function (ctx) {
ctx.ProjectEntityUpdateHandler.promises.mkdirp.withoutLock
.calledWith(projectId, '/folder', userId)
.should.equal(true)
})
it('upserts the doc', function (ctx) {
ctx.ProjectEntityUpdateHandler.promises.upsertDoc.withoutLock
.calledWith(
projectId,
ctx.folder._id,
'doc.tex',
ctx.docLines,
ctx.source,
userId
)
.should.equal(true)
})
it('returns a doc, the isNewDoc flag, newFolders and a folder', function (ctx) {
expect(upsertDocWithPathResult).to.eql({
doc: ctx.doc,
isNew: ctx.isNewDoc,
newFolders: ctx.newFolders,
folder: ctx.folder,
})
})
})
describe('upserting a doc with an invalid path', function () {
beforeEach(function (ctx) {
ctx.path = '/*folder/doc.tex'
ctx.newFolders = ['mock-a', 'mock-b']
ctx.folder = { _id: folderId }
ctx.doc = { _id: docId }
ctx.isNewDoc = true
ctx.ProjectEntityUpdateHandler.promises.mkdirp = {
withoutLock: sinon
.stub()
.resolves({ newFolders: ctx.newFolders, folder: ctx.folder }),
}
ctx.ProjectEntityUpdateHandler.promises.upsertDoc = {
withoutLock: sinon
.stub()
.resolves({ doc: ctx.doc, isNew: ctx.isNewDoc }),
}
})
it('returns an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.upsertDocWithPath(
projectId,
ctx.path,
ctx.docLines,
ctx.source,
userId
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
describe('upserting a doc with an invalid name', function () {
beforeEach(function (ctx) {
ctx.path = '/folder/*doc.tex'
ctx.newFolders = ['mock-a', 'mock-b']
ctx.folder = { _id: folderId }
ctx.doc = { _id: docId }
ctx.isNewDoc = true
ctx.ProjectEntityUpdateHandler.promises.mkdirp = {
withoutLock: sinon
.stub()
.resolves({ newFolders: ctx.newFolders, folder: ctx.folder }),
}
ctx.ProjectEntityUpdateHandler.promises.upsertDoc = {
withoutLock: sinon
.stub()
.resolves({ doc: ctx.doc, isNew: ctx.isNewDoc }),
}
})
it('returns an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.upsertDocWithPath(
projectId,
ctx.path,
ctx.docLines,
ctx.source,
userId
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
})
describe('upsertFileWithPath', function () {
describe('upserting a file', function () {
let upsertFileWithPathResult
beforeEach(async function (ctx) {
ctx.path = '/folder/file.png'
ctx.newFolders = ['mock-a', 'mock-b']
ctx.folder = { _id: folderId }
ctx.file = { _id: fileId }
ctx.isNewFile = true
ctx.FileStoreHandler.promises.uploadFileFromDisk.resolves({
fileRef: ctx.newFile,
createdBlob: true,
})
ctx.ProjectEntityUpdateHandler.promises.mkdirp = {
withoutLock: sinon
.stub()
.resolves({ newFolders: ctx.newFolders, folder: ctx.folder }),
}
ctx.ProjectEntityUpdateHandler.promises.upsertFile = {
mainTask: sinon
.stub()
.resolves({ fileRef: ctx.file, isNew: ctx.isNewFile }),
}
upsertFileWithPathResult =
await ctx.ProjectEntityUpdateHandler.promises.upsertFileWithPath(
projectId,
ctx.path,
ctx.fileSystemPath,
ctx.linkedFileData,
userId,
ctx.source
)
})
it('creates any necessary folders', function (ctx) {
ctx.ProjectEntityUpdateHandler.promises.mkdirp.withoutLock
.calledWith(projectId, '/folder', userId)
.should.equal(true)
})
it('upserts the file', function (ctx) {
ctx.ProjectEntityUpdateHandler.promises.upsertFile.mainTask.should.have.been.calledWith(
{
projectId,
folderId: ctx.folder._id,
fileName: 'file.png',
fsPath: ctx.fileSystemPath,
linkedFileData: ctx.linkedFileData,
userId,
fileRef: ctx.newFile,
source: ctx.source,
createdBlob: true,
}
)
})
it('returns an object with the fileRef, isNew flag, undefined oldFileRef, newFolders, and folder', function (ctx) {
expect(upsertFileWithPathResult).to.eql({
fileRef: ctx.file,
isNew: ctx.isNewFile,
newFolders: ctx.newFolders,
folder: ctx.folder,
oldFileRef: undefined,
})
})
})
describe('upserting a file with an invalid path', function () {
beforeEach(function (ctx) {
ctx.path = '/*folder/file.png'
ctx.newFolders = ['mock-a', 'mock-b']
ctx.folder = { _id: folderId }
ctx.file = { _id: fileId }
ctx.isNewFile = true
ctx.ProjectEntityUpdateHandler.promises.mkdirp = {
withoutLock: sinon
.stub()
.resolves({ newFolders: ctx.newFolders, folder: ctx.folder }),
}
ctx.ProjectEntityUpdateHandler.promises.upsertFile = {
mainTask: sinon
.stub()
.resolves({ doc: ctx.file, isNew: ctx.isNewFile }),
}
})
it('returns an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.upsertFileWithPath(
projectId,
ctx.path,
ctx.fileSystemPath,
ctx.linkedFileData,
userId,
ctx.source
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
describe('upserting a file with an invalid name', function () {
beforeEach(function (ctx) {
ctx.path = '/folder/*file.png'
ctx.newFolders = ['mock-a', 'mock-b']
ctx.folder = { _id: folderId }
ctx.file = { _id: fileId }
ctx.isNewFile = true
ctx.ProjectEntityUpdateHandler.promises.mkdirp = {
withoutLock: sinon
.stub()
.resolves({ newFolders: ctx.newFolders, folder: ctx.folder }),
}
ctx.ProjectEntityUpdateHandler.promises.upsertFile = {
mainTask: sinon
.stub()
.resolves({ doc: ctx.file, isNew: ctx.isNewFile }),
}
})
it('returns an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.upsertFileWithPath(
projectId,
ctx.path,
ctx.fileSystemPath,
ctx.linkedFileData,
userId,
ctx.source
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
})
describe('deleteEntity', function () {
let deleteEntityResult
beforeEach(async function (ctx) {
ctx.path = '/path/to/doc.tex'
ctx.doc = { _id: docId }
ctx.projectBeforeDeletion = { _id: projectId, name: 'project' }
ctx.newProject = 'new-project'
ctx.ProjectEntityMongoUpdateHandler.promises.deleteEntity.resolves({
entity: ctx.doc,
path: { fileSystem: ctx.path },
projectBeforeDeletion: ctx.projectBeforeDeletion,
newProject: ctx.newProject,
})
ctx.ProjectEntityUpdateHandler._cleanUpEntity = sinon
.stub()
.resolves([{ type: 'doc', entity: ctx.doc, path: ctx.path }])
deleteEntityResult =
await ctx.ProjectEntityUpdateHandler.promises.deleteEntity(
projectId,
docId,
'doc',
userId,
ctx.source
)
})
it('flushes the project to mongo', function (ctx) {
ctx.DocumentUpdaterHandler.promises.flushProjectToMongo.should.have.been.calledWith(
projectId
)
})
it('deletes the entity in mongo', function (ctx) {
ctx.ProjectEntityMongoUpdateHandler.promises.deleteEntity
.calledWith(projectId, docId, 'doc', userId)
.should.equal(true)
})
it('cleans up the doc in the docstore', function (ctx) {
ctx.ProjectEntityUpdateHandler._cleanUpEntity
.calledWith(
ctx.projectBeforeDeletion,
ctx.newProject,
ctx.doc,
'doc',
ctx.path,
userId,
ctx.source
)
.should.equal(true)
})
it('it notifies the tpds', function (ctx) {
ctx.TpdsUpdateSender.promises.deleteEntity.should.have.been.calledWith({
projectId,
path: ctx.path,
projectName: ctx.projectBeforeDeletion.name,
entityId: docId,
entityType: 'doc',
subtreeEntityIds: [ctx.doc._id],
})
})
it('retuns the entity_id', function () {
expect(deleteEntityResult).to.equal(docId)
})
})
describe('deleteEntityWithPath', function () {
describe('when the entity exists', function () {
beforeEach(async function (ctx) {
ctx.doc = { _id: docId }
ctx.ProjectLocator.promises.findElementByPath.resolves({
element: ctx.doc,
type: 'doc',
})
ctx.ProjectEntityUpdateHandler.promises.deleteEntity = {
withoutLock: sinon.stub().resolves(),
}
ctx.path = '/path/to/doc.tex'
await ctx.ProjectEntityUpdateHandler.promises.deleteEntityWithPath(
projectId,
ctx.path,
userId,
ctx.source
)
})
it('finds the entity', function (ctx) {
ctx.ProjectLocator.promises.findElementByPath
.calledWith({
project_id: projectId,
path: ctx.path,
exactCaseMatch: true,
})
.should.equal(true)
})
it('deletes the entity', function (ctx) {
ctx.ProjectEntityUpdateHandler.promises.deleteEntity.withoutLock.should.have.been.calledWith(
projectId,
ctx.doc._id,
'doc',
userId,
ctx.source
)
})
})
describe('when the entity does not exist', function () {
beforeEach(function (ctx) {
ctx.ProjectLocator.promises.findElementByPath.resolves({
element: null,
})
ctx.path = '/doc.tex'
})
it('returns an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.deleteEntityWithPath(
projectId,
ctx.path,
userId,
ctx.source
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Errors.NotFoundError)
})
})
})
describe('mkdirp', function () {
beforeEach(async function (ctx) {
ctx.docPath = '/folder/doc.tex'
ctx.ProjectEntityMongoUpdateHandler.promises.mkdirp.resolves({})
await ctx.ProjectEntityUpdateHandler.promises.mkdirp(
projectId,
ctx.docPath,
userId
)
})
it('calls ProjectEntityMongoUpdateHandler', function (ctx) {
ctx.ProjectEntityMongoUpdateHandler.promises.mkdirp
.calledWith(projectId, ctx.docPath, userId)
.should.equal(true)
})
})
describe('mkdirpWithExactCase', function () {
beforeEach(async function (ctx) {
ctx.docPath = '/folder/doc.tex'
ctx.ProjectEntityMongoUpdateHandler.promises.mkdirp.resolves({})
await ctx.ProjectEntityUpdateHandler.promises.mkdirpWithExactCase(
projectId,
ctx.docPath,
userId
)
})
it('calls ProjectEntityMongoUpdateHandler', function (ctx) {
ctx.ProjectEntityMongoUpdateHandler.promises.mkdirp
.calledWith(projectId, ctx.docPath, userId, { exactCaseMatch: true })
.should.equal(true)
})
})
describe('addFolder', function () {
describe('adding a folder', function () {
beforeEach(async function (ctx) {
ctx.parentFolderId = '123asdf'
ctx.folderName = 'new-folder'
ctx.ProjectEntityMongoUpdateHandler.promises.addFolder.resolves({})
await ctx.ProjectEntityUpdateHandler.promises.addFolder(
projectId,
ctx.parentFolderId,
ctx.folderName,
userId
)
})
it('calls ProjectEntityMongoUpdateHandler', function (ctx) {
ctx.ProjectEntityMongoUpdateHandler.promises.addFolder
.calledWith(projectId, ctx.parentFolderId, ctx.folderName, userId)
.should.equal(true)
})
})
describe('adding a folder with an invalid name', function () {
beforeEach(function (ctx) {
ctx.parentFolderId = '123asdf'
ctx.folderName = '*new-folder'
ctx.ProjectEntityMongoUpdateHandler.promises.addFolder.resolves({})
})
it('returns an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.addFolder(
projectId,
ctx.parentFolderId,
ctx.folderName
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
})
describe('moveEntity', function () {
beforeEach(async function (ctx) {
ctx.project_name = 'project name'
ctx.startPath = '/a.tex'
ctx.endPath = '/folder/b.tex'
ctx.rev = 2
ctx.changes = { newDocs: ['old-doc'], newFiles: ['old-file'] }
ctx.ProjectEntityMongoUpdateHandler.promises.moveEntity.resolves({
project: ctx.project,
startPath: ctx.startPath,
endPath: ctx.endPath,
rev: ctx.rev,
changes: ctx.changes,
})
await ctx.ProjectEntityUpdateHandler.promises.moveEntity(
projectId,
docId,
folderId,
'doc',
userId,
ctx.source
)
})
it('moves the entity in mongo', function (ctx) {
ctx.ProjectEntityMongoUpdateHandler.promises.moveEntity
.calledWith(projectId, docId, folderId, 'doc', userId)
.should.equal(true)
})
it('notifies tpds', function (ctx) {
ctx.TpdsUpdateSender.promises.moveEntity
.calledWith({
projectId,
projectName: ctx.project_name,
startPath: ctx.startPath,
endPath: ctx.endPath,
rev: ctx.rev,
entityId: docId,
entityType: 'doc',
folderId,
})
.should.equal(true)
})
it('sends the changes in project structure to the doc updater', function (ctx) {
ctx.DocumentUpdaterHandler.promises.updateProjectStructure
.calledWith(
projectId,
projectHistoryId,
userId,
ctx.changes,
ctx.source
)
.should.equal(true)
})
})
describe('renameEntity', function () {
describe('renaming an entity', function () {
beforeEach(async function (ctx) {
ctx.project_name = 'project name'
ctx.startPath = '/folder/a.tex'
ctx.endPath = '/folder/b.tex'
ctx.rev = 2
ctx.changes = { newDocs: ['old-doc'], newFiles: ['old-file'] }
ctx.newDocName = 'b.tex'
ctx.ProjectEntityMongoUpdateHandler.promises.renameEntity.resolves({
project: ctx.project,
startPath: ctx.startPath,
endPath: ctx.endPath,
rev: ctx.rev,
changes: ctx.changes,
})
await ctx.ProjectEntityUpdateHandler.promises.renameEntity(
projectId,
docId,
'doc',
ctx.newDocName,
userId,
ctx.source
)
})
it('moves the entity in mongo', function (ctx) {
ctx.ProjectEntityMongoUpdateHandler.promises.renameEntity
.calledWith(projectId, docId, 'doc', ctx.newDocName, userId)
.should.equal(true)
})
it('notifies tpds', function (ctx) {
ctx.TpdsUpdateSender.promises.moveEntity
.calledWith({
projectId,
projectName: ctx.project_name,
startPath: ctx.startPath,
endPath: ctx.endPath,
rev: ctx.rev,
entityId: docId,
entityType: 'doc',
folderId: null,
})
.should.equal(true)
})
it('flushes the project in doc updater', function (ctx) {
ctx.DocumentUpdaterHandler.promises.flushProjectToMongo.should.have.been.calledWith(
projectId
)
})
it('sends the changes in project structure to the doc updater', function (ctx) {
ctx.DocumentUpdaterHandler.promises.updateProjectStructure
.calledWith(
projectId,
projectHistoryId,
userId,
ctx.changes,
ctx.source
)
.should.equal(true)
})
})
describe('renaming an entity to an invalid name', function () {
beforeEach(function (ctx) {
ctx.project_name = 'project name'
ctx.startPath = '/folder/a.tex'
ctx.endPath = '/folder/b.tex'
ctx.rev = 2
ctx.changes = { newDocs: ['old-doc'], newFiles: ['old-file'] }
ctx.newDocName = '*b.tex'
ctx.ProjectEntityMongoUpdateHandler.promises.renameEntity.resolves({
project: ctx.project,
startPath: ctx.startPath,
endPath: ctx.endPath,
rev: ctx.rev,
changes: ctx.changes,
})
})
it('returns an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.renameEntity(
projectId,
docId,
'doc',
ctx.newDocName,
userId,
ctx.source
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Errors.InvalidNameError)
})
})
describe('renaming an entity with a non-string value', function () {
beforeEach(function (ctx) {
ctx.project_name = 'project name'
ctx.startPath = '/folder/a.tex'
ctx.endPath = '/folder/b.tex'
ctx.rev = 2
ctx.changes = { newDocs: ['old-doc'], newFiles: ['old-file'] }
ctx.newDocName = ['hello']
ctx.ProjectEntityMongoUpdateHandler.promises.renameEntity.resolves({
project: ctx.project,
startPath: ctx.startPath,
endPath: ctx.endPath,
rev: ctx.rev,
changes: ctx.changes,
})
})
it('returns an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.renameEntity(
projectId,
docId,
'doc',
ctx.newDocName,
userId,
ctx.source
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Error)
expect(
ctx.ProjectEntityMongoUpdateHandler.promises.renameEntity.called
).to.equal(false)
})
})
})
describe('resyncProjectHistory', function () {
describe('a deleted project', function () {
beforeEach(function (ctx) {
ctx.ProjectGetter.promises.getProject.resolves({})
})
it('should return an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory(
projectId,
{}
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Errors.ProjectHistoryDisabledError)
expect(error).to.have.property(
'message',
`project history not enabled for ${projectId}`
)
})
})
describe('a project without project-history enabled', function () {
beforeEach(function (ctx) {
ctx.project.overleaf = {}
ctx.ProjectGetter.promises.getProject.resolves(ctx.project)
})
it('should return an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory(
projectId,
{}
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Errors.ProjectHistoryDisabledError)
expect(error).to.have.property(
'message',
`project history not enabled for ${projectId}`
)
})
})
describe('a project with project-history enabled', function () {
const docs = [{ doc: { _id: docId, name: 'main.tex' }, path: 'main.tex' }]
const files = [
{
file: { _id: fileId, name: 'universe.png', hash: '123456' },
path: 'universe.png',
},
]
beforeEach(async function (ctx) {
ctx.ProjectGetter.promises.getProject.resolves(ctx.project)
const folders = []
ctx.ProjectEntityHandler.getAllEntitiesFromProject.returns({
docs,
files,
folders,
})
await ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory(
projectId,
{}
)
})
it('gets the project', function (ctx) {
ctx.ProjectGetter.promises.getProject.should.have.been.calledWith(
projectId
)
})
it('gets the entities for the project', function (ctx) {
ctx.ProjectEntityHandler.getAllEntitiesFromProject.should.have.been.calledWith(
ctx.project
)
})
it('uses an extended timeout', function (ctx) {
ctx.LockManager.withTimeout.should.have.been.calledWith(6 * 60)
})
it('tells the doc updater to sync the project', function (ctx) {
ctx.DocumentUpdaterHandler.promises.resyncProjectHistory
.calledWith(projectId, projectHistoryId, docs, files)
.should.equal(true)
})
})
describe('a project with duplicate filenames', function () {
beforeEach(async function (ctx) {
ctx.ProjectGetter.promises.getProject.resolves(ctx.project)
ctx.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',
},
]
ctx.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)',
},
]
ctx.ProjectEntityHandler.getAllEntitiesFromProject.returns({
docs: ctx.docs,
files: ctx.files,
folders: [],
})
await ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory(
projectId,
{}
)
})
it('renames the duplicate files', function (ctx) {
const renameEntity =
ctx.ProjectEntityMongoUpdateHandler.promises.renameEntity
expect(renameEntity).to.have.callCount(4)
expect(renameEntity).to.have.been.calledWith(
projectId,
'doc3',
'doc',
'duplicate.tex (1)',
null
)
expect(renameEntity).to.have.been.calledWith(
projectId,
'doc5',
'doc',
'duplicate.tex (2)',
null
)
expect(renameEntity).to.have.been.calledWith(
projectId,
'file3',
'file',
'duplicate.jpg (1)',
null
)
expect(renameEntity).to.have.been.calledWith(
projectId,
'file4',
'file',
'another dupe (23)',
null
)
})
it('tells the doc updater to resync the project', function (ctx) {
const docs = ctx.docs.map(d => {
if (d.doc._id === 'doc3') {
return Object.assign({}, d, { path: 'a/b/c/duplicate.tex (1)' })
}
if (d.doc._id === 'doc5') {
return Object.assign({}, d, { path: 'a/b/c/duplicate.tex (2)' })
}
return d
})
const files = ctx.files.map(f => {
if (f.file._id === 'file3') {
return Object.assign({}, f, { path: 'duplicate.jpg (1)' })
}
if (f.file._id === 'file4') {
return Object.assign({}, f, { path: 'another dupe (23)' })
}
return f
})
expect(
ctx.DocumentUpdaterHandler.promises.resyncProjectHistory
).to.have.been.calledWith(projectId, projectHistoryId, docs, files)
})
})
describe('a project with bad filenames', function () {
beforeEach(async function (ctx) {
ctx.ProjectGetter.promises.getProject.resolves(ctx.project)
ctx.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',
},
]
ctx.files = [
{
file: { _id: 'file1', name: 'A*.png', hash: 'hash1' },
path: 'A*.png',
},
{
file: { _id: 'file2', name: 'A_.png', hash: 'hash2' },
path: 'A_.png',
},
]
ctx.ProjectEntityHandler.getAllEntitiesFromProject.returns({
docs: ctx.docs,
files: ctx.files,
folders: [],
})
await ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory(
projectId,
{}
)
})
it('renames the files', function (ctx) {
const renameEntity =
ctx.ProjectEntityMongoUpdateHandler.promises.renameEntity
expect(renameEntity).to.have.callCount(4)
expect(renameEntity).to.have.been.calledWith(
projectId,
'doc1',
'doc',
'_d_e_f_test.tex',
null
)
expect(renameEntity).to.have.been.calledWith(
projectId,
'doc2',
'doc',
'untitled',
null
)
expect(renameEntity).to.have.been.calledWith(
projectId,
'file1',
'file',
'A_.png',
null
)
expect(renameEntity).to.have.been.calledWith(
projectId,
'file2',
'file',
'A_.png (1)',
null
)
})
it('tells the doc updater to resync the project', function (ctx) {
const docs = ctx.docs.map(d => {
if (d.doc._id === 'doc1') {
return Object.assign({}, d, { path: 'a/b/c/_d_e_f_test.tex' })
}
if (d.doc._id === 'doc2') {
return Object.assign({}, d, { path: 'a/untitled' })
}
return d
})
const files = ctx.files.map(f => {
if (f.file._id === 'file1') {
return Object.assign({}, f, { path: 'A_.png' })
}
if (f.file._id === 'file2') {
return Object.assign({}, f, { path: 'A_.png (1)' })
}
return f
})
expect(
ctx.DocumentUpdaterHandler.promises.resyncProjectHistory
).to.have.been.calledWith(projectId, projectHistoryId, docs, files)
})
})
describe('a project with a bad folder name', function () {
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 = []
beforeEach(async function (ctx) {
ctx.ProjectGetter.promises.getProject.resolves(ctx.project)
ctx.ProjectEntityHandler.getAllEntitiesFromProject.returns({
docs,
files,
folders,
})
await ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory(
projectId,
{}
)
})
it('renames the folder', function (ctx) {
const renameEntity =
ctx.ProjectEntityMongoUpdateHandler.promises.renameEntity
expect(renameEntity).to.have.callCount(1)
expect(renameEntity).to.have.been.calledWith(
projectId,
'folder2',
'folder',
'bad_',
null
)
})
it('tells the doc updater to resync the project', function (ctx) {
const fixedDocs = docs.map(d => {
if (d.doc._id === 'doc2') {
return Object.assign({}, d, { path: 'bad_/doc2.tex' })
}
return d
})
expect(
ctx.DocumentUpdaterHandler.promises.resyncProjectHistory
).to.have.been.calledWith(projectId, projectHistoryId, fixedDocs, files)
})
})
describe('a project with duplicate names between a folder and a doc', function () {
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 = []
beforeEach(async function (ctx) {
ctx.ProjectGetter.promises.getProject.resolves(ctx.project)
ctx.ProjectEntityHandler.getAllEntitiesFromProject.returns({
docs,
files,
folders,
})
await ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory(
projectId,
{}
)
})
it('renames the doc', function (ctx) {
const renameEntity =
ctx.ProjectEntityMongoUpdateHandler.promises.renameEntity
expect(renameEntity).to.have.callCount(1)
expect(renameEntity).to.have.been.calledWith(
projectId,
'doc1',
'doc',
'chapters (1)',
null
)
})
it('tells the doc updater to resync the project', function (ctx) {
const fixedDocs = docs.map(d => {
if (d.doc._id === 'doc1') {
return Object.assign({}, d, { path: 'chapters (1)' })
}
return d
})
expect(
ctx.DocumentUpdaterHandler.promises.resyncProjectHistory
).to.have.been.calledWith(projectId, projectHistoryId, fixedDocs, files)
})
})
describe('a project with an invalid file tree', function () {
beforeEach(function (ctx) {
ctx.callback = sinon.stub()
ctx.ProjectGetter.promises.getProject.resolves(ctx.project)
ctx.ProjectEntityHandler.getAllEntitiesFromProject.throws()
})
it('calls the callback with an error', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory(
projectId,
{}
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Error)
})
})
})
describe('_cleanUpEntity', function () {
beforeEach(function (ctx) {
ctx.entityId = '4eecaffcbffa66588e000009'
ctx.ProjectEntityUpdateHandler.promises.unsetRootDoc = sinon
.stub()
.resolves()
})
describe('a file', function () {
beforeEach(async function (ctx) {
ctx.path = '/file/system/path.png'
ctx.entity = { _id: ctx.entityId }
ctx.newProject = 'new-project'
ctx.subtreeListing =
await ctx.ProjectEntityUpdateHandler._cleanUpEntity(
ctx.project,
ctx.newProject,
ctx.entity,
'file',
ctx.path,
userId,
ctx.source
)
})
it('should not attempt to delete from the document updater', function (ctx) {
ctx.DocumentUpdaterHandler.promises.deleteDoc.called.should.equal(false)
})
it('should send the update to the doc updater', function (ctx) {
const oldFiles = [{ file: ctx.entity, path: ctx.path }]
ctx.DocumentUpdaterHandler.promises.updateProjectStructure.should.have.been.calledWith(
projectId,
projectHistoryId,
userId,
{
oldFiles,
oldDocs: [],
newProject: ctx.newProject,
},
ctx.source
)
})
it('should return a subtree listing containing only the file', function (ctx) {
expect(ctx.subtreeListing).to.deep.equal([
{ type: 'file', entity: ctx.entity, path: ctx.path },
])
})
})
describe('a doc', function () {
beforeEach(async function (ctx) {
ctx.path = '/file/system/path.tex'
ctx.ProjectEntityUpdateHandler._cleanUpDoc = sinon.stub().resolves()
ctx.entity = { _id: ctx.entityId }
ctx.newProject = 'new-project'
ctx.subtreeListing =
await ctx.ProjectEntityUpdateHandler._cleanUpEntity(
ctx.project,
ctx.newProject,
ctx.entity,
'doc',
ctx.path,
userId,
ctx.source
)
})
it('should clean up the doc', function (ctx) {
ctx.ProjectEntityUpdateHandler._cleanUpDoc
.calledWith(ctx.project, ctx.entity, ctx.path, userId)
.should.equal(true)
})
it('should send the update to the doc updater', function (ctx) {
const oldDocs = [{ doc: ctx.entity, path: ctx.path }]
ctx.DocumentUpdaterHandler.promises.updateProjectStructure.should.have.been.calledWith(
projectId,
projectHistoryId,
userId,
{
oldDocs,
oldFiles: [],
newProject: ctx.newProject,
},
ctx.source
)
})
it('should return a subtree listing containing only the doc', function (ctx) {
expect(ctx.subtreeListing).to.deep.equal([
{ type: 'doc', entity: ctx.entity, path: ctx.path },
])
})
})
describe('a folder', function () {
beforeEach(async function (ctx) {
ctx.folder = {
folders: [
{
name: 'subfolder',
fileRefs: [
(ctx.file1 = { _id: 'file-id-1', name: 'file-name-1' }),
],
docs: [(ctx.doc1 = { _id: 'doc-id-1', name: 'doc-name-1' })],
folders: [],
},
],
fileRefs: [(ctx.file2 = { _id: 'file-id-2', name: 'file-name-2' })],
docs: [(ctx.doc2 = { _id: 'doc-id-2', name: 'doc-name-2' })],
}
ctx.ProjectEntityUpdateHandler._cleanUpDoc = sinon.stub().resolves()
const path = '/folder'
ctx.newProject = 'new-project'
ctx.subtreeListing =
await ctx.ProjectEntityUpdateHandler._cleanUpEntity(
ctx.project,
ctx.newProject,
ctx.folder,
'folder',
path,
userId,
ctx.source
)
})
it('should clean up all sub docs', function (ctx) {
ctx.ProjectEntityUpdateHandler._cleanUpDoc
.calledWith(
ctx.project,
ctx.doc1,
'/folder/subfolder/doc-name-1',
userId
)
.should.equal(true)
ctx.ProjectEntityUpdateHandler._cleanUpDoc
.calledWith(ctx.project, ctx.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 (ctx) {
const oldFiles = [
{ file: ctx.file2, path: '/folder/file-name-2' },
{ file: ctx.file1, path: '/folder/subfolder/file-name-1' },
]
const oldDocs = [
{ doc: ctx.doc2, path: '/folder/doc-name-2' },
{ doc: ctx.doc1, path: '/folder/subfolder/doc-name-1' },
]
ctx.DocumentUpdaterHandler.promises.updateProjectStructure
.calledWith(
projectId,
projectHistoryId,
userId,
{
oldFiles,
oldDocs,
newProject: ctx.newProject,
},
ctx.source
)
.should.equal(true)
})
it('should return a subtree listing containing all sub-entities', function (ctx) {
expect(ctx.subtreeListing).to.have.deep.members([
{ type: 'folder', entity: ctx.folder, path: '/folder' },
{
type: 'folder',
entity: ctx.folder.folders[0],
path: '/folder/subfolder',
},
{
type: 'file',
entity: ctx.file1,
path: '/folder/subfolder/file-name-1',
},
{
type: 'doc',
entity: ctx.doc1,
path: '/folder/subfolder/doc-name-1',
},
{ type: 'file', entity: ctx.file2, path: '/folder/file-name-2' },
{ type: 'doc', entity: ctx.doc2, path: '/folder/doc-name-2' },
])
})
})
})
describe('_cleanUpDoc', function () {
beforeEach(function (ctx) {
ctx.doc = {
_id: new ObjectId(),
name: 'test.tex',
}
ctx.path = '/path/to/doc'
ctx.ProjectEntityUpdateHandler.promises.unsetRootDoc = sinon
.stub()
.resolves()
ctx.DocstoreManager.promises.deleteDoc.resolves()
})
describe('when the doc is the root doc', function () {
beforeEach(async function (ctx) {
ctx.project.rootDoc_id = ctx.doc._id
await ctx.ProjectEntityUpdateHandler._cleanUpDoc(
ctx.project,
ctx.doc,
ctx.path,
userId
)
})
it('should unset the root doc', function (ctx) {
ctx.ProjectEntityUpdateHandler.promises.unsetRootDoc.should.have.been.calledWith(
projectId
)
})
it('should delete the doc in the doc updater', function (ctx) {
ctx.DocumentUpdaterHandler.promises.deleteDoc
.calledWith(projectId, ctx.doc._id.toString())
.should.equal(true)
})
it('should delete the doc in the doc store', function (ctx) {
ctx.DocstoreManager.promises.deleteDoc
.calledWith(projectId, ctx.doc._id.toString(), 'test.tex')
.should.equal(true)
})
})
describe('when the doc is not the root doc', function () {
beforeEach(async function (ctx) {
ctx.project.rootDoc_id = new ObjectId()
await ctx.ProjectEntityUpdateHandler._cleanUpDoc(
ctx.project,
ctx.doc,
ctx.path,
userId
)
})
it('should not unset the root doc', function (ctx) {
ctx.ProjectEntityUpdateHandler.promises.unsetRootDoc.called.should.equal(
false
)
})
})
})
describe('convertDocToFile', function () {
beforeEach(function (ctx) {
ctx.docPath = '/folder/doc.tex'
ctx.docLines = ['line one', 'line two']
ctx.tmpFilePath = '/tmp/file'
ctx.fileStoreUrl = 'http://filestore/file'
ctx.folder = { _id: new ObjectId() }
ctx.rev = 3
ctx.ProjectLocator.promises.findElement
.withArgs({
project_id: ctx.project._id,
element_id: ctx.doc._id,
type: 'doc',
})
.resolves({
element: ctx.doc,
path: { fileSystem: ctx.docPath },
folder: ctx.folder,
})
ctx.ProjectLocator.promises.findElement
.withArgs({
project_id: ctx.project._id.toString(),
element_id: ctx.file._id,
type: 'file',
})
.resolves({
element: ctx.file,
path: { fileSystem: ctx.docPath },
folder: ctx.folder,
})
ctx.DocumentUpdaterHandler.promises.getDocument
.withArgs(ctx.project._id, ctx.doc._id, -1)
.resolves({ lines: ctx.docLines })
ctx.FileWriter.promises.writeLinesToDisk.resolves(ctx.tmpFilePath)
ctx.FileStoreHandler.promises.uploadFileFromDisk.resolves({
fileRef: ctx.file,
createdBlob: true,
})
ctx.ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile.resolves(
ctx.project
)
})
describe('successfully', function () {
beforeEach(async function (ctx) {
await ctx.ProjectEntityUpdateHandler.promises.convertDocToFile(
ctx.project._id,
ctx.doc._id,
userId,
ctx.source
)
})
it('deletes the document in doc updater', function (ctx) {
expect(
ctx.DocumentUpdaterHandler.promises.deleteDoc
).to.have.been.calledWith(ctx.project._id, ctx.doc._id)
})
it('uploads the file to filestore', function (ctx) {
expect(
ctx.FileStoreHandler.promises.uploadFileFromDisk
).to.have.been.calledWith(
ctx.project._id,
{ name: ctx.doc.name },
ctx.tmpFilePath
)
})
it('cleans up the temporary file', function (ctx) {
expect(ctx.fs.promises.unlink).to.have.been.calledWith(ctx.tmpFilePath)
})
it('replaces the doc with the file', function (ctx) {
expect(
ctx.ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile
).to.have.been.calledWith(
ctx.project._id,
ctx.doc._id,
ctx.file,
userId
)
})
it('notifies document updater of changes', function (ctx) {
expect(
ctx.DocumentUpdaterHandler.promises.updateProjectStructure
).to.have.been.calledWith(
ctx.project._id,
ctx.project.overleaf.history.id,
userId,
{
oldDocs: [{ doc: ctx.doc, path: ctx.docPath }],
newFiles: [
{
file: ctx.file,
path: ctx.docPath,
createdBlob: true,
},
],
newProject: ctx.project,
},
ctx.source
)
})
it('should notify real-time of the doc deletion', function (ctx) {
expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
ctx.project._id,
'removeEntity',
ctx.doc._id,
'convertDocToFile'
)
})
it('should notify real-time of the file creation', function (ctx) {
expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
ctx.project._id,
'reciveNewFile',
ctx.folder._id,
ctx.file,
'convertDocToFile',
null
)
})
})
describe('when the doc has ranges', function () {
it('should throw a DocHasRangesError', async function (ctx) {
ctx.ranges = { comments: [{ id: 123 }] }
ctx.DocumentUpdaterHandler.promises.getDocument
.withArgs(ctx.project._id, ctx.doc._id, -1)
.resolves({
lines: ctx.docLines,
ranges: ctx.ranges,
})
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.convertDocToFile(
ctx.project._id,
ctx.doc._id,
ctx.user._id,
ctx.source
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Errors.DocHasRangesError)
})
})
})
describe('isPathValidForMainBibliographyDoc', function () {
it('should not allow other endings than .bib', function (ctx) {
const endings = ['.tex', '.png', '.jpg', '.pdf', '.docx', '.doc']
endings.forEach(ending => {
expect(
ctx.ProjectEntityUpdateHandler.isPathValidForMainBibliographyDoc(
`/foo/bar/baz${ending}`
)
).to.be.false
})
})
it('should allow a mix of lower and uppercase letters', function (ctx) {
const endings = ['.bib', '.BiB', '.BIB', '.bIB']
endings.forEach(ending => {
expect(
ctx.ProjectEntityUpdateHandler.isPathValidForMainBibliographyDoc(
`/foo/bar/baz.${ending}`
)
).to.be.true
})
})
it('should not allow a path without an extension', function (ctx) {
expect(
ctx.ProjectEntityUpdateHandler.isPathValidForMainBibliographyDoc(
'/foo/bar/baz'
)
).to.be.false
})
it('should not allow the empty path', function (ctx) {
expect(
ctx.ProjectEntityUpdateHandler.isPathValidForMainBibliographyDoc('')
).to.be.false
})
})
describe('setMainBibliographyDoc', function () {
describe('on success', function () {
beforeEach(async function (ctx) {
ctx.doc = {
_id: new ObjectId(),
name: 'test.bib',
}
ctx.path = '/path/to/test.bib'
ctx.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId
.withArgs(ctx.project._id, ctx.doc._id)
.resolves(ctx.path)
await ctx.ProjectEntityUpdateHandler.promises.setMainBibliographyDoc(
ctx.project._id,
ctx.doc._id
)
})
it('should update the project with the new main bibliography doc', function (ctx) {
expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
{ _id: ctx.project._id },
{ mainBibliographyDoc_id: ctx.doc._id }
)
})
})
describe('on failure', function () {
describe("when document can't be found", function () {
let setMainBibliographyDocPromise
beforeEach(function (ctx) {
ctx.doc = {
_id: new ObjectId(),
name: 'test.bib',
}
ctx.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId
.withArgs(ctx.project._id, ctx.doc._id)
.rejects(new Error('error'))
setMainBibliographyDocPromise =
ctx.ProjectEntityUpdateHandler.promises.setMainBibliographyDoc(
ctx.project._id,
ctx.doc._id
)
})
it('should call the callback with an error', async function () {
let error
try {
await setMainBibliographyDocPromise
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Error)
})
it('should not update the project with the new main bibliography doc', async function (ctx) {
let error
try {
await setMainBibliographyDocPromise
} catch (err) {
error = err
}
expect(error).to.exist
expect(ctx.ProjectModel.updateOne).to.not.have.been.called
})
})
describe("when path is not a bib file can't be found", function () {
let setMainBibliographyDocPromise
beforeEach(function (ctx) {
ctx.doc = {
_id: new ObjectId(),
name: 'test.bib',
}
ctx.path = '/path/to/test.tex'
ctx.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId
.withArgs(ctx.project._id, ctx.doc._id)
.resolves(ctx.path)
setMainBibliographyDocPromise =
ctx.ProjectEntityUpdateHandler.promises.setMainBibliographyDoc(
ctx.project._id,
ctx.doc._id
)
})
it('should reject with an error', async function () {
let error
try {
await setMainBibliographyDocPromise
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Error)
})
it('should not update the project with the new main bibliography doc', async function (ctx) {
let error
try {
await setMainBibliographyDocPromise
} catch (err) {
error = err
}
expect(error).to.exist
expect(ctx.ProjectModel.updateOne).to.not.have.been.called
})
})
})
})
describe('appendToDoc', function () {
describe('when document cannot be found', function () {
let appendToDocPromise
beforeEach(function (ctx) {
ctx.appendedLines = ['5678', 'def']
ctx.DocumentUpdaterHandler.promises.appendToDocument = sinon.stub()
ctx.ProjectLocator.promises.findElement = sinon.stub()
ctx.ProjectLocator.promises.findElement
.withArgs({ project_id: projectId, element_id: docId, type: 'doc' })
.rejects(new Errors.NotFoundError())
appendToDocPromise =
ctx.ProjectEntityUpdateHandler.promises.appendToDocWithPath(
projectId,
docId,
ctx.appendedLines,
ctx.source,
userId
)
})
it('should not talk to DocumentUpdaterHandler', async function (ctx) {
let error
try {
await appendToDocPromise
} catch (err) {
error = err
}
expect(error).to.exist
ctx.DocumentUpdaterHandler.promises.appendToDocument.should.not.have
.been.called
})
it('should throw the error', async function () {
let error
try {
await appendToDocPromise
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Errors.NotFoundError)
})
})
describe('when document is found', function () {
let appendToDocResult
beforeEach(async function (ctx) {
ctx.appendedLines = ['5678', 'def']
ctx.DocumentUpdaterHandler.promises.appendToDocument = sinon.stub()
ctx.DocumentUpdaterHandler.promises.appendToDocument
.withArgs(projectId, docId, userId, ctx.appendedLines, ctx.source)
.resolves({ rev: 1 })
ctx.ProjectLocator.promises.findElement = sinon.stub()
ctx.ProjectLocator.promises.findElement
.withArgs({ project_id: projectId, element_id: docId, type: 'doc' })
.resolves({ element: { _id: docId } })
appendToDocResult =
await ctx.ProjectEntityUpdateHandler.promises.appendToDocWithPath(
projectId,
docId,
ctx.appendedLines,
ctx.source,
userId
)
})
it('should forward call to DocumentUpdaterHandler.appendToDocument', function (ctx) {
ctx.DocumentUpdaterHandler.promises.appendToDocument.should.have.been.calledWith(
projectId,
docId,
userId,
ctx.appendedLines,
ctx.source
)
})
it('should return the response from DocumentUpdaterHandler', function () {
expect(appendToDocResult).to.eql({ rev: 1 })
})
})
describe('when DocumentUpdater throws an error', function () {
beforeEach(function (ctx) {
ctx.appendedLines = ['5678', 'def']
ctx.DocumentUpdaterHandler.promises.appendToDocument = sinon.stub()
ctx.DocumentUpdaterHandler.promises.appendToDocument.rejects(
new Error()
)
ctx.ProjectLocator.promises.findElement = sinon.stub()
ctx.ProjectLocator.promises.findElement
.withArgs({ project_id: projectId, element_id: docId, type: 'doc' })
.resolves({ element: { _id: docId } })
})
it('should return the response from DocumentUpdaterHandler', async function (ctx) {
let error
try {
await ctx.ProjectEntityUpdateHandler.promises.appendToDocWithPath(
projectId,
docId,
ctx.appendedLines,
ctx.source,
userId
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(Error)
})
})
})
})