UpdateManagerTests.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888
  1. const { createHash } = require('node:crypto')
  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.pathname = '/a/b/c.tex'
  13. this.Metrics = {
  14. inc: sinon.stub(),
  15. Timer: class Timer {},
  16. }
  17. this.Metrics.Timer.prototype.done = sinon.stub()
  18. this.Profiler = class Profiler {}
  19. this.Profiler.prototype.log = sinon.stub().returns({ end: sinon.stub() })
  20. this.Profiler.prototype.end = sinon.stub()
  21. this.LockManager = {
  22. promises: {
  23. tryLock: sinon.stub().resolves(this.lockValue),
  24. getLock: sinon.stub().resolves(this.lockValue),
  25. releaseLock: sinon.stub().resolves(),
  26. },
  27. }
  28. this.RedisManager = {
  29. promises: {
  30. setDocument: sinon.stub().resolves(),
  31. updateDocument: sinon.stub(),
  32. recordProjectNotificationTimestamp: sinon.stub().resolves(),
  33. },
  34. }
  35. this.RealTimeRedisManager = {
  36. sendData: sinon.stub(),
  37. promises: {
  38. getUpdatesLength: sinon.stub(),
  39. getPendingUpdatesForDoc: sinon.stub(),
  40. },
  41. }
  42. this.ShareJsUpdateManager = {
  43. promises: {
  44. applyUpdate: sinon.stub(),
  45. },
  46. }
  47. this.HistoryManager = {
  48. recordAndFlushHistoryOps: sinon.stub(),
  49. }
  50. this.Settings = {}
  51. this.DocumentManager = {
  52. promises: {
  53. getDoc: sinon.stub(),
  54. },
  55. }
  56. this.RangesManager = {
  57. applyUpdate: sinon.stub(),
  58. }
  59. this.SnapshotManager = {
  60. promises: {
  61. recordSnapshot: sinon.stub().resolves(),
  62. },
  63. }
  64. this.ProjectHistoryRedisManager = {
  65. promises: {
  66. queueOps: sinon
  67. .stub()
  68. .callsFake(async (projectId, ...ops) => ops.length),
  69. },
  70. }
  71. this.UpdateManager = SandboxedModule.require(MODULE_PATH, {
  72. requires: {
  73. './LockManager': this.LockManager,
  74. './RedisManager': this.RedisManager,
  75. './RealTimeRedisManager': this.RealTimeRedisManager,
  76. './ShareJsUpdateManager': this.ShareJsUpdateManager,
  77. './HistoryManager': this.HistoryManager,
  78. './Metrics': this.Metrics,
  79. '@overleaf/settings': this.Settings,
  80. './DocumentManager': this.DocumentManager,
  81. './RangesManager': this.RangesManager,
  82. './SnapshotManager': this.SnapshotManager,
  83. './Profiler': this.Profiler,
  84. './ProjectHistoryRedisManager': this.ProjectHistoryRedisManager,
  85. },
  86. })
  87. })
  88. describe('processOutstandingUpdates', function () {
  89. beforeEach(async function () {
  90. this.UpdateManager.promises.fetchAndApplyUpdates = sinon.stub().resolves()
  91. await this.UpdateManager.promises.processOutstandingUpdates(
  92. this.project_id,
  93. this.doc_id
  94. )
  95. })
  96. it('should apply the updates', function () {
  97. this.UpdateManager.promises.fetchAndApplyUpdates
  98. .calledWith(this.project_id, this.doc_id)
  99. .should.equal(true)
  100. })
  101. it('should time the execution', function () {
  102. this.Metrics.Timer.prototype.done.called.should.equal(true)
  103. })
  104. })
  105. describe('processOutstandingUpdatesWithLock', function () {
  106. describe('when the lock is free', function () {
  107. beforeEach(function () {
  108. this.UpdateManager.promises.continueProcessingUpdatesWithLock = sinon
  109. .stub()
  110. .resolves()
  111. this.UpdateManager.promises.processOutstandingUpdates = sinon
  112. .stub()
  113. .resolves()
  114. })
  115. describe('successfully', function () {
  116. beforeEach(async function () {
  117. await this.UpdateManager.promises.processOutstandingUpdatesWithLock(
  118. this.project_id,
  119. this.doc_id
  120. )
  121. })
  122. it('should acquire the lock', function () {
  123. this.LockManager.promises.tryLock
  124. .calledWith(this.doc_id)
  125. .should.equal(true)
  126. })
  127. it('should free the lock', function () {
  128. this.LockManager.promises.releaseLock
  129. .calledWith(this.doc_id, this.lockValue)
  130. .should.equal(true)
  131. })
  132. it('should process the outstanding updates', function () {
  133. this.UpdateManager.promises.processOutstandingUpdates
  134. .calledWith(this.project_id, this.doc_id)
  135. .should.equal(true)
  136. })
  137. it('should do everything with the lock acquired', function () {
  138. this.UpdateManager.promises.processOutstandingUpdates
  139. .calledAfter(this.LockManager.promises.tryLock)
  140. .should.equal(true)
  141. this.UpdateManager.promises.processOutstandingUpdates
  142. .calledBefore(this.LockManager.promises.releaseLock)
  143. .should.equal(true)
  144. })
  145. it('should continue processing new updates that may have come in', function () {
  146. this.UpdateManager.promises.continueProcessingUpdatesWithLock
  147. .calledWith(this.project_id, this.doc_id)
  148. .should.equal(true)
  149. })
  150. })
  151. describe('when processOutstandingUpdates returns an error', function () {
  152. beforeEach(async function () {
  153. this.error = new Error('Something went wrong')
  154. this.UpdateManager.promises.processOutstandingUpdates = sinon
  155. .stub()
  156. .rejects(this.error)
  157. await expect(
  158. this.UpdateManager.promises.processOutstandingUpdatesWithLock(
  159. this.project_id,
  160. this.doc_id
  161. )
  162. ).to.be.rejectedWith(this.error)
  163. })
  164. it('should free the lock', function () {
  165. this.LockManager.promises.releaseLock
  166. .calledWith(this.doc_id, this.lockValue)
  167. .should.equal(true)
  168. })
  169. })
  170. })
  171. describe('when the lock is taken', function () {
  172. beforeEach(async function () {
  173. this.LockManager.promises.tryLock.resolves(null)
  174. this.UpdateManager.promises.processOutstandingUpdates = sinon
  175. .stub()
  176. .resolves()
  177. await this.UpdateManager.promises.processOutstandingUpdatesWithLock(
  178. this.project_id,
  179. this.doc_id
  180. )
  181. })
  182. it('should not process the updates', function () {
  183. this.UpdateManager.promises.processOutstandingUpdates.called.should.equal(
  184. false
  185. )
  186. })
  187. })
  188. })
  189. describe('continueProcessingUpdatesWithLock', function () {
  190. describe('when there are outstanding updates', function () {
  191. beforeEach(async function () {
  192. this.RealTimeRedisManager.promises.getUpdatesLength.resolves(3)
  193. this.UpdateManager.promises.processOutstandingUpdatesWithLock = sinon
  194. .stub()
  195. .resolves()
  196. await this.UpdateManager.promises.continueProcessingUpdatesWithLock(
  197. this.project_id,
  198. this.doc_id
  199. )
  200. })
  201. it('should process the outstanding updates', function () {
  202. this.UpdateManager.promises.processOutstandingUpdatesWithLock
  203. .calledWith(this.project_id, this.doc_id)
  204. .should.equal(true)
  205. })
  206. })
  207. describe('when there are no outstanding updates', function () {
  208. beforeEach(async function () {
  209. this.RealTimeRedisManager.promises.getUpdatesLength.resolves(0)
  210. this.UpdateManager.promises.processOutstandingUpdatesWithLock = sinon
  211. .stub()
  212. .resolves()
  213. await this.UpdateManager.promises.continueProcessingUpdatesWithLock(
  214. this.project_id,
  215. this.doc_id
  216. )
  217. })
  218. it('should not try to process the outstanding updates', function () {
  219. this.UpdateManager.promises.processOutstandingUpdatesWithLock.called.should.equal(
  220. false
  221. )
  222. })
  223. })
  224. })
  225. describe('fetchAndApplyUpdates', function () {
  226. describe('with updates', function () {
  227. beforeEach(async function () {
  228. this.updates = [{ p: 1, t: 'foo' }]
  229. this.updatedDocLines = ['updated', 'lines']
  230. this.version = 34
  231. this.RealTimeRedisManager.promises.getPendingUpdatesForDoc.resolves(
  232. this.updates
  233. )
  234. this.UpdateManager.promises.applyUpdate = sinon.stub().resolves()
  235. await this.UpdateManager.promises.fetchAndApplyUpdates(
  236. this.project_id,
  237. this.doc_id
  238. )
  239. })
  240. it('should get the pending updates', function () {
  241. this.RealTimeRedisManager.promises.getPendingUpdatesForDoc
  242. .calledWith(this.doc_id)
  243. .should.equal(true)
  244. })
  245. it('should apply the updates', function () {
  246. this.updates.map(update =>
  247. this.UpdateManager.promises.applyUpdate
  248. .calledWith(this.project_id, this.doc_id, update)
  249. .should.equal(true)
  250. )
  251. })
  252. })
  253. describe('when there are no updates', function () {
  254. beforeEach(async function () {
  255. this.updates = []
  256. this.RealTimeRedisManager.promises.getPendingUpdatesForDoc.resolves(
  257. this.updates
  258. )
  259. this.UpdateManager.promises.applyUpdate = sinon.stub().resolves()
  260. await this.UpdateManager.promises.fetchAndApplyUpdates(
  261. this.project_id,
  262. this.doc_id
  263. )
  264. })
  265. it('should not call applyUpdate', function () {
  266. this.UpdateManager.promises.applyUpdate.called.should.equal(false)
  267. })
  268. })
  269. })
  270. describe('applyUpdate', function () {
  271. beforeEach(function () {
  272. this.updateMeta = { user_id: 'last-author-fake-id' }
  273. this.update = { op: [{ p: 42, i: 'foo' }], meta: this.updateMeta }
  274. this.updatedDocLines = ['updated', 'lines']
  275. this.version = 34
  276. this.lines = ['original', 'lines']
  277. this.ranges = { entries: 'mock', comments: 'mock' }
  278. this.updated_ranges = { entries: 'updated', comments: 'updated' }
  279. this.appliedOps = [
  280. { v: 42, op: 'mock-op-42' },
  281. { v: 45, op: 'mock-op-45' },
  282. ]
  283. this.historyUpdates = [
  284. 'history-update-1',
  285. 'history-update-2',
  286. 'history-update-3',
  287. ]
  288. this.project_ops_length = 123
  289. this.DocumentManager.promises.getDoc.resolves({
  290. lines: this.lines,
  291. version: this.version,
  292. ranges: this.ranges,
  293. pathname: this.pathname,
  294. projectHistoryId: this.projectHistoryId,
  295. historyRangesSupport: false,
  296. type: 'sharejs-text-ot',
  297. })
  298. this.RangesManager.applyUpdate.returns({
  299. newRanges: this.updated_ranges,
  300. rangesWereCollapsed: false,
  301. historyUpdates: this.historyUpdates,
  302. })
  303. this.ShareJsUpdateManager.promises.applyUpdate = sinon.stub().resolves({
  304. updatedDocLines: this.updatedDocLines,
  305. version: this.version,
  306. appliedOps: this.appliedOps,
  307. })
  308. this.RedisManager.promises.updateDocument.resolves()
  309. this.UpdateManager.promises._adjustHistoryUpdatesMetadata = sinon.stub()
  310. })
  311. describe('normally', function () {
  312. beforeEach(async function () {
  313. await this.UpdateManager.promises.applyUpdate(
  314. this.project_id,
  315. this.doc_id,
  316. this.update
  317. )
  318. })
  319. it('should apply the updates via ShareJS', function () {
  320. this.ShareJsUpdateManager.promises.applyUpdate
  321. .calledWith(
  322. this.project_id,
  323. this.doc_id,
  324. this.update,
  325. this.lines,
  326. this.version
  327. )
  328. .should.equal(true)
  329. })
  330. it('should update the ranges', function () {
  331. this.RangesManager.applyUpdate
  332. .calledWith(
  333. this.project_id,
  334. this.doc_id,
  335. this.ranges,
  336. this.appliedOps,
  337. this.updatedDocLines
  338. )
  339. .should.equal(true)
  340. })
  341. it('should save the document', function () {
  342. this.RedisManager.promises.updateDocument
  343. .calledWith(
  344. this.project_id,
  345. this.doc_id,
  346. this.updatedDocLines,
  347. this.version,
  348. this.appliedOps,
  349. this.updated_ranges,
  350. this.updateMeta
  351. )
  352. .should.equal(true)
  353. })
  354. it('should add metadata to the ops', function () {
  355. this.UpdateManager.promises._adjustHistoryUpdatesMetadata.should.have.been.calledWith(
  356. this.historyUpdates,
  357. this.pathname,
  358. this.projectHistoryId,
  359. this.lines,
  360. this.ranges,
  361. this.updatedDocLines
  362. )
  363. })
  364. it('should push the applied ops into the history queue', function () {
  365. this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWith(
  366. this.project_id,
  367. ...this.historyUpdates.map(op => JSON.stringify(op))
  368. )
  369. this.HistoryManager.recordAndFlushHistoryOps.should.have.been.calledWith(
  370. this.project_id,
  371. this.historyUpdates,
  372. this.historyUpdates.length
  373. )
  374. })
  375. it('should record the project notification timestamp', function () {
  376. this.RedisManager.promises.recordProjectNotificationTimestamp.should.have.been.calledWith(
  377. this.project_id,
  378. sinon.match.number
  379. )
  380. })
  381. })
  382. describe('when update has a timestamp in meta', function () {
  383. beforeEach(async function () {
  384. this.timestamp = 1234567890
  385. this.update = {
  386. op: [{ p: 42, i: 'foo' }],
  387. meta: { ...this.updateMeta, ts: this.timestamp },
  388. }
  389. await this.UpdateManager.promises.applyUpdate(
  390. this.project_id,
  391. this.doc_id,
  392. this.update
  393. )
  394. })
  395. it('should record the project notification timestamp with the provided timestamp', function () {
  396. this.RedisManager.promises.recordProjectNotificationTimestamp.should.have.been.calledWith(
  397. this.project_id,
  398. this.timestamp
  399. )
  400. })
  401. })
  402. describe('when there are no history updates', function () {
  403. beforeEach(async function () {
  404. this.RangesManager.applyUpdate.returns({
  405. newRanges: this.updated_ranges,
  406. rangesWereCollapsed: false,
  407. historyUpdates: [],
  408. })
  409. await this.UpdateManager.promises.applyUpdate(
  410. this.project_id,
  411. this.doc_id,
  412. this.update
  413. )
  414. })
  415. it('should not record the project notification timestamp', function () {
  416. this.RedisManager.promises.recordProjectNotificationTimestamp.should.not
  417. .have.been.called
  418. })
  419. })
  420. describe('with UTF-16 surrogate pairs in the update', function () {
  421. beforeEach(async function () {
  422. this.update = { op: [{ p: 42, i: '\uD835\uDC00' }] }
  423. await this.UpdateManager.promises.applyUpdate(
  424. this.project_id,
  425. this.doc_id,
  426. this.update
  427. )
  428. })
  429. it('should apply the update but with surrogate pairs removed', function () {
  430. this.ShareJsUpdateManager.promises.applyUpdate
  431. .calledWith(this.project_id, this.doc_id, this.update)
  432. .should.equal(true)
  433. // \uFFFD is 'replacement character'
  434. this.update.op[0].i.should.equal('\uFFFD\uFFFD')
  435. })
  436. })
  437. describe('with an error', function () {
  438. beforeEach(async function () {
  439. this.error = new Error('something went wrong')
  440. this.ShareJsUpdateManager.promises.applyUpdate.rejects(this.error)
  441. await expect(
  442. this.UpdateManager.promises.applyUpdate(
  443. this.project_id,
  444. this.doc_id,
  445. this.update
  446. )
  447. ).to.be.rejectedWith(this.error)
  448. })
  449. it('should call RealTimeRedisManager.sendData with the error', function () {
  450. this.RealTimeRedisManager.sendData
  451. .calledWith({
  452. project_id: this.project_id,
  453. doc_id: this.doc_id,
  454. error: this.error.message,
  455. })
  456. .should.equal(true)
  457. })
  458. })
  459. describe('when ranges get collapsed', function () {
  460. beforeEach(async function () {
  461. this.RangesManager.applyUpdate.returns({
  462. newRanges: this.updated_ranges,
  463. rangesWereCollapsed: true,
  464. historyUpdates: this.historyUpdates,
  465. })
  466. await this.UpdateManager.promises.applyUpdate(
  467. this.project_id,
  468. this.doc_id,
  469. this.update
  470. )
  471. })
  472. it('should increment the doc-snapshot metric', function () {
  473. this.Metrics.inc.calledWith('doc-snapshot').should.equal(true)
  474. })
  475. it('should call SnapshotManager.recordSnapshot', function () {
  476. this.SnapshotManager.promises.recordSnapshot
  477. .calledWith(
  478. this.project_id,
  479. this.doc_id,
  480. this.version,
  481. this.pathname,
  482. this.lines,
  483. this.ranges
  484. )
  485. .should.equal(true)
  486. })
  487. })
  488. describe('when history ranges are supported', function () {
  489. beforeEach(async function () {
  490. this.DocumentManager.promises.getDoc.resolves({
  491. lines: this.lines,
  492. version: this.version,
  493. ranges: this.ranges,
  494. pathname: this.pathname,
  495. projectHistoryId: this.projectHistoryId,
  496. historyRangesSupport: true,
  497. type: 'sharejs-text-ot',
  498. })
  499. await this.UpdateManager.promises.applyUpdate(
  500. this.project_id,
  501. this.doc_id,
  502. this.update
  503. )
  504. })
  505. it('should push the history updates into the history queue', function () {
  506. this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWith(
  507. this.project_id,
  508. ...this.historyUpdates.map(op => JSON.stringify(op))
  509. )
  510. this.HistoryManager.recordAndFlushHistoryOps.should.have.been.calledWith(
  511. this.project_id,
  512. this.historyUpdates,
  513. this.historyUpdates.length
  514. )
  515. })
  516. })
  517. })
  518. describe('_adjustHistoryUpdatesMetadata', function () {
  519. beforeEach(function () {
  520. this.lines = ['some', 'test', 'data']
  521. this.updatedDocLines = ['after', 'updates']
  522. this.historyUpdates = [
  523. {
  524. v: 42,
  525. op: [
  526. { i: 'bing', p: 12, trackedDeleteRejection: true },
  527. { i: 'foo', p: 4 },
  528. { i: 'bar', p: 6 },
  529. ],
  530. },
  531. {
  532. v: 45,
  533. op: [
  534. { d: 'qux', p: 4 },
  535. { i: 'bazbaz', p: 14 },
  536. {
  537. d: 'bong',
  538. p: 28,
  539. trackedChanges: [{ type: 'insert', offset: 0, length: 4 }],
  540. },
  541. ],
  542. meta: {
  543. tc: 'tracking-info',
  544. },
  545. },
  546. {
  547. v: 47,
  548. op: [{ d: 'so', p: 0 }],
  549. },
  550. { v: 49, op: [{ i: 'penguin', p: 18 }] },
  551. ]
  552. this.ranges = {
  553. changes: [
  554. { op: { d: 'bingbong', p: 12 } },
  555. { op: { i: 'test', p: 5 } },
  556. ],
  557. }
  558. })
  559. it('should add projectHistoryId, pathname and doc_length metadata to the ops', function () {
  560. this.UpdateManager._adjustHistoryUpdatesMetadata(
  561. this.historyUpdates,
  562. this.pathname,
  563. this.projectHistoryId,
  564. this.lines,
  565. this.updatedDocLines,
  566. this.ranges,
  567. false
  568. )
  569. this.historyUpdates.should.deep.equal([
  570. {
  571. projectHistoryId: this.projectHistoryId,
  572. v: 42,
  573. op: [
  574. { i: 'bing', p: 12, trackedDeleteRejection: true },
  575. { i: 'foo', p: 4 },
  576. { i: 'bar', p: 6 },
  577. ],
  578. meta: {
  579. pathname: this.pathname,
  580. doc_length: 14,
  581. },
  582. },
  583. {
  584. projectHistoryId: this.projectHistoryId,
  585. v: 45,
  586. op: [
  587. { d: 'qux', p: 4 },
  588. { i: 'bazbaz', p: 14 },
  589. {
  590. d: 'bong',
  591. p: 28,
  592. trackedChanges: [{ type: 'insert', offset: 0, length: 4 }],
  593. },
  594. ],
  595. meta: {
  596. pathname: this.pathname,
  597. doc_length: 24, // 14 + 'bing' + 'foo' + 'bar'
  598. },
  599. },
  600. {
  601. projectHistoryId: this.projectHistoryId,
  602. v: 47,
  603. op: [{ d: 'so', p: 0 }],
  604. meta: {
  605. pathname: this.pathname,
  606. doc_length: 23, // 24 - 'qux' + 'bazbaz' - 'bong'
  607. },
  608. },
  609. {
  610. projectHistoryId: this.projectHistoryId,
  611. v: 49,
  612. op: [{ i: 'penguin', p: 18 }],
  613. meta: {
  614. pathname: this.pathname,
  615. doc_length: 21, // 23 - 'so'
  616. },
  617. },
  618. ])
  619. })
  620. it('should add additional metadata when ranges support is enabled', function () {
  621. this.UpdateManager._adjustHistoryUpdatesMetadata(
  622. this.historyUpdates,
  623. this.pathname,
  624. this.projectHistoryId,
  625. this.lines,
  626. this.ranges,
  627. this.updatedDocLines,
  628. true
  629. )
  630. this.historyUpdates.should.deep.equal([
  631. {
  632. projectHistoryId: this.projectHistoryId,
  633. v: 42,
  634. op: [
  635. { i: 'bing', p: 12, trackedDeleteRejection: true },
  636. { i: 'foo', p: 4 },
  637. { i: 'bar', p: 6 },
  638. ],
  639. meta: {
  640. pathname: this.pathname,
  641. doc_length: 14,
  642. history_doc_length: 22,
  643. },
  644. },
  645. {
  646. projectHistoryId: this.projectHistoryId,
  647. v: 45,
  648. op: [
  649. { d: 'qux', p: 4 },
  650. { i: 'bazbaz', p: 14 },
  651. {
  652. d: 'bong',
  653. p: 28,
  654. trackedChanges: [{ type: 'insert', offset: 0, length: 4 }],
  655. },
  656. ],
  657. meta: {
  658. pathname: this.pathname,
  659. doc_length: 24, // 14 + 'bing' + 'foo' + 'bar'
  660. history_doc_length: 28, // 22 + 'foo' + 'bar'
  661. tc: 'tracking-info',
  662. },
  663. },
  664. {
  665. projectHistoryId: this.projectHistoryId,
  666. v: 47,
  667. op: [{ d: 'so', p: 0 }],
  668. meta: {
  669. pathname: this.pathname,
  670. doc_length: 23, // 24 - 'qux' + 'bazbaz' - 'bong'
  671. history_doc_length: 30, // 28 - 'bong' + 'bazbaz'
  672. },
  673. },
  674. {
  675. projectHistoryId: this.projectHistoryId,
  676. v: 49,
  677. op: [{ i: 'penguin', p: 18 }],
  678. meta: {
  679. pathname: this.pathname,
  680. doc_length: 21, // 23 - 'so'
  681. doc_hash: stringHash(this.updatedDocLines.join('\n')),
  682. history_doc_length: 28, // 30 - 'so'
  683. },
  684. },
  685. ])
  686. })
  687. it('should calculate the right doc length for an empty document', function () {
  688. this.historyUpdates = [{ v: 42, op: [{ i: 'foobar', p: 0 }] }]
  689. this.UpdateManager._adjustHistoryUpdatesMetadata(
  690. this.historyUpdates,
  691. this.pathname,
  692. this.projectHistoryId,
  693. [],
  694. {},
  695. ['foobar'],
  696. false
  697. )
  698. this.historyUpdates.should.deep.equal([
  699. {
  700. projectHistoryId: this.projectHistoryId,
  701. v: 42,
  702. op: [{ i: 'foobar', p: 0 }],
  703. meta: {
  704. pathname: this.pathname,
  705. doc_length: 0,
  706. },
  707. },
  708. ])
  709. })
  710. })
  711. describe('lockUpdatesAndDo', function () {
  712. beforeEach(function () {
  713. this.methodResult = 'method result'
  714. this.method = sinon.stub().resolves(this.methodResult)
  715. this.arg1 = 'argument 1'
  716. })
  717. describe('successfully', function () {
  718. beforeEach(async function () {
  719. this.UpdateManager.promises.continueProcessingUpdatesWithLock = sinon
  720. .stub()
  721. .resolves()
  722. this.UpdateManager.promises.processOutstandingUpdates = sinon
  723. .stub()
  724. .resolves()
  725. this.response = await this.UpdateManager.promises.lockUpdatesAndDo(
  726. this.method,
  727. this.project_id,
  728. this.doc_id,
  729. this.arg1
  730. )
  731. })
  732. it('should lock the doc', function () {
  733. this.LockManager.promises.getLock
  734. .calledWith(this.doc_id)
  735. .should.equal(true)
  736. })
  737. it('should process any outstanding updates', function () {
  738. this.UpdateManager.promises.processOutstandingUpdates.should.have.been.calledWith(
  739. this.project_id,
  740. this.doc_id
  741. )
  742. })
  743. it('should call the method', function () {
  744. this.method
  745. .calledWith(this.project_id, this.doc_id, this.arg1)
  746. .should.equal(true)
  747. })
  748. it('should return the method response arguments', function () {
  749. expect(this.response).to.equal(this.methodResult)
  750. })
  751. it('should release the lock', function () {
  752. this.LockManager.promises.releaseLock
  753. .calledWith(this.doc_id, this.lockValue)
  754. .should.equal(true)
  755. })
  756. it('should continue processing updates', function () {
  757. this.UpdateManager.promises.continueProcessingUpdatesWithLock
  758. .calledWith(this.project_id, this.doc_id)
  759. .should.equal(true)
  760. })
  761. })
  762. describe('when processOutstandingUpdates returns an error', function () {
  763. beforeEach(async function () {
  764. this.error = new Error('Something went wrong')
  765. this.UpdateManager.promises.processOutstandingUpdates = sinon
  766. .stub()
  767. .rejects(this.error)
  768. await expect(
  769. this.UpdateManager.promises.lockUpdatesAndDo(
  770. this.method,
  771. this.project_id,
  772. this.doc_id,
  773. this.arg1
  774. )
  775. ).to.be.rejectedWith(this.error)
  776. })
  777. it('should free the lock', function () {
  778. this.LockManager.promises.releaseLock
  779. .calledWith(this.doc_id, this.lockValue)
  780. .should.equal(true)
  781. })
  782. })
  783. describe('when the method returns an error', function () {
  784. beforeEach(async function () {
  785. this.error = new Error('something went wrong')
  786. this.UpdateManager.promises.processOutstandingUpdates = sinon
  787. .stub()
  788. .resolves()
  789. this.method = sinon.stub().rejects(this.error)
  790. await expect(
  791. this.UpdateManager.promises.lockUpdatesAndDo(
  792. this.method,
  793. this.project_id,
  794. this.doc_id,
  795. this.arg1
  796. )
  797. ).to.be.rejectedWith(this.error)
  798. })
  799. it('should free the lock', function () {
  800. this.LockManager.promises.releaseLock
  801. .calledWith(this.doc_id, this.lockValue)
  802. .should.equal(true)
  803. })
  804. })
  805. })
  806. })
  807. function stringHash(s) {
  808. const hash = createHash('sha1')
  809. hash.update(s)
  810. return hash.digest('hex')
  811. }