Explorar el Código

Merge pull request #14413 from overleaf/gs-kustomize-tpref

Add Kustomization for third-party-references

GitOrigin-RevId: 5e9508e0f66df6b462fb49786b0941d52c2eb1f4
Gernot Schulz hace 2 años
padre
commit
1a7b0f0414

+ 1 - 0
services/web/modules/history-v1/index.js

@@ -0,0 +1 @@
+module.exports = {}

+ 7 - 0
services/web/modules/history-v1/test/acceptance/config/settings.test.js

@@ -0,0 +1,7 @@
+const base = require(process.env.BASE_CONFIG)
+
+module.exports = base.mergeWith({
+  test: {
+    counterInit: 190000,
+  },
+})

+ 347 - 0
services/web/modules/history-v1/test/acceptance/src/HistoryTests.js

@@ -0,0 +1,347 @@
+/* eslint-disable
+    max-len,
+    no-unused-vars,
+*/
+// TODO: This file was created by bulk-decaffeinate.
+// Fix any style issues and re-enable lint.
+/*
+ * decaffeinate suggestions:
+ * DS102: Remove unnecessary code created because of implicit returns
+ * DS207: Consider shorter variations of null checks
+ * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
+ */
+const { expect } = require('chai')
+const _ = require('lodash')
+
+const {
+  db,
+  ObjectId,
+} = require('../../../../../app/src/infrastructure/mongodb')
+const User = require('../../../../../test/acceptance/src/helpers/User')
+const MockV1HistoryApiClass = require('../../../../../test/acceptance/src/mocks/MockV1HistoryApi')
+
+let MockV1HistoryApi
+
+before(function () {
+  MockV1HistoryApi = MockV1HistoryApiClass.instance()
+})
+
+describe('History', function () {
+  beforeEach(function (done) {
+    this.owner = new User()
+    return this.owner.login(done)
+  })
+
+  describe('zip download of version', function () {
+    it('should stream the zip file of a version', function (done) {
+      return this.owner.createProject('example-project', (error, projectId) => {
+        this.project_id = projectId
+        if (error != null) {
+          return done(error)
+        }
+        this.v1_history_id = 42
+        return db.projects.updateOne(
+          {
+            _id: ObjectId(this.project_id),
+          },
+          {
+            $set: {
+              'overleaf.history.id': this.v1_history_id,
+            },
+          },
+          error => {
+            if (error != null) {
+              return done(error)
+            }
+            return this.owner.request(
+              `/project/${this.project_id}/version/42/zip`,
+              (error, response, body) => {
+                if (error != null) {
+                  return done(error)
+                }
+                expect(response.statusCode).to.equal(200)
+                expect(response.headers['content-type']).to.equal(
+                  'application/zip'
+                )
+                expect(response.headers['content-disposition']).to.equal(
+                  'attachment; filename="example-project (Version 42).zip"'
+                )
+                expect(body).to.equal(
+                  `Mock zip for ${this.v1_history_id} at version 42`
+                )
+                return done()
+              }
+            )
+          }
+        )
+      })
+    })
+
+    describe('request abort', function () {
+      // Optional manual verification: add unique logging statements into
+      //   HistoryController._pipeHistoryZipToResponse
+      // in each of the `req.destroyed` branches and confirm that each branch
+      //  was covered.
+      beforeEach(function setupNewProject(done) {
+        this.owner.createProject('example-project', (error, projectId) => {
+          this.project_id = projectId
+          if (error) {
+            return done(error)
+          }
+          this.v1_history_id = 42
+          db.projects.updateOne(
+            { _id: ObjectId(this.project_id) },
+            {
+              $set: {
+                'overleaf.history.id': this.v1_history_id,
+              },
+            },
+            done
+          )
+        })
+      })
+
+      it('should abort the upstream request', function (done) {
+        const request = this.owner.request(
+          `/project/${this.project_id}/version/100/zip`
+        )
+        request.on('error', err => {
+          if (err.code !== 'ECONNRESET') {
+            done(err)
+          }
+        })
+        request.on('response', response => {
+          expect(response.statusCode).to.equal(200)
+          let receivedChunks = 0
+          response.on('data', () => {
+            receivedChunks++
+          })
+          response.resume()
+
+          setTimeout(() => {
+            request.abort()
+            const receivedSoFar = receivedChunks
+            const sentSoFar = MockV1HistoryApi.sentChunks
+            // Ihe next assertions should verify that chunks are emitted
+            //  and received -- the exact number is not important.
+            // In theory we are now emitting the 3rd chunk,
+            //  so this should be exactly 3, to not make this
+            //  test flaky, we allow +- 2 chunks.
+            expect(sentSoFar).to.be.within(1, 4)
+            expect(receivedSoFar).to.be.within(1, 4)
+            setTimeout(() => {
+              // The fake-s3 service should have stopped emitting chunks.
+              // If not, that would be +5 in an ideal world (1 every 100ms).
+              // On the happy-path (it stopped) it emitted +1 which was
+              //  in-flight and another +1 before it received the abort.
+              expect(MockV1HistoryApi.sentChunks).to.be.below(sentSoFar + 5)
+              expect(MockV1HistoryApi.sentChunks).to.be.within(
+                sentSoFar,
+                sentSoFar + 2
+              )
+              done()
+            }, 500)
+          }, 200)
+        })
+      })
+
+      it('should skip the v1-history request', function (done) {
+        const request = this.owner.request(
+          `/project/${this.project_id}/version/100/zip`
+        )
+        setTimeout(() => {
+          // This is a race-condition to abort the request after the
+          //  processing of all the the express middleware completed.
+          // In case we abort before they complete, we do not hit our
+          //  abort logic, but express internal logic, which is OK.
+          request.abort()
+        }, 2)
+        request.on('error', done)
+        setTimeout(() => {
+          expect(MockV1HistoryApi.requestedZipPacks).to.equal(0)
+          done()
+        }, 500)
+      })
+
+      it('should skip the async-polling', function (done) {
+        const request = this.owner.request(
+          `/project/${this.project_id}/version/100/zip`
+        )
+        MockV1HistoryApi.events.on('v1-history-pack-zip', () => {
+          request.abort()
+        })
+        request.on('error', done)
+        setTimeout(() => {
+          expect(MockV1HistoryApi.fakeZipCall).to.equal(0)
+          done()
+        }, 3000) // initial polling delay is 2s
+      })
+
+      it('should skip the upstream request', function (done) {
+        const request = this.owner.request(
+          `/project/${this.project_id}/version/100/zip`
+        )
+        MockV1HistoryApi.events.on('v1-history-pack-zip', () => {
+          setTimeout(() => {
+            request.abort()
+          }, 1000)
+        })
+        request.on('error', done)
+        setTimeout(() => {
+          expect(MockV1HistoryApi.fakeZipCall).to.equal(0)
+          done()
+        }, 3000) // initial polling delay is 2s
+      })
+    })
+
+    it('should return 402 for non-v2-history project', function (done) {
+      return this.owner.createProject('non-v2-project', (error, projectId) => {
+        this.project_id = projectId
+        if (error != null) {
+          return done(error)
+        }
+        return db.projects.updateOne(
+          {
+            _id: ObjectId(this.project_id),
+          },
+          {
+            $unset: {
+              'overleaf.history.id': true,
+            },
+          },
+          error => {
+            if (error != null) {
+              return done(error)
+            }
+            return this.owner.request(
+              `/project/${this.project_id}/version/42/zip`,
+              (error, response, body) => {
+                if (error != null) {
+                  return done(error)
+                }
+                expect(response.statusCode).to.equal(402)
+                return done()
+              }
+            )
+          }
+        )
+      })
+    })
+  })
+
+  describe('zip download, with upstream 404', function () {
+    beforeEach(function () {
+      _.remove(
+        MockV1HistoryApi.app._router.stack,
+        appRoute =>
+          (appRoute.route != null ? appRoute.route.path : undefined) ===
+          '/api/projects/:project_id/version/:version/zip'
+      )
+      MockV1HistoryApi.app.post(
+        '/api/projects/:project_id/version/:version/zip',
+        (req, res, next) => {
+          res.sendStatus(404)
+        }
+      )
+    })
+
+    afterEach(function () {
+      MockV1HistoryApi = MockV1HistoryApiClass.instance()
+      MockV1HistoryApi.reset()
+      MockV1HistoryApi.applyRoutes()
+    })
+
+    it('should produce 404 when post request produces 404', function (done) {
+      this.owner.createProject('example-project', (error, projectId) => {
+        if (error) {
+          return done(error)
+        }
+        this.project_id = projectId
+        this.v1_history_id = 42
+        return db.projects.updateOne(
+          {
+            _id: ObjectId(this.project_id),
+          },
+          {
+            $set: {
+              'overleaf.history.id': this.v1_history_id,
+            },
+          },
+          error => {
+            if (error) {
+              return done(error)
+            }
+            this.owner.request(
+              `/project/${this.project_id}/version/42/zip`,
+              (error, response, body) => {
+                if (error != null) {
+                  return done(error)
+                }
+                expect(response.statusCode).to.equal(404)
+                return done()
+              }
+            )
+          }
+        )
+      })
+    })
+  })
+
+  describe('zip download, with no zipUrl from upstream', function () {
+    beforeEach(function () {
+      _.remove(
+        MockV1HistoryApi.app._router.stack,
+        appRoute =>
+          (appRoute.route != null ? appRoute.route.path : undefined) ===
+          '/api/projects/:project_id/version/:version/zip'
+      )
+      MockV1HistoryApi.app.post(
+        '/api/projects/:project_id/version/:version/zip',
+        (req, res, next) => {
+          res.json({ message: 'lol' })
+        }
+      )
+    })
+
+    afterEach(function () {
+      MockV1HistoryApi = MockV1HistoryApiClass.instance()
+      MockV1HistoryApi.reset()
+      MockV1HistoryApi.applyRoutes()
+    })
+
+    it('should produce 404 when post request produces 404', function (done) {
+      this.owner.createProject('example-project', (error, projectId) => {
+        if (error) {
+          return done(error)
+        }
+        this.project_id = projectId
+        this.v1_history_id = 42
+        return db.projects.updateOne(
+          {
+            _id: ObjectId(this.project_id),
+          },
+          {
+            $set: {
+              'overleaf.history.id': this.v1_history_id,
+            },
+          },
+          error => {
+            if (error) {
+              return done(error)
+            }
+            this.owner.request(
+              `/project/${this.project_id}/version/42/zip`,
+              (error, response, body) => {
+                if (error != null) {
+                  return done(error)
+                }
+                expect(response.statusCode).to.equal(500)
+                return done()
+              }
+            )
+          }
+        )
+      })
+    })
+  })
+})

+ 23 - 0
services/web/modules/history-v1/test/acceptance/src/Init.js

@@ -0,0 +1,23 @@
+require('../../../../../test/acceptance/src/helpers/InitApp')
+
+const MockDocstoreApi = require('../../../../../test/acceptance/src/mocks/MockDocstoreApi')
+const MockDocUpdaterApi = require('../../../../../test/acceptance/src/mocks/MockDocUpdaterApi')
+const MockFilestoreApi = require('../../../../../test/acceptance/src/mocks/MockFilestoreApi')
+const MockNotificationsApi = require('../../../../../test/acceptance/src/mocks/MockNotificationsApi')
+const MockProjectHistoryApi = require('../../../../../test/acceptance/src/mocks/MockProjectHistoryApi')
+const MockSpellingApi = require('../../../../../test/acceptance/src/mocks/MockSpellingApi')
+const MockV1Api = require('../../../../../test/acceptance/src/mocks/MockV1Api')
+const MockV1HistoryApi = require('../../../../../test/acceptance/src/mocks/MockV1HistoryApi')
+
+const mockOpts = {
+  debug: ['1', 'true', 'TRUE'].includes(process.env.DEBUG_MOCKS),
+}
+
+MockDocstoreApi.initialize(23016, mockOpts)
+MockDocUpdaterApi.initialize(23003, mockOpts)
+MockFilestoreApi.initialize(23009, mockOpts)
+MockNotificationsApi.initialize(23042, mockOpts)
+MockProjectHistoryApi.initialize(23054, mockOpts)
+MockSpellingApi.initialize(23005, mockOpts)
+MockV1Api.initialize(25000, mockOpts)
+MockV1HistoryApi.initialize(23100, mockOpts)

+ 126 - 0
services/web/modules/history-v1/test/acceptance/src/LabelsTests.js

@@ -0,0 +1,126 @@
+/* eslint-disable
+    max-len,
+    no-unused-vars,
+*/
+// TODO: This file was created by bulk-decaffeinate.
+// Fix any style issues and re-enable lint.
+/*
+ * decaffeinate suggestions:
+ * DS102: Remove unnecessary code created because of implicit returns
+ * DS207: Consider shorter variations of null checks
+ * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
+ */
+const _ = require('underscore')
+const { expect } = require('chai')
+const { ObjectId } = require('mongodb')
+
+const User = require('../../../../../test/acceptance/src/helpers/User')
+const MockProjectHistoryApiClass = require('../../../../../test/acceptance/src/mocks/MockProjectHistoryApi')
+
+let MockProjectHistoryApi
+
+before(function () {
+  MockProjectHistoryApi = MockProjectHistoryApiClass.instance()
+})
+
+describe('Labels', function () {
+  beforeEach(function (done) {
+    this.owner = new User()
+    return this.owner.login(error => {
+      if (error != null) {
+        throw error
+      }
+      return this.owner.createProject(
+        'example-project',
+        { template: 'example' },
+        (error, projectId) => {
+          this.project_id = projectId
+          if (error != null) {
+            throw error
+          }
+          return done()
+        }
+      )
+    })
+  })
+
+  it('getting labels', function (done) {
+    const labelId = new ObjectId().toString()
+    const comment = 'a label comment'
+    const version = 3
+    MockProjectHistoryApi.addLabel(this.project_id, {
+      id: labelId,
+      comment,
+      version,
+    })
+
+    return this.owner.request(
+      {
+        method: 'GET',
+        url: `/project/${this.project_id}/labels`,
+        json: true,
+      },
+      (error, response, body) => {
+        if (error != null) {
+          throw error
+        }
+        expect(response.statusCode).to.equal(200)
+        expect(body).to.deep.equal([{ id: labelId, comment, version }])
+        return done()
+      }
+    )
+  })
+
+  it('creating a label', function (done) {
+    const comment = 'a label comment'
+    const version = 3
+
+    return this.owner.request(
+      {
+        method: 'POST',
+        url: `/project/${this.project_id}/labels`,
+        json: { comment, version },
+      },
+      (error, response, body) => {
+        if (error != null) {
+          throw error
+        }
+        expect(response.statusCode).to.equal(200)
+        const { label_id: labelId } = body
+        expect(MockProjectHistoryApi.getLabels(this.project_id)).to.deep.equal([
+          { id: labelId, comment, version },
+        ])
+        return done()
+      }
+    )
+  })
+
+  it('deleting a label', function (done) {
+    const labelId = new ObjectId().toString()
+    const comment = 'a label comment'
+    const version = 3
+    MockProjectHistoryApi.addLabel(this.project_id, {
+      id: labelId,
+      comment,
+      version,
+    })
+
+    return this.owner.request(
+      {
+        method: 'DELETE',
+        url: `/project/${this.project_id}/labels/${labelId}`,
+        json: true,
+      },
+      (error, response, body) => {
+        if (error != null) {
+          throw error
+        }
+        expect(response.statusCode).to.equal(204)
+        expect(MockProjectHistoryApi.getLabels(this.project_id)).to.deep.equal(
+          []
+        )
+        return done()
+      }
+    )
+  })
+})

+ 1071 - 0
services/web/modules/history-v1/test/acceptance/src/ProjectStructureTests.js

@@ -0,0 +1,1071 @@
+const { expect } = require('chai')
+const { ObjectId } = require('mongodb')
+const Path = require('path')
+const fs = require('fs')
+const Settings = require('@overleaf/settings')
+const _ = require('underscore')
+
+const ProjectGetter = require('../../../../../app/src/Features/Project/ProjectGetter.js')
+
+const User = require('../../../../../test/acceptance/src/helpers/User')
+const MockDocUpdaterApiClass = require('../../../../../test/acceptance/src/mocks/MockDocUpdaterApi')
+
+const FILES_PATH = Path.join(__dirname, '../../../../../test/acceptance/files')
+
+let MockDocUpdaterApi
+
+before(function () {
+  MockDocUpdaterApi = MockDocUpdaterApiClass.instance()
+})
+
+describe('ProjectStructureChanges', function () {
+  let owner
+
+  beforeEach(function (done) {
+    owner = new User()
+    owner.login(done)
+  })
+
+  function createExampleProject(owner, callback) {
+    owner.createProject(
+      'example-project',
+      { template: 'example' },
+      (error, projectId) => {
+        if (error) {
+          return callback(error)
+        }
+
+        ProjectGetter.getProject(projectId, (error, project) => {
+          if (error) {
+            return callback(error)
+          }
+          const rootFolderId = project.rootFolder[0]._id.toString()
+          callback(null, projectId, rootFolderId)
+        })
+      }
+    )
+  }
+
+  function createExampleDoc(owner, projectId, callback) {
+    ProjectGetter.getProject(projectId, (error, project) => {
+      if (error) {
+        return callback(error)
+      }
+      owner.request.post(
+        {
+          uri: `project/${projectId}/doc`,
+          json: {
+            name: 'new.tex',
+            parent_folder_id: project.rootFolder[0]._id,
+          },
+        },
+        (error, res, body) => {
+          if (error) {
+            return callback(error)
+          }
+          if (res.statusCode < 200 || res.statusCode >= 300) {
+            return callback(new Error(`failed to add doc ${res.statusCode}`))
+          }
+          callback(null, body._id)
+        }
+      )
+    })
+  }
+
+  function createExampleFolder(owner, projectId, callback) {
+    owner.request.post(
+      {
+        uri: `project/${projectId}/folder`,
+        json: {
+          name: 'foo',
+        },
+      },
+      (error, res, body) => {
+        if (error) {
+          return callback(error)
+        }
+        if (res.statusCode < 200 || res.statusCode >= 300) {
+          return callback(new Error(`failed to add doc ${res.statusCode}`))
+        }
+        callback(null, body._id)
+      }
+    )
+  }
+
+  function uploadFile(
+    owner,
+    projectId,
+    folderId,
+    file,
+    name,
+    contentType,
+    callback
+  ) {
+    owner.uploadFileInProject(
+      projectId,
+      folderId,
+      file,
+      name,
+      contentType,
+      callback
+    )
+  }
+
+  function uploadExampleFile(owner, projectId, folderId, callback) {
+    owner.uploadExampleFileInProject(
+      projectId,
+      folderId,
+      '1pixel.png',
+      callback
+    )
+  }
+
+  function uploadExampleProject(owner, zipFilename, options, callback) {
+    if (typeof options === 'function') {
+      callback = options
+      options = {}
+    }
+
+    const zipFile = fs.createReadStream(Path.join(FILES_PATH, zipFilename))
+
+    owner.request.post(
+      {
+        uri: 'project/new/upload',
+        formData: {
+          name: zipFilename,
+          qqfile: zipFile,
+        },
+      },
+      (error, res, body) => {
+        if (error) {
+          return callback(error)
+        }
+        if (
+          !options.allowBadStatus &&
+          (res.statusCode < 200 || res.statusCode >= 300)
+        ) {
+          return new Error(`failed to upload project ${res.statusCode}`)
+        }
+        callback(null, JSON.parse(body).project_id, res)
+      }
+    )
+  }
+
+  function moveItem(owner, projectId, type, itemId, folderId, callback) {
+    owner.moveItemInProject(projectId, type, itemId, folderId, callback)
+  }
+
+  function renameItem(owner, projectId, type, itemId, name, callback) {
+    owner.renameItemInProject(projectId, type, itemId, name, callback)
+  }
+
+  function deleteItem(owner, projectId, type, itemId, callback) {
+    owner.deleteItemInProject(projectId, type, itemId, callback)
+  }
+
+  function verifyVersionIncremented(
+    projectId,
+    oldVersion,
+    updateVersion,
+    increment,
+    callback
+  ) {
+    expect(updateVersion).to.equal(oldVersion + increment)
+
+    ProjectGetter.getProject(projectId, (error, newProject) => {
+      if (error) {
+        return callback(error)
+      }
+
+      expect(newProject.version).to.equal(updateVersion)
+      callback()
+    })
+  }
+
+  describe('creating a project from the example template', function () {
+    let exampleProjectId
+
+    beforeEach(function (done) {
+      createExampleProject(owner, (err, projectId) => {
+        exampleProjectId = projectId
+        done(err)
+      })
+    })
+
+    it('should version creating a doc and a file', function () {
+      const { updates, version } =
+        MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+      expect(updates.length).to.equal(3)
+      for (const update of updates.slice(0, 2)) {
+        expect(update.type).to.equal('add-doc')
+        expect(update.userId).to.equal(owner._id)
+        expect(update.docLines).to.be.a('string')
+      }
+      expect(_.where(updates, { pathname: '/main.tex' }).length).to.equal(1)
+      expect(_.where(updates, { pathname: '/sample.bib' }).length).to.equal(1)
+      expect(updates[2].type).to.equal('add-file')
+      expect(updates[2].userId).to.equal(owner._id)
+      expect(updates[2].pathname).to.equal('/frog.jpg')
+      expect(updates[2].url).to.be.a('string')
+      expect(version).to.equal(3)
+    })
+  })
+
+  describe('duplicating a project', function () {
+    let dupProjectId
+
+    beforeEach(function (done) {
+      createExampleProject(owner, (err, projectId) => {
+        if (err) {
+          return done(err)
+        }
+        owner.request.post(
+          {
+            uri: `/Project/${projectId}/clone`,
+            json: {
+              projectName: 'new.tex',
+            },
+          },
+          (error, res, body) => {
+            if (error) {
+              throw error
+            }
+            if (res.statusCode < 200 || res.statusCode >= 300) {
+              throw new Error(`failed to clone project ${res.statusCode}`)
+            }
+            dupProjectId = body.project_id
+            done()
+          }
+        )
+      })
+    })
+
+    it('should version the docs and files created', function () {
+      const { updates, version } =
+        MockDocUpdaterApi.getProjectStructureUpdates(dupProjectId)
+      expect(updates.length).to.equal(3)
+      for (const update of updates.slice(0, 2)) {
+        expect(update.type).to.equal('add-doc')
+        expect(update.userId).to.equal(owner._id)
+        expect(update.docLines).to.be.a('string')
+      }
+      expect(_.where(updates, { pathname: '/main.tex' }).length).to.equal(1)
+      expect(_.where(updates, { pathname: '/sample.bib' }).length).to.equal(1)
+      expect(updates[2].type).to.equal('add-file')
+      expect(updates[2].userId).to.equal(owner._id)
+      expect(updates[2].pathname).to.equal('/frog.jpg')
+      expect(updates[2].url).to.be.a('string')
+      expect(version).to.equal(1)
+    })
+  })
+
+  describe('adding a doc', function () {
+    let exampleProjectId, oldVersion
+
+    beforeEach(function (done) {
+      createExampleProject(owner, (err, projectId) => {
+        if (err) {
+          return done(err)
+        }
+        exampleProjectId = projectId
+        MockDocUpdaterApi.reset()
+
+        ProjectGetter.getProject(projectId, (error, project) => {
+          if (error) {
+            return done(error)
+          }
+          oldVersion = project.version
+          createExampleDoc(owner, projectId, done)
+        })
+      })
+    })
+
+    it('should version the doc added', function (done) {
+      const { updates, version: newVersion } =
+        MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+      expect(updates.length).to.equal(1)
+      const update = updates[0]
+      expect(update.type).to.equal('add-doc')
+      expect(update.userId).to.equal(owner._id)
+      expect(update.pathname).to.equal('/new.tex')
+      expect(update.docLines).to.be.a('string')
+
+      verifyVersionIncremented(
+        exampleProjectId,
+        oldVersion,
+        newVersion,
+        1,
+        done
+      )
+    })
+  })
+
+  describe('uploading a project', function () {
+    let exampleProjectId
+
+    beforeEach(function (done) {
+      uploadExampleProject(owner, 'test_project.zip', (err, projectId) => {
+        if (err) {
+          return done(err)
+        }
+        exampleProjectId = projectId
+        done()
+      })
+    })
+
+    it('should version the docs and files created', function () {
+      const { updates, version } =
+        MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+      expect(updates.length).to.equal(2)
+      expect(updates[0].type).to.equal('add-doc')
+      expect(updates[0].userId).to.equal(owner._id)
+      expect(updates[0].pathname).to.equal('/main.tex')
+      expect(updates[0].docLines).to.equal('Test')
+      expect(updates[1].type).to.equal('add-file')
+      expect(updates[1].userId).to.equal(owner._id)
+      expect(updates[1].pathname).to.equal('/1pixel.png')
+      expect(updates[1].url).to.be.a('string')
+      expect(version).to.equal(1)
+    })
+  })
+
+  describe('uploading a project with files in different encodings', function () {
+    let updates
+    beforeEach(function (done) {
+      uploadExampleProject(owner, 'charsets/charsets.zip', (err, projectId) => {
+        if (err) {
+          return done(err)
+        }
+
+        updates =
+          MockDocUpdaterApi.getProjectStructureUpdates(projectId).updates
+        done()
+      })
+    })
+
+    it('should correctly parse windows-1252', function () {
+      const update = _.find(
+        updates,
+        update => update.pathname === '/test-german-windows-1252.tex'
+      )
+      expect(update.docLines).to.contain(
+        'Der schnelle braune Fuchs sprang träge über den Hund.'
+      )
+    })
+
+    it('should correctly parse German utf8', function () {
+      const update = _.find(
+        updates,
+        update => update.pathname === '/test-german-utf8x.tex'
+      )
+      expect(update.docLines).to.contain(
+        'Der schnelle braune Fuchs sprang träge über den Hund.'
+      )
+    })
+
+    it('should correctly parse little-endian utf16', function () {
+      const update = _.find(
+        updates,
+        update => update.pathname === '/test-greek-utf16-le-bom.tex'
+      )
+      expect(update.docLines).to.contain(
+        'Η γρήγορη καστανή αλεπού πήδηξε χαλαρά πάνω από το σκυλί.'
+      )
+    })
+
+    it('should correctly parse Greek utf8', function () {
+      const update = _.find(
+        updates,
+        update => update.pathname === '/test-greek-utf8x.tex'
+      )
+      expect(update.docLines).to.contain(
+        'Η γρήγορη καστανή αλεπού πήδηξε χαλαρά πάνω από το σκυλί.'
+      )
+    })
+  })
+
+  describe('uploading a file', function () {
+    let exampleProjectId, oldVersion, rootFolderId
+
+    beforeEach(function (done) {
+      createExampleProject(owner, (err, projectId, folderId) => {
+        if (err) {
+          return done(err)
+        }
+        exampleProjectId = projectId
+        rootFolderId = folderId
+        MockDocUpdaterApi.reset()
+        ProjectGetter.getProject(projectId, (error, project) => {
+          if (error) {
+            throw error
+          }
+
+          oldVersion = project.version
+
+          uploadExampleFile(owner, projectId, rootFolderId, done)
+        })
+      })
+    })
+
+    it('should version a newly uploaded file', function (done) {
+      const { updates, version } =
+        MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+      expect(updates.length).to.equal(1)
+      const update = updates[0]
+      expect(update.type).to.equal('add-file')
+      expect(update.userId).to.equal(owner._id)
+      expect(update.pathname).to.equal('/1pixel.png')
+      expect(update.url).to.be.a('string')
+
+      // one file upload
+      verifyVersionIncremented(exampleProjectId, oldVersion, version, 1, done)
+    })
+
+    it('should version a replacement file', function (done) {
+      MockDocUpdaterApi.reset()
+
+      uploadFile(
+        owner,
+        exampleProjectId,
+        rootFolderId,
+        '2pixel.png',
+        '1pixel.png',
+        'image/png',
+        () => {
+          const { updates, version } =
+            MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+          expect(updates.length).to.equal(2)
+          expect(updates[0].type).to.equal('rename-file')
+          expect(updates[0].userId).to.equal(owner._id)
+          expect(updates[0].pathname).to.equal('/1pixel.png')
+          expect(updates[0].newPathname).to.equal('')
+          expect(updates[1].type).to.equal('add-file')
+          expect(updates[1].userId).to.equal(owner._id)
+          expect(updates[1].pathname).to.equal('/1pixel.png')
+          expect(updates[1].url).to.be.a('string')
+
+          // two file uploads
+          verifyVersionIncremented(
+            exampleProjectId,
+            oldVersion,
+            version,
+            2,
+            done
+          )
+        }
+      )
+    })
+  })
+
+  describe('moving entities', function () {
+    let exampleProjectId,
+      oldVersion,
+      exampleDocId,
+      exampleFileId,
+      exampleFolderId
+
+    beforeEach(function (done) {
+      createExampleProject(owner, (err, projectId, rootFolderId) => {
+        if (err) {
+          return done(err)
+        }
+        exampleProjectId = projectId
+        createExampleDoc(owner, projectId, (err, docId) => {
+          if (err) {
+            return done(err)
+          }
+          exampleDocId = docId
+          uploadExampleFile(owner, projectId, rootFolderId, (err, fileId) => {
+            if (err) {
+              return done(err)
+            }
+            exampleFileId = fileId
+            createExampleFolder(owner, projectId, (err, folderId) => {
+              if (err) {
+                return done(err)
+              }
+              exampleFolderId = folderId
+
+              ProjectGetter.getProject(projectId, (error, project) => {
+                if (error) {
+                  throw error
+                }
+                oldVersion = project.version
+                MockDocUpdaterApi.reset()
+                done()
+              })
+            })
+          })
+        })
+      })
+    })
+
+    it('should version moving a doc', function (done) {
+      moveItem(
+        owner,
+        exampleProjectId,
+        'doc',
+        exampleDocId,
+        exampleFolderId,
+        () => {
+          const { updates, version } =
+            MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+          expect(updates.length).to.equal(1)
+          const update = updates[0]
+          expect(update.type).to.equal('rename-doc')
+          expect(update.userId).to.equal(owner._id)
+          expect(update.pathname).to.equal('/new.tex')
+          expect(update.newPathname).to.equal('/foo/new.tex')
+
+          // 2, because it's a delete and then add
+          verifyVersionIncremented(
+            exampleProjectId,
+            oldVersion,
+            version,
+            2,
+            done
+          )
+        }
+      )
+    })
+
+    it('should version moving a file', function (done) {
+      moveItem(
+        owner,
+        exampleProjectId,
+        'file',
+        exampleFileId,
+        exampleFolderId,
+        () => {
+          const { updates, version } =
+            MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+          expect(updates.length).to.equal(1)
+          const update = updates[0]
+          expect(update.type).to.equal('rename-file')
+          expect(update.userId).to.equal(owner._id)
+          expect(update.pathname).to.equal('/1pixel.png')
+          expect(update.newPathname).to.equal('/foo/1pixel.png')
+
+          // 2, because it's a delete and then add
+          verifyVersionIncremented(
+            exampleProjectId,
+            oldVersion,
+            version,
+            2,
+            done
+          )
+        }
+      )
+    })
+
+    it('should version moving a folder', function (done) {
+      moveItem(
+        owner,
+        exampleProjectId,
+        'doc',
+        exampleDocId,
+        exampleFolderId,
+        () => {
+          MockDocUpdaterApi.reset()
+
+          owner.request.post(
+            {
+              uri: `project/${exampleProjectId}/folder`,
+              json: {
+                name: 'bar',
+              },
+            },
+            (error, res, body) => {
+              if (error) {
+                throw error
+              }
+              const newFolderId = body._id
+
+              moveItem(
+                owner,
+                exampleProjectId,
+                'folder',
+                exampleFolderId,
+                newFolderId,
+                () => {
+                  const { updates, version } =
+                    MockDocUpdaterApi.getProjectStructureUpdates(
+                      exampleProjectId
+                    )
+                  expect(updates.length).to.equal(1)
+                  const update = updates[0]
+                  expect(update.type).to.equal('rename-doc')
+                  expect(update.userId).to.equal(owner._id)
+                  expect(update.pathname).to.equal('/foo/new.tex')
+                  expect(update.newPathname).to.equal('/bar/foo/new.tex')
+
+                  // 5, because it's two file moves plus a folder
+                  verifyVersionIncremented(
+                    exampleProjectId,
+                    oldVersion,
+                    version,
+                    5,
+                    done
+                  )
+                }
+              )
+            }
+          )
+        }
+      )
+    })
+  })
+
+  describe('renaming entities', function () {
+    let exampleProjectId,
+      exampleDocId,
+      exampleFileId,
+      exampleFolderId,
+      oldVersion
+
+    beforeEach(function (done) {
+      createExampleProject(owner, (err, projectId, rootFolderId) => {
+        if (err) {
+          return done(err)
+        }
+        exampleProjectId = projectId
+        createExampleDoc(owner, projectId, (err, docId) => {
+          if (err) {
+            return done(err)
+          }
+          exampleDocId = docId
+          uploadExampleFile(owner, projectId, rootFolderId, (err, fileId) => {
+            if (err) {
+              return done(err)
+            }
+            exampleFileId = fileId
+            createExampleFolder(owner, projectId, (err, folderId) => {
+              if (err) {
+                return done(err)
+              }
+              exampleFolderId = folderId
+              moveItem(owner, projectId, 'doc', docId, folderId, () => {
+                moveItem(owner, projectId, 'file', fileId, folderId, () => {
+                  MockDocUpdaterApi.reset()
+                  ProjectGetter.getProject(
+                    exampleProjectId,
+                    (error, project) => {
+                      if (error) {
+                        throw error
+                      }
+                      oldVersion = project.version
+                      done()
+                    }
+                  )
+                })
+              })
+            })
+          })
+        })
+      })
+    })
+
+    it('should version renaming a doc', function (done) {
+      renameItem(
+        owner,
+        exampleProjectId,
+        'Doc',
+        exampleDocId,
+        'wombat.tex',
+        () => {
+          const { updates, version } =
+            MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+          expect(updates.length).to.equal(1)
+          const update = updates[0]
+          expect(update.type).to.equal('rename-doc')
+          expect(update.userId).to.equal(owner._id)
+          expect(update.pathname).to.equal('/foo/new.tex')
+          expect(update.newPathname).to.equal('/foo/wombat.tex')
+
+          verifyVersionIncremented(
+            exampleProjectId,
+            oldVersion,
+            version,
+            1,
+            done
+          )
+        }
+      )
+    })
+
+    it('should version renaming a file', function (done) {
+      renameItem(
+        owner,
+        exampleProjectId,
+        'file',
+        exampleFileId,
+        'potato.png',
+        () => {
+          const { updates, version } =
+            MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+          expect(updates.length).to.equal(1)
+          const update = updates[0]
+          expect(update.type).to.equal('rename-file')
+          expect(update.userId).to.equal(owner._id)
+          expect(update.pathname).to.equal('/foo/1pixel.png')
+          expect(update.newPathname).to.equal('/foo/potato.png')
+
+          verifyVersionIncremented(
+            exampleProjectId,
+            oldVersion,
+            version,
+            1,
+            done
+          )
+        }
+      )
+    })
+
+    it('should version renaming a folder', function (done) {
+      renameItem(
+        owner,
+        exampleProjectId,
+        'folder',
+        exampleFolderId,
+        'giraffe',
+        () => {
+          const { updates, version } =
+            MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+          expect(updates.length).to.equal(2)
+          expect(updates[0].type).to.equal('rename-doc')
+          expect(updates[0].userId).to.equal(owner._id)
+          expect(updates[0].pathname).to.equal('/foo/new.tex')
+          expect(updates[0].newPathname).to.equal('/giraffe/new.tex')
+          expect(updates[1].type).to.equal('rename-file')
+          expect(updates[1].userId).to.equal(owner._id)
+          expect(updates[1].pathname).to.equal('/foo/1pixel.png')
+          expect(updates[1].newPathname).to.equal('/giraffe/1pixel.png')
+
+          verifyVersionIncremented(
+            exampleProjectId,
+            oldVersion,
+            version,
+            1,
+            done
+          )
+        }
+      )
+    })
+  })
+
+  describe('deleting entities', function () {
+    let exampleProjectId, oldVersion, exampleFolderId
+
+    beforeEach(function (done) {
+      createExampleProject(owner, (err, projectId) => {
+        if (err) {
+          return done(err)
+        }
+        exampleProjectId = projectId
+        createExampleFolder(owner, exampleProjectId, (err, folderId) => {
+          if (err) {
+            return done(err)
+          }
+          exampleFolderId = folderId
+          createExampleDoc(owner, projectId, (err, docId) => {
+            if (err) {
+              return done(err)
+            }
+            uploadExampleFile(owner, projectId, folderId, (err, fileId) => {
+              if (err) {
+                return done(err)
+              }
+              moveItem(owner, projectId, 'doc', docId, folderId, () => {
+                moveItem(owner, projectId, 'file', fileId, folderId, () => {
+                  MockDocUpdaterApi.reset()
+                  ProjectGetter.getProject(
+                    exampleProjectId,
+                    (error, project) => {
+                      if (error) {
+                        throw error
+                      }
+                      oldVersion = project.version
+                      done()
+                    }
+                  )
+                })
+              })
+            })
+          })
+        })
+      })
+    })
+
+    it('should version deleting a folder', function (done) {
+      deleteItem(owner, exampleProjectId, 'folder', exampleFolderId, () => {
+        const { updates, version } =
+          MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+        expect(updates.length).to.equal(2)
+        expect(updates[0].type).to.equal('rename-doc')
+        expect(updates[0].userId).to.equal(owner._id)
+        expect(updates[0].pathname).to.equal('/foo/new.tex')
+        expect(updates[0].newPathname).to.equal('')
+        expect(updates[1].type).to.equal('rename-file')
+        expect(updates[1].userId).to.equal(owner._id)
+        expect(updates[1].pathname).to.equal('/foo/1pixel.png')
+        expect(updates[1].newPathname).to.equal('')
+
+        verifyVersionIncremented(exampleProjectId, oldVersion, version, 1, done)
+      })
+    })
+  })
+
+  describe('tpds', function () {
+    let projectName, exampleProjectId, oldVersion, rootFolderId
+
+    beforeEach(function (done) {
+      projectName = `tpds-project-${new ObjectId().toString()}`
+      owner.createProject(projectName, (error, projectId) => {
+        if (error) {
+          throw error
+        }
+        exampleProjectId = projectId
+        fs.mkdir(Settings.path.dumpFolder, { recursive: true }, error => {
+          if (error) {
+            throw error
+          }
+          ProjectGetter.getProject(exampleProjectId, (error, project) => {
+            if (error) {
+              throw error
+            }
+            MockDocUpdaterApi.reset()
+            rootFolderId = project.rootFolder[0]._id.toString()
+            oldVersion = project.version
+            done()
+          })
+        })
+      })
+    })
+
+    it('should version adding a doc', function (done) {
+      const req = owner.request.post({
+        uri: `/user/${owner._id}/update/${projectName}/test.tex`,
+        auth: {
+          user: _.keys(Settings.httpAuthUsers)[0],
+          pass: _.values(Settings.httpAuthUsers)[0],
+          sendImmediately: true,
+        },
+        body: fs.createReadStream(Path.join(FILES_PATH, 'test.tex')),
+      })
+
+      req.on('error', err => {
+        throw err
+      })
+
+      req.on('response', res => {
+        if (res.statusCode < 200 || res.statusCode >= 300) {
+          throw new Error(`failed to upload file ${res.statusCode}`)
+        }
+
+        const { updates, version } =
+          MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+        expect(updates.length).to.equal(1)
+        const update = updates[0]
+        expect(update.type).to.equal('add-doc')
+        expect(update.userId).to.equal(owner._id)
+        expect(update.pathname).to.equal('/test.tex')
+        expect(update.docLines).to.equal('Test')
+
+        verifyVersionIncremented(exampleProjectId, oldVersion, version, 1, done)
+      })
+    })
+
+    it('should version adding a new file', function (done) {
+      const req = owner.request.post({
+        uri: `/user/${owner._id}/update/${projectName}/1pixel.png`,
+        auth: {
+          user: _.keys(Settings.httpAuthUsers)[0],
+          pass: _.values(Settings.httpAuthUsers)[0],
+          sendImmediately: true,
+        },
+        body: fs.createReadStream(Path.join(FILES_PATH, '1pixel.png')),
+      })
+
+      req.on('error', err => {
+        throw err
+      })
+
+      req.on('response', res => {
+        if (res.statusCode < 200 || res.statusCode >= 300) {
+          throw new Error(`failed to upload file ${res.statusCode}`)
+        }
+
+        const { updates, version } =
+          MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+        expect(updates.length).to.equal(1)
+        const update = updates[0]
+        expect(update.type).to.equal('add-file')
+        expect(update.userId).to.equal(owner._id)
+        expect(update.pathname).to.equal('/1pixel.png')
+        expect(update.url).to.be.a('string')
+
+        verifyVersionIncremented(exampleProjectId, oldVersion, version, 1, done)
+      })
+    })
+
+    describe('when there are files in the project', function () {
+      beforeEach(function (done) {
+        uploadExampleFile(owner, exampleProjectId, rootFolderId, () => {
+          createExampleDoc(owner, exampleProjectId, () => {
+            ProjectGetter.getProject(exampleProjectId, (error, project) => {
+              if (error) {
+                throw error
+              }
+              MockDocUpdaterApi.reset()
+              oldVersion = project.version
+              done()
+            })
+          })
+        })
+      })
+
+      it('should version replacing a file', function (done) {
+        const req = owner.request.post({
+          uri: `/user/${owner._id}/update/${projectName}/1pixel.png`,
+          auth: {
+            user: _.keys(Settings.httpAuthUsers)[0],
+            pass: _.values(Settings.httpAuthUsers)[0],
+            sendImmediately: true,
+          },
+          body: fs.createReadStream(Path.join(FILES_PATH, '2pixel.png')),
+        })
+
+        req.on('error', err => {
+          throw err
+        })
+
+        req.on('response', res => {
+          if (res.statusCode < 200 || res.statusCode >= 300) {
+            throw new Error(`failed to upload file ${res.statusCode}`)
+          }
+
+          const { updates, version } =
+            MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+          expect(updates.length).to.equal(2)
+          expect(updates[0].type).to.equal('rename-file')
+          expect(updates[0].userId).to.equal(owner._id)
+          expect(updates[0].pathname).to.equal('/1pixel.png')
+          expect(updates[0].newPathname).to.equal('')
+          expect(updates[1].type).to.equal('add-file')
+          expect(updates[1].userId).to.equal(owner._id)
+          expect(updates[1].pathname).to.equal('/1pixel.png')
+          expect(updates[1].url).to.be.a('string')
+
+          verifyVersionIncremented(
+            exampleProjectId,
+            oldVersion,
+            version,
+            1,
+            done
+          )
+        })
+      })
+
+      it('should version deleting a doc', function (done) {
+        owner.request.delete(
+          {
+            uri: `/user/${owner._id}/update/${projectName}/new.tex`,
+            auth: {
+              user: _.keys(Settings.httpAuthUsers)[0],
+              pass: _.values(Settings.httpAuthUsers)[0],
+              sendImmediately: true,
+            },
+          },
+          (error, res) => {
+            if (error) {
+              throw error
+            }
+            if (res.statusCode < 200 || res.statusCode >= 300) {
+              throw new Error(`failed to delete doc ${res.statusCode}`)
+            }
+
+            const { updates, version } =
+              MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+            expect(updates.length).to.equal(1)
+            const update = updates[0]
+            expect(update.type).to.equal('rename-doc')
+            expect(update.userId).to.equal(owner._id)
+            expect(update.pathname).to.equal('/new.tex')
+            expect(update.newPathname).to.equal('')
+
+            verifyVersionIncremented(
+              exampleProjectId,
+              oldVersion,
+              version,
+              1,
+              done
+            )
+          }
+        )
+      })
+    })
+  })
+
+  describe('uploading a document', function () {
+    let exampleProjectId, rootFolderId
+    beforeEach(function (done) {
+      createExampleProject(owner, (err, projectId, folderId) => {
+        if (err) {
+          return done(err)
+        }
+        exampleProjectId = projectId
+        rootFolderId = folderId
+        MockDocUpdaterApi.reset()
+        done()
+      })
+    })
+
+    describe('with an unusual character set', function () {
+      it('should correctly handle utf16-le data', function (done) {
+        uploadFile(
+          owner,
+          exampleProjectId,
+          rootFolderId,
+          'charsets/test-greek-utf16-le-bom.tex',
+          'test-greek-utf16-le-bom.tex',
+          'text/x-tex',
+          () => {
+            const { updates } =
+              MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+            expect(updates.length).to.equal(1)
+            const update = updates[0]
+            expect(update.type).to.equal('add-doc')
+            expect(update.pathname).to.equal('/test-greek-utf16-le-bom.tex')
+            expect(update.docLines).to.contain(
+              'Η γρήγορη καστανή αλεπού πήδηξε χαλαρά πάνω από το σκυλί.'
+            )
+            done()
+          }
+        )
+      })
+
+      it('should correctly handle windows1252/iso-8859-1/latin1 data', function (done) {
+        uploadFile(
+          owner,
+          exampleProjectId,
+          rootFolderId,
+          'charsets/test-german-windows-1252.tex',
+          'test-german-windows-1252.tex',
+          'text/x-tex',
+          () => {
+            const { updates } =
+              MockDocUpdaterApi.getProjectStructureUpdates(exampleProjectId)
+            expect(updates.length).to.equal(1)
+            const update = updates[0]
+            expect(update.type).to.equal('add-doc')
+            expect(update.pathname).to.equal('/test-german-windows-1252.tex')
+            expect(update.docLines).to.contain(
+              'Der schnelle braune Fuchs sprang träge über den Hund.'
+            )
+            done()
+          }
+        )
+      })
+    })
+  })
+})

+ 292 - 0
services/web/modules/history-v1/test/acceptance/src/RestoringFilesTest.js

@@ -0,0 +1,292 @@
+/* eslint-disable
+    max-len,
+    no-unused-vars,
+*/
+// TODO: This file was created by bulk-decaffeinate.
+// Fix any style issues and re-enable lint.
+/*
+ * decaffeinate suggestions:
+ * DS102: Remove unnecessary code created because of implicit returns
+ * DS207: Consider shorter variations of null checks
+ * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
+ */
+const { expect } = require('chai')
+const _ = require('underscore')
+const fs = require('fs')
+const Path = require('path')
+
+const User = require('../../../../../test/acceptance/src/helpers/User')
+const MockProjectHistoryApiClass = require('../../../../../test/acceptance/src/mocks/MockProjectHistoryApi')
+const MockDocstoreApiClass = require('../../../../../test/acceptance/src/mocks/MockDocstoreApi')
+const MockFilestoreApiClass = require('../../../../../test/acceptance/src/mocks/MockFilestoreApi')
+
+let MockProjectHistoryApi, MockDocstoreApi, MockFilestoreApi
+
+before(function () {
+  MockProjectHistoryApi = MockProjectHistoryApiClass.instance()
+  MockDocstoreApi = MockDocstoreApiClass.instance()
+  MockFilestoreApi = MockFilestoreApiClass.instance()
+})
+
+describe('RestoringFiles', function () {
+  beforeEach(function (done) {
+    this.owner = new User()
+    return this.owner.login(error => {
+      if (error != null) {
+        throw error
+      }
+      return this.owner.createProject(
+        'example-project',
+        { template: 'example' },
+        (error, projectId) => {
+          this.project_id = projectId
+          if (error != null) {
+            throw error
+          }
+          return done()
+        }
+      )
+    })
+  })
+
+  describe('restoring from v2 history', function () {
+    describe('restoring a text file', function () {
+      beforeEach(function (done) {
+        MockProjectHistoryApi.addOldFile(
+          this.project_id,
+          42,
+          'foo.tex',
+          'hello world, this is foo.tex!'
+        )
+        return this.owner.request(
+          {
+            method: 'POST',
+            url: `/project/${this.project_id}/restore_file`,
+            json: {
+              pathname: 'foo.tex',
+              version: 42,
+            },
+          },
+          (error, response, body) => {
+            if (error != null) {
+              throw error
+            }
+            expect(response.statusCode).to.equal(200)
+            return done()
+          }
+        )
+      })
+
+      it('should have created a doc', function (done) {
+        return this.owner.getProject(this.project_id, (error, project) => {
+          if (error != null) {
+            throw error
+          }
+          let doc = _.find(
+            project.rootFolder[0].docs,
+            doc => doc.name === 'foo.tex'
+          )
+          doc = MockDocstoreApi.docs[this.project_id][doc._id]
+          expect(doc.lines).to.deep.equal(['hello world, this is foo.tex!'])
+          return done()
+        })
+      })
+    })
+
+    describe('restoring a binary file', function () {
+      beforeEach(function (done) {
+        this.pngData = fs.readFileSync(
+          Path.resolve(
+            __dirname,
+            '../../../../../test/acceptance/files/1pixel.png'
+          ),
+          'binary'
+        )
+        MockProjectHistoryApi.addOldFile(
+          this.project_id,
+          42,
+          'image.png',
+          this.pngData
+        )
+        return this.owner.request(
+          {
+            method: 'POST',
+            url: `/project/${this.project_id}/restore_file`,
+            json: {
+              pathname: 'image.png',
+              version: 42,
+            },
+          },
+          (error, response, body) => {
+            if (error != null) {
+              throw error
+            }
+            expect(response.statusCode).to.equal(200)
+            return done()
+          }
+        )
+      })
+
+      it('should have created a file', function (done) {
+        return this.owner.getProject(this.project_id, (error, project) => {
+          if (error != null) {
+            throw error
+          }
+          let file = _.find(
+            project.rootFolder[0].fileRefs,
+            file => file.name === 'image.png'
+          )
+          file = MockFilestoreApi.files[this.project_id][file._id]
+          expect(file.content).to.equal(this.pngData)
+          return done()
+        })
+      })
+    })
+
+    describe('restoring to a directory that exists', function () {
+      beforeEach(function (done) {
+        MockProjectHistoryApi.addOldFile(
+          this.project_id,
+          42,
+          'foldername/foo2.tex',
+          'hello world, this is foo-2.tex!'
+        )
+        return this.owner.request.post(
+          {
+            uri: `project/${this.project_id}/folder`,
+            json: {
+              name: 'foldername',
+            },
+          },
+          (error, response, body) => {
+            if (error != null) {
+              throw error
+            }
+            expect(response.statusCode).to.equal(200)
+            return this.owner.request(
+              {
+                method: 'POST',
+                url: `/project/${this.project_id}/restore_file`,
+                json: {
+                  pathname: 'foldername/foo2.tex',
+                  version: 42,
+                },
+              },
+              (error, response, body) => {
+                if (error != null) {
+                  throw error
+                }
+                expect(response.statusCode).to.equal(200)
+                return done()
+              }
+            )
+          }
+        )
+      })
+
+      it('should have created the doc in the named folder', function (done) {
+        return this.owner.getProject(this.project_id, (error, project) => {
+          if (error != null) {
+            throw error
+          }
+          const folder = _.find(
+            project.rootFolder[0].folders,
+            folder => folder.name === 'foldername'
+          )
+          let doc = _.find(folder.docs, doc => doc.name === 'foo2.tex')
+          doc = MockDocstoreApi.docs[this.project_id][doc._id]
+          expect(doc.lines).to.deep.equal(['hello world, this is foo-2.tex!'])
+          return done()
+        })
+      })
+    })
+
+    describe('restoring to a directory that no longer exists', function () {
+      beforeEach(function (done) {
+        MockProjectHistoryApi.addOldFile(
+          this.project_id,
+          42,
+          'nothere/foo3.tex',
+          'hello world, this is foo-3.tex!'
+        )
+        return this.owner.request(
+          {
+            method: 'POST',
+            url: `/project/${this.project_id}/restore_file`,
+            json: {
+              pathname: 'nothere/foo3.tex',
+              version: 42,
+            },
+          },
+          (error, response, body) => {
+            if (error != null) {
+              throw error
+            }
+            expect(response.statusCode).to.equal(200)
+            return done()
+          }
+        )
+      })
+
+      it('should have created the folder and restored the doc to it', function (done) {
+        return this.owner.getProject(this.project_id, (error, project) => {
+          if (error != null) {
+            throw error
+          }
+          const folder = _.find(
+            project.rootFolder[0].folders,
+            folder => folder.name === 'nothere'
+          )
+          expect(folder).to.exist
+          let doc = _.find(folder.docs, doc => doc.name === 'foo3.tex')
+          doc = MockDocstoreApi.docs[this.project_id][doc._id]
+          expect(doc.lines).to.deep.equal(['hello world, this is foo-3.tex!'])
+          return done()
+        })
+      })
+    })
+
+    describe('restoring to a filename that already exists', function () {
+      beforeEach(function (done) {
+        MockProjectHistoryApi.addOldFile(
+          this.project_id,
+          42,
+          'main.tex',
+          'hello world, this is main.tex!'
+        )
+        return this.owner.request(
+          {
+            method: 'POST',
+            url: `/project/${this.project_id}/restore_file`,
+            json: {
+              pathname: 'main.tex',
+              version: 42,
+            },
+          },
+          (error, response, body) => {
+            if (error != null) {
+              throw error
+            }
+            expect(response.statusCode).to.equal(200)
+            return done()
+          }
+        )
+      })
+
+      it('should have created the doc in the root folder', function (done) {
+        return this.owner.getProject(this.project_id, (error, project) => {
+          if (error != null) {
+            throw error
+          }
+          let doc = _.find(project.rootFolder[0].docs, doc =>
+            doc.name.match(/main \(Restored on/)
+          )
+          expect(doc).to.exist
+          doc = MockDocstoreApi.docs[this.project_id][doc._id]
+          expect(doc.lines).to.deep.equal(['hello world, this is main.tex!'])
+          return done()
+        })
+      })
+    })
+  })
+})