DocumentUpdaterManager.test.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. // TODO: This file was created by bulk-decaffeinate.
  2. // Fix any style issues and re-enable lint.
  3. /*
  4. * decaffeinate suggestions:
  5. * DS102: Remove unnecessary code created because of implicit returns
  6. * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  7. */
  8. import sinon from 'sinon'
  9. import { expect, vi, describe, beforeEach, it, afterEach } from 'vitest'
  10. import _ from 'lodash'
  11. const modulePath = '../../../app/js/DocumentUpdaterManager'
  12. describe('DocumentUpdaterManager', function () {
  13. beforeEach(async function (ctx) {
  14. ctx.project_id = 'project-id-923'
  15. ctx.doc_id = 'doc-id-394'
  16. ctx.lines = ['one', 'two', 'three']
  17. ctx.version = 42
  18. ctx.settings = {
  19. apis: { documentupdater: { url: 'http://doc-updater.example.com' } },
  20. redis: {
  21. documentupdater: {
  22. key_schema: {
  23. pendingUpdates({ doc_id: docId }) {
  24. return `PendingUpdates:${docId}`
  25. },
  26. },
  27. },
  28. },
  29. maxUpdateSize: 7 * 1024 * 1024,
  30. pendingUpdateListShardCount: 10,
  31. }
  32. ctx.rclient = { auth() {} }
  33. ctx.fetchJson = sinon.stub()
  34. ctx.fetchNothing = sinon.stub()
  35. ctx.RequestFailedError = class RequestFailedError extends Error {
  36. constructor(message, url, options, response, body) {
  37. super(message)
  38. this.url = url
  39. this.options = options
  40. this.response = response
  41. this.body = body
  42. }
  43. }
  44. vi.doMock('@overleaf/settings', () => ({
  45. default: ctx.settings,
  46. }))
  47. vi.doMock('@overleaf/fetch-utils', () => ({
  48. fetchJson: ctx.fetchJson,
  49. fetchNothing: ctx.fetchNothing,
  50. RequestFailedError: ctx.RequestFailedError,
  51. }))
  52. vi.doMock('@overleaf/redis-wrapper', () => ({
  53. default: { createClient: () => ctx.rclient },
  54. }))
  55. class Timer {
  56. done() {}
  57. }
  58. vi.doMock('@overleaf/metrics', () => ({
  59. default: (ctx.Metrics = {
  60. summary: sinon.stub(),
  61. Timer,
  62. }),
  63. }))
  64. ctx.DocumentUpdaterManager = (await import(modulePath)).default
  65. }) // avoid modifying JSON object directly
  66. describe('getDocument', function () {
  67. describe('successfully', function () {
  68. beforeEach(async function (ctx) {
  69. ctx.body = {
  70. lines: ctx.lines,
  71. version: ctx.version,
  72. ops: (ctx.ops = ['mock-op-1', 'mock-op-2']),
  73. ranges: (ctx.ranges = { mock: 'ranges' }),
  74. }
  75. ctx.fromVersion = 2
  76. ctx.fetchJson.resolves(ctx.body)
  77. ctx.result = await ctx.DocumentUpdaterManager.promises.getDocument(
  78. ctx.project_id,
  79. ctx.doc_id,
  80. ctx.fromVersion
  81. )
  82. })
  83. it('should get the document from the document updater', function (ctx) {
  84. const url = `${ctx.settings.apis.documentupdater.url}/project/${ctx.project_id}/doc/${ctx.doc_id}?fromVersion=${ctx.fromVersion}&historyOTSupport=true`
  85. ctx.fetchJson.calledWith(url).should.equal(true)
  86. })
  87. it('should return the lines, version, ranges and ops', function (ctx) {
  88. ctx.result.lines.should.deep.equal(ctx.lines)
  89. ctx.result.version.should.equal(ctx.version)
  90. ctx.result.ranges.should.deep.equal(ctx.ranges)
  91. ctx.result.ops.should.deep.equal(ctx.ops)
  92. })
  93. })
  94. describe('when the document updater API returns an error', function () {
  95. beforeEach(function (ctx) {
  96. ctx.error = new Error('something went wrong')
  97. ctx.fetchJson.rejects(ctx.error)
  98. })
  99. it('should throw an error', async function (ctx) {
  100. await expect(
  101. ctx.DocumentUpdaterManager.promises.getDocument(
  102. ctx.project_id,
  103. ctx.doc_id,
  104. ctx.fromVersion
  105. )
  106. ).to.be.rejectedWith(ctx.error)
  107. })
  108. })
  109. ;[404, 422].forEach(statusCode =>
  110. describe(`when the document updater returns a ${statusCode} status code`, function () {
  111. beforeEach(function (ctx) {
  112. const error = new ctx.RequestFailedError(
  113. 'error',
  114. 'url',
  115. {},
  116. { status: statusCode },
  117. null
  118. )
  119. ctx.fetchJson.rejects(error)
  120. })
  121. it('should throw an error with the status code', async function (ctx) {
  122. const error = await expect(
  123. ctx.DocumentUpdaterManager.promises.getDocument(
  124. ctx.project_id,
  125. ctx.doc_id,
  126. ctx.fromVersion
  127. )
  128. ).to.be.rejected
  129. error.message.should.equal('doc updater could not load requested ops')
  130. error.info.statusCode.should.equal(statusCode)
  131. ctx.logger.error.called.should.equal(false)
  132. ctx.logger.warn.called.should.equal(false)
  133. })
  134. })
  135. )
  136. describe('when the document updater returns a failure error code', function () {
  137. beforeEach(function (ctx) {
  138. const error = new ctx.RequestFailedError(
  139. 'error',
  140. 'url',
  141. {},
  142. { status: 500 },
  143. null
  144. )
  145. ctx.fetchJson.rejects(error)
  146. })
  147. it('should throw an error', async function (ctx) {
  148. const error = await expect(
  149. ctx.DocumentUpdaterManager.promises.getDocument(
  150. ctx.project_id,
  151. ctx.doc_id,
  152. ctx.fromVersion
  153. )
  154. ).to.be.rejected
  155. error.message.should.equal(
  156. 'doc updater returned a non-success status code'
  157. )
  158. error.info.action.should.equal('getDocument')
  159. error.info.statusCode.should.equal(500)
  160. ctx.logger.error.called.should.equal(false)
  161. })
  162. })
  163. })
  164. describe('flushProjectToMongoAndDelete', function () {
  165. describe('successfully', function () {
  166. beforeEach(async function (ctx) {
  167. ctx.fetchNothing.resolves()
  168. await ctx.DocumentUpdaterManager.promises.flushProjectToMongoAndDelete(
  169. ctx.project_id
  170. )
  171. })
  172. it('should delete the project from the document updater', function (ctx) {
  173. const url = `${ctx.settings.apis.documentupdater.url}/project/${ctx.project_id}?background=true`
  174. ctx.fetchNothing
  175. .calledWith(url, { method: 'DELETE' })
  176. .should.equal(true)
  177. })
  178. })
  179. describe('when the document updater API returns an error', function () {
  180. beforeEach(function (ctx) {
  181. ctx.error = new Error('something went wrong')
  182. ctx.fetchNothing.rejects(ctx.error)
  183. })
  184. it('should throw an error', async function (ctx) {
  185. await expect(
  186. ctx.DocumentUpdaterManager.promises.flushProjectToMongoAndDelete(
  187. ctx.project_id
  188. )
  189. ).to.be.rejectedWith(ctx.error)
  190. })
  191. })
  192. describe('when the document updater returns a failure error code', function () {
  193. beforeEach(function (ctx) {
  194. const error = new ctx.RequestFailedError(
  195. 'error',
  196. 'url',
  197. {},
  198. { status: 500 },
  199. null
  200. )
  201. ctx.fetchNothing.rejects(error)
  202. })
  203. it('should throw an error', async function (ctx) {
  204. const error = await expect(
  205. ctx.DocumentUpdaterManager.promises.flushProjectToMongoAndDelete(
  206. ctx.project_id
  207. )
  208. ).to.be.rejected
  209. error.message.should.equal(
  210. 'doc updater returned a non-success status code'
  211. )
  212. error.info.action.should.equal('flushProjectToMongoAndDelete')
  213. error.info.statusCode.should.equal(500)
  214. })
  215. })
  216. })
  217. describe('queueChange', function () {
  218. beforeEach(function (ctx) {
  219. ctx.change = {
  220. doc: '1234567890',
  221. op: [{ d: 'test', p: 345 }],
  222. v: 789,
  223. }
  224. ctx.rclient.rpush = sinon.stub().resolves()
  225. })
  226. describe('successfully', function () {
  227. beforeEach(async function (ctx) {
  228. await ctx.DocumentUpdaterManager.promises.queueChange(
  229. ctx.project_id,
  230. ctx.doc_id,
  231. ctx.change
  232. )
  233. })
  234. it('should push the change', function (ctx) {
  235. ctx.rclient.rpush
  236. .calledWith(
  237. `PendingUpdates:${ctx.doc_id}`,
  238. JSON.stringify(ctx.change)
  239. )
  240. .should.equal(true)
  241. })
  242. it('should notify the doc updater of the change via the pending-updates-list queue', function (ctx) {
  243. // The second call should be to a pending-updates-list key (either base or sharded)
  244. const secondCall = ctx.rclient.rpush.secondCall
  245. secondCall.should.exist
  246. const queueKey = secondCall.args[0]
  247. queueKey.should.match(/^pending-updates-list(-\d+)?$/)
  248. secondCall.args[1].should.equal(`${ctx.project_id}:${ctx.doc_id}`)
  249. })
  250. })
  251. describe('with error talking to redis during rpush', function () {
  252. beforeEach(function (ctx) {
  253. ctx.rclient.rpush = sinon
  254. .stub()
  255. .rejects(new Error('something went wrong'))
  256. })
  257. it('should throw an error', async function (ctx) {
  258. await expect(
  259. ctx.DocumentUpdaterManager.promises.queueChange(
  260. ctx.project_id,
  261. ctx.doc_id,
  262. ctx.change
  263. )
  264. ).to.be.rejected
  265. })
  266. })
  267. describe('with null byte corruption', function () {
  268. beforeEach(function (ctx) {
  269. ctx.stringifyStub = sinon
  270. .stub(JSON, 'stringify')
  271. .callsFake(() => '["bad bytes! \u0000 <- here"]')
  272. })
  273. afterEach(function (ctx) {
  274. ctx.stringifyStub.restore()
  275. })
  276. it('should throw an error', async function (ctx) {
  277. await expect(
  278. ctx.DocumentUpdaterManager.promises.queueChange(
  279. ctx.project_id,
  280. ctx.doc_id,
  281. ctx.change
  282. )
  283. ).to.be.rejected
  284. })
  285. it('should not push the change onto the pending-updates-list queue', async function (ctx) {
  286. await expect(
  287. ctx.DocumentUpdaterManager.promises.queueChange(
  288. ctx.project_id,
  289. ctx.doc_id,
  290. ctx.change
  291. )
  292. ).to.be.rejected
  293. ctx.rclient.rpush.called.should.equal(false)
  294. })
  295. })
  296. describe('when the update is too large', function () {
  297. beforeEach(function (ctx) {
  298. ctx.change = {
  299. op: { p: 12, t: 'update is too large'.repeat(1024 * 400) },
  300. }
  301. })
  302. it('should throw an error', async function (ctx) {
  303. await expect(
  304. ctx.DocumentUpdaterManager.promises.queueChange(
  305. ctx.project_id,
  306. ctx.doc_id,
  307. ctx.change
  308. )
  309. ).to.be.rejected
  310. })
  311. it('should add the size to the error', async function (ctx) {
  312. const error = await expect(
  313. ctx.DocumentUpdaterManager.promises.queueChange(
  314. ctx.project_id,
  315. ctx.doc_id,
  316. ctx.change
  317. )
  318. ).to.be.rejected
  319. error.info.updateSize.should.equal(7782422)
  320. })
  321. it('should not push the change onto the pending-updates-list queue', async function (ctx) {
  322. await expect(
  323. ctx.DocumentUpdaterManager.promises.queueChange(
  324. ctx.project_id,
  325. ctx.doc_id,
  326. ctx.change
  327. )
  328. ).to.be.rejected
  329. ctx.rclient.rpush.called.should.equal(false)
  330. })
  331. })
  332. describe('with invalid keys', function () {
  333. beforeEach(async function (ctx) {
  334. ctx.change = {
  335. op: [{ d: 'test', p: 345 }],
  336. version: 789, // not a valid key
  337. }
  338. await ctx.DocumentUpdaterManager.promises.queueChange(
  339. ctx.project_id,
  340. ctx.doc_id,
  341. ctx.change
  342. )
  343. })
  344. it('should remove the invalid keys from the change', function (ctx) {
  345. ctx.rclient.rpush
  346. .calledWith(
  347. `PendingUpdates:${ctx.doc_id}`,
  348. JSON.stringify({ op: ctx.change.op })
  349. )
  350. .should.equal(true)
  351. })
  352. })
  353. })
  354. describe('_getPendingUpdateListKey', function () {
  355. beforeEach(function (ctx) {
  356. const keys = _.times(
  357. 10000,
  358. ctx.DocumentUpdaterManager._getPendingUpdateListKey
  359. )
  360. ctx.keys = _.uniq(keys)
  361. })
  362. it('should return normal pending updates key', function (ctx) {
  363. _.includes(ctx.keys, 'pending-updates-list').should.equal(true)
  364. })
  365. it('should return pending-updates-list-n keys', function (ctx) {
  366. _.includes(ctx.keys, 'pending-updates-list-1').should.equal(true)
  367. _.includes(ctx.keys, 'pending-updates-list-3').should.equal(true)
  368. _.includes(ctx.keys, 'pending-updates-list-9').should.equal(true)
  369. })
  370. it('should not include pending-updates-list-0 key', function (ctx) {
  371. _.includes(ctx.keys, 'pending-updates-list-0').should.equal(false)
  372. })
  373. it('should not include maximum as pendingUpdateListShardCount value', function (ctx) {
  374. _.includes(ctx.keys, 'pending-updates-list-10').should.equal(false)
  375. })
  376. })
  377. })