ApplyingUpdatesToADocTests.js 19 KB

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