UpdateManagerTests.js 27 KB

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