MongoManager.test.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. import sinon from 'sinon'
  2. import mongodb from 'mongodb-legacy'
  3. import path from 'node:path'
  4. import { assert, beforeEach, describe, expect, it, vi } from 'vitest'
  5. import Errors from '../../../app/js/Errors.js'
  6. const { ObjectId, BSON } = mongodb
  7. const modulePath = path.join(
  8. import.meta.dirname,
  9. '../../../app/js/MongoManager'
  10. )
  11. describe('MongoManager', () => {
  12. beforeEach(async ctx => {
  13. ctx.db = {
  14. docs: {
  15. findOneAndUpdate: sinon.stub().resolves({}),
  16. updateOne: sinon.stub().resolves({ matchedCount: 1 }),
  17. insertOne: sinon.stub().resolves(),
  18. },
  19. }
  20. vi.doMock('../../../app/js/mongodb', () => ({
  21. default: {
  22. db: ctx.db,
  23. ObjectId,
  24. BSON,
  25. },
  26. }))
  27. vi.doMock('@overleaf/settings', () => ({
  28. default: {
  29. max_deleted_docs: 42,
  30. docstore: { archivingLockDurationMs: 5000 },
  31. },
  32. }))
  33. vi.doMock('../../../app/js/Errors', () => ({
  34. default: Errors,
  35. }))
  36. ctx.MongoManager = (await import(modulePath)).default
  37. ctx.projectId = new ObjectId().toString()
  38. ctx.docId = new ObjectId().toString()
  39. ctx.rev = 42
  40. ctx.stubbedErr = new Error('hello world')
  41. ctx.lines = ['Three French hens', 'Two turtle doves']
  42. })
  43. describe('convertUpdateToPipeline', () => {
  44. it('should convert the update', ctx => {
  45. const update = {
  46. $set: { lines: ['foo', 'bar'], ranges: { comments: [] }, rev: 42 },
  47. $unset: { inS3: true },
  48. }
  49. const pipeline = ctx.MongoManager.convertUpdateToPipeline(update)
  50. expect(pipeline).to.deep.equal([
  51. { $set: { lines: { $literal: ['foo', 'bar'] } } },
  52. { $set: { ranges: { $literal: { comments: [] } } } },
  53. { $set: { rev: { $literal: 42 } } },
  54. { $unset: 'inS3' },
  55. ])
  56. })
  57. })
  58. describe('findDoc', () => {
  59. beforeEach(async ctx => {
  60. ctx.doc = { name: 'mock-doc' }
  61. ctx.db.docs.findOne = sinon.stub().resolves(ctx.doc)
  62. ctx.filter = { lines: true }
  63. ctx.result = await ctx.MongoManager.findDoc(
  64. ctx.projectId,
  65. ctx.docId,
  66. ctx.filter
  67. )
  68. })
  69. it('should find the doc', ctx => {
  70. ctx.db.docs.findOne
  71. .calledWith(
  72. {
  73. _id: new ObjectId(ctx.docId),
  74. project_id: new ObjectId(ctx.projectId),
  75. },
  76. {
  77. projection: ctx.filter,
  78. }
  79. )
  80. .should.equal(true)
  81. })
  82. it('should return the doc', ctx => {
  83. expect(ctx.doc).to.deep.equal(ctx.doc)
  84. })
  85. })
  86. describe('patchDoc', () => {
  87. beforeEach(async ctx => {
  88. ctx.meta = { name: 'foo.tex' }
  89. await ctx.MongoManager.patchDoc(ctx.projectId, ctx.docId, ctx.meta)
  90. })
  91. it('should pass the parameter along', ctx => {
  92. ctx.db.docs.updateOne.should.have.been.calledWith(
  93. {
  94. _id: new ObjectId(ctx.docId),
  95. project_id: new ObjectId(ctx.projectId),
  96. },
  97. {
  98. $set: ctx.meta,
  99. }
  100. )
  101. })
  102. })
  103. describe('getProjectsDocs', () => {
  104. beforeEach(ctx => {
  105. ctx.filter = { lines: true }
  106. ctx.doc1 = { name: 'mock-doc1' }
  107. ctx.doc2 = { name: 'mock-doc2' }
  108. ctx.doc3 = { name: 'mock-doc3' }
  109. ctx.doc4 = { name: 'mock-doc4' }
  110. ctx.db.docs.find = sinon.stub().returns({
  111. toArray: sinon.stub().resolves([ctx.doc, ctx.doc3, ctx.doc4]),
  112. })
  113. })
  114. describe('with included_deleted = false', () => {
  115. beforeEach(async ctx => {
  116. ctx.result = await ctx.MongoManager.getProjectsDocs(
  117. ctx.projectId,
  118. { include_deleted: false },
  119. ctx.filter
  120. )
  121. })
  122. it('should find the non-deleted docs via the project_id', ctx => {
  123. ctx.db.docs.find
  124. .calledWith(
  125. {
  126. project_id: new ObjectId(ctx.projectId),
  127. deleted: { $ne: true },
  128. },
  129. {
  130. projection: ctx.filter,
  131. }
  132. )
  133. .should.equal(true)
  134. })
  135. it('should call return the docs', ctx => {
  136. expect(ctx.result).to.deep.equal([ctx.doc, ctx.doc3, ctx.doc4])
  137. })
  138. })
  139. describe('with included_deleted = true', () => {
  140. beforeEach(async ctx => {
  141. ctx.result = await ctx.MongoManager.getProjectsDocs(
  142. ctx.projectId,
  143. { include_deleted: true },
  144. ctx.filter
  145. )
  146. })
  147. it('should find all via the project_id', ctx => {
  148. ctx.db.docs.find
  149. .calledWith(
  150. {
  151. project_id: new ObjectId(ctx.projectId),
  152. },
  153. {
  154. projection: ctx.filter,
  155. }
  156. )
  157. .should.equal(true)
  158. })
  159. it('should return the docs', ctx => {
  160. expect(ctx.result).to.deep.equal([ctx.doc, ctx.doc3, ctx.doc4])
  161. })
  162. })
  163. })
  164. describe('getProjectsDeletedDocs', () => {
  165. beforeEach(async ctx => {
  166. ctx.filter = { name: true }
  167. ctx.doc1 = { _id: '1', name: 'mock-doc1.tex' }
  168. ctx.doc2 = { _id: '2', name: 'mock-doc2.tex' }
  169. ctx.doc3 = { _id: '3', name: 'mock-doc3.tex' }
  170. ctx.db.docs.find = sinon.stub().returns({
  171. toArray: sinon.stub().resolves([ctx.doc1, ctx.doc2, ctx.doc3]),
  172. })
  173. ctx.result = await ctx.MongoManager.getProjectsDeletedDocs(
  174. ctx.projectId,
  175. ctx.filter
  176. )
  177. })
  178. it('should find the deleted docs via the project_id', ctx => {
  179. ctx.db.docs.find
  180. .calledWith({
  181. project_id: new ObjectId(ctx.projectId),
  182. deleted: true,
  183. })
  184. .should.equal(true)
  185. })
  186. it('should filter, sort by deletedAt and limit', ctx => {
  187. ctx.db.docs.find
  188. .calledWith(sinon.match.any, {
  189. projection: ctx.filter,
  190. sort: { deletedAt: -1 },
  191. limit: 42,
  192. })
  193. .should.equal(true)
  194. })
  195. it('should return the docs', ctx => {
  196. expect(ctx.result).to.deep.equal([ctx.doc1, ctx.doc2, ctx.doc3])
  197. })
  198. })
  199. describe('upsertIntoDocCollection', () => {
  200. beforeEach(ctx => {
  201. ctx.oldRev = 77
  202. })
  203. it('should upsert the document', async ctx => {
  204. await ctx.MongoManager.upsertIntoDocCollection(
  205. ctx.projectId,
  206. ctx.docId,
  207. ctx.oldRev,
  208. { lines: ctx.lines }
  209. )
  210. const args = ctx.db.docs.updateOne.args[0]
  211. assert.deepEqual(args[0], {
  212. _id: new ObjectId(ctx.docId),
  213. project_id: new ObjectId(ctx.projectId),
  214. rev: ctx.oldRev,
  215. })
  216. assert.equal(args[1][0].$set.lines.$literal, ctx.lines)
  217. assert.equal(args[1][1].$set.rev.$literal, ctx.oldRev + 1)
  218. })
  219. it('should handle update error', async ctx => {
  220. ctx.db.docs.updateOne.rejects(ctx.stubbedErr)
  221. await expect(
  222. ctx.MongoManager.upsertIntoDocCollection(
  223. ctx.projectId,
  224. ctx.docId,
  225. ctx.rev,
  226. {
  227. lines: ctx.lines,
  228. }
  229. )
  230. ).to.be.rejectedWith(ctx.stubbedErr)
  231. })
  232. it('should insert without a previous rev', async ctx => {
  233. await ctx.MongoManager.upsertIntoDocCollection(
  234. ctx.projectId,
  235. ctx.docId,
  236. null,
  237. { lines: ctx.lines, ranges: ctx.ranges }
  238. )
  239. expect(ctx.db.docs.insertOne).to.have.been.calledWith({
  240. _id: new ObjectId(ctx.docId),
  241. project_id: new ObjectId(ctx.projectId),
  242. rev: 1,
  243. lines: ctx.lines,
  244. ranges: ctx.ranges,
  245. })
  246. })
  247. it('should handle generic insert error', async ctx => {
  248. ctx.db.docs.insertOne.rejects(ctx.stubbedErr)
  249. await expect(
  250. ctx.MongoManager.upsertIntoDocCollection(
  251. ctx.projectId,
  252. ctx.docId,
  253. null,
  254. { lines: ctx.lines, ranges: ctx.ranges }
  255. )
  256. ).to.be.rejectedWith(ctx.stubbedErr)
  257. })
  258. it('should handle duplicate insert error', async ctx => {
  259. ctx.db.docs.insertOne.rejects({ code: 11000 })
  260. await expect(
  261. ctx.MongoManager.upsertIntoDocCollection(
  262. ctx.projectId,
  263. ctx.docId,
  264. null,
  265. { lines: ctx.lines, ranges: ctx.ranges }
  266. )
  267. ).to.be.rejectedWith(Errors.DocRevValueError)
  268. })
  269. })
  270. describe('destroyProject', () => {
  271. beforeEach(async ctx => {
  272. ctx.projectId = new ObjectId()
  273. ctx.db.docs.deleteMany = sinon.stub().resolves()
  274. await ctx.MongoManager.destroyProject(ctx.projectId)
  275. })
  276. it('should destroy all docs', ctx => {
  277. sinon.assert.calledWith(ctx.db.docs.deleteMany, {
  278. project_id: ctx.projectId,
  279. })
  280. })
  281. })
  282. describe('checkRevUnchanged', ctx => {
  283. beforeEach(ctx => {
  284. ctx.doc = { _id: new ObjectId(), name: 'mock-doc', rev: 1 }
  285. })
  286. it('should not error when the rev has not changed', async ctx => {
  287. ctx.db.docs.findOne = sinon.stub().resolves({ rev: 1 })
  288. await ctx.MongoManager.checkRevUnchanged(ctx.doc)
  289. })
  290. it('should return an error when the rev has changed', async ctx => {
  291. ctx.db.docs.findOne = sinon.stub().resolves({ rev: 2 })
  292. await expect(
  293. ctx.MongoManager.checkRevUnchanged(ctx.doc)
  294. ).to.be.rejectedWith(Errors.DocModifiedError)
  295. })
  296. it('should return a value error if incoming rev is NaN', async ctx => {
  297. ctx.db.docs.findOne = sinon.stub().resolves({ rev: 2 })
  298. ctx.doc = { _id: new ObjectId(), name: 'mock-doc', rev: NaN }
  299. await expect(
  300. ctx.MongoManager.checkRevUnchanged(ctx.doc)
  301. ).to.be.rejectedWith(Errors.DocRevValueError)
  302. })
  303. it('should return a value error if checked doc rev is NaN', async ctx => {
  304. ctx.db.docs.findOne = sinon.stub().resolves({ rev: NaN })
  305. await expect(
  306. ctx.MongoManager.checkRevUnchanged(ctx.doc)
  307. ).to.be.rejectedWith(Errors.DocRevValueError)
  308. })
  309. })
  310. describe('restoreArchivedDoc', () => {
  311. beforeEach(ctx => {
  312. ctx.archivedDoc = {
  313. lines: ['a', 'b', 'c'],
  314. ranges: { some: 'ranges' },
  315. rev: 2,
  316. }
  317. })
  318. describe('complete doc', () => {
  319. it('updates Mongo', async ctx => {
  320. await ctx.MongoManager.restoreArchivedDoc(
  321. ctx.projectId,
  322. ctx.docId,
  323. ctx.archivedDoc
  324. )
  325. expect(ctx.db.docs.updateOne).to.have.been.calledWith(
  326. {
  327. _id: new ObjectId(ctx.docId),
  328. project_id: new ObjectId(ctx.projectId),
  329. rev: ctx.archivedDoc.rev,
  330. },
  331. [
  332. { $set: { lines: { $literal: ctx.archivedDoc.lines } } },
  333. { $set: { ranges: { $literal: ctx.archivedDoc.ranges } } },
  334. { $unset: 'inS3' },
  335. ]
  336. )
  337. })
  338. })
  339. describe('without ranges', () => {
  340. beforeEach(async ctx => {
  341. delete ctx.archivedDoc.ranges
  342. })
  343. it('sets ranges to an empty object', async ctx => {
  344. await ctx.MongoManager.restoreArchivedDoc(
  345. ctx.projectId,
  346. ctx.docId,
  347. ctx.archivedDoc
  348. )
  349. expect(ctx.db.docs.updateOne).to.have.been.calledWith(
  350. {
  351. _id: new ObjectId(ctx.docId),
  352. project_id: new ObjectId(ctx.projectId),
  353. rev: ctx.archivedDoc.rev,
  354. },
  355. [
  356. {
  357. $set: {
  358. lines: {
  359. $literal: ctx.archivedDoc.lines,
  360. },
  361. },
  362. },
  363. { $set: { ranges: { $literal: {} } } },
  364. { $unset: 'inS3' },
  365. ]
  366. )
  367. })
  368. })
  369. describe("when the update doesn't succeed", () => {
  370. it('throws a DocRevValueError', async ctx => {
  371. ctx.db.docs.updateOne.resolves({ matchedCount: 0 })
  372. await expect(
  373. ctx.MongoManager.restoreArchivedDoc(
  374. ctx.projectId,
  375. ctx.docId,
  376. ctx.archivedDoc
  377. )
  378. ).to.be.rejectedWith(Errors.DocRevValueError)
  379. })
  380. })
  381. })
  382. })