MongoManager.test.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  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: ['foo', 'bar'] } },
  52. { $set: { ranges: { comments: [] } } },
  53. { $set: { rev: 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. ctx.db.docs.findOneAndUpdate.resolves({
  203. lines: ctx.lines,
  204. })
  205. })
  206. it('should upsert the document', async ctx => {
  207. await ctx.MongoManager.upsertIntoDocCollection(
  208. ctx.projectId,
  209. ctx.docId,
  210. ctx.oldRev,
  211. { lines: ctx.lines }
  212. )
  213. const args = ctx.db.docs.findOneAndUpdate.args[0]
  214. assert.deepEqual(args[0], {
  215. _id: new ObjectId(ctx.docId),
  216. project_id: new ObjectId(ctx.projectId),
  217. rev: ctx.oldRev,
  218. })
  219. assert.equal(args[1][0].$set.lines, ctx.lines)
  220. assert.equal(args[1][1].$set.rev, ctx.oldRev + 1)
  221. })
  222. it('should fallback on mismatch', async ctx => {
  223. ctx.db.docs.findOneAndUpdate.resolves({
  224. lines: [],
  225. })
  226. await ctx.MongoManager.upsertIntoDocCollection(
  227. ctx.projectId,
  228. ctx.docId,
  229. ctx.oldRev,
  230. { lines: ctx.lines }
  231. )
  232. const args = ctx.db.docs.updateOne.args[0]
  233. assert.deepEqual(args[0], {
  234. _id: new ObjectId(ctx.docId),
  235. project_id: new ObjectId(ctx.projectId),
  236. rev: ctx.oldRev + 1,
  237. })
  238. assert.equal(args[1].$set.lines, ctx.lines)
  239. assert.equal(args[1].$set.rev, ctx.oldRev + 2)
  240. })
  241. it('should handle update error', async ctx => {
  242. ctx.db.docs.findOneAndUpdate.rejects(ctx.stubbedErr)
  243. await expect(
  244. ctx.MongoManager.upsertIntoDocCollection(
  245. ctx.projectId,
  246. ctx.docId,
  247. ctx.rev,
  248. {
  249. lines: ctx.lines,
  250. }
  251. )
  252. ).to.be.rejectedWith(ctx.stubbedErr)
  253. })
  254. it('should handle update error of the fallback', async ctx => {
  255. ctx.db.docs.findOneAndUpdate.resolves({
  256. lines: [],
  257. })
  258. ctx.db.docs.updateOne.rejects(ctx.stubbedErr)
  259. await expect(
  260. ctx.MongoManager.upsertIntoDocCollection(
  261. ctx.projectId,
  262. ctx.docId,
  263. ctx.rev,
  264. {
  265. lines: ctx.lines,
  266. }
  267. )
  268. ).to.be.rejectedWith(ctx.stubbedErr)
  269. })
  270. it('should insert without a previous rev', async ctx => {
  271. await ctx.MongoManager.upsertIntoDocCollection(
  272. ctx.projectId,
  273. ctx.docId,
  274. null,
  275. { lines: ctx.lines, ranges: ctx.ranges }
  276. )
  277. expect(ctx.db.docs.insertOne).to.have.been.calledWith({
  278. _id: new ObjectId(ctx.docId),
  279. project_id: new ObjectId(ctx.projectId),
  280. rev: 1,
  281. lines: ctx.lines,
  282. ranges: ctx.ranges,
  283. })
  284. })
  285. it('should handle generic insert error', async ctx => {
  286. ctx.db.docs.insertOne.rejects(ctx.stubbedErr)
  287. await expect(
  288. ctx.MongoManager.upsertIntoDocCollection(
  289. ctx.projectId,
  290. ctx.docId,
  291. null,
  292. { lines: ctx.lines, ranges: ctx.ranges }
  293. )
  294. ).to.be.rejectedWith(ctx.stubbedErr)
  295. })
  296. it('should handle duplicate insert error', async ctx => {
  297. ctx.db.docs.insertOne.rejects({ code: 11000 })
  298. await expect(
  299. ctx.MongoManager.upsertIntoDocCollection(
  300. ctx.projectId,
  301. ctx.docId,
  302. null,
  303. { lines: ctx.lines, ranges: ctx.ranges }
  304. )
  305. ).to.be.rejectedWith(Errors.DocRevValueError)
  306. })
  307. })
  308. describe('destroyProject', () => {
  309. beforeEach(async ctx => {
  310. ctx.projectId = new ObjectId()
  311. ctx.db.docs.deleteMany = sinon.stub().resolves()
  312. await ctx.MongoManager.destroyProject(ctx.projectId)
  313. })
  314. it('should destroy all docs', ctx => {
  315. sinon.assert.calledWith(ctx.db.docs.deleteMany, {
  316. project_id: ctx.projectId,
  317. })
  318. })
  319. })
  320. describe('checkRevUnchanged', ctx => {
  321. beforeEach(ctx => {
  322. ctx.doc = { _id: new ObjectId(), name: 'mock-doc', rev: 1 }
  323. })
  324. it('should not error when the rev has not changed', async ctx => {
  325. ctx.db.docs.findOne = sinon.stub().resolves({ rev: 1 })
  326. await ctx.MongoManager.checkRevUnchanged(ctx.doc)
  327. })
  328. it('should return an error when the rev has changed', async ctx => {
  329. ctx.db.docs.findOne = sinon.stub().resolves({ rev: 2 })
  330. await expect(
  331. ctx.MongoManager.checkRevUnchanged(ctx.doc)
  332. ).to.be.rejectedWith(Errors.DocModifiedError)
  333. })
  334. it('should return a value error if incoming rev is NaN', async ctx => {
  335. ctx.db.docs.findOne = sinon.stub().resolves({ rev: 2 })
  336. ctx.doc = { _id: new ObjectId(), name: 'mock-doc', rev: NaN }
  337. await expect(
  338. ctx.MongoManager.checkRevUnchanged(ctx.doc)
  339. ).to.be.rejectedWith(Errors.DocRevValueError)
  340. })
  341. it('should return a value error if checked doc rev is NaN', async ctx => {
  342. ctx.db.docs.findOne = sinon.stub().resolves({ rev: NaN })
  343. await expect(
  344. ctx.MongoManager.checkRevUnchanged(ctx.doc)
  345. ).to.be.rejectedWith(Errors.DocRevValueError)
  346. })
  347. })
  348. describe('restoreArchivedDoc', () => {
  349. beforeEach(ctx => {
  350. ctx.archivedDoc = {
  351. lines: ['a', 'b', 'c'],
  352. ranges: { some: 'ranges' },
  353. rev: 2,
  354. }
  355. })
  356. describe('complete doc', () => {
  357. beforeEach(async ctx => {
  358. ctx.db.docs.findOneAndUpdate.resolves({
  359. lines: ctx.archivedDoc.lines,
  360. ranges: ctx.archivedDoc.ranges,
  361. })
  362. })
  363. it('updates Mongo', async ctx => {
  364. await ctx.MongoManager.restoreArchivedDoc(
  365. ctx.projectId,
  366. ctx.docId,
  367. ctx.archivedDoc
  368. )
  369. expect(ctx.db.docs.findOneAndUpdate).to.have.been.calledWith(
  370. {
  371. _id: new ObjectId(ctx.docId),
  372. project_id: new ObjectId(ctx.projectId),
  373. rev: ctx.archivedDoc.rev,
  374. },
  375. [
  376. {
  377. $set: {
  378. lines: ctx.archivedDoc.lines,
  379. },
  380. },
  381. {
  382. $set: {
  383. ranges: ctx.archivedDoc.ranges,
  384. },
  385. },
  386. { $unset: 'inS3' },
  387. ]
  388. )
  389. })
  390. it('updates Mongo on fallback', async ctx => {
  391. ctx.db.docs.findOneAndUpdate.resolves({
  392. lines: ['foo'],
  393. ranges: ctx.archivedDoc.ranges,
  394. })
  395. await ctx.MongoManager.restoreArchivedDoc(
  396. ctx.projectId,
  397. ctx.docId,
  398. ctx.archivedDoc
  399. )
  400. expect(ctx.db.docs.updateOne).to.have.been.calledWith(
  401. {
  402. _id: new ObjectId(ctx.docId),
  403. project_id: new ObjectId(ctx.projectId),
  404. rev: ctx.archivedDoc.rev,
  405. },
  406. {
  407. $set: {
  408. lines: ctx.archivedDoc.lines,
  409. ranges: ctx.archivedDoc.ranges,
  410. },
  411. $unset: {
  412. inS3: true,
  413. },
  414. }
  415. )
  416. })
  417. })
  418. describe('without ranges', () => {
  419. beforeEach(async ctx => {
  420. delete ctx.archivedDoc.ranges
  421. })
  422. it('sets ranges to an empty object', async ctx => {
  423. await ctx.MongoManager.restoreArchivedDoc(
  424. ctx.projectId,
  425. ctx.docId,
  426. ctx.archivedDoc
  427. )
  428. expect(ctx.db.docs.findOneAndUpdate).to.have.been.calledWith(
  429. {
  430. _id: new ObjectId(ctx.docId),
  431. project_id: new ObjectId(ctx.projectId),
  432. rev: ctx.archivedDoc.rev,
  433. },
  434. [
  435. {
  436. $set: {
  437. lines: ctx.archivedDoc.lines,
  438. },
  439. },
  440. {
  441. $set: {
  442. ranges: {},
  443. },
  444. },
  445. { $unset: 'inS3' },
  446. ]
  447. )
  448. })
  449. })
  450. describe("when the update doesn't succeed", () => {
  451. it('throws a DocRevValueError', async ctx => {
  452. ctx.db.docs.findOneAndUpdate.resolves(null)
  453. await expect(
  454. ctx.MongoManager.restoreArchivedDoc(
  455. ctx.projectId,
  456. ctx.docId,
  457. ctx.archivedDoc
  458. )
  459. ).to.be.rejectedWith(Errors.DocRevValueError)
  460. })
  461. it('throws a DocRevValueError on fallback', async ctx => {
  462. ctx.db.docs.findOneAndUpdate.resolves({
  463. lines: ['foo'],
  464. ranges: ctx.archivedDoc.ranges,
  465. })
  466. ctx.db.docs.updateOne.resolves({ matchedCount: 0 })
  467. await expect(
  468. ctx.MongoManager.restoreArchivedDoc(
  469. ctx.projectId,
  470. ctx.docId,
  471. ctx.archivedDoc
  472. )
  473. ).to.be.rejectedWith(Errors.DocRevValueError)
  474. })
  475. })
  476. })
  477. })