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)
      })
    })
  })
})
