Files
overleaf-cep/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterHandlerTests.js
T
andrew rumble b00d1336d4 Switch to mongodb-legacy
GitOrigin-RevId: 11e09528c153de6b7766d18c3c90d94962190371
2024-08-21 08:04:24 +00:00

1607 lines
46 KiB
JavaScript

const sinon = require('sinon')
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const { ObjectId } = require('mongodb-legacy')
const modulePath = path.join(
__dirname,
'../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler'
)
describe('DocumentUpdaterHandler', function () {
beforeEach(function () {
this.project_id = 'project-id-923'
this.projectHistoryId = 'ol-project-id-1'
this.doc_id = 'doc-id-394'
this.lines = ['one', 'two', 'three']
this.version = 42
this.user_id = 'mock-user-id-123'
this.project = { _id: this.project_id }
this.request = sinon.stub()
this.projectEntityHandler = {}
this.settings = {
apis: {
documentupdater: {
url: 'http://document_updater.example.com',
},
project_history: {
url: 'http://project_history.example.com',
},
},
}
this.source = 'dropbox'
this.callback = sinon.stub()
this.handler = SandboxedModule.require(modulePath, {
requires: {
request: {
defaults: () => {
return this.request
},
},
'@overleaf/settings': this.settings,
'../Project/ProjectEntityHandler': this.projectEntityHandler,
'../../models/Project': {
Project: (this.Project = {}),
},
'../Project/ProjectGetter': (this.ProjectGetter = {
getProjectWithoutLock: sinon.stub(),
}),
'../../Features/Project/ProjectLocator': {},
'@overleaf/metrics': {
Timer: class {
done() {}
},
},
'../FileStore/FileStoreHandler': {
_buildUrl: sinon.stub().callsFake((projectId, fileId) => {
return `http://filestore/project/${projectId}/file/${fileId}`
}),
},
},
})
this.ProjectGetter.getProjectWithoutLock
.withArgs(this.project_id)
.yields(null, this.project)
})
describe('flushProjectToMongo', function () {
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 204 }, '')
this.handler.flushProjectToMongo(this.project_id, this.callback)
})
it('should flush the document from the document updater', function () {
this.request
.calledWithMatch({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/flush`,
method: 'POST',
})
.should.equal(true)
})
it('should call the callback with no error', function () {
this.callback.calledWith(null).should.equal(true)
})
})
describe('when the document updater API returns an error', function () {
beforeEach(function () {
this.request.callsArgWith(
1,
new Error('something went wrong'),
null,
null
)
this.handler.flushProjectToMongo(this.project_id, this.callback)
})
it('should return an error to the callback', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
this.handler.flushProjectToMongo(this.project_id, this.callback)
})
it('should return the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'document updater returned a failure status code: 500'
)
)
)
.should.equal(true)
})
})
})
describe('flushProjectToMongoAndDelete', function () {
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 204 }, '')
this.handler.flushProjectToMongoAndDelete(
this.project_id,
this.callback
)
})
it('should delete the project from the document updater', function () {
this.request
.calledWithMatch({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}`,
method: 'DELETE',
})
.should.equal(true)
})
it('should call the callback with no error', function () {
this.callback.calledWith(null).should.equal(true)
})
})
describe('when the document updater API returns an error', function () {
beforeEach(function () {
this.request.callsArgWith(
1,
new Error('something went wrong'),
null,
null
)
this.handler.flushProjectToMongoAndDelete(
this.project_id,
this.callback
)
})
it('should return an error to the callback', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
this.handler.flushProjectToMongoAndDelete(
this.project_id,
this.callback
)
})
it('should return the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'document updater returned a failure status code: 500'
)
)
)
.should.equal(true)
})
})
})
describe('flushDocToMongo', function () {
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 204 }, '')
this.handler.flushDocToMongo(
this.project_id,
this.doc_id,
this.callback
)
})
it('should flush the document from the document updater', function () {
this.request
.calledWithMatch({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}/flush`,
method: 'POST',
})
.should.equal(true)
})
it('should call the callback with no error', function () {
this.callback.calledWith(null).should.equal(true)
})
})
describe('when the document updater API returns an error', function () {
beforeEach(function () {
this.request.callsArgWith(
1,
new Error('something went wrong'),
null,
null
)
this.handler.flushDocToMongo(
this.project_id,
this.doc_id,
this.callback
)
})
it('should return an error to the callback', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
this.handler.flushDocToMongo(
this.project_id,
this.doc_id,
this.callback
)
})
it('should return the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'document updater returned a failure status code: 500'
)
)
)
.should.equal(true)
})
})
})
describe('deleteDoc', function () {
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 204 }, '')
this.handler.deleteDoc(this.project_id, this.doc_id, this.callback)
})
it('should delete the document from the document updater', function () {
this.request
.calledWithMatch({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}`,
method: 'DELETE',
})
.should.equal(true)
})
it('should call the callback with no error', function () {
this.callback.calledWith(null).should.equal(true)
})
})
describe('when the document updater API returns an error', function () {
beforeEach(function () {
this.request.callsArgWith(
1,
new Error('something went wrong'),
null,
null
)
this.handler.deleteDoc(this.project_id, this.doc_id, this.callback)
})
it('should return an error to the callback', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
this.handler.deleteDoc(this.project_id, this.doc_id, this.callback)
})
it('should return the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'document updater returned a failure status code: 500'
)
)
)
.should.equal(true)
})
})
describe("with 'ignoreFlushErrors' option", function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 204 }, '')
})
it('when option is true, should send a `ignore_flush_errors=true` URL query to document-updater', function () {
this.handler.deleteDoc(
this.project_id,
this.doc_id,
true,
this.callback
)
this.request
.calledWithMatch({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}?ignore_flush_errors=true`,
method: 'DELETE',
})
.should.equal(true)
})
it("when option is false, shouldn't send any URL query to document-updater", function () {
this.handler.deleteDoc(
this.project_id,
this.doc_id,
false,
this.callback
)
this.request
.calledWithMatch({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}`,
method: 'DELETE',
})
.should.equal(true)
})
})
})
describe('setDocument', function () {
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 204 }, '')
this.handler.setDocument(
this.project_id,
this.doc_id,
this.user_id,
this.lines,
this.source,
this.callback
)
})
it('should set the document in the document updater', function () {
this.request
.calledWith({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}`,
json: {
lines: this.lines,
source: this.source,
user_id: this.user_id,
},
method: 'POST',
timeout: 30 * 1000,
})
.should.equal(true)
})
it('should call the callback with no error', function () {
this.callback.calledWith(null).should.equal(true)
})
})
describe('when the document updater API returns an error', function () {
beforeEach(function () {
this.request.callsArgWith(
1,
new Error('something went wrong'),
null,
null
)
this.handler.setDocument(
this.project_id,
this.doc_id,
this.user_id,
this.lines,
this.source,
this.callback
)
})
it('should return an error to the callback', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
this.handler.setDocument(
this.project_id,
this.doc_id,
this.user_id,
this.lines,
this.source,
this.callback
)
})
it('should return the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'document updater returned a failure status code: 500'
)
)
)
.should.equal(true)
})
})
})
describe('getDocument', function () {
describe('successfully', function () {
beforeEach(function () {
this.body = {
lines: this.lines,
version: this.version,
ops: (this.ops = ['mock-op-1', 'mock-op-2']),
ranges: (this.ranges = { mock: 'ranges' }),
}
this.fromVersion = 2
this.request.callsArgWith(1, null, { statusCode: 200 }, this.body)
this.handler.getDocument(
this.project_id,
this.doc_id,
this.fromVersion,
this.callback
)
})
it('should get the document from the document updater', function () {
this.request
.calledWith({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}?fromVersion=${this.fromVersion}`,
method: 'GET',
json: true,
timeout: 30 * 1000,
})
.should.equal(true)
})
it('should call the callback with the lines and version', function () {
this.callback
.calledWith(null, this.lines, this.version, this.ranges, this.ops)
.should.equal(true)
})
})
describe('when the document updater API returns an error', function () {
beforeEach(function () {
this.request.callsArgWith(
1,
new Error('something went wrong'),
null,
null
)
this.handler.getDocument(
this.project_id,
this.doc_id,
this.fromVersion,
this.callback
)
})
it('should return an error to the callback', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
this.handler.getDocument(
this.project_id,
this.doc_id,
this.fromVersion,
this.callback
)
})
it('should return the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'document updater returned a failure status code: 500'
)
)
)
.should.equal(true)
})
})
})
describe('getProjectDocsIfMatch', function () {
beforeEach(function () {
this.project_state_hash = '1234567890abcdef'
})
describe('successfully', function () {
beforeEach(function () {
this.doc0 = {
_id: this.doc_id,
lines: this.lines,
v: this.version,
}
this.docs = [this.doc0, this.doc0, this.doc0]
this.body = JSON.stringify(this.docs)
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 200 }, this.body)
this.handler.getProjectDocsIfMatch(
this.project_id,
this.project_state_hash,
this.callback
)
})
it('should get the documents from the document updater', function () {
const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}/get_and_flush_if_old?state=${this.project_state_hash}`
this.request.post.calledWith(url).should.equal(true)
})
it('should call the callback with the documents', function () {
this.callback.calledWithExactly(null, this.docs).should.equal(true)
})
})
describe('when the document updater API returns an error', function () {
beforeEach(function () {
this.request.post = sinon
.stub()
.callsArgWith(1, new Error('something went wrong'), null, null)
this.handler.getProjectDocsIfMatch(
this.project_id,
this.project_state_hash,
this.callback
)
})
it('should return an error to the callback', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
describe('when the document updater returns a conflict error code', function () {
beforeEach(function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 409 }, 'Conflict')
this.handler.getProjectDocsIfMatch(
this.project_id,
this.project_state_hash,
this.callback
)
})
it('should return the callback with no documents', function () {
this.callback.alwaysCalledWithExactly().should.equal(true)
})
})
})
describe('clearProjectState', function () {
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 200 })
this.handler.clearProjectState(this.project_id, this.callback)
})
it('should clear the project state from the document updater', function () {
this.request
.calledWithMatch({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/clearState`,
method: 'POST',
})
.should.equal(true)
})
it('should call the callback', function () {
this.callback.calledWith(null).should.equal(true)
})
})
describe('when the document updater API returns an error', function () {
beforeEach(function () {
this.request.callsArgWith(
1,
new Error('something went wrong'),
null,
null
)
this.handler.clearProjectState(this.project_id, this.callback)
})
it('should return an error to the callback', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
describe('when the document updater returns an error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, null)
this.handler.clearProjectState(this.project_id, this.callback)
})
it('should return the callback with no documents', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'document updater returned a failure status code: 500'
)
)
)
.should.equal(true)
})
})
})
describe('acceptChanges', function () {
beforeEach(function () {
this.change_id = 'mock-change-id-1'
})
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 200 }, this.body)
this.handler.acceptChanges(
this.project_id,
this.doc_id,
[this.change_id],
this.callback
)
})
it('should accept the change in the document updater', function () {
this.request
.calledWith({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}/change/accept`,
json: {
change_ids: [this.change_id],
},
method: 'POST',
timeout: 30 * 1000,
})
.should.equal(true)
})
it('should call the callback', function () {
this.callback.calledWith(null).should.equal(true)
})
})
describe('when the document updater API returns an error', function () {
beforeEach(function () {
this.request.callsArgWith(
1,
new Error('something went wrong'),
null,
null
)
this.handler.acceptChanges(
this.project_id,
this.doc_id,
[this.change_id],
this.callback
)
})
it('should return an error to the callback', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
this.handler.acceptChanges(
this.project_id,
this.doc_id,
[this.change_id],
this.callback
)
})
it('should return the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'document updater returned a failure status code: 500'
)
)
)
.should.equal(true)
})
})
})
describe('deleteThread', function () {
beforeEach(function () {
this.thread_id = 'mock-thread-id-1'
})
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 200 }, this.body)
this.handler.deleteThread(
this.project_id,
this.doc_id,
this.thread_id,
this.user_id,
this.callback
)
})
it('should delete the thread in the document updater', function () {
this.request
.calledWithMatch({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}/comment/${this.thread_id}`,
method: 'DELETE',
})
.should.equal(true)
})
it('should call the callback', function () {
this.callback.calledWith(null).should.equal(true)
})
})
describe('when the document updater API returns an error', function () {
beforeEach(function () {
this.request.callsArgWith(
1,
new Error('something went wrong'),
null,
null
)
this.handler.deleteThread(
this.project_id,
this.doc_id,
this.thread_id,
this.user_id,
this.callback
)
})
it('should return an error to the callback', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
this.handler.deleteThread(
this.project_id,
this.doc_id,
this.thread_id,
this.user_id,
this.callback
)
})
it('should return the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'document updater returned a failure status code: 500'
)
)
)
.should.equal(true)
})
})
})
describe('resolveThread', function () {
beforeEach(function () {
this.thread_id = 'mock-thread-id-1'
})
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 200 }, this.body)
this.handler.resolveThread(
this.project_id,
this.doc_id,
this.thread_id,
this.user_id,
this.callback
)
})
it('should resolve the thread in the document updater', function () {
this.request
.calledWithMatch({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}/comment/${this.thread_id}/resolve`,
method: 'POST',
})
.should.equal(true)
})
it('should call the callback', function () {
this.callback.calledWith(null).should.equal(true)
})
})
describe('when the document updater API returns an error', function () {
beforeEach(function () {
this.request.callsArgWith(
1,
new Error('something went wrong'),
null,
null
)
this.handler.resolveThread(
this.project_id,
this.doc_id,
this.thread_id,
this.user_id,
this.callback
)
})
it('should return an error to the callback', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
this.handler.resolveThread(
this.project_id,
this.doc_id,
this.thread_id,
this.user_id,
this.callback
)
})
it('should return the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'document updater returned a failure status code: 500'
)
)
)
.should.equal(true)
})
})
})
describe('reopenThread', function () {
beforeEach(function () {
this.thread_id = 'mock-thread-id-1'
})
describe('successfully', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 200 }, this.body)
this.handler.reopenThread(
this.project_id,
this.doc_id,
this.thread_id,
this.user_id,
this.callback
)
})
it('should reopen the thread in the document updater', function () {
this.request
.calledWithMatch({
url: `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}/comment/${this.thread_id}/reopen`,
method: 'POST',
})
.should.equal(true)
})
it('should call the callback', function () {
this.callback.calledWith(null).should.equal(true)
})
})
describe('when the document updater API returns an error', function () {
beforeEach(function () {
this.request.callsArgWith(
1,
new Error('something went wrong'),
null,
null
)
this.handler.reopenThread(
this.project_id,
this.doc_id,
this.thread_id,
this.user_id,
this.callback
)
})
it('should return an error to the callback', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
this.handler.reopenThread(
this.project_id,
this.doc_id,
this.thread_id,
this.user_id,
this.callback
)
})
it('should return the callback with an error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(
sinon.match.has(
'message',
'document updater returned a failure status code: 500'
)
)
)
.should.equal(true)
})
})
})
describe('updateProjectStructure ', function () {
beforeEach(function () {
this.user_id = 1234
this.version = 999
})
describe('with project history disabled', function () {
beforeEach(function () {
this.settings.apis.project_history.sendProjectStructureOps = false
this.handler.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
{},
this.source,
this.callback
)
})
it('does not make a web request', function () {
this.request.called.should.equal(false)
})
it('calls the callback', function () {
this.callback.called.should.equal(true)
})
})
describe('with project history enabled', function () {
beforeEach(function () {
this.settings.apis.project_history.sendProjectStructureOps = true
this.url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}`
this.request.callsArgWith(1, null, { statusCode: 204 }, '')
})
describe('when an entity has changed name', function () {
it('should send the structure update to the document updater', function (done) {
this.docIdA = new ObjectId()
this.docIdB = new ObjectId()
this.changes = {
oldDocs: [
{ path: '/old_a', doc: { _id: this.docIdA } },
{ path: '/old_b', doc: { _id: this.docIdB } },
],
// create new instances of the same ObjectIds so that == doesn't pass
newDocs: [
{
path: '/old_a',
doc: { _id: new ObjectId(this.docIdA.toString()) },
},
{
path: '/new_b',
doc: { _id: new ObjectId(this.docIdB.toString()) },
},
],
newProject: { version: this.version },
}
const updates = [
{
type: 'rename-doc',
id: this.docIdB.toString(),
pathname: '/old_b',
newPathname: '/new_b',
},
]
this.handler.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
this.changes,
this.source,
() => {
this.request
.calledWith({
url: this.url,
method: 'POST',
json: {
updates,
userId: this.user_id,
version: this.version,
projectHistoryId: this.projectHistoryId,
source: this.source,
},
timeout: 30 * 1000,
})
.should.equal(true)
done()
}
)
})
})
describe('when a doc has been added', function () {
it('should send the structure update to the document updater', function (done) {
this.docId = new ObjectId()
this.changes = {
newDocs: [
{ path: '/foo', docLines: 'a\nb', doc: { _id: this.docId } },
],
newProject: { version: this.version },
}
const updates = [
{
type: 'add-doc',
id: this.docId.toString(),
pathname: '/foo',
docLines: 'a\nb',
historyRangesSupport: false,
url: undefined,
hash: undefined,
ranges: undefined,
metadata: undefined,
},
]
this.handler.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
this.changes,
this.source,
() => {
this.request.should.have.been.calledWith({
url: this.url,
method: 'POST',
json: {
updates,
userId: this.user_id,
version: this.version,
projectHistoryId: this.projectHistoryId,
source: this.source,
},
timeout: 30 * 1000,
})
done()
}
)
})
})
describe('when a file has been added', function () {
it('should send the structure update to the document updater', function (done) {
this.fileId = new ObjectId()
this.changes = {
newFiles: [
{
path: '/bar',
url: 'filestore.example.com/file',
file: { _id: this.fileId, hash: '12345' },
},
],
newProject: { version: this.version },
}
const updates = [
{
type: 'add-file',
id: this.fileId.toString(),
pathname: '/bar',
url: 'filestore.example.com/file',
docLines: undefined,
historyRangesSupport: false,
hash: '12345',
ranges: undefined,
metadata: undefined,
},
]
this.handler.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
this.changes,
this.source,
() => {
this.request.should.have.been.calledWith({
url: this.url,
method: 'POST',
json: {
updates,
userId: this.user_id,
version: this.version,
projectHistoryId: this.projectHistoryId,
source: this.source,
},
timeout: 30 * 1000,
})
done()
}
)
})
})
describe('when an entity has been deleted', function () {
it('should end the structure update to the document updater', function (done) {
this.docId = new ObjectId()
this.changes = {
oldDocs: [
{ path: '/foo', docLines: 'a\nb', doc: { _id: this.docId } },
],
newProject: { version: this.version },
}
const updates = [
{
type: 'rename-doc',
id: this.docId.toString(),
pathname: '/foo',
newPathname: '',
},
]
this.handler.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
this.changes,
this.source,
() => {
this.request.should.have.been.calledWith({
url: this.url,
method: 'POST',
json: {
updates,
userId: this.user_id,
version: this.version,
projectHistoryId: this.projectHistoryId,
source: this.source,
},
timeout: 30 * 1000,
})
done()
}
)
})
})
describe('when a file is converted to a doc', function () {
it('should send the delete first', function (done) {
this.docId = new ObjectId()
this.fileId = new ObjectId()
this.changes = {
oldFiles: [
{
path: '/foo.doc',
url: 'filestore.example.com/file',
file: { _id: this.fileId },
},
],
newDocs: [
{
path: '/foo.doc',
docLines: 'hello there',
doc: { _id: this.docId },
},
],
newProject: { version: this.version },
}
const updates = [
{
type: 'rename-file',
id: this.fileId.toString(),
pathname: '/foo.doc',
newPathname: '',
},
{
type: 'add-doc',
id: this.docId.toString(),
pathname: '/foo.doc',
docLines: 'hello there',
historyRangesSupport: false,
url: undefined,
hash: undefined,
ranges: undefined,
metadata: undefined,
},
]
this.handler.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
this.changes,
this.source,
() => {
this.request.should.have.been.calledWith({
url: this.url,
method: 'POST',
json: {
updates,
userId: this.user_id,
version: this.version,
projectHistoryId: this.projectHistoryId,
source: this.source,
},
timeout: 30 * 1000,
})
done()
}
)
})
})
describe('when the project version is missing', function () {
it('should call the callback with an error', function () {
this.docId = new ObjectId()
this.changes = {
oldDocs: [
{ path: '/foo', docLines: 'a\nb', doc: { _id: this.docId } },
],
}
this.handler.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
this.changes,
this.source,
this.callback
)
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
const firstCallArgs = this.callback.args[0]
firstCallArgs[0].message.should.equal(
'did not receive project version in changes'
)
})
})
describe('when ranges are present', function () {
beforeEach(function () {
this.docId = new ObjectId()
this.ranges = {
changes: [
{
op: { p: 0, i: 'foo' },
metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-1' },
},
{
op: { p: 7, d: ' baz' },
metadata: { ts: '2024-02-01T00:00:00.000Z', user_id: 'user-1' },
},
],
comments: [
{
op: { p: 4, c: 'bar', t: 'comment-1' },
metadata: { resolved: false },
},
],
}
this.changes = {
newDocs: [
{
path: '/foo',
docLines: 'foo\nbar',
doc: { _id: this.docId },
ranges: this.ranges,
},
],
newProject: { version: this.version },
}
})
it('should forward ranges', function (done) {
const updates = [
{
type: 'add-doc',
id: this.docId.toString(),
pathname: '/foo',
docLines: 'foo\nbar',
historyRangesSupport: false,
url: undefined,
hash: undefined,
ranges: this.ranges,
metadata: undefined,
},
]
this.handler.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
this.changes,
this.source,
() => {
this.request.should.have.been.calledWith({
url: this.url,
method: 'POST',
json: {
updates,
userId: this.user_id,
version: this.version,
projectHistoryId: this.projectHistoryId,
source: this.source,
},
timeout: 30 * 1000,
})
done()
}
)
})
it('should include flag when history ranges support is enabled', function (done) {
this.ProjectGetter.getProjectWithoutLock
.withArgs(this.project_id)
.yields(null, {
_id: this.project_id,
overleaf: { history: { rangesSupportEnabled: true } },
})
const updates = [
{
type: 'add-doc',
id: this.docId.toString(),
pathname: '/foo',
docLines: 'foo\nbar',
historyRangesSupport: true,
url: undefined,
hash: undefined,
ranges: this.ranges,
metadata: undefined,
},
]
this.handler.updateProjectStructure(
this.project_id,
this.projectHistoryId,
this.user_id,
this.changes,
this.source,
() => {
this.request.should.have.been.calledWith({
url: this.url,
method: 'POST',
json: {
updates,
userId: this.user_id,
version: this.version,
projectHistoryId: this.projectHistoryId,
source: this.source,
},
timeout: 30 * 1000,
})
done()
}
)
})
})
})
})
describe('resyncProjectHistory', function () {
it('should add docs', function (done) {
const docId1 = new ObjectId()
const docId2 = new ObjectId()
const docs = [
{ doc: { _id: docId1 }, path: 'main.tex' },
{ doc: { _id: docId2 }, path: 'references.bib' },
]
const files = []
this.request.yields(null, { statusCode: 200 })
const projectId = new ObjectId()
const projectHistoryId = 99
this.handler.resyncProjectHistory(
projectId,
projectHistoryId,
docs,
files,
{},
() => {
this.request.should.have.been.calledWith({
url: `${this.settings.apis.documentupdater.url}/project/${projectId}/history/resync`,
method: 'POST',
json: {
docs: [
{ doc: docId1, path: 'main.tex' },
{ doc: docId2, path: 'references.bib' },
],
files: [],
projectHistoryId,
},
timeout: 6 * 60 * 1000,
})
done()
}
)
})
it('should add files', function (done) {
const fileId1 = new ObjectId()
const fileId2 = new ObjectId()
const fileId3 = new ObjectId()
const fileCreated2 = new Date()
const fileCreated3 = new Date()
const otherProjectId = new ObjectId().toString()
const files = [
{ file: { _id: fileId1, hash: '42' }, path: '1.png' },
{
file: {
_id: fileId2,
hash: '1337',
created: fileCreated2,
linkedFileData: {
provider: 'references-provider',
},
},
path: '1.bib',
},
{
file: {
_id: fileId3,
hash: '21',
created: fileCreated3,
linkedFileData: {
provider: 'project_output_file',
build_id: '1234-abc',
clsiServerId: 'server-1',
source_project_id: otherProjectId,
source_output_file_path: 'foo/bar.txt',
},
},
path: 'bar.txt',
},
]
const docs = []
this.request.yields(null, { statusCode: 200 })
const projectId = new ObjectId()
const projectHistoryId = 99
this.handler.resyncProjectHistory(
projectId,
projectHistoryId,
docs,
files,
{},
() => {
this.request.should.have.been.calledWith({
url: `${this.settings.apis.documentupdater.url}/project/${projectId}/history/resync`,
method: 'POST',
json: {
docs: [],
files: [
{
file: fileId1,
_hash: '42',
path: '1.png',
url: `http://filestore/project/${projectId}/file/${fileId1}`,
metadata: undefined,
},
{
file: fileId2,
_hash: '1337',
path: '1.bib',
url: `http://filestore/project/${projectId}/file/${fileId2}`,
metadata: {
importedAt: fileCreated2,
provider: 'references-provider',
},
},
{
file: fileId3,
_hash: '21',
path: 'bar.txt',
url: `http://filestore/project/${projectId}/file/${fileId3}`,
metadata: {
importedAt: fileCreated3,
provider: 'project_output_file',
source_project_id: otherProjectId,
source_output_file_path: 'foo/bar.txt',
// build_id and clsiServerId are omitted
},
},
],
projectHistoryId,
},
timeout: 6 * 60 * 1000,
})
done()
}
)
})
})
})