import sinon from 'sinon'
import { expect } from 'chai'
import { ObjectId } from 'mongodb'
import tk from 'timekeeper'
import { strict as esmock } from 'esmock'

const MODULE_PATH = '../../../../app/js/SyncManager.js'

const timestamp = new Date()

const resyncProjectStructureUpdate = (docs, files) => ({
  resyncProjectStructure: { docs, files },

  meta: {
    ts: timestamp,
  },
})

const docContentSyncUpdate = (doc, content) => ({
  path: doc.path,
  doc: doc.doc,

  resyncDocContent: {
    content,
  },

  meta: {
    ts: timestamp,
  },
})

describe('SyncManager', function () {
  beforeEach(async function () {
    this.now = new Date()
    tk.freeze(this.now)
    this.projectId = new ObjectId().toString()
    this.historyId = 'mock-overleaf-id'
    this.syncState = { origin: { kind: 'history-resync' } }
    this.db = {
      projectHistorySyncState: {
        findOne: sinon.stub().yields(null, this.syncState),
        updateOne: sinon.stub().yields(),
      },
    }
    this.callback = sinon.stub()
    this.extendLock = sinon.stub().yields()
    this.LockManager = {
      runWithLock: sinon.spy((key, runner, callback) => {
        runner(this.extendLock, callback)
      }),
    }
    this.UpdateCompressor = {
      diffAsShareJsOps: sinon.stub(),
    }
    this.UpdateTranslator = {
      isTextUpdate: sinon.stub(),
      _convertPathname: sinon.stub(),
    }
    this.WebApiManager = {
      getHistoryId: sinon.stub(),
      requestResync: sinon.stub().yields(),
    }
    this.WebApiManager.getHistoryId
      .withArgs(this.projectId)
      .yields(null, this.historyId)
    this.ErrorRecorder = {
      record: sinon.stub().yields(),
      recordSyncStart: sinon.stub().yields(),
    }
    this.RedisManager = {}
    this.SnapshotManager = {
      getLatestSnapshot: sinon.stub(),
    }
    this.HistoryStoreManager = {
      getBlobStore: sinon.stub(),
      _getBlobHashFromString: sinon.stub().returns('random-hash'),
    }
    this.HashManager = {
      _getBlobHashFromString: sinon.stub(),
    }
    this.Metrics = { inc: sinon.stub() }
    this.Settings = {
      redis: {
        lock: {
          key_schema: {
            projectHistoryLock({ project_id: projectId }) {
              return `ProjectHistoryLock:${projectId}`
            },
          },
        },
      },
    }
    this.SyncManager = await esmock(MODULE_PATH, {
      '../../../../app/js/LockManager.js': this.LockManager,
      '../../../../app/js/UpdateCompressor.js': this.UpdateCompressor,
      '../../../../app/js/UpdateTranslator.js': this.UpdateTranslator,
      '../../../../app/js/mongodb.js': { ObjectId, db: this.db },
      '../../../../app/js/WebApiManager.js': this.WebApiManager,
      '../../../../app/js/ErrorRecorder.js': this.ErrorRecorder,
      '../../../../app/js/RedisManager.js': this.RedisManager,
      '../../../../app/js/SnapshotManager.js': this.SnapshotManager,
      '../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager,
      '../../../../app/js/HashManager.js': this.HashManager,
      '@overleaf/metrics': this.Metrics,
      '@overleaf/settings': this.Settings,
    })
  })

  afterEach(function () {
    tk.reset()
  })

  describe('startResync', function () {
    describe('if a sync is not in progress', function () {
      beforeEach(function () {
        this.db.projectHistorySyncState.findOne.yields(null, {})
        this.SyncManager.startResync(this.projectId, this.callback)
      })

      it('takes the project lock', function () {
        expect(this.LockManager.runWithLock).to.have.been.calledWith(
          `ProjectHistoryLock:${this.projectId}`
        )
      })

      it('gets the sync state from mongo', function () {
        expect(this.db.projectHistorySyncState.findOne).to.have.been.calledWith(
          { project_id: ObjectId(this.projectId) }
        )
      })

      it('requests a resync from web', function () {
        expect(this.WebApiManager.requestResync).to.have.been.calledWith(
          this.projectId
        )
      })

      it('sets the sync state in mongo and prevents it expiring', function () {
        expect(
          this.db.projectHistorySyncState.updateOne
        ).to.have.been.calledWith(
          {
            project_id: ObjectId(this.projectId),
          },
          sinon.match({
            $set: {
              resyncProjectStructure: true,
              resyncDocContents: [],
              origin: { kind: 'history-resync' },
            },
            $currentDate: { lastUpdated: true },
            $inc: { resyncCount: 1 },
            $unset: { expiresAt: true },
          }),
          {
            upsert: true,
          }
        )
      })

      it('calls the callback', function () {
        expect(this.callback).to.have.been.called
      })
    })

    describe('if project structure sync is in progress', function () {
      beforeEach(function () {
        const syncState = { resyncProjectStructure: true }
        this.db.projectHistorySyncState.findOne.yields(null, syncState)
        this.SyncManager.startResync(this.projectId, this.callback)
      })

      it('returns an error if already syncing', function () {
        expect(this.callback).to.have.been.calledWithMatch({
          message: 'sync ongoing',
        })
      })
    })

    describe('if doc content sync in is progress', function () {
      beforeEach(function () {
        const syncState = { resyncDocContents: ['/foo.tex'] }
        this.db.projectHistorySyncState.findOne.yields(null, syncState)
        this.SyncManager.startResync(this.projectId, this.callback)
      })

      it('returns an error if already syncing', function () {
        expect(this.callback).to.have.been.calledWithMatch({
          message: 'sync ongoing',
        })
      })
    })
  })

  describe('setResyncState', function () {
    describe('when the sync is starting', function () {
      beforeEach(function () {
        this.syncState = {
          toRaw() {
            return {
              resyncProjectStructure: true,
              resyncDocContents: [],
              origin: { kind: 'history-resync' },
            }
          },
          isSyncOngoing: sinon.stub().returns(true),
        }
      })

      it('sets the sync state in mongo and prevents it expiring', function (done) {
        // SyncState is a private class of SyncManager
        // we know the interface however:
        this.SyncManager.setResyncState(
          this.projectId,
          this.syncState,
          error => {
            expect(error).to.be.undefined
            expect(
              this.db.projectHistorySyncState.updateOne
            ).to.have.been.calledWith(
              {
                project_id: ObjectId(this.projectId),
              },
              sinon.match({
                $set: this.syncState.toRaw(),
                $currentDate: { lastUpdated: true },
                $inc: { resyncCount: 1 },
                $unset: { expiresAt: true },
              }),
              {
                upsert: true,
              }
            )
            done()
          }
        )
      })
    })

    describe('when the sync is ending', function () {
      beforeEach(function () {
        this.syncState = {
          toRaw() {
            return {
              resyncProjectStructure: false,
              resyncDocContents: [],
              origin: { kind: 'history-resync' },
            }
          },
          isSyncOngoing: sinon.stub().returns(false),
        }
      })

      it('sets the sync state entry in mongo to expire', function (done) {
        this.SyncManager.setResyncState(
          this.projectId,
          this.syncState,
          error => {
            expect(error).to.be.undefined
            expect(
              this.db.projectHistorySyncState.updateOne
            ).to.have.been.calledWith(
              {
                project_id: ObjectId(this.projectId),
              },
              sinon.match({
                $set: {
                  resyncProjectStructure: false,
                  resyncDocContents: [],
                  origin: { kind: 'history-resync' },
                  expiresAt: new Date(
                    this.now.getTime() + 90 * 24 * 3600 * 1000
                  ),
                },
                $currentDate: { lastUpdated: true },
              }),
              {
                upsert: true,
              }
            )
            done()
          }
        )
      })
    })

    describe('when the new sync state is null', function () {
      it('does not update the sync state in mongo', function (done) {
        // SyncState is a private class of SyncManager
        // we know the interface however:
        this.SyncManager.setResyncState(this.projectId, null, error => {
          expect(error).to.be.undefined
          expect(this.db.projectHistorySyncState.updateOne).to.not.have.been
            .called
          done()
        })
      })
    })
  })

  describe('skipUpdatesDuringSync', function () {
    describe('if a sync is not in progress', function () {
      beforeEach(function () {
        this.db.projectHistorySyncState.findOne.yields(null, {})
        this.updates = ['some', 'mock', 'updates']
        this.SyncManager.skipUpdatesDuringSync(
          this.projectId,
          this.updates,
          this.callback
        )
      })

      it('returns all updates', function () {
        expect(this.callback).to.have.been.calledWith(null, this.updates)
      })

      it('should not return any newSyncState', function () {
        expect(this.callback).to.have.been.calledWithExactly(null, this.updates)
      })
    })

    describe('if a sync in is progress', function () {
      beforeEach(function () {
        this.renameUpdate = {
          pathname: 'old.tex',
          newPathname: 'new.tex',
        }
        this.projectStructureSyncUpdate = {
          resyncProjectStructure: {
            docs: [{ path: 'new.tex' }],
            files: [],
          },
        }
        this.textUpdate = {
          doc: new ObjectId(),
          op: [{ i: 'a', p: 4 }],
          meta: {
            pathname: 'new.tex',
            doc_length: 4,
          },
        }
        this.docContentSyncUpdate = {
          path: 'new.tex',
          resyncDocContent: {
            content: 'a',
          },
        }
        this.UpdateTranslator.isTextUpdate
          .withArgs(this.renameUpdate)
          .returns(false)
        this.UpdateTranslator.isTextUpdate
          .withArgs(this.projectStructureSyncUpdate)
          .returns(false)
        this.UpdateTranslator.isTextUpdate
          .withArgs(this.docContentSyncUpdate)
          .returns(false)
        this.UpdateTranslator.isTextUpdate
          .withArgs(this.textUpdate)
          .returns(true)

        const syncState = {
          resyncProjectStructure: true,
          resyncDocContents: [],
          origin: { kind: 'history-resync' },
        }
        this.db.projectHistorySyncState.findOne.yields(null, syncState)
      })

      it('remove updates before a project structure sync update', function () {
        const updates = [
          this.renameUpdate,
          this.textUpdate,
          this.projectStructureSyncUpdate,
        ]
        this.SyncManager.skipUpdatesDuringSync(
          this.projectId,
          updates,
          (error, filteredUpdates, syncState) => {
            expect(error).to.be.null
            expect(filteredUpdates).to.deep.equal([
              this.projectStructureSyncUpdate,
            ])
            expect(syncState.toRaw()).to.deep.equal({
              resyncProjectStructure: false,
              resyncDocContents: ['new.tex'],
              origin: { kind: 'history-resync' },
            })
          }
        )
      })

      it('allow project structure updates after project structure sync update', function () {
        const updates = [this.projectStructureSyncUpdate, this.renameUpdate]
        this.SyncManager.skipUpdatesDuringSync(
          this.projectId,
          updates,
          (error, filteredUpdates, syncState) => {
            expect(error).to.be.null
            expect(filteredUpdates).to.deep.equal([
              this.projectStructureSyncUpdate,
              this.renameUpdate,
            ])
            expect(syncState.toRaw()).to.deep.equal({
              resyncProjectStructure: false,
              resyncDocContents: ['new.tex'],
              origin: { kind: 'history-resync' },
            })
          }
        )
      })

      it('remove text updates for a doc before doc sync update', function () {
        const updates = [
          this.projectStructureSyncUpdate,
          this.textUpdate,
          this.docContentSyncUpdate,
        ]
        this.SyncManager.skipUpdatesDuringSync(
          this.projectId,
          updates,
          (error, filteredUpdates, syncState) => {
            expect(error).to.be.null
            expect(filteredUpdates).to.deep.equal([
              this.projectStructureSyncUpdate,
              this.docContentSyncUpdate,
            ])
            expect(syncState.toRaw()).to.deep.equal({
              resyncProjectStructure: false,
              resyncDocContents: [],
              origin: { kind: 'history-resync' },
            })
          }
        )
      })

      it('allow text updates for a doc after doc sync update', function () {
        const updates = [
          this.projectStructureSyncUpdate,
          this.docContentSyncUpdate,
          this.textUpdate,
        ]
        this.SyncManager.skipUpdatesDuringSync(
          this.projectId,
          updates,
          (error, filteredUpdates, syncState) => {
            expect(error).to.be.null
            expect(filteredUpdates).to.deep.equal([
              this.projectStructureSyncUpdate,
              this.docContentSyncUpdate,
              this.textUpdate,
            ])
            expect(syncState.toRaw()).to.deep.equal({
              resyncProjectStructure: false,
              resyncDocContents: [],
              origin: { kind: 'history-resync' },
            })
          }
        )
      })
    })
  })

  describe('expandSyncUpdates', function () {
    beforeEach(function () {
      this.persistedDoc = {
        doc: { data: { hash: 'abcdef' } },
        path: 'main.tex',
        content: 'asdf',
      }
      this.persistedFile = {
        file: { data: { hash: '123456789a' } },
        path: '1.png',
      }
      this.fileMap = {
        'main.tex': {
          isEditable: sinon.stub().returns(true),
          content: this.persistedDoc.content,
        },
        '1.png': {
          isEditable: sinon.stub().returns(false),
          data: { hash: this.persistedFile.file.data.hash },
        },
      }
      this.UpdateTranslator._convertPathname
        .withArgs('main.tex')
        .returns('main.tex')
      this.UpdateTranslator._convertPathname
        .withArgs('/main.tex')
        .returns('main.tex')
      this.UpdateTranslator._convertPathname
        .withArgs('another.tex')
        .returns('another.tex')
      this.UpdateTranslator._convertPathname.withArgs('1.png').returns('1.png')
      this.UpdateTranslator._convertPathname.withArgs('2.png').returns('2.png')
      this.SnapshotManager.getLatestSnapshot.yields(null, this.fileMap)
    })

    it('returns updates if no sync updates are queued', function () {
      const updates = ['some', 'mock', 'updates']
      this.SyncManager.expandSyncUpdates(
        this.projectId,
        this.historyId,
        updates,
        this.extendLock,
        (error, expandedUpdates) => {
          expect(error).to.be.null
          expect(expandedUpdates).to.equal(updates)
          expect(this.SnapshotManager.getLatestSnapshot).to.not.have.been.called
          expect(this.extendLock).to.not.have.been.called
        }
      )
    })

    describe('expanding project structure sync updates', function () {
      it('queues nothing for expected docs and files', function () {
        const updates = [
          resyncProjectStructureUpdate(
            [this.persistedDoc],
            [this.persistedFile]
          ),
        ]
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.be.null
            expect(expandedUpdates).to.deep.equal([])
            expect(this.extendLock).to.have.been.called
          }
        )
      })

      it('queues file removes for unexpected files', function () {
        const updates = [resyncProjectStructureUpdate([this.persistedDoc], [])]
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.be.null
            expect(expandedUpdates).to.deep.equal([
              {
                pathname: this.persistedFile.path,
                new_pathname: '',
                meta: {
                  resync: true,
                  ts: timestamp,
                  origin: { kind: 'history-resync' },
                },
              },
            ])
            expect(this.extendLock).to.have.been.called
          }
        )
      })

      it('queues doc removes for unexpected docs', function () {
        const updates = [resyncProjectStructureUpdate([], [this.persistedFile])]
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.be.null
            expect(expandedUpdates).to.deep.equal([
              {
                pathname: this.persistedDoc.path,
                new_pathname: '',
                meta: {
                  resync: true,
                  ts: timestamp,
                  origin: { kind: 'history-resync' },
                },
              },
            ])
            expect(this.extendLock).to.have.been.called
          }
        )
      })

      it('queues file additions for missing files', function () {
        const newFile = {
          path: '2.png',
          file: {},
          url: 'filestore/2.png',
        }
        const updates = [
          resyncProjectStructureUpdate(
            [this.persistedDoc],
            [this.persistedFile, newFile]
          ),
        ]
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.be.null
            expect(expandedUpdates).to.deep.equal([
              {
                pathname: newFile.path,
                file: newFile.file,
                url: newFile.url,
                meta: {
                  resync: true,
                  ts: timestamp,
                  origin: { kind: 'history-resync' },
                },
              },
            ])
            expect(this.extendLock).to.have.been.called
          }
        )
      })

      it('queues blank doc additions for missing docs', function () {
        const newDoc = {
          path: 'another.tex',
          doc: ObjectId().toString(),
        }
        const updates = [
          resyncProjectStructureUpdate(
            [this.persistedDoc, newDoc],
            [this.persistedFile]
          ),
        ]
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.be.null
            expect(expandedUpdates).to.deep.equal([
              {
                pathname: newDoc.path,
                doc: newDoc.doc,
                docLines: '',
                meta: {
                  resync: true,
                  ts: timestamp,
                  origin: { kind: 'history-resync' },
                },
              },
            ])
            expect(this.extendLock).to.have.been.called
          }
        )
      })

      it('removes and re-adds files if whether they are binary differs', function () {
        const fileWichWasADoc = {
          path: this.persistedDoc.path,
          url: 'filestore/2.png',
          _hash: 'other-hash',
        }

        const updates = [
          resyncProjectStructureUpdate(
            [],
            [fileWichWasADoc, this.persistedFile]
          ),
        ]
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.be.null
            expect(expandedUpdates).to.deep.equal([
              {
                pathname: fileWichWasADoc.path,
                new_pathname: '',
                meta: {
                  resync: true,
                  ts: timestamp,
                  origin: { kind: 'history-resync' },
                },
              },
              {
                pathname: fileWichWasADoc.path,
                file: fileWichWasADoc.file,
                url: fileWichWasADoc.url,
                meta: {
                  resync: true,
                  ts: timestamp,
                  origin: { kind: 'history-resync' },
                },
              },
            ])
            expect(this.extendLock).to.have.been.called
          }
        )
      })

      it("does not remove and re-add files if the expected file doesn't have a hash", function () {
        const fileWichWasADoc = {
          path: this.persistedDoc.path,
          url: 'filestore/2.png',
        }

        const updates = [
          resyncProjectStructureUpdate(
            [],
            [fileWichWasADoc, this.persistedFile]
          ),
        ]
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.be.null
            expect(expandedUpdates).to.deep.equal([])
            expect(this.extendLock).to.have.been.called
          }
        )
      })

      it('does not remove and re-add editable files if there is a binary file with same hash', function () {
        const binaryFile = {
          file: Object().toString(),
          // The paths in the resyncProjectStructureUpdate must have a leading slash ('/')
          // The other unit tests in this file are incorrectly missing the leading slash.
          // The leading slash is present in web where the paths are created with
          // ProjectEntityHandler.getAllEntitiesFromProject in ProjectEntityUpdateHandler.resyncProjectHistory.
          path: '/' + this.persistedDoc.path,
          url: 'filestore/12345',
          _hash: 'abcdef',
        }
        this.fileMap['main.tex'].data = { hash: 'abcdef' }

        const updates = [
          resyncProjectStructureUpdate([], [binaryFile, this.persistedFile]),
        ]
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.be.null
            expect(expandedUpdates).to.deep.equal([])
            expect(this.extendLock).to.have.been.called
          }
        )
      })

      it('removes and re-adds binary files if they do not have same hash', function () {
        const persistedFileWithNewContent = {
          _hash: 'anotherhashvalue',
          hello: 'world',
          path: '1.png',
          url: 'filestore-new-url',
        }
        const updates = [
          resyncProjectStructureUpdate(
            [this.persistedDoc],
            [persistedFileWithNewContent]
          ),
        ]
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.be.null
            expect(expandedUpdates).to.deep.equal([
              {
                pathname: persistedFileWithNewContent.path,
                new_pathname: '',
                meta: {
                  resync: true,
                  ts: timestamp,
                  origin: { kind: 'history-resync' },
                },
              },
              {
                pathname: persistedFileWithNewContent.path,
                file: persistedFileWithNewContent.file,
                url: persistedFileWithNewContent.url,
                meta: {
                  resync: true,
                  ts: timestamp,
                  origin: { kind: 'history-resync' },
                },
              },
            ])
            expect(this.extendLock).to.have.been.called
          }
        )
      })

      it('preserves other updates', function () {
        const update = 'mock-update'
        const updates = [
          update,
          resyncProjectStructureUpdate(
            [this.persistedDoc],
            [this.persistedFile]
          ),
        ]
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.be.null
            expect(expandedUpdates).to.deep.equal([update])
            expect(this.extendLock).to.have.been.called
          }
        )
      })
    })

    describe('expanding doc contents sync updates', function () {
      it('returns errors from diffAsShareJsOps', function () {
        const diffError = new Error('test')
        this.UpdateCompressor.diffAsShareJsOps.throws(diffError)
        const updates = [
          resyncProjectStructureUpdate(
            [this.persistedDoc],
            [this.persistedFile]
          ),
          docContentSyncUpdate(this.persistedDoc, this.persistedDoc.content),
        ]
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.equal(diffError)
            expect(this.extendLock).to.have.been.called
          }
        )
      })

      it('handles an update for a file that is missing from the snapshot', function () {
        const updates = [docContentSyncUpdate('not-in-snapshot.txt', 'test')]
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.exist
            expect(error.message).to.equal('unrecognised file: not in snapshot')
          }
        )
      })

      it('queues nothing for in docs whose contents is in sync', function () {
        const updates = [
          resyncProjectStructureUpdate(
            [this.persistedDoc],
            [this.persistedFile]
          ),
          docContentSyncUpdate(this.persistedDoc, this.persistedDoc.content),
        ]
        this.UpdateCompressor.diffAsShareJsOps.returns([])
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.be.null
            expect(expandedUpdates).to.deep.equal([])
            expect(this.extendLock).to.have.been.called
          }
        )
      })

      it('queues text updates for docs whose contents is out of sync', function () {
        const updates = [
          resyncProjectStructureUpdate(
            [this.persistedDoc],
            [this.persistedFile]
          ),
          docContentSyncUpdate(this.persistedDoc, 'a'),
        ]
        this.UpdateCompressor.diffAsShareJsOps.returns([{ d: 'sdf', p: 1 }])
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.be.null
            expect(expandedUpdates).to.deep.equal([
              {
                doc: this.persistedDoc.doc,
                op: [{ d: 'sdf', p: 1 }],
                meta: {
                  pathname: this.persistedDoc.path,
                  doc_length: 4,
                  resync: true,
                  ts: timestamp,
                  origin: { kind: 'history-resync' },
                },
              },
            ])
            expect(this.extendLock).to.have.been.called
          }
        )
      })

      it('queues text updates for docs created by project structure sync', function () {
        this.UpdateCompressor.diffAsShareJsOps.returns([{ i: 'a', p: 0 }])
        const newDoc = {
          path: 'another.tex',
          doc: ObjectId().toString(),
          content: 'a',
        }
        const updates = [
          resyncProjectStructureUpdate(
            [this.persistedDoc, newDoc],
            [this.persistedFile]
          ),
          docContentSyncUpdate(newDoc, newDoc.content),
        ]
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.be.null
            expect(expandedUpdates).to.deep.equal([
              {
                pathname: newDoc.path,
                doc: newDoc.doc,
                docLines: '',
                meta: {
                  resync: true,
                  ts: timestamp,
                  origin: { kind: 'history-resync' },
                },
              },
              {
                doc: newDoc.doc,
                op: [{ i: 'a', p: 0 }],
                meta: {
                  pathname: newDoc.path,
                  doc_length: 0,
                  resync: true,
                  ts: timestamp,
                  origin: { kind: 'history-resync' },
                },
              },
            ])
            expect(this.extendLock).to.have.been.called
          }
        )
      })

      it('skips text updates for docs when hashes match', function () {
        this.fileMap['main.tex'].getHash = sinon.stub().returns('special-hash')
        this.HashManager._getBlobHashFromString.returns('special-hash')
        const updates = [
          resyncProjectStructureUpdate(
            [this.persistedDoc],
            [this.persistedFile]
          ),
          docContentSyncUpdate(this.persistedDoc, 'hello'),
        ]
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.be.null
            expect(expandedUpdates).to.deep.equal([])
            expect(this.extendLock).to.have.been.called
          }
        )
      })

      it('computes text updates for docs when hashes differ', function () {
        this.fileMap['main.tex'].getHash = sinon.stub().returns('first-hash')
        this.HashManager._getBlobHashFromString.returns('second-hash')
        this.UpdateCompressor.diffAsShareJsOps.returns([
          { i: 'test diff', p: 0 },
        ])
        const updates = [
          resyncProjectStructureUpdate(
            [this.persistedDoc],
            [this.persistedFile]
          ),
          docContentSyncUpdate(this.persistedDoc, 'hello'),
        ]
        this.SyncManager.expandSyncUpdates(
          this.projectId,
          this.historyId,
          updates,
          this.extendLock,
          (error, expandedUpdates) => {
            expect(error).to.be.null
            expect(expandedUpdates).to.deep.equal([
              {
                doc: this.persistedDoc.doc,
                op: [{ i: 'test diff', p: 0 }],
                meta: {
                  pathname: this.persistedDoc.path,
                  doc_length: 4,
                  resync: true,
                  ts: timestamp,
                  origin: { kind: 'history-resync' },
                },
              },
            ])
            expect(this.extendLock).to.have.been.called
          }
        )
      })

      describe('for docs whose contents is out of sync', function () {
        beforeEach(function (done) {
          const updates = [
            resyncProjectStructureUpdate(
              [this.persistedDoc],
              [this.persistedFile]
            ),
            docContentSyncUpdate(this.persistedDoc, 'a'),
          ]
          const file = { getContent: sinon.stub().returns('stored content') }
          this.fileMap['main.tex'].load = sinon.stub().resolves(file)
          this.UpdateCompressor.diffAsShareJsOps.returns([{ d: 'sdf', p: 1 }])
          this.SyncManager.expandSyncUpdates(
            this.projectId,
            this.historyId,
            updates,
            this.extendLock,
            (error, expandedUpdates) => {
              this.error = error
              this.expandedUpdates = expandedUpdates
              done()
            }
          )
        })

        it('loads content from the history service when needed', function () {
          expect(this.error).to.be.null
          expect(this.expandedUpdates).to.deep.equal([
            {
              doc: this.persistedDoc.doc,
              op: [{ d: 'sdf', p: 1 }],
              meta: {
                pathname: this.persistedDoc.path,
                doc_length: 'stored content'.length,
                resync: true,
                ts: timestamp,
                origin: { kind: 'history-resync' },
              },
            },
          ])
          expect(this.extendLock).to.have.been.called
        })
      })
    })
  })
})
