MongoManager.test.js 11 KB

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