UpdateManagerTests.js 19 KB

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