DocumentUpdaterManagerTests.js 13 KB

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