UpdateManagerTests.js 29 KB

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