diff --git a/services/web/modules/history-v1/index.js b/services/web/modules/history-v1/index.js new file mode 100644 index 0000000000..4ba52ba2c8 --- /dev/null +++ b/services/web/modules/history-v1/index.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/services/web/modules/history-v1/test/acceptance/config/settings.test.js b/services/web/modules/history-v1/test/acceptance/config/settings.test.js new file mode 100644 index 0000000000..07225a85fb --- /dev/null +++ b/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, + }, +}) diff --git a/services/web/modules/history-v1/test/acceptance/src/HistoryTests.js b/services/web/modules/history-v1/test/acceptance/src/HistoryTests.js new file mode 100644 index 0000000000..25c73cc47c --- /dev/null +++ b/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() + } + ) + } + ) + }) + }) + }) +}) diff --git a/services/web/modules/history-v1/test/acceptance/src/Init.js b/services/web/modules/history-v1/test/acceptance/src/Init.js new file mode 100644 index 0000000000..743a69677c --- /dev/null +++ b/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) diff --git a/services/web/modules/history-v1/test/acceptance/src/LabelsTests.js b/services/web/modules/history-v1/test/acceptance/src/LabelsTests.js new file mode 100644 index 0000000000..99bfd39d54 --- /dev/null +++ b/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() + } + ) + }) +}) diff --git a/services/web/modules/history-v1/test/acceptance/src/ProjectStructureTests.js b/services/web/modules/history-v1/test/acceptance/src/ProjectStructureTests.js new file mode 100644 index 0000000000..798c168eeb --- /dev/null +++ b/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() + } + ) + }) + }) + }) +}) diff --git a/services/web/modules/history-v1/test/acceptance/src/RestoringFilesTest.js b/services/web/modules/history-v1/test/acceptance/src/RestoringFilesTest.js new file mode 100644 index 0000000000..b1f31dcc9a --- /dev/null +++ b/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() + }) + }) + }) + }) +})