overleaf/services/docstore/test/unit/js/DocManagerTests.js
Christopher Hoskin 5c70e5c534 Merge pull request #15475 from overleaf/csh-issue-11625-mongo-ug-5-docstore
Upgrade mongodb module for docstore from 4.11.0 to 6.2.0

GitOrigin-RevId: 443bdcc80398f7cd21bc78a801af3033d2b8e921
2023-12-14 09:03:21 +00:00

794 lines
24 KiB
JavaScript

const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = require('path').join(__dirname, '../../../app/js/DocManager')
const { ObjectId } = require('mongodb')
const Errors = require('../../../app/js/Errors')
describe('DocManager', function () {
beforeEach(function () {
this.doc_id = new ObjectId().toString()
this.project_id = new ObjectId().toString()
this.another_project_id = new ObjectId().toString()
this.stubbedError = new Error('blew up')
this.version = 42
this.MongoManager = {
promises: {
findDoc: sinon.stub(),
getDocVersion: sinon.stub().resolves(this.version),
getProjectsDocs: sinon.stub(),
patchDoc: sinon.stub().resolves(),
upsertIntoDocCollection: sinon.stub().resolves(),
},
}
this.DocArchiveManager = {
promises: {
unarchiveDoc: sinon.stub(),
unArchiveAllDocs: sinon.stub(),
archiveDoc: sinon.stub().resolves(),
},
}
this.RangeManager = {
jsonRangesToMongo(r) {
return r
},
shouldUpdateRanges: sinon.stub().returns(false),
}
this.settings = { docstore: {} }
this.DocManager = SandboxedModule.require(modulePath, {
requires: {
'./MongoManager': this.MongoManager,
'./DocArchiveManager': this.DocArchiveManager,
'./RangeManager': this.RangeManager,
'@overleaf/settings': this.settings,
'./Errors': Errors,
},
})
})
describe('getFullDoc', function () {
beforeEach(function () {
this.DocManager.promises._getDoc = sinon.stub()
this.doc = {
_id: this.doc_id,
lines: ['2134'],
}
})
it('should call get doc with a quick filter', async function () {
this.DocManager.promises._getDoc.resolves(this.doc)
const doc = await this.DocManager.promises.getFullDoc(
this.project_id,
this.doc_id
)
doc.should.equal(this.doc)
this.DocManager.promises._getDoc
.calledWith(this.project_id, this.doc_id, {
lines: true,
rev: true,
deleted: true,
version: true,
ranges: true,
inS3: true,
})
.should.equal(true)
})
it('should return error when get doc errors', async function () {
this.DocManager.promises._getDoc.rejects(this.stubbedError)
await expect(
this.DocManager.promises.getFullDoc(this.project_id, this.doc_id)
).to.be.rejectedWith(this.stubbedError)
})
})
describe('getRawDoc', function () {
beforeEach(function () {
this.DocManager.promises._getDoc = sinon.stub()
this.doc = { lines: ['2134'] }
})
it('should call get doc with a quick filter', async function () {
this.DocManager.promises._getDoc.resolves(this.doc)
const doc = await this.DocManager.promises.getDocLines(
this.project_id,
this.doc_id
)
doc.should.equal(this.doc)
this.DocManager.promises._getDoc
.calledWith(this.project_id, this.doc_id, {
lines: true,
inS3: true,
})
.should.equal(true)
})
it('should return error when get doc errors', async function () {
this.DocManager.promises._getDoc.rejects(this.stubbedError)
await expect(
this.DocManager.promises.getDocLines(this.project_id, this.doc_id)
).to.be.rejectedWith(this.stubbedError)
})
})
describe('getDoc', function () {
beforeEach(function () {
this.project = { name: 'mock-project' }
this.doc = {
_id: this.doc_id,
project_id: this.project_id,
lines: ['mock-lines'],
}
})
describe('when using a filter', function () {
beforeEach(function () {
this.MongoManager.promises.findDoc.resolves(this.doc)
})
it('should error if inS3 is not set to true', async function () {
await expect(
this.DocManager.promises._getDoc(this.project_id, this.doc_id, {
inS3: false,
})
).to.be.rejected
})
it('should always get inS3 even when no filter is passed', async function () {
await expect(
this.DocManager.promises._getDoc(this.project_id, this.doc_id)
).to.be.rejected
this.MongoManager.promises.findDoc.called.should.equal(false)
})
it('should not error if inS3 is set to true', async function () {
await this.DocManager.promises._getDoc(this.project_id, this.doc_id, {
inS3: true,
})
})
})
describe('when the doc is in the doc collection', function () {
beforeEach(async function () {
this.MongoManager.promises.findDoc.resolves(this.doc)
this.result = await this.DocManager.promises._getDoc(
this.project_id,
this.doc_id,
{ version: true, inS3: true }
)
})
it('should get the doc from the doc collection', function () {
this.MongoManager.promises.findDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should get the doc version from the docOps collection', function () {
this.MongoManager.promises.getDocVersion
.calledWith(this.doc_id)
.should.equal(true)
})
it('should return the doc with the version', function () {
this.result.lines.should.equal(this.doc.lines)
this.result.version.should.equal(this.version)
})
})
describe('without the version filter', function () {
beforeEach(async function () {
this.MongoManager.promises.findDoc.resolves(this.doc)
await this.DocManager.promises._getDoc(this.project_id, this.doc_id, {
version: false,
inS3: true,
})
})
it('should not get the doc version from the docOps collection', function () {
this.MongoManager.promises.getDocVersion.called.should.equal(false)
})
})
describe('when the doc record already has a version', function () {
beforeEach(async function () {
this.docWithVersion = { ...this.doc, version: 109 }
this.MongoManager.promises.findDoc.resolves(this.docWithVersion)
this.result = await this.DocManager.promises._getDoc(
this.project_id,
this.doc_id,
{
version: true,
inS3: true,
}
)
})
it('should return that version', function () {
expect(this.result).to.deep.equal(this.docWithVersion)
})
it('should not fetch the doc version from the docOps collection', function () {
expect(this.MongoManager.promises.getDocVersion).not.to.have.been.called
})
})
describe('when MongoManager.findDoc errors', function () {
it('should return the error', async function () {
this.MongoManager.promises.findDoc.rejects(this.stubbedError)
await expect(
this.DocManager.promises._getDoc(this.project_id, this.doc_id, {
version: true,
inS3: true,
})
).to.be.rejectedWith(this.stubbedError)
})
})
describe('when the doc is archived', function () {
beforeEach(async function () {
this.doc = {
_id: this.doc_id,
project_id: this.project_id,
inS3: true,
}
this.unarchivedDoc = {
_id: this.doc_id,
project_id: this.project_id,
lines: ['mock-lines'],
inS3: false,
}
this.MongoManager.promises.findDoc.resolves(this.doc)
this.DocArchiveManager.promises.unarchiveDoc.callsFake(
async (projectId, docId) => {
this.MongoManager.promises.findDoc.resolves({
...this.unarchivedDoc,
})
}
)
this.result = await this.DocManager.promises._getDoc(
this.project_id,
this.doc_id,
{
version: true,
inS3: true,
}
)
})
it('should call the DocArchive to unarchive the doc', function () {
this.DocArchiveManager.promises.unarchiveDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should look up the doc twice', function () {
this.MongoManager.promises.findDoc.calledTwice.should.equal(true)
})
it('should return the doc', function () {
expect(this.result).to.deep.equal({
...this.unarchivedDoc,
version: this.version,
})
})
})
describe('when the doc does not exist in the docs collection', function () {
it('should return a NotFoundError', async function () {
this.MongoManager.promises.findDoc.resolves(null)
await expect(
this.DocManager.promises._getDoc(this.project_id, this.doc_id, {
version: true,
inS3: true,
})
).to.be.rejectedWith(
`No such doc: ${this.doc_id} in project ${this.project_id}`
)
})
})
})
describe('getAllNonDeletedDocs', function () {
describe('when the project exists', function () {
beforeEach(async function () {
this.docs = [
{
_id: this.doc_id,
project_id: this.project_id,
lines: ['mock-lines'],
},
]
this.MongoManager.promises.getProjectsDocs.resolves(this.docs)
this.DocArchiveManager.promises.unArchiveAllDocs.resolves(this.docs)
this.filter = { lines: true }
this.result = await this.DocManager.promises.getAllNonDeletedDocs(
this.project_id,
this.filter
)
})
it('should get the project from the database', function () {
this.MongoManager.promises.getProjectsDocs.should.have.been.calledWith(
this.project_id,
{ include_deleted: false },
this.filter
)
})
it('should return the docs', function () {
expect(this.result).to.deep.equal(this.docs)
})
})
describe('when there are no docs for the project', function () {
it('should return a NotFoundError', async function () {
this.MongoManager.promises.getProjectsDocs.resolves(null)
this.DocArchiveManager.promises.unArchiveAllDocs.resolves(null)
await expect(
this.DocManager.promises.getAllNonDeletedDocs(
this.project_id,
this.filter
)
).to.be.rejectedWith(`No docs for project ${this.project_id}`)
})
})
})
describe('patchDoc', function () {
describe('when the doc exists', function () {
beforeEach(function () {
this.lines = ['mock', 'doc', 'lines']
this.rev = 77
this.MongoManager.promises.findDoc.resolves({
_id: new ObjectId(this.doc_id),
})
this.meta = {}
})
describe('standard path', function () {
beforeEach(async function () {
await this.DocManager.promises.patchDoc(
this.project_id,
this.doc_id,
this.meta
)
})
it('should get the doc', function () {
expect(this.MongoManager.promises.findDoc).to.have.been.calledWith(
this.project_id,
this.doc_id
)
})
it('should persist the meta', function () {
expect(this.MongoManager.promises.patchDoc).to.have.been.calledWith(
this.project_id,
this.doc_id,
this.meta
)
})
})
describe('background flush disabled and deleting a doc', function () {
beforeEach(async function () {
this.settings.docstore.archiveOnSoftDelete = false
this.meta.deleted = true
await this.DocManager.promises.patchDoc(
this.project_id,
this.doc_id,
this.meta
)
})
it('should not flush the doc out of mongo', function () {
expect(this.DocArchiveManager.promises.archiveDoc).to.not.have.been
.called
})
})
describe('background flush enabled and not deleting a doc', function () {
beforeEach(async function () {
this.settings.docstore.archiveOnSoftDelete = false
this.meta.deleted = false
await this.DocManager.promises.patchDoc(
this.project_id,
this.doc_id,
this.meta
)
})
it('should not flush the doc out of mongo', function () {
expect(this.DocArchiveManager.promises.archiveDoc).to.not.have.been
.called
})
})
describe('background flush enabled and deleting a doc', function () {
beforeEach(function () {
this.settings.docstore.archiveOnSoftDelete = true
this.meta.deleted = true
})
describe('when the background flush succeeds', function () {
beforeEach(async function () {
await this.DocManager.promises.patchDoc(
this.project_id,
this.doc_id,
this.meta
)
})
it('should not log a warning', function () {
expect(this.logger.warn).to.not.have.been.called
})
it('should flush the doc out of mongo', function () {
expect(
this.DocArchiveManager.promises.archiveDoc
).to.have.been.calledWith(this.project_id, this.doc_id)
})
})
describe('when the background flush fails', function () {
beforeEach(async function () {
this.err = new Error('foo')
this.DocArchiveManager.promises.archiveDoc.rejects(this.err)
await this.DocManager.promises.patchDoc(
this.project_id,
this.doc_id,
this.meta
)
})
it('should log a warning', function () {
expect(this.logger.warn).to.have.been.calledWith(
sinon.match({
projectId: this.project_id,
docId: this.doc_id,
err: this.err,
}),
'archiving a single doc in the background failed'
)
})
})
})
})
describe('when the doc does not exist', function () {
it('should return a NotFoundError', async function () {
this.MongoManager.promises.findDoc.resolves(null)
await expect(
this.DocManager.promises.patchDoc(this.project_id, this.doc_id, {})
).to.be.rejectedWith(
`No such project/doc to delete: ${this.project_id}/${this.doc_id}`
)
})
})
})
describe('updateDoc', function () {
beforeEach(function () {
this.oldDocLines = ['old', 'doc', 'lines']
this.newDocLines = ['new', 'doc', 'lines']
this.originalRanges = {
changes: [
{
id: new ObjectId().toString(),
op: { i: 'foo', p: 3 },
meta: {
user_id: new ObjectId().toString(),
ts: new Date().toString(),
},
},
],
}
this.newRanges = {
changes: [
{
id: new ObjectId().toString(),
op: { i: 'bar', p: 6 },
meta: {
user_id: new ObjectId().toString(),
ts: new Date().toString(),
},
},
],
}
this.version = 42
this.doc = {
_id: this.doc_id,
project_id: this.project_id,
lines: this.oldDocLines,
rev: (this.rev = 5),
version: this.version,
ranges: this.originalRanges,
}
this.DocManager.promises._getDoc = sinon.stub()
})
describe('when only the doc lines have changed', function () {
beforeEach(async function () {
this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc)
this.result = await this.DocManager.promises.updateDoc(
this.project_id,
this.doc_id,
this.newDocLines,
this.version,
this.originalRanges
)
})
it('should get the existing doc', function () {
this.DocManager.promises._getDoc
.calledWith(this.project_id, this.doc_id, {
version: true,
rev: true,
lines: true,
ranges: true,
inS3: true,
})
.should.equal(true)
})
it('should upsert the document to the doc collection', function () {
this.MongoManager.promises.upsertIntoDocCollection
.calledWith(this.project_id, this.doc_id, this.rev, {
lines: this.newDocLines,
})
.should.equal(true)
})
it('should return the new rev', function () {
expect(this.result).to.deep.equal({ modified: true, rev: this.rev + 1 })
})
})
describe('when the doc ranges have changed', function () {
beforeEach(async function () {
this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc)
this.RangeManager.shouldUpdateRanges.returns(true)
this.result = await this.DocManager.promises.updateDoc(
this.project_id,
this.doc_id,
this.oldDocLines,
this.version,
this.newRanges
)
})
it('should upsert the ranges', function () {
this.MongoManager.promises.upsertIntoDocCollection
.calledWith(this.project_id, this.doc_id, this.rev, {
ranges: this.newRanges,
})
.should.equal(true)
})
it('should return the new rev', function () {
expect(this.result).to.deep.equal({ modified: true, rev: this.rev + 1 })
})
})
describe('when only the version has changed', function () {
beforeEach(async function () {
this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc)
this.result = await this.DocManager.promises.updateDoc(
this.project_id,
this.doc_id,
this.oldDocLines,
this.version + 1,
this.originalRanges
)
})
it('should update the version', function () {
this.MongoManager.promises.upsertIntoDocCollection.should.have.been.calledWith(
this.project_id,
this.doc_id,
this.rev,
{ version: this.version + 1 }
)
})
it('should return the old rev', function () {
expect(this.result).to.deep.equal({ modified: true, rev: this.rev })
})
})
describe('when the doc has not changed at all', function () {
beforeEach(async function () {
this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc)
this.result = await this.DocManager.promises.updateDoc(
this.project_id,
this.doc_id,
this.oldDocLines,
this.version,
this.originalRanges
)
})
it('should not update the ranges or lines or version', function () {
this.MongoManager.promises.upsertIntoDocCollection.called.should.equal(
false
)
})
it('should return the old rev and modified == false', function () {
expect(this.result).to.deep.equal({ modified: false, rev: this.rev })
})
})
describe('when the version is null', function () {
it('should return an error', async function () {
await expect(
this.DocManager.promises.updateDoc(
this.project_id,
this.doc_id,
this.newDocLines,
null,
this.originalRanges
)
).to.be.rejectedWith('no lines, version or ranges provided')
})
})
describe('when the lines are null', function () {
it('should return an error', async function () {
await expect(
this.DocManager.promises.updateDoc(
this.project_id,
this.doc_id,
null,
this.version,
this.originalRanges
)
).to.be.rejectedWith('no lines, version or ranges provided')
})
})
describe('when the ranges are null', function () {
it('should return an error', async function () {
await expect(
this.DocManager.promises.updateDoc(
this.project_id,
this.doc_id,
this.newDocLines,
this.version,
null
)
).to.be.rejectedWith('no lines, version or ranges provided')
})
})
describe('when there is a generic error getting the doc', function () {
beforeEach(async function () {
this.error = new Error('doc could not be found')
this.DocManager.promises._getDoc = sinon.stub().rejects(this.error)
await expect(
this.DocManager.promises.updateDoc(
this.project_id,
this.doc_id,
this.newDocLines,
this.version,
this.originalRanges
)
).to.be.rejectedWith(this.error)
})
it('should not upsert the document to the doc collection', function () {
this.MongoManager.promises.upsertIntoDocCollection.should.not.have.been
.called
})
})
describe('when the version was decremented', function () {
it('should return an error', async function () {
this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc)
await expect(
this.DocManager.promises.updateDoc(
this.project_id,
this.doc_id,
this.newDocLines,
this.version - 1,
this.originalRanges
)
).to.be.rejectedWith(Errors.DocVersionDecrementedError)
})
})
describe('when the doc lines have not changed', function () {
beforeEach(async function () {
this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc)
this.result = await this.DocManager.promises.updateDoc(
this.project_id,
this.doc_id,
this.oldDocLines.slice(),
this.version,
this.originalRanges
)
})
it('should not update the doc', function () {
this.MongoManager.promises.upsertIntoDocCollection.called.should.equal(
false
)
})
it('should return the existing rev', function () {
expect(this.result).to.deep.equal({ modified: false, rev: this.rev })
})
})
describe('when the doc does not exist', function () {
beforeEach(async function () {
this.DocManager.promises._getDoc = sinon.stub().resolves(null)
this.result = await this.DocManager.promises.updateDoc(
this.project_id,
this.doc_id,
this.newDocLines,
this.version,
this.originalRanges
)
})
it('should upsert the document to the doc collection', function () {
this.MongoManager.promises.upsertIntoDocCollection.should.have.been.calledWith(
this.project_id,
this.doc_id,
undefined,
{
lines: this.newDocLines,
ranges: this.originalRanges,
version: this.version,
}
)
})
it('should return the new rev', function () {
expect(this.result).to.deep.equal({ modified: true, rev: 1 })
})
})
describe('when another update is racing', function () {
beforeEach(async function () {
this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc)
this.MongoManager.promises.upsertIntoDocCollection
.onFirstCall()
.rejects(new Errors.DocRevValueError())
this.RangeManager.shouldUpdateRanges.returns(true)
this.result = await this.DocManager.promises.updateDoc(
this.project_id,
this.doc_id,
this.newDocLines,
this.version + 1,
this.newRanges
)
})
it('should upsert the doc twice', function () {
this.MongoManager.promises.upsertIntoDocCollection.should.have.been.calledWith(
this.project_id,
this.doc_id,
this.rev,
{
ranges: this.newRanges,
lines: this.newDocLines,
version: this.version + 1,
}
)
this.MongoManager.promises.upsertIntoDocCollection.should.have.been
.calledTwice
})
it('should return the new rev', function () {
expect(this.result).to.deep.equal({ modified: true, rev: this.rev + 1 })
})
})
})
})