DocManagerTests.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755
  1. const SandboxedModule = require('sandboxed-module')
  2. const sinon = require('sinon')
  3. const { expect } = require('chai')
  4. const modulePath = require('node:path').join(
  5. __dirname,
  6. '../../../app/js/DocManager'
  7. )
  8. const { ObjectId } = require('mongodb-legacy')
  9. const Errors = require('../../../app/js/Errors')
  10. describe('DocManager', function () {
  11. beforeEach(function () {
  12. this.doc_id = new ObjectId().toString()
  13. this.project_id = new ObjectId().toString()
  14. this.another_project_id = new ObjectId().toString()
  15. this.stubbedError = new Error('blew up')
  16. this.version = 42
  17. this.MongoManager = {
  18. promises: {
  19. findDoc: sinon.stub(),
  20. getProjectsDocs: sinon.stub(),
  21. patchDoc: sinon.stub().resolves(),
  22. upsertIntoDocCollection: sinon.stub().resolves(),
  23. },
  24. }
  25. this.DocArchiveManager = {
  26. promises: {
  27. unarchiveDoc: sinon.stub(),
  28. unArchiveAllDocs: sinon.stub(),
  29. archiveDoc: sinon.stub().resolves(),
  30. },
  31. }
  32. this.RangeManager = {
  33. jsonRangesToMongo(r) {
  34. return r
  35. },
  36. shouldUpdateRanges: sinon.stub().returns(false),
  37. }
  38. this.settings = { docstore: {} }
  39. this.DocManager = SandboxedModule.require(modulePath, {
  40. requires: {
  41. './MongoManager': this.MongoManager,
  42. './DocArchiveManager': this.DocArchiveManager,
  43. './RangeManager': this.RangeManager,
  44. '@overleaf/settings': this.settings,
  45. './Errors': Errors,
  46. },
  47. })
  48. })
  49. describe('getFullDoc', function () {
  50. beforeEach(function () {
  51. this.DocManager.promises._getDoc = sinon.stub()
  52. this.doc = {
  53. _id: this.doc_id,
  54. lines: ['2134'],
  55. }
  56. })
  57. it('should call get doc with a quick filter', async function () {
  58. this.DocManager.promises._getDoc.resolves(this.doc)
  59. const doc = await this.DocManager.promises.getFullDoc(
  60. this.project_id,
  61. this.doc_id
  62. )
  63. doc.should.equal(this.doc)
  64. this.DocManager.promises._getDoc
  65. .calledWith(this.project_id, this.doc_id, {
  66. lines: true,
  67. rev: true,
  68. deleted: true,
  69. version: true,
  70. ranges: true,
  71. inS3: true,
  72. })
  73. .should.equal(true)
  74. })
  75. it('should return error when get doc errors', async function () {
  76. this.DocManager.promises._getDoc.rejects(this.stubbedError)
  77. await expect(
  78. this.DocManager.promises.getFullDoc(this.project_id, this.doc_id)
  79. ).to.be.rejectedWith(this.stubbedError)
  80. })
  81. })
  82. describe('getRawDoc', function () {
  83. beforeEach(function () {
  84. this.DocManager.promises._getDoc = sinon.stub()
  85. this.doc = { lines: ['2134'] }
  86. })
  87. it('should call get doc with a quick filter', async function () {
  88. this.DocManager.promises._getDoc.resolves(this.doc)
  89. const doc = await this.DocManager.promises.getDocLines(
  90. this.project_id,
  91. this.doc_id
  92. )
  93. doc.should.equal(this.doc)
  94. this.DocManager.promises._getDoc
  95. .calledWith(this.project_id, this.doc_id, {
  96. lines: true,
  97. inS3: true,
  98. })
  99. .should.equal(true)
  100. })
  101. it('should return error when get doc errors', async function () {
  102. this.DocManager.promises._getDoc.rejects(this.stubbedError)
  103. await expect(
  104. this.DocManager.promises.getDocLines(this.project_id, this.doc_id)
  105. ).to.be.rejectedWith(this.stubbedError)
  106. })
  107. })
  108. describe('getDoc', function () {
  109. beforeEach(function () {
  110. this.project = { name: 'mock-project' }
  111. this.doc = {
  112. _id: this.doc_id,
  113. project_id: this.project_id,
  114. lines: ['mock-lines'],
  115. version: this.version,
  116. }
  117. })
  118. describe('when using a filter', function () {
  119. beforeEach(function () {
  120. this.MongoManager.promises.findDoc.resolves(this.doc)
  121. })
  122. it('should error if inS3 is not set to true', async function () {
  123. await expect(
  124. this.DocManager.promises._getDoc(this.project_id, this.doc_id, {
  125. inS3: false,
  126. })
  127. ).to.be.rejected
  128. })
  129. it('should always get inS3 even when no filter is passed', async function () {
  130. await expect(
  131. this.DocManager.promises._getDoc(this.project_id, this.doc_id)
  132. ).to.be.rejected
  133. this.MongoManager.promises.findDoc.called.should.equal(false)
  134. })
  135. it('should not error if inS3 is set to true', async function () {
  136. await this.DocManager.promises._getDoc(this.project_id, this.doc_id, {
  137. inS3: true,
  138. })
  139. })
  140. })
  141. describe('when the doc is in the doc collection', function () {
  142. beforeEach(async function () {
  143. this.MongoManager.promises.findDoc.resolves(this.doc)
  144. this.result = await this.DocManager.promises._getDoc(
  145. this.project_id,
  146. this.doc_id,
  147. { version: true, inS3: true }
  148. )
  149. })
  150. it('should get the doc from the doc collection', function () {
  151. this.MongoManager.promises.findDoc
  152. .calledWith(this.project_id, this.doc_id)
  153. .should.equal(true)
  154. })
  155. it('should return the doc with the version', function () {
  156. this.result.lines.should.equal(this.doc.lines)
  157. this.result.version.should.equal(this.version)
  158. })
  159. })
  160. describe('when MongoManager.findDoc errors', function () {
  161. it('should return the error', async function () {
  162. this.MongoManager.promises.findDoc.rejects(this.stubbedError)
  163. await expect(
  164. this.DocManager.promises._getDoc(this.project_id, this.doc_id, {
  165. version: true,
  166. inS3: true,
  167. })
  168. ).to.be.rejectedWith(this.stubbedError)
  169. })
  170. })
  171. describe('when the doc is archived', function () {
  172. beforeEach(async function () {
  173. this.doc = {
  174. _id: this.doc_id,
  175. project_id: this.project_id,
  176. version: 2,
  177. inS3: true,
  178. }
  179. this.unarchivedDoc = {
  180. _id: this.doc_id,
  181. project_id: this.project_id,
  182. lines: ['mock-lines'],
  183. version: 2,
  184. inS3: false,
  185. }
  186. this.MongoManager.promises.findDoc.resolves(this.doc)
  187. this.DocArchiveManager.promises.unarchiveDoc.callsFake(
  188. async (projectId, docId) => {
  189. this.MongoManager.promises.findDoc.resolves({
  190. ...this.unarchivedDoc,
  191. })
  192. }
  193. )
  194. this.result = await this.DocManager.promises._getDoc(
  195. this.project_id,
  196. this.doc_id,
  197. {
  198. version: true,
  199. inS3: true,
  200. }
  201. )
  202. })
  203. it('should call the DocArchive to unarchive the doc', function () {
  204. this.DocArchiveManager.promises.unarchiveDoc
  205. .calledWith(this.project_id, this.doc_id)
  206. .should.equal(true)
  207. })
  208. it('should look up the doc twice', function () {
  209. this.MongoManager.promises.findDoc.calledTwice.should.equal(true)
  210. })
  211. it('should return the doc', function () {
  212. expect(this.result).to.deep.equal({
  213. ...this.unarchivedDoc,
  214. })
  215. })
  216. })
  217. describe('when the doc does not exist in the docs collection', function () {
  218. it('should return a NotFoundError', async function () {
  219. this.MongoManager.promises.findDoc.resolves(null)
  220. await expect(
  221. this.DocManager.promises._getDoc(this.project_id, this.doc_id, {
  222. version: true,
  223. inS3: true,
  224. })
  225. ).to.be.rejectedWith(
  226. `No such doc: ${this.doc_id} in project ${this.project_id}`
  227. )
  228. })
  229. })
  230. })
  231. describe('getAllNonDeletedDocs', function () {
  232. describe('when the project exists', function () {
  233. beforeEach(async function () {
  234. this.docs = [
  235. {
  236. _id: this.doc_id,
  237. project_id: this.project_id,
  238. lines: ['mock-lines'],
  239. },
  240. ]
  241. this.MongoManager.promises.getProjectsDocs.resolves(this.docs)
  242. this.DocArchiveManager.promises.unArchiveAllDocs.resolves(this.docs)
  243. this.filter = { lines: true }
  244. this.result = await this.DocManager.promises.getAllNonDeletedDocs(
  245. this.project_id,
  246. this.filter
  247. )
  248. })
  249. it('should get the project from the database', function () {
  250. this.MongoManager.promises.getProjectsDocs.should.have.been.calledWith(
  251. this.project_id,
  252. { include_deleted: false },
  253. this.filter
  254. )
  255. })
  256. it('should return the docs', function () {
  257. expect(this.result).to.deep.equal(this.docs)
  258. })
  259. })
  260. describe('when there are no docs for the project', function () {
  261. it('should return a NotFoundError', async function () {
  262. this.MongoManager.promises.getProjectsDocs.resolves(null)
  263. this.DocArchiveManager.promises.unArchiveAllDocs.resolves(null)
  264. await expect(
  265. this.DocManager.promises.getAllNonDeletedDocs(
  266. this.project_id,
  267. this.filter
  268. )
  269. ).to.be.rejectedWith(`No docs for project ${this.project_id}`)
  270. })
  271. })
  272. })
  273. describe('patchDoc', function () {
  274. describe('when the doc exists', function () {
  275. beforeEach(function () {
  276. this.lines = ['mock', 'doc', 'lines']
  277. this.rev = 77
  278. this.MongoManager.promises.findDoc.resolves({
  279. _id: new ObjectId(this.doc_id),
  280. })
  281. this.meta = {}
  282. })
  283. describe('standard path', function () {
  284. beforeEach(async function () {
  285. await this.DocManager.promises.patchDoc(
  286. this.project_id,
  287. this.doc_id,
  288. this.meta
  289. )
  290. })
  291. it('should get the doc', function () {
  292. expect(this.MongoManager.promises.findDoc).to.have.been.calledWith(
  293. this.project_id,
  294. this.doc_id
  295. )
  296. })
  297. it('should persist the meta', function () {
  298. expect(this.MongoManager.promises.patchDoc).to.have.been.calledWith(
  299. this.project_id,
  300. this.doc_id,
  301. this.meta
  302. )
  303. })
  304. })
  305. describe('background flush disabled and deleting a doc', function () {
  306. beforeEach(async function () {
  307. this.settings.docstore.archiveOnSoftDelete = false
  308. this.meta.deleted = true
  309. await this.DocManager.promises.patchDoc(
  310. this.project_id,
  311. this.doc_id,
  312. this.meta
  313. )
  314. })
  315. it('should not flush the doc out of mongo', function () {
  316. expect(this.DocArchiveManager.promises.archiveDoc).to.not.have.been
  317. .called
  318. })
  319. })
  320. describe('background flush enabled and not deleting a doc', function () {
  321. beforeEach(async function () {
  322. this.settings.docstore.archiveOnSoftDelete = false
  323. this.meta.deleted = false
  324. await this.DocManager.promises.patchDoc(
  325. this.project_id,
  326. this.doc_id,
  327. this.meta
  328. )
  329. })
  330. it('should not flush the doc out of mongo', function () {
  331. expect(this.DocArchiveManager.promises.archiveDoc).to.not.have.been
  332. .called
  333. })
  334. })
  335. describe('background flush enabled and deleting a doc', function () {
  336. beforeEach(function () {
  337. this.settings.docstore.archiveOnSoftDelete = true
  338. this.meta.deleted = true
  339. })
  340. describe('when the background flush succeeds', function () {
  341. beforeEach(async function () {
  342. await this.DocManager.promises.patchDoc(
  343. this.project_id,
  344. this.doc_id,
  345. this.meta
  346. )
  347. })
  348. it('should not log a warning', function () {
  349. expect(this.logger.warn).to.not.have.been.called
  350. })
  351. it('should flush the doc out of mongo', function () {
  352. expect(
  353. this.DocArchiveManager.promises.archiveDoc
  354. ).to.have.been.calledWith(this.project_id, this.doc_id)
  355. })
  356. })
  357. describe('when the background flush fails', function () {
  358. beforeEach(async function () {
  359. this.err = new Error('foo')
  360. this.DocArchiveManager.promises.archiveDoc.rejects(this.err)
  361. await this.DocManager.promises.patchDoc(
  362. this.project_id,
  363. this.doc_id,
  364. this.meta
  365. )
  366. })
  367. it('should log a warning', function () {
  368. expect(this.logger.warn).to.have.been.calledWith(
  369. sinon.match({
  370. projectId: this.project_id,
  371. docId: this.doc_id,
  372. err: this.err,
  373. }),
  374. 'archiving a single doc in the background failed'
  375. )
  376. })
  377. })
  378. })
  379. })
  380. describe('when the doc does not exist', function () {
  381. it('should return a NotFoundError', async function () {
  382. this.MongoManager.promises.findDoc.resolves(null)
  383. await expect(
  384. this.DocManager.promises.patchDoc(this.project_id, this.doc_id, {})
  385. ).to.be.rejectedWith(
  386. `No such project/doc to delete: ${this.project_id}/${this.doc_id}`
  387. )
  388. })
  389. })
  390. })
  391. describe('updateDoc', function () {
  392. beforeEach(function () {
  393. this.oldDocLines = ['old', 'doc', 'lines']
  394. this.newDocLines = ['new', 'doc', 'lines']
  395. this.originalRanges = {
  396. changes: [
  397. {
  398. id: new ObjectId().toString(),
  399. op: { i: 'foo', p: 3 },
  400. meta: {
  401. user_id: new ObjectId().toString(),
  402. ts: new Date().toString(),
  403. },
  404. },
  405. ],
  406. }
  407. this.newRanges = {
  408. changes: [
  409. {
  410. id: new ObjectId().toString(),
  411. op: { i: 'bar', p: 6 },
  412. meta: {
  413. user_id: new ObjectId().toString(),
  414. ts: new Date().toString(),
  415. },
  416. },
  417. ],
  418. }
  419. this.version = 42
  420. this.doc = {
  421. _id: this.doc_id,
  422. project_id: this.project_id,
  423. lines: this.oldDocLines,
  424. rev: (this.rev = 5),
  425. version: this.version,
  426. ranges: this.originalRanges,
  427. }
  428. this.DocManager.promises._getDoc = sinon.stub()
  429. })
  430. describe('when only the doc lines have changed', function () {
  431. beforeEach(async function () {
  432. this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc)
  433. this.result = await this.DocManager.promises.updateDoc(
  434. this.project_id,
  435. this.doc_id,
  436. this.newDocLines,
  437. this.version,
  438. this.originalRanges
  439. )
  440. })
  441. it('should get the existing doc', function () {
  442. this.DocManager.promises._getDoc
  443. .calledWith(this.project_id, this.doc_id, {
  444. version: true,
  445. rev: true,
  446. lines: true,
  447. ranges: true,
  448. inS3: true,
  449. })
  450. .should.equal(true)
  451. })
  452. it('should upsert the document to the doc collection', function () {
  453. this.MongoManager.promises.upsertIntoDocCollection
  454. .calledWith(this.project_id, this.doc_id, this.rev, {
  455. lines: this.newDocLines,
  456. })
  457. .should.equal(true)
  458. })
  459. it('should return the new rev', function () {
  460. expect(this.result).to.deep.equal({ modified: true, rev: this.rev + 1 })
  461. })
  462. })
  463. describe('when the doc ranges have changed', function () {
  464. beforeEach(async function () {
  465. this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc)
  466. this.RangeManager.shouldUpdateRanges.returns(true)
  467. this.result = await this.DocManager.promises.updateDoc(
  468. this.project_id,
  469. this.doc_id,
  470. this.oldDocLines,
  471. this.version,
  472. this.newRanges
  473. )
  474. })
  475. it('should upsert the ranges', function () {
  476. this.MongoManager.promises.upsertIntoDocCollection
  477. .calledWith(this.project_id, this.doc_id, this.rev, {
  478. ranges: this.newRanges,
  479. })
  480. .should.equal(true)
  481. })
  482. it('should return the new rev', function () {
  483. expect(this.result).to.deep.equal({ modified: true, rev: this.rev + 1 })
  484. })
  485. })
  486. describe('when only the version has changed', function () {
  487. beforeEach(async function () {
  488. this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc)
  489. this.result = await this.DocManager.promises.updateDoc(
  490. this.project_id,
  491. this.doc_id,
  492. this.oldDocLines,
  493. this.version + 1,
  494. this.originalRanges
  495. )
  496. })
  497. it('should update the version', function () {
  498. this.MongoManager.promises.upsertIntoDocCollection.should.have.been.calledWith(
  499. this.project_id,
  500. this.doc_id,
  501. this.rev,
  502. { version: this.version + 1 }
  503. )
  504. })
  505. it('should return the old rev', function () {
  506. expect(this.result).to.deep.equal({ modified: true, rev: this.rev })
  507. })
  508. })
  509. describe('when the doc has not changed at all', function () {
  510. beforeEach(async function () {
  511. this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc)
  512. this.result = await this.DocManager.promises.updateDoc(
  513. this.project_id,
  514. this.doc_id,
  515. this.oldDocLines,
  516. this.version,
  517. this.originalRanges
  518. )
  519. })
  520. it('should not update the ranges or lines or version', function () {
  521. this.MongoManager.promises.upsertIntoDocCollection.called.should.equal(
  522. false
  523. )
  524. })
  525. it('should return the old rev and modified == false', function () {
  526. expect(this.result).to.deep.equal({ modified: false, rev: this.rev })
  527. })
  528. })
  529. describe('when the version is null', function () {
  530. it('should return an error', async function () {
  531. await expect(
  532. this.DocManager.promises.updateDoc(
  533. this.project_id,
  534. this.doc_id,
  535. this.newDocLines,
  536. null,
  537. this.originalRanges
  538. )
  539. ).to.be.rejectedWith('no lines, version or ranges provided')
  540. })
  541. })
  542. describe('when the lines are null', function () {
  543. it('should return an error', async function () {
  544. await expect(
  545. this.DocManager.promises.updateDoc(
  546. this.project_id,
  547. this.doc_id,
  548. null,
  549. this.version,
  550. this.originalRanges
  551. )
  552. ).to.be.rejectedWith('no lines, version or ranges provided')
  553. })
  554. })
  555. describe('when the ranges are null', function () {
  556. it('should return an error', async function () {
  557. await expect(
  558. this.DocManager.promises.updateDoc(
  559. this.project_id,
  560. this.doc_id,
  561. this.newDocLines,
  562. this.version,
  563. null
  564. )
  565. ).to.be.rejectedWith('no lines, version or ranges provided')
  566. })
  567. })
  568. describe('when there is a generic error getting the doc', function () {
  569. beforeEach(async function () {
  570. this.error = new Error('doc could not be found')
  571. this.DocManager.promises._getDoc = sinon.stub().rejects(this.error)
  572. await expect(
  573. this.DocManager.promises.updateDoc(
  574. this.project_id,
  575. this.doc_id,
  576. this.newDocLines,
  577. this.version,
  578. this.originalRanges
  579. )
  580. ).to.be.rejectedWith(this.error)
  581. })
  582. it('should not upsert the document to the doc collection', function () {
  583. this.MongoManager.promises.upsertIntoDocCollection.should.not.have.been
  584. .called
  585. })
  586. })
  587. describe('when the version was decremented', function () {
  588. it('should return an error', async function () {
  589. this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc)
  590. await expect(
  591. this.DocManager.promises.updateDoc(
  592. this.project_id,
  593. this.doc_id,
  594. this.newDocLines,
  595. this.version - 1,
  596. this.originalRanges
  597. )
  598. ).to.be.rejectedWith(Errors.DocVersionDecrementedError)
  599. })
  600. })
  601. describe('when the doc lines have not changed', function () {
  602. beforeEach(async function () {
  603. this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc)
  604. this.result = await this.DocManager.promises.updateDoc(
  605. this.project_id,
  606. this.doc_id,
  607. this.oldDocLines.slice(),
  608. this.version,
  609. this.originalRanges
  610. )
  611. })
  612. it('should not update the doc', function () {
  613. this.MongoManager.promises.upsertIntoDocCollection.called.should.equal(
  614. false
  615. )
  616. })
  617. it('should return the existing rev', function () {
  618. expect(this.result).to.deep.equal({ modified: false, rev: this.rev })
  619. })
  620. })
  621. describe('when the doc does not exist', function () {
  622. beforeEach(async function () {
  623. this.DocManager.promises._getDoc = sinon.stub().resolves(null)
  624. this.result = await this.DocManager.promises.updateDoc(
  625. this.project_id,
  626. this.doc_id,
  627. this.newDocLines,
  628. this.version,
  629. this.originalRanges
  630. )
  631. })
  632. it('should upsert the document to the doc collection', function () {
  633. this.MongoManager.promises.upsertIntoDocCollection.should.have.been.calledWith(
  634. this.project_id,
  635. this.doc_id,
  636. undefined,
  637. {
  638. lines: this.newDocLines,
  639. ranges: this.originalRanges,
  640. version: this.version,
  641. }
  642. )
  643. })
  644. it('should return the new rev', function () {
  645. expect(this.result).to.deep.equal({ modified: true, rev: 1 })
  646. })
  647. })
  648. describe('when another update is racing', function () {
  649. beforeEach(async function () {
  650. this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc)
  651. this.MongoManager.promises.upsertIntoDocCollection
  652. .onFirstCall()
  653. .rejects(new Errors.DocRevValueError())
  654. this.RangeManager.shouldUpdateRanges.returns(true)
  655. this.result = await this.DocManager.promises.updateDoc(
  656. this.project_id,
  657. this.doc_id,
  658. this.newDocLines,
  659. this.version + 1,
  660. this.newRanges
  661. )
  662. })
  663. it('should upsert the doc twice', function () {
  664. this.MongoManager.promises.upsertIntoDocCollection.should.have.been.calledWith(
  665. this.project_id,
  666. this.doc_id,
  667. this.rev,
  668. {
  669. ranges: this.newRanges,
  670. lines: this.newDocLines,
  671. version: this.version + 1,
  672. }
  673. )
  674. this.MongoManager.promises.upsertIntoDocCollection.should.have.been
  675. .calledTwice
  676. })
  677. it('should return the new rev', function () {
  678. expect(this.result).to.deep.equal({ modified: true, rev: this.rev + 1 })
  679. })
  680. })
  681. })
  682. })