UpdateManagerTests.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
  1. // @ts-check
  2. const sinon = require('sinon')
  3. const { expect } = require('chai')
  4. const SandboxedModule = require('sandboxed-module')
  5. const MODULE_PATH = '../../../../app/js/UpdateManager.js'
  6. describe('UpdateManager', function () {
  7. beforeEach(function () {
  8. this.project_id = 'project-id-123'
  9. this.projectHistoryId = 'history-id-123'
  10. this.doc_id = 'document-id-123'
  11. this.lockValue = 'mock-lock-value'
  12. this.Metrics = {
  13. inc: sinon.stub(),
  14. Timer: class Timer {},
  15. }
  16. this.Metrics.Timer.prototype.done = sinon.stub()
  17. this.Profiler = class Profiler {}
  18. this.Profiler.prototype.log = sinon.stub().returns({ end: sinon.stub() })
  19. this.Profiler.prototype.end = sinon.stub()
  20. this.LockManager = {
  21. promises: {
  22. tryLock: sinon.stub().resolves(this.lockValue),
  23. getLock: sinon.stub().resolves(this.lockValue),
  24. releaseLock: sinon.stub().resolves(),
  25. },
  26. }
  27. this.RedisManager = {
  28. promises: {
  29. setDocument: sinon.stub().resolves(),
  30. updateDocument: sinon.stub(),
  31. },
  32. }
  33. this.RealTimeRedisManager = {
  34. sendData: sinon.stub(),
  35. promises: {
  36. getUpdatesLength: sinon.stub(),
  37. getPendingUpdatesForDoc: sinon.stub(),
  38. },
  39. }
  40. this.ShareJsUpdateManager = {
  41. promises: {
  42. applyUpdate: sinon.stub(),
  43. },
  44. }
  45. this.HistoryManager = {
  46. recordAndFlushHistoryOps: sinon.stub(),
  47. }
  48. this.Settings = {}
  49. this.DocumentManager = {
  50. promises: {
  51. getDoc: sinon.stub(),
  52. },
  53. }
  54. this.RangesManager = {
  55. applyUpdate: sinon.stub(),
  56. }
  57. this.SnapshotManager = {
  58. promises: {
  59. recordSnapshot: sinon.stub().resolves(),
  60. },
  61. }
  62. this.UpdateManager = SandboxedModule.require(MODULE_PATH, {
  63. requires: {
  64. './LockManager': this.LockManager,
  65. './RedisManager': this.RedisManager,
  66. './RealTimeRedisManager': this.RealTimeRedisManager,
  67. './ShareJsUpdateManager': this.ShareJsUpdateManager,
  68. './HistoryManager': this.HistoryManager,
  69. './Metrics': this.Metrics,
  70. '@overleaf/settings': this.Settings,
  71. './DocumentManager': this.DocumentManager,
  72. './RangesManager': this.RangesManager,
  73. './SnapshotManager': this.SnapshotManager,
  74. './Profiler': this.Profiler,
  75. },
  76. })
  77. })
  78. describe('processOutstandingUpdates', function () {
  79. beforeEach(async function () {
  80. this.UpdateManager.promises.fetchAndApplyUpdates = sinon.stub().resolves()
  81. await this.UpdateManager.promises.processOutstandingUpdates(
  82. this.project_id,
  83. this.doc_id
  84. )
  85. })
  86. it('should apply the updates', function () {
  87. this.UpdateManager.promises.fetchAndApplyUpdates
  88. .calledWith(this.project_id, this.doc_id)
  89. .should.equal(true)
  90. })
  91. it('should time the execution', function () {
  92. this.Metrics.Timer.prototype.done.called.should.equal(true)
  93. })
  94. })
  95. describe('processOutstandingUpdatesWithLock', function () {
  96. describe('when the lock is free', function () {
  97. beforeEach(function () {
  98. this.UpdateManager.promises.continueProcessingUpdatesWithLock = sinon
  99. .stub()
  100. .resolves()
  101. this.UpdateManager.promises.processOutstandingUpdates = sinon
  102. .stub()
  103. .resolves()
  104. })
  105. describe('successfully', function () {
  106. beforeEach(async function () {
  107. await this.UpdateManager.promises.processOutstandingUpdatesWithLock(
  108. this.project_id,
  109. this.doc_id
  110. )
  111. })
  112. it('should acquire the lock', function () {
  113. this.LockManager.promises.tryLock
  114. .calledWith(this.doc_id)
  115. .should.equal(true)
  116. })
  117. it('should free the lock', function () {
  118. this.LockManager.promises.releaseLock
  119. .calledWith(this.doc_id, this.lockValue)
  120. .should.equal(true)
  121. })
  122. it('should process the outstanding updates', function () {
  123. this.UpdateManager.promises.processOutstandingUpdates
  124. .calledWith(this.project_id, this.doc_id)
  125. .should.equal(true)
  126. })
  127. it('should do everything with the lock acquired', function () {
  128. this.UpdateManager.promises.processOutstandingUpdates
  129. .calledAfter(this.LockManager.promises.tryLock)
  130. .should.equal(true)
  131. this.UpdateManager.promises.processOutstandingUpdates
  132. .calledBefore(this.LockManager.promises.releaseLock)
  133. .should.equal(true)
  134. })
  135. it('should continue processing new updates that may have come in', function () {
  136. this.UpdateManager.promises.continueProcessingUpdatesWithLock
  137. .calledWith(this.project_id, this.doc_id)
  138. .should.equal(true)
  139. })
  140. })
  141. describe('when processOutstandingUpdates returns an error', function () {
  142. beforeEach(async function () {
  143. this.error = new Error('Something went wrong')
  144. this.UpdateManager.promises.processOutstandingUpdates = sinon
  145. .stub()
  146. .rejects(this.error)
  147. await expect(
  148. this.UpdateManager.promises.processOutstandingUpdatesWithLock(
  149. this.project_id,
  150. this.doc_id
  151. )
  152. ).to.be.rejectedWith(this.error)
  153. })
  154. it('should free the lock', function () {
  155. this.LockManager.promises.releaseLock
  156. .calledWith(this.doc_id, this.lockValue)
  157. .should.equal(true)
  158. })
  159. })
  160. })
  161. describe('when the lock is taken', function () {
  162. beforeEach(async function () {
  163. this.LockManager.promises.tryLock.resolves(null)
  164. this.UpdateManager.promises.processOutstandingUpdates = sinon
  165. .stub()
  166. .resolves()
  167. await this.UpdateManager.promises.processOutstandingUpdatesWithLock(
  168. this.project_id,
  169. this.doc_id
  170. )
  171. })
  172. it('should not process the updates', function () {
  173. this.UpdateManager.promises.processOutstandingUpdates.called.should.equal(
  174. false
  175. )
  176. })
  177. })
  178. })
  179. describe('continueProcessingUpdatesWithLock', function () {
  180. describe('when there are outstanding updates', function () {
  181. beforeEach(async function () {
  182. this.RealTimeRedisManager.promises.getUpdatesLength.resolves(3)
  183. this.UpdateManager.promises.processOutstandingUpdatesWithLock = sinon
  184. .stub()
  185. .resolves()
  186. await this.UpdateManager.promises.continueProcessingUpdatesWithLock(
  187. this.project_id,
  188. this.doc_id
  189. )
  190. })
  191. it('should process the outstanding updates', function () {
  192. this.UpdateManager.promises.processOutstandingUpdatesWithLock
  193. .calledWith(this.project_id, this.doc_id)
  194. .should.equal(true)
  195. })
  196. })
  197. describe('when there are no outstanding updates', function () {
  198. beforeEach(async function () {
  199. this.RealTimeRedisManager.promises.getUpdatesLength.resolves(0)
  200. this.UpdateManager.promises.processOutstandingUpdatesWithLock = sinon
  201. .stub()
  202. .resolves()
  203. await this.UpdateManager.promises.continueProcessingUpdatesWithLock(
  204. this.project_id,
  205. this.doc_id
  206. )
  207. })
  208. it('should not try to process the outstanding updates', function () {
  209. this.UpdateManager.promises.processOutstandingUpdatesWithLock.called.should.equal(
  210. false
  211. )
  212. })
  213. })
  214. })
  215. describe('fetchAndApplyUpdates', function () {
  216. describe('with updates', function () {
  217. beforeEach(async function () {
  218. this.updates = [{ p: 1, t: 'foo' }]
  219. this.updatedDocLines = ['updated', 'lines']
  220. this.version = 34
  221. this.RealTimeRedisManager.promises.getPendingUpdatesForDoc.resolves(
  222. this.updates
  223. )
  224. this.UpdateManager.promises.applyUpdate = sinon.stub().resolves()
  225. await this.UpdateManager.promises.fetchAndApplyUpdates(
  226. this.project_id,
  227. this.doc_id
  228. )
  229. })
  230. it('should get the pending updates', function () {
  231. this.RealTimeRedisManager.promises.getPendingUpdatesForDoc
  232. .calledWith(this.doc_id)
  233. .should.equal(true)
  234. })
  235. it('should apply the updates', function () {
  236. this.updates.map(update =>
  237. this.UpdateManager.promises.applyUpdate
  238. .calledWith(this.project_id, this.doc_id, update)
  239. .should.equal(true)
  240. )
  241. })
  242. })
  243. describe('when there are no updates', function () {
  244. beforeEach(async function () {
  245. this.updates = []
  246. this.RealTimeRedisManager.promises.getPendingUpdatesForDoc.resolves(
  247. this.updates
  248. )
  249. this.UpdateManager.promises.applyUpdate = sinon.stub().resolves()
  250. await this.UpdateManager.promises.fetchAndApplyUpdates(
  251. this.project_id,
  252. this.doc_id
  253. )
  254. })
  255. it('should not call applyUpdate', function () {
  256. this.UpdateManager.promises.applyUpdate.called.should.equal(false)
  257. })
  258. })
  259. })
  260. describe('applyUpdate', function () {
  261. beforeEach(function () {
  262. this.updateMeta = { user_id: 'last-author-fake-id' }
  263. this.update = { op: [{ p: 42, i: 'foo' }], meta: this.updateMeta }
  264. this.updatedDocLines = ['updated', 'lines']
  265. this.version = 34
  266. this.lines = ['original', 'lines']
  267. this.ranges = { entries: 'mock', comments: 'mock' }
  268. this.updated_ranges = { entries: 'updated', comments: 'updated' }
  269. this.appliedOps = [
  270. { v: 42, op: 'mock-op-42' },
  271. { v: 45, op: 'mock-op-45' },
  272. ]
  273. this.project_ops_length = 123
  274. this.pathname = '/a/b/c.tex'
  275. this.DocumentManager.promises.getDoc.resolves({
  276. lines: this.lines,
  277. version: this.version,
  278. ranges: this.ranges,
  279. pathname: this.pathname,
  280. projectHistoryId: this.projectHistoryId,
  281. })
  282. this.RangesManager.applyUpdate.returns({
  283. newRanges: this.updated_ranges,
  284. rangesWereCollapsed: false,
  285. })
  286. this.ShareJsUpdateManager.promises.applyUpdate = sinon.stub().resolves({
  287. updatedDocLines: this.updatedDocLines,
  288. version: this.version,
  289. appliedOps: this.appliedOps,
  290. })
  291. this.RedisManager.promises.updateDocument.resolves(
  292. this.project_ops_length
  293. )
  294. this.UpdateManager.promises._addProjectHistoryMetadataToOps = sinon.stub()
  295. })
  296. describe('normally', function () {
  297. beforeEach(async function () {
  298. await this.UpdateManager.promises.applyUpdate(
  299. this.project_id,
  300. this.doc_id,
  301. this.update
  302. )
  303. })
  304. it('should apply the updates via ShareJS', function () {
  305. this.ShareJsUpdateManager.promises.applyUpdate
  306. .calledWith(
  307. this.project_id,
  308. this.doc_id,
  309. this.update,
  310. this.lines,
  311. this.version
  312. )
  313. .should.equal(true)
  314. })
  315. it('should update the ranges', function () {
  316. this.RangesManager.applyUpdate
  317. .calledWith(
  318. this.project_id,
  319. this.doc_id,
  320. this.ranges,
  321. this.appliedOps,
  322. this.updatedDocLines
  323. )
  324. .should.equal(true)
  325. })
  326. it('should save the document', function () {
  327. this.RedisManager.promises.updateDocument
  328. .calledWith(
  329. this.project_id,
  330. this.doc_id,
  331. this.updatedDocLines,
  332. this.version,
  333. this.appliedOps,
  334. this.updated_ranges,
  335. this.updateMeta
  336. )
  337. .should.equal(true)
  338. })
  339. it('should add metadata to the ops', function () {
  340. this.UpdateManager.promises._addProjectHistoryMetadataToOps.should.have.been.calledWith(
  341. this.appliedOps,
  342. this.pathname,
  343. this.projectHistoryId,
  344. this.lines
  345. )
  346. })
  347. it('should push the applied ops into the history queue', function () {
  348. this.HistoryManager.recordAndFlushHistoryOps
  349. .calledWith(this.project_id, this.appliedOps, this.project_ops_length)
  350. .should.equal(true)
  351. })
  352. })
  353. describe('with UTF-16 surrogate pairs in the update', function () {
  354. beforeEach(async function () {
  355. this.update = { op: [{ p: 42, i: '\uD835\uDC00' }] }
  356. await this.UpdateManager.promises.applyUpdate(
  357. this.project_id,
  358. this.doc_id,
  359. this.update
  360. )
  361. })
  362. it('should apply the update but with surrogate pairs removed', function () {
  363. this.ShareJsUpdateManager.promises.applyUpdate
  364. .calledWith(this.project_id, this.doc_id, this.update)
  365. .should.equal(true)
  366. // \uFFFD is 'replacement character'
  367. this.update.op[0].i.should.equal('\uFFFD\uFFFD')
  368. })
  369. })
  370. describe('with an error', function () {
  371. beforeEach(async function () {
  372. this.error = new Error('something went wrong')
  373. this.ShareJsUpdateManager.promises.applyUpdate.rejects(this.error)
  374. await expect(
  375. this.UpdateManager.promises.applyUpdate(
  376. this.project_id,
  377. this.doc_id,
  378. this.update
  379. )
  380. ).to.be.rejectedWith(this.error)
  381. })
  382. it('should call RealTimeRedisManager.sendData with the error', function () {
  383. this.RealTimeRedisManager.sendData
  384. .calledWith({
  385. project_id: this.project_id,
  386. doc_id: this.doc_id,
  387. error: this.error.message,
  388. })
  389. .should.equal(true)
  390. })
  391. })
  392. describe('when ranges get collapsed', function () {
  393. beforeEach(async function () {
  394. this.RangesManager.applyUpdate.returns({
  395. newRanges: this.updated_ranges,
  396. rangesWereCollapsed: true,
  397. })
  398. await this.UpdateManager.promises.applyUpdate(
  399. this.project_id,
  400. this.doc_id,
  401. this.update
  402. )
  403. })
  404. it('should increment the doc-snapshot metric', function () {
  405. this.Metrics.inc.calledWith('doc-snapshot').should.equal(true)
  406. })
  407. it('should call SnapshotManager.recordSnapshot', function () {
  408. this.SnapshotManager.promises.recordSnapshot
  409. .calledWith(
  410. this.project_id,
  411. this.doc_id,
  412. this.version,
  413. this.pathname,
  414. this.lines,
  415. this.ranges
  416. )
  417. .should.equal(true)
  418. })
  419. })
  420. })
  421. describe('_addProjectHistoryMetadataToOps', function () {
  422. it('should add projectHistoryId, pathname and doc_length metadata to the ops', function () {
  423. const lines = ['some', 'test', 'data']
  424. const appliedOps = [
  425. {
  426. v: 42,
  427. op: [
  428. { i: 'foo', p: 4 },
  429. { i: 'bar', p: 6 },
  430. ],
  431. },
  432. {
  433. v: 45,
  434. op: [
  435. { d: 'qux', p: 4 },
  436. { i: 'bazbaz', p: 14 },
  437. ],
  438. },
  439. { v: 49, op: [{ i: 'penguin', p: 18 }] },
  440. ]
  441. this.UpdateManager._addProjectHistoryMetadataToOps(
  442. appliedOps,
  443. this.pathname,
  444. this.projectHistoryId,
  445. lines
  446. )
  447. appliedOps.should.deep.equal([
  448. {
  449. projectHistoryId: this.projectHistoryId,
  450. v: 42,
  451. op: [
  452. { i: 'foo', p: 4 },
  453. { i: 'bar', p: 6 },
  454. ],
  455. meta: {
  456. pathname: this.pathname,
  457. doc_length: 14,
  458. },
  459. },
  460. {
  461. projectHistoryId: this.projectHistoryId,
  462. v: 45,
  463. op: [
  464. { d: 'qux', p: 4 },
  465. { i: 'bazbaz', p: 14 },
  466. ],
  467. meta: {
  468. pathname: this.pathname,
  469. doc_length: 20,
  470. }, // 14 + 'foo' + 'bar'
  471. },
  472. {
  473. projectHistoryId: this.projectHistoryId,
  474. v: 49,
  475. op: [{ i: 'penguin', p: 18 }],
  476. meta: {
  477. pathname: this.pathname,
  478. doc_length: 23,
  479. }, // 14 - 'qux' + 'bazbaz'
  480. },
  481. ])
  482. })
  483. })
  484. describe('lockUpdatesAndDo', function () {
  485. beforeEach(function () {
  486. this.method = sinon
  487. .stub()
  488. .yields(null, this.response_arg1, this.response_arg2)
  489. this.arg1 = 'argument 1'
  490. this.response_arg1 = 'response argument 1'
  491. this.response_arg2 = 'response argument 2'
  492. })
  493. describe('successfully', function () {
  494. beforeEach(async function () {
  495. this.UpdateManager.promises.continueProcessingUpdatesWithLock = sinon
  496. .stub()
  497. .resolves()
  498. this.UpdateManager.promises.processOutstandingUpdates = sinon
  499. .stub()
  500. .resolves()
  501. this.response = await this.UpdateManager.promises.lockUpdatesAndDo(
  502. this.method,
  503. this.project_id,
  504. this.doc_id,
  505. this.arg1
  506. )
  507. })
  508. it('should lock the doc', function () {
  509. this.LockManager.promises.getLock
  510. .calledWith(this.doc_id)
  511. .should.equal(true)
  512. })
  513. it('should process any outstanding updates', function () {
  514. this.UpdateManager.promises.processOutstandingUpdates.should.have.been.calledWith(
  515. this.project_id,
  516. this.doc_id
  517. )
  518. })
  519. it('should call the method', function () {
  520. this.method
  521. .calledWith(this.project_id, this.doc_id, this.arg1)
  522. .should.equal(true)
  523. })
  524. it('should return the method response arguments', function () {
  525. expect(this.response).to.deep.equal([
  526. this.response_arg1,
  527. this.response_arg2,
  528. ])
  529. })
  530. it('should release the lock', function () {
  531. this.LockManager.promises.releaseLock
  532. .calledWith(this.doc_id, this.lockValue)
  533. .should.equal(true)
  534. })
  535. it('should continue processing updates', function () {
  536. this.UpdateManager.promises.continueProcessingUpdatesWithLock
  537. .calledWith(this.project_id, this.doc_id)
  538. .should.equal(true)
  539. })
  540. })
  541. describe('when processOutstandingUpdates returns an error', function () {
  542. beforeEach(async function () {
  543. this.error = new Error('Something went wrong')
  544. this.UpdateManager.promises.processOutstandingUpdates = sinon
  545. .stub()
  546. .rejects(this.error)
  547. await expect(
  548. this.UpdateManager.promises.lockUpdatesAndDo(
  549. this.method,
  550. this.project_id,
  551. this.doc_id,
  552. this.arg1
  553. )
  554. ).to.be.rejectedWith(this.error)
  555. })
  556. it('should free the lock', function () {
  557. this.LockManager.promises.releaseLock
  558. .calledWith(this.doc_id, this.lockValue)
  559. .should.equal(true)
  560. })
  561. })
  562. describe('when the method returns an error', function () {
  563. beforeEach(async function () {
  564. this.error = new Error('something went wrong')
  565. this.UpdateManager.promises.processOutstandingUpdates = sinon
  566. .stub()
  567. .resolves()
  568. this.method = sinon.stub().yields(this.error)
  569. await expect(
  570. this.UpdateManager.promises.lockUpdatesAndDo(
  571. this.method,
  572. this.project_id,
  573. this.doc_id,
  574. this.arg1
  575. )
  576. ).to.be.rejectedWith(this.error)
  577. })
  578. it('should free the lock', function () {
  579. this.LockManager.promises.releaseLock
  580. .calledWith(this.doc_id, this.lockValue)
  581. .should.equal(true)
  582. })
  583. })
  584. })
  585. })