ApplyingUpdatesToADocTests.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  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. it('should yield last updated time', function (done) {
  104. DocUpdaterClient.getProjectLastUpdatedAt(
  105. this.project_id,
  106. (error, res, body) => {
  107. if (error != null) {
  108. throw error
  109. }
  110. res.statusCode.should.equal(200)
  111. body.lastUpdatedAt.should.be.within(this.startTime, Date.now())
  112. done()
  113. }
  114. )
  115. })
  116. it('should yield no last updated time for another project', function (done) {
  117. DocUpdaterClient.getProjectLastUpdatedAt(
  118. DocUpdaterClient.randomId(),
  119. (error, res, body) => {
  120. if (error != null) {
  121. throw error
  122. }
  123. res.statusCode.should.equal(200)
  124. body.should.deep.equal({})
  125. done()
  126. }
  127. )
  128. })
  129. describe('when sending another update', function () {
  130. before(function (done) {
  131. this.timeout(10000)
  132. this.second_update = Object.assign({}, this.update)
  133. this.second_update.v = this.version + 1
  134. this.secondStartTime = Date.now()
  135. DocUpdaterClient.sendUpdate(
  136. this.project_id,
  137. this.doc_id,
  138. this.second_update,
  139. error => {
  140. if (error != null) {
  141. throw error
  142. }
  143. setTimeout(done, 200)
  144. }
  145. )
  146. })
  147. it('should update the doc', function (done) {
  148. DocUpdaterClient.getDoc(
  149. this.project_id,
  150. this.doc_id,
  151. (error, res, doc) => {
  152. if (error) done(error)
  153. doc.lines.should.deep.equal([
  154. 'one',
  155. 'one and a half',
  156. 'one and a half',
  157. 'two',
  158. 'three',
  159. ])
  160. done()
  161. }
  162. )
  163. })
  164. it('should not change the first op timestamp', function (done) {
  165. rclientProjectHistory.get(
  166. ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
  167. project_id: this.project_id,
  168. }),
  169. (error, result) => {
  170. if (error != null) {
  171. throw error
  172. }
  173. result = parseInt(result, 10)
  174. result.should.equal(this.firstOpTimestamp)
  175. done()
  176. }
  177. )
  178. })
  179. it('should yield last updated time', function (done) {
  180. DocUpdaterClient.getProjectLastUpdatedAt(
  181. this.project_id,
  182. (error, res, body) => {
  183. if (error != null) {
  184. throw error
  185. }
  186. res.statusCode.should.equal(200)
  187. body.lastUpdatedAt.should.be.within(
  188. this.secondStartTime,
  189. Date.now()
  190. )
  191. done()
  192. }
  193. )
  194. })
  195. })
  196. })
  197. describe('when the document is loaded', function () {
  198. before(function (done) {
  199. this.project_id = DocUpdaterClient.randomId()
  200. this.doc_id = DocUpdaterClient.randomId()
  201. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  202. lines: this.lines,
  203. version: this.version,
  204. })
  205. DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
  206. if (error != null) {
  207. throw error
  208. }
  209. sinon.spy(MockWebApi, 'getDocument')
  210. DocUpdaterClient.sendUpdate(
  211. this.project_id,
  212. this.doc_id,
  213. this.update,
  214. error => {
  215. if (error != null) {
  216. throw error
  217. }
  218. setTimeout(done, 200)
  219. }
  220. )
  221. })
  222. })
  223. after(function () {
  224. MockWebApi.getDocument.restore()
  225. })
  226. it('should not need to call the web api', function () {
  227. MockWebApi.getDocument.called.should.equal(false)
  228. })
  229. it('should update the doc', function (done) {
  230. DocUpdaterClient.getDoc(
  231. this.project_id,
  232. this.doc_id,
  233. (error, res, doc) => {
  234. if (error) return done(error)
  235. doc.lines.should.deep.equal(this.result)
  236. done()
  237. }
  238. )
  239. })
  240. it('should push the applied updates to the project history changes api', function (done) {
  241. rclientProjectHistory.lrange(
  242. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  243. 0,
  244. -1,
  245. (error, updates) => {
  246. if (error) return done(error)
  247. JSON.parse(updates[0]).op.should.deep.equal([this.op])
  248. done()
  249. }
  250. )
  251. })
  252. })
  253. describe('when the document is loaded and is using project-history only', function () {
  254. before(function (done) {
  255. this.project_id = DocUpdaterClient.randomId()
  256. this.doc_id = DocUpdaterClient.randomId()
  257. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  258. lines: this.lines,
  259. version: this.version,
  260. })
  261. DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
  262. if (error != null) {
  263. throw error
  264. }
  265. sinon.spy(MockWebApi, 'getDocument')
  266. DocUpdaterClient.sendUpdate(
  267. this.project_id,
  268. this.doc_id,
  269. this.update,
  270. error => {
  271. if (error != null) {
  272. throw error
  273. }
  274. setTimeout(done, 200)
  275. }
  276. )
  277. })
  278. })
  279. after(function () {
  280. MockWebApi.getDocument.restore()
  281. })
  282. it('should update the doc', function (done) {
  283. DocUpdaterClient.getDoc(
  284. this.project_id,
  285. this.doc_id,
  286. (error, res, doc) => {
  287. if (error) return done(error)
  288. doc.lines.should.deep.equal(this.result)
  289. done()
  290. }
  291. )
  292. })
  293. it('should push the applied updates to the project history changes api', function (done) {
  294. rclientProjectHistory.lrange(
  295. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  296. 0,
  297. -1,
  298. (error, updates) => {
  299. if (error) return done(error)
  300. JSON.parse(updates[0]).op.should.deep.equal([this.op])
  301. done()
  302. }
  303. )
  304. })
  305. })
  306. describe('when the document has been deleted', function () {
  307. describe('when the ops come in a single linear order', function () {
  308. before(function (done) {
  309. this.project_id = DocUpdaterClient.randomId()
  310. this.doc_id = DocUpdaterClient.randomId()
  311. const lines = ['', '', '']
  312. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  313. lines,
  314. version: 0,
  315. })
  316. this.updates = [
  317. { doc_id: this.doc_id, v: 0, op: [{ i: 'h', p: 0 }] },
  318. { doc_id: this.doc_id, v: 1, op: [{ i: 'e', p: 1 }] },
  319. { doc_id: this.doc_id, v: 2, op: [{ i: 'l', p: 2 }] },
  320. { doc_id: this.doc_id, v: 3, op: [{ i: 'l', p: 3 }] },
  321. { doc_id: this.doc_id, v: 4, op: [{ i: 'o', p: 4 }] },
  322. { doc_id: this.doc_id, v: 5, op: [{ i: ' ', p: 5 }] },
  323. { doc_id: this.doc_id, v: 6, op: [{ i: 'w', p: 6 }] },
  324. { doc_id: this.doc_id, v: 7, op: [{ i: 'o', p: 7 }] },
  325. { doc_id: this.doc_id, v: 8, op: [{ i: 'r', p: 8 }] },
  326. { doc_id: this.doc_id, v: 9, op: [{ i: 'l', p: 9 }] },
  327. { doc_id: this.doc_id, v: 10, op: [{ i: 'd', p: 10 }] },
  328. ]
  329. this.my_result = ['hello world', '', '']
  330. done()
  331. })
  332. it('should be able to continue applying updates when the project has been deleted', function (done) {
  333. let update
  334. const actions = []
  335. for (update of this.updates.slice(0, 6)) {
  336. ;(update => {
  337. actions.push(callback =>
  338. DocUpdaterClient.sendUpdate(
  339. this.project_id,
  340. this.doc_id,
  341. update,
  342. callback
  343. )
  344. )
  345. })(update)
  346. }
  347. actions.push(callback =>
  348. DocUpdaterClient.deleteDoc(this.project_id, this.doc_id, callback)
  349. )
  350. for (update of this.updates.slice(6)) {
  351. ;(update => {
  352. actions.push(callback =>
  353. DocUpdaterClient.sendUpdate(
  354. this.project_id,
  355. this.doc_id,
  356. update,
  357. callback
  358. )
  359. )
  360. })(update)
  361. }
  362. async.series(actions, error => {
  363. if (error != null) {
  364. throw error
  365. }
  366. DocUpdaterClient.getDoc(
  367. this.project_id,
  368. this.doc_id,
  369. (error, res, doc) => {
  370. if (error) return done(error)
  371. doc.lines.should.deep.equal(this.my_result)
  372. done()
  373. }
  374. )
  375. })
  376. })
  377. it('should store the doc ops in the correct order', function (done) {
  378. rclientDU.lrange(
  379. Keys.docOps({ doc_id: this.doc_id }),
  380. 0,
  381. -1,
  382. (error, updates) => {
  383. if (error) return done(error)
  384. updates = updates.map(u => JSON.parse(u))
  385. for (let i = 0; i < this.updates.length; i++) {
  386. const appliedUpdate = this.updates[i]
  387. appliedUpdate.op.should.deep.equal(updates[i].op)
  388. }
  389. done()
  390. }
  391. )
  392. })
  393. })
  394. describe('when older ops come in after the delete', function () {
  395. before(function (done) {
  396. this.project_id = DocUpdaterClient.randomId()
  397. this.doc_id = DocUpdaterClient.randomId()
  398. const lines = ['', '', '']
  399. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  400. lines,
  401. version: 0,
  402. })
  403. this.updates = [
  404. { doc_id: this.doc_id, v: 0, op: [{ i: 'h', p: 0 }] },
  405. { doc_id: this.doc_id, v: 1, op: [{ i: 'e', p: 1 }] },
  406. { doc_id: this.doc_id, v: 2, op: [{ i: 'l', p: 2 }] },
  407. { doc_id: this.doc_id, v: 3, op: [{ i: 'l', p: 3 }] },
  408. { doc_id: this.doc_id, v: 4, op: [{ i: 'o', p: 4 }] },
  409. { doc_id: this.doc_id, v: 0, op: [{ i: 'world', p: 1 }] },
  410. ]
  411. this.my_result = ['hello', 'world', '']
  412. done()
  413. })
  414. it('should be able to continue applying updates when the project has been deleted', function (done) {
  415. let update
  416. const actions = []
  417. for (update of this.updates.slice(0, 5)) {
  418. ;(update => {
  419. actions.push(callback =>
  420. DocUpdaterClient.sendUpdate(
  421. this.project_id,
  422. this.doc_id,
  423. update,
  424. callback
  425. )
  426. )
  427. })(update)
  428. }
  429. actions.push(callback =>
  430. DocUpdaterClient.deleteDoc(this.project_id, this.doc_id, callback)
  431. )
  432. for (update of this.updates.slice(5)) {
  433. ;(update => {
  434. actions.push(callback =>
  435. DocUpdaterClient.sendUpdate(
  436. this.project_id,
  437. this.doc_id,
  438. update,
  439. callback
  440. )
  441. )
  442. })(update)
  443. }
  444. async.series(actions, error => {
  445. if (error != null) {
  446. throw error
  447. }
  448. DocUpdaterClient.getDoc(
  449. this.project_id,
  450. this.doc_id,
  451. (error, res, doc) => {
  452. if (error) return done(error)
  453. doc.lines.should.deep.equal(this.my_result)
  454. done()
  455. }
  456. )
  457. })
  458. })
  459. })
  460. })
  461. describe('with a broken update', function () {
  462. before(function (done) {
  463. this.project_id = DocUpdaterClient.randomId()
  464. this.doc_id = DocUpdaterClient.randomId()
  465. this.broken_update = {
  466. doc_id: this.doc_id,
  467. v: this.version,
  468. op: [{ d: 'not the correct content', p: 0 }],
  469. }
  470. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  471. lines: this.lines,
  472. version: this.version,
  473. })
  474. DocUpdaterClient.subscribeToAppliedOps(
  475. (this.messageCallback = sinon.stub())
  476. )
  477. DocUpdaterClient.sendUpdate(
  478. this.project_id,
  479. this.doc_id,
  480. this.broken_update,
  481. error => {
  482. if (error != null) {
  483. throw error
  484. }
  485. setTimeout(done, 200)
  486. }
  487. )
  488. })
  489. it('should not update the doc', function (done) {
  490. DocUpdaterClient.getDoc(
  491. this.project_id,
  492. this.doc_id,
  493. (error, res, doc) => {
  494. if (error) return done(error)
  495. doc.lines.should.deep.equal(this.lines)
  496. done()
  497. }
  498. )
  499. })
  500. it('should send a message with an error', function () {
  501. this.messageCallback.called.should.equal(true)
  502. const [channel, message] = this.messageCallback.args[0]
  503. channel.should.equal('applied-ops')
  504. JSON.parse(message).should.deep.include({
  505. project_id: this.project_id,
  506. doc_id: this.doc_id,
  507. error: 'Delete component does not match',
  508. })
  509. })
  510. })
  511. describe('when there is no version in Mongo', function () {
  512. before(function (done) {
  513. this.project_id = DocUpdaterClient.randomId()
  514. this.doc_id = DocUpdaterClient.randomId()
  515. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  516. lines: this.lines,
  517. })
  518. const update = {
  519. doc: this.doc_id,
  520. op: this.update.op,
  521. v: 0,
  522. }
  523. DocUpdaterClient.sendUpdate(
  524. this.project_id,
  525. this.doc_id,
  526. update,
  527. error => {
  528. if (error != null) {
  529. throw error
  530. }
  531. setTimeout(done, 200)
  532. }
  533. )
  534. })
  535. it('should update the doc (using version = 0)', function (done) {
  536. DocUpdaterClient.getDoc(
  537. this.project_id,
  538. this.doc_id,
  539. (error, res, doc) => {
  540. if (error) return done(error)
  541. doc.lines.should.deep.equal(this.result)
  542. done()
  543. }
  544. )
  545. })
  546. })
  547. describe('when the sending duplicate ops', function () {
  548. before(function (done) {
  549. this.project_id = DocUpdaterClient.randomId()
  550. this.doc_id = DocUpdaterClient.randomId()
  551. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  552. lines: this.lines,
  553. version: this.version,
  554. })
  555. DocUpdaterClient.subscribeToAppliedOps(
  556. (this.messageCallback = sinon.stub())
  557. )
  558. // One user delete 'one', the next turns it into 'once'. The second becomes a NOP.
  559. DocUpdaterClient.sendUpdate(
  560. this.project_id,
  561. this.doc_id,
  562. {
  563. doc: this.doc_id,
  564. op: [
  565. {
  566. i: 'one and a half\n',
  567. p: 4,
  568. },
  569. ],
  570. v: this.version,
  571. meta: {
  572. source: 'ikHceq3yfAdQYzBo4-xZ',
  573. },
  574. },
  575. error => {
  576. if (error != null) {
  577. throw error
  578. }
  579. setTimeout(() => {
  580. DocUpdaterClient.sendUpdate(
  581. this.project_id,
  582. this.doc_id,
  583. {
  584. doc: this.doc_id,
  585. op: [
  586. {
  587. i: 'one and a half\n',
  588. p: 4,
  589. },
  590. ],
  591. v: this.version,
  592. dupIfSource: ['ikHceq3yfAdQYzBo4-xZ'],
  593. meta: {
  594. source: 'ikHceq3yfAdQYzBo4-xZ',
  595. },
  596. },
  597. error => {
  598. if (error != null) {
  599. throw error
  600. }
  601. setTimeout(done, 200)
  602. }
  603. )
  604. }, 200)
  605. }
  606. )
  607. })
  608. it('should update the doc', function (done) {
  609. DocUpdaterClient.getDoc(
  610. this.project_id,
  611. this.doc_id,
  612. (error, res, doc) => {
  613. if (error) return done(error)
  614. doc.lines.should.deep.equal(this.result)
  615. done()
  616. }
  617. )
  618. })
  619. it('should return a message about duplicate ops', function () {
  620. this.messageCallback.calledTwice.should.equal(true)
  621. this.messageCallback.args[0][0].should.equal('applied-ops')
  622. expect(JSON.parse(this.messageCallback.args[0][1]).op.dup).to.be.undefined
  623. this.messageCallback.args[1][0].should.equal('applied-ops')
  624. expect(JSON.parse(this.messageCallback.args[1][1]).op.dup).to.equal(true)
  625. })
  626. })
  627. describe('when sending updates for a non-existing doc id', function () {
  628. before(function (done) {
  629. this.project_id = DocUpdaterClient.randomId()
  630. this.doc_id = DocUpdaterClient.randomId()
  631. this.non_existing = {
  632. doc_id: this.doc_id,
  633. v: this.version,
  634. op: [{ d: 'content', p: 0 }],
  635. }
  636. DocUpdaterClient.subscribeToAppliedOps(
  637. (this.messageCallback = sinon.stub())
  638. )
  639. DocUpdaterClient.sendUpdate(
  640. this.project_id,
  641. this.doc_id,
  642. this.non_existing,
  643. error => {
  644. if (error != null) {
  645. throw error
  646. }
  647. setTimeout(done, 200)
  648. }
  649. )
  650. })
  651. it('should not update or create a doc', function (done) {
  652. DocUpdaterClient.getDoc(
  653. this.project_id,
  654. this.doc_id,
  655. (error, res, doc) => {
  656. if (error) return done(error)
  657. res.statusCode.should.equal(404)
  658. done()
  659. }
  660. )
  661. })
  662. it('should send a message with an error', function () {
  663. this.messageCallback.called.should.equal(true)
  664. const [channel, message] = this.messageCallback.args[0]
  665. channel.should.equal('applied-ops')
  666. JSON.parse(message).should.deep.include({
  667. project_id: this.project_id,
  668. doc_id: this.doc_id,
  669. error: `doc not not found: /project/${this.project_id}/doc/${this.doc_id}`,
  670. })
  671. })
  672. })
  673. })