ApplyingUpdatesToADocTests.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. // TODO: This file was created by bulk-decaffeinate.
  2. // Fix any style issues and re-enable lint.
  3. /*
  4. * decaffeinate suggestions:
  5. * DS101: Remove unnecessary use of Array.from
  6. * DS207: Consider shorter variations of null checks
  7. * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  8. */
  9. const sinon = require('sinon')
  10. const { expect } = require('chai')
  11. const async = require('async')
  12. const Settings = require('@overleaf/settings')
  13. const rclientProjectHistory = require('@overleaf/redis-wrapper').createClient(
  14. Settings.redis.project_history
  15. )
  16. const rclientDU = require('@overleaf/redis-wrapper').createClient(
  17. Settings.redis.documentupdater
  18. )
  19. const Keys = Settings.redis.documentupdater.key_schema
  20. const ProjectHistoryKeys = Settings.redis.project_history.key_schema
  21. const MockWebApi = require('./helpers/MockWebApi')
  22. const DocUpdaterClient = require('./helpers/DocUpdaterClient')
  23. const DocUpdaterApp = require('./helpers/DocUpdaterApp')
  24. describe('Applying updates to a doc', function () {
  25. before(function (done) {
  26. this.lines = ['one', 'two', 'three']
  27. this.version = 42
  28. this.update = {
  29. doc: this.doc_id,
  30. op: [
  31. {
  32. i: 'one and a half\n',
  33. p: 4,
  34. },
  35. ],
  36. v: this.version,
  37. }
  38. this.result = ['one', 'one and a half', 'two', 'three']
  39. DocUpdaterApp.ensureRunning(done)
  40. })
  41. describe('when the document is not loaded', function () {
  42. before(function (done) {
  43. ;[this.project_id, this.doc_id] = Array.from([
  44. DocUpdaterClient.randomId(),
  45. DocUpdaterClient.randomId(),
  46. ])
  47. sinon.spy(MockWebApi, 'getDocument')
  48. this.startTime = Date.now()
  49. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  50. lines: this.lines,
  51. version: this.version,
  52. })
  53. DocUpdaterClient.sendUpdate(
  54. this.project_id,
  55. this.doc_id,
  56. this.update,
  57. error => {
  58. if (error != null) {
  59. throw error
  60. }
  61. setTimeout(done, 200)
  62. }
  63. )
  64. })
  65. after(function () {
  66. MockWebApi.getDocument.restore()
  67. })
  68. it('should load the document from the web API', function () {
  69. MockWebApi.getDocument
  70. .calledWith(this.project_id, this.doc_id)
  71. .should.equal(true)
  72. })
  73. it('should update the doc', function (done) {
  74. DocUpdaterClient.getDoc(
  75. this.project_id,
  76. this.doc_id,
  77. (error, res, doc) => {
  78. if (error) done(error)
  79. doc.lines.should.deep.equal(this.result)
  80. done()
  81. }
  82. )
  83. })
  84. it('should push the applied updates to the project history changes api', function (done) {
  85. rclientProjectHistory.lrange(
  86. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  87. 0,
  88. -1,
  89. (error, updates) => {
  90. if (error != null) {
  91. throw error
  92. }
  93. JSON.parse(updates[0]).op.should.deep.equal(this.update.op)
  94. done()
  95. }
  96. )
  97. })
  98. it('should set the first op timestamp', function (done) {
  99. rclientProjectHistory.get(
  100. ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
  101. project_id: this.project_id,
  102. }),
  103. (error, result) => {
  104. if (error != null) {
  105. throw error
  106. }
  107. result = parseInt(result, 10)
  108. result.should.be.within(this.startTime, Date.now())
  109. this.firstOpTimestamp = result
  110. done()
  111. }
  112. )
  113. })
  114. describe('when sending another update', function () {
  115. before(function (done) {
  116. this.timeout = 10000
  117. this.second_update = Object.create(this.update)
  118. this.second_update.v = this.version + 1
  119. DocUpdaterClient.sendUpdate(
  120. this.project_id,
  121. this.doc_id,
  122. this.second_update,
  123. error => {
  124. if (error != null) {
  125. throw error
  126. }
  127. setTimeout(done, 200)
  128. }
  129. )
  130. })
  131. it('should not change the first op timestamp', function (done) {
  132. rclientProjectHistory.get(
  133. ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
  134. project_id: this.project_id,
  135. }),
  136. (error, result) => {
  137. if (error != null) {
  138. throw error
  139. }
  140. result = parseInt(result, 10)
  141. result.should.equal(this.firstOpTimestamp)
  142. done()
  143. }
  144. )
  145. })
  146. })
  147. })
  148. describe('when the document is loaded', function () {
  149. before(function (done) {
  150. ;[this.project_id, this.doc_id] = Array.from([
  151. DocUpdaterClient.randomId(),
  152. DocUpdaterClient.randomId(),
  153. ])
  154. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  155. lines: this.lines,
  156. version: this.version,
  157. })
  158. DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
  159. if (error != null) {
  160. throw error
  161. }
  162. sinon.spy(MockWebApi, 'getDocument')
  163. DocUpdaterClient.sendUpdate(
  164. this.project_id,
  165. this.doc_id,
  166. this.update,
  167. error => {
  168. if (error != null) {
  169. throw error
  170. }
  171. setTimeout(done, 200)
  172. }
  173. )
  174. })
  175. })
  176. after(function () {
  177. MockWebApi.getDocument.restore()
  178. })
  179. it('should not need to call the web api', function () {
  180. MockWebApi.getDocument.called.should.equal(false)
  181. })
  182. it('should update the doc', function (done) {
  183. DocUpdaterClient.getDoc(
  184. this.project_id,
  185. this.doc_id,
  186. (error, res, doc) => {
  187. if (error) return done(error)
  188. doc.lines.should.deep.equal(this.result)
  189. done()
  190. }
  191. )
  192. })
  193. it('should push the applied updates to the project history changes api', function (done) {
  194. rclientProjectHistory.lrange(
  195. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  196. 0,
  197. -1,
  198. (error, updates) => {
  199. if (error) return done(error)
  200. JSON.parse(updates[0]).op.should.deep.equal(this.update.op)
  201. done()
  202. }
  203. )
  204. })
  205. })
  206. describe('when the document is loaded and is using project-history only', function () {
  207. before(function (done) {
  208. ;[this.project_id, this.doc_id] = Array.from([
  209. DocUpdaterClient.randomId(),
  210. DocUpdaterClient.randomId(),
  211. ])
  212. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  213. lines: this.lines,
  214. version: this.version,
  215. })
  216. DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
  217. if (error != null) {
  218. throw error
  219. }
  220. sinon.spy(MockWebApi, 'getDocument')
  221. DocUpdaterClient.sendUpdate(
  222. this.project_id,
  223. this.doc_id,
  224. this.update,
  225. error => {
  226. if (error != null) {
  227. throw error
  228. }
  229. setTimeout(done, 200)
  230. }
  231. )
  232. })
  233. })
  234. after(function () {
  235. MockWebApi.getDocument.restore()
  236. })
  237. it('should update the doc', function (done) {
  238. DocUpdaterClient.getDoc(
  239. this.project_id,
  240. this.doc_id,
  241. (error, res, doc) => {
  242. if (error) return done(error)
  243. doc.lines.should.deep.equal(this.result)
  244. done()
  245. }
  246. )
  247. })
  248. it('should push the applied updates to the project history changes api', function (done) {
  249. rclientProjectHistory.lrange(
  250. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  251. 0,
  252. -1,
  253. (error, updates) => {
  254. if (error) return done(error)
  255. JSON.parse(updates[0]).op.should.deep.equal(this.update.op)
  256. done()
  257. }
  258. )
  259. })
  260. })
  261. describe('when the document has been deleted', function () {
  262. describe('when the ops come in a single linear order', function () {
  263. before(function (done) {
  264. ;[this.project_id, this.doc_id] = Array.from([
  265. DocUpdaterClient.randomId(),
  266. DocUpdaterClient.randomId(),
  267. ])
  268. const lines = ['', '', '']
  269. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  270. lines,
  271. version: 0,
  272. })
  273. this.updates = [
  274. { doc_id: this.doc_id, v: 0, op: [{ i: 'h', p: 0 }] },
  275. { doc_id: this.doc_id, v: 1, op: [{ i: 'e', p: 1 }] },
  276. { doc_id: this.doc_id, v: 2, op: [{ i: 'l', p: 2 }] },
  277. { doc_id: this.doc_id, v: 3, op: [{ i: 'l', p: 3 }] },
  278. { doc_id: this.doc_id, v: 4, op: [{ i: 'o', p: 4 }] },
  279. { doc_id: this.doc_id, v: 5, op: [{ i: ' ', p: 5 }] },
  280. { doc_id: this.doc_id, v: 6, op: [{ i: 'w', p: 6 }] },
  281. { doc_id: this.doc_id, v: 7, op: [{ i: 'o', p: 7 }] },
  282. { doc_id: this.doc_id, v: 8, op: [{ i: 'r', p: 8 }] },
  283. { doc_id: this.doc_id, v: 9, op: [{ i: 'l', p: 9 }] },
  284. { doc_id: this.doc_id, v: 10, op: [{ i: 'd', p: 10 }] },
  285. ]
  286. this.my_result = ['hello world', '', '']
  287. done()
  288. })
  289. it('should be able to continue applying updates when the project has been deleted', function (done) {
  290. let update
  291. const actions = []
  292. for (update of Array.from(this.updates.slice(0, 6))) {
  293. ;(update => {
  294. actions.push(callback =>
  295. DocUpdaterClient.sendUpdate(
  296. this.project_id,
  297. this.doc_id,
  298. update,
  299. callback
  300. )
  301. )
  302. })(update)
  303. }
  304. actions.push(callback =>
  305. DocUpdaterClient.deleteDoc(this.project_id, this.doc_id, callback)
  306. )
  307. for (update of Array.from(this.updates.slice(6))) {
  308. ;(update => {
  309. actions.push(callback =>
  310. DocUpdaterClient.sendUpdate(
  311. this.project_id,
  312. this.doc_id,
  313. update,
  314. callback
  315. )
  316. )
  317. })(update)
  318. }
  319. async.series(actions, error => {
  320. if (error != null) {
  321. throw error
  322. }
  323. DocUpdaterClient.getDoc(
  324. this.project_id,
  325. this.doc_id,
  326. (error, res, doc) => {
  327. if (error) return done(error)
  328. doc.lines.should.deep.equal(this.my_result)
  329. done()
  330. }
  331. )
  332. })
  333. })
  334. it('should store the doc ops in the correct order', function (done) {
  335. rclientDU.lrange(
  336. Keys.docOps({ doc_id: this.doc_id }),
  337. 0,
  338. -1,
  339. (error, updates) => {
  340. if (error) return done(error)
  341. updates = Array.from(updates).map(u => JSON.parse(u))
  342. for (let i = 0; i < this.updates.length; i++) {
  343. const appliedUpdate = this.updates[i]
  344. appliedUpdate.op.should.deep.equal(updates[i].op)
  345. }
  346. done()
  347. }
  348. )
  349. })
  350. })
  351. describe('when older ops come in after the delete', function () {
  352. before(function (done) {
  353. ;[this.project_id, this.doc_id] = Array.from([
  354. DocUpdaterClient.randomId(),
  355. DocUpdaterClient.randomId(),
  356. ])
  357. const lines = ['', '', '']
  358. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  359. lines,
  360. version: 0,
  361. })
  362. this.updates = [
  363. { doc_id: this.doc_id, v: 0, op: [{ i: 'h', p: 0 }] },
  364. { doc_id: this.doc_id, v: 1, op: [{ i: 'e', p: 1 }] },
  365. { doc_id: this.doc_id, v: 2, op: [{ i: 'l', p: 2 }] },
  366. { doc_id: this.doc_id, v: 3, op: [{ i: 'l', p: 3 }] },
  367. { doc_id: this.doc_id, v: 4, op: [{ i: 'o', p: 4 }] },
  368. { doc_id: this.doc_id, v: 0, op: [{ i: 'world', p: 1 }] },
  369. ]
  370. this.my_result = ['hello', 'world', '']
  371. done()
  372. })
  373. it('should be able to continue applying updates when the project has been deleted', function (done) {
  374. let update
  375. const actions = []
  376. for (update of Array.from(this.updates.slice(0, 5))) {
  377. ;(update => {
  378. actions.push(callback =>
  379. DocUpdaterClient.sendUpdate(
  380. this.project_id,
  381. this.doc_id,
  382. update,
  383. callback
  384. )
  385. )
  386. })(update)
  387. }
  388. actions.push(callback =>
  389. DocUpdaterClient.deleteDoc(this.project_id, this.doc_id, callback)
  390. )
  391. for (update of Array.from(this.updates.slice(5))) {
  392. ;(update => {
  393. actions.push(callback =>
  394. DocUpdaterClient.sendUpdate(
  395. this.project_id,
  396. this.doc_id,
  397. update,
  398. callback
  399. )
  400. )
  401. })(update)
  402. }
  403. async.series(actions, error => {
  404. if (error != null) {
  405. throw error
  406. }
  407. DocUpdaterClient.getDoc(
  408. this.project_id,
  409. this.doc_id,
  410. (error, res, doc) => {
  411. if (error) return done(error)
  412. doc.lines.should.deep.equal(this.my_result)
  413. done()
  414. }
  415. )
  416. })
  417. })
  418. })
  419. })
  420. describe('with a broken update', function () {
  421. before(function (done) {
  422. ;[this.project_id, this.doc_id] = Array.from([
  423. DocUpdaterClient.randomId(),
  424. DocUpdaterClient.randomId(),
  425. ])
  426. this.broken_update = {
  427. doc_id: this.doc_id,
  428. v: this.version,
  429. op: [{ d: 'not the correct content', p: 0 }],
  430. }
  431. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  432. lines: this.lines,
  433. version: this.version,
  434. })
  435. DocUpdaterClient.subscribeToAppliedOps(
  436. (this.messageCallback = sinon.stub())
  437. )
  438. DocUpdaterClient.sendUpdate(
  439. this.project_id,
  440. this.doc_id,
  441. this.broken_update,
  442. error => {
  443. if (error != null) {
  444. throw error
  445. }
  446. setTimeout(done, 200)
  447. }
  448. )
  449. })
  450. it('should not update the doc', function (done) {
  451. DocUpdaterClient.getDoc(
  452. this.project_id,
  453. this.doc_id,
  454. (error, res, doc) => {
  455. if (error) return done(error)
  456. doc.lines.should.deep.equal(this.lines)
  457. done()
  458. }
  459. )
  460. })
  461. it('should send a message with an error', function () {
  462. this.messageCallback.called.should.equal(true)
  463. const [channel, message] = Array.from(this.messageCallback.args[0])
  464. channel.should.equal('applied-ops')
  465. JSON.parse(message).should.deep.include({
  466. project_id: this.project_id,
  467. doc_id: this.doc_id,
  468. error: 'Delete component does not match',
  469. })
  470. })
  471. })
  472. describe('when there is no version in Mongo', function () {
  473. before(function (done) {
  474. ;[this.project_id, this.doc_id] = Array.from([
  475. DocUpdaterClient.randomId(),
  476. DocUpdaterClient.randomId(),
  477. ])
  478. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  479. lines: this.lines,
  480. })
  481. const update = {
  482. doc: this.doc_id,
  483. op: this.update.op,
  484. v: 0,
  485. }
  486. DocUpdaterClient.sendUpdate(
  487. this.project_id,
  488. this.doc_id,
  489. update,
  490. error => {
  491. if (error != null) {
  492. throw error
  493. }
  494. setTimeout(done, 200)
  495. }
  496. )
  497. })
  498. it('should update the doc (using version = 0)', function (done) {
  499. DocUpdaterClient.getDoc(
  500. this.project_id,
  501. this.doc_id,
  502. (error, res, doc) => {
  503. if (error) return done(error)
  504. doc.lines.should.deep.equal(this.result)
  505. done()
  506. }
  507. )
  508. })
  509. })
  510. describe('when the sending duplicate ops', function () {
  511. before(function (done) {
  512. ;[this.project_id, this.doc_id] = Array.from([
  513. DocUpdaterClient.randomId(),
  514. DocUpdaterClient.randomId(),
  515. ])
  516. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  517. lines: this.lines,
  518. version: this.version,
  519. })
  520. DocUpdaterClient.subscribeToAppliedOps(
  521. (this.messageCallback = sinon.stub())
  522. )
  523. // One user delete 'one', the next turns it into 'once'. The second becomes a NOP.
  524. DocUpdaterClient.sendUpdate(
  525. this.project_id,
  526. this.doc_id,
  527. {
  528. doc: this.doc_id,
  529. op: [
  530. {
  531. i: 'one and a half\n',
  532. p: 4,
  533. },
  534. ],
  535. v: this.version,
  536. meta: {
  537. source: 'ikHceq3yfAdQYzBo4-xZ',
  538. },
  539. },
  540. error => {
  541. if (error != null) {
  542. throw error
  543. }
  544. setTimeout(() => {
  545. DocUpdaterClient.sendUpdate(
  546. this.project_id,
  547. this.doc_id,
  548. {
  549. doc: this.doc_id,
  550. op: [
  551. {
  552. i: 'one and a half\n',
  553. p: 4,
  554. },
  555. ],
  556. v: this.version,
  557. dupIfSource: ['ikHceq3yfAdQYzBo4-xZ'],
  558. meta: {
  559. source: 'ikHceq3yfAdQYzBo4-xZ',
  560. },
  561. },
  562. error => {
  563. if (error != null) {
  564. throw error
  565. }
  566. setTimeout(done, 200)
  567. }
  568. )
  569. }, 200)
  570. }
  571. )
  572. })
  573. it('should update the doc', function (done) {
  574. DocUpdaterClient.getDoc(
  575. this.project_id,
  576. this.doc_id,
  577. (error, res, doc) => {
  578. if (error) return done(error)
  579. doc.lines.should.deep.equal(this.result)
  580. done()
  581. }
  582. )
  583. })
  584. it('should return a message about duplicate ops', function () {
  585. this.messageCallback.calledTwice.should.equal(true)
  586. this.messageCallback.args[0][0].should.equal('applied-ops')
  587. expect(JSON.parse(this.messageCallback.args[0][1]).op.dup).to.be.undefined
  588. this.messageCallback.args[1][0].should.equal('applied-ops')
  589. expect(JSON.parse(this.messageCallback.args[1][1]).op.dup).to.equal(true)
  590. })
  591. })
  592. describe('when sending updates for a non-existing doc id', function () {
  593. before(function (done) {
  594. ;[this.project_id, this.doc_id] = Array.from([
  595. DocUpdaterClient.randomId(),
  596. DocUpdaterClient.randomId(),
  597. ])
  598. this.non_existing = {
  599. doc_id: this.doc_id,
  600. v: this.version,
  601. op: [{ d: 'content', p: 0 }],
  602. }
  603. DocUpdaterClient.subscribeToAppliedOps(
  604. (this.messageCallback = sinon.stub())
  605. )
  606. DocUpdaterClient.sendUpdate(
  607. this.project_id,
  608. this.doc_id,
  609. this.non_existing,
  610. error => {
  611. if (error != null) {
  612. throw error
  613. }
  614. setTimeout(done, 200)
  615. }
  616. )
  617. })
  618. it('should not update or create a doc', function (done) {
  619. DocUpdaterClient.getDoc(
  620. this.project_id,
  621. this.doc_id,
  622. (error, res, doc) => {
  623. if (error) return done(error)
  624. res.statusCode.should.equal(404)
  625. done()
  626. }
  627. )
  628. })
  629. it('should send a message with an error', function () {
  630. this.messageCallback.called.should.equal(true)
  631. const [channel, message] = Array.from(this.messageCallback.args[0])
  632. channel.should.equal('applied-ops')
  633. JSON.parse(message).should.deep.include({
  634. project_id: this.project_id,
  635. doc_id: this.doc_id,
  636. error: `doc not not found: /project/${this.project_id}/doc/${this.doc_id}`,
  637. })
  638. })
  639. })
  640. })