ApplyingUpdatesToADocTests.js 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927
  1. const sinon = require('sinon')
  2. const { expect } = require('chai')
  3. const { setTimeout } = require('node:timers/promises')
  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. const { RequestFailedError } = require('@overleaf/fetch-utils')
  17. async function sendUpdateAndWait(projectId, docId, update) {
  18. await DocUpdaterClient.sendUpdate(projectId, docId, update)
  19. // It seems that we need to wait for a little while
  20. await setTimeout(200)
  21. }
  22. describe('Applying updates to a doc', function () {
  23. beforeEach(async function () {
  24. sinon.spy(MockWebApi, 'getDocument')
  25. this.lines = ['one', 'two', 'three']
  26. this.version = 42
  27. this.op = {
  28. i: 'one and a half\n',
  29. p: 4,
  30. }
  31. this.project_id = DocUpdaterClient.randomId()
  32. this.doc_id = DocUpdaterClient.randomId()
  33. this.update = {
  34. doc: this.doc_id,
  35. op: [this.op],
  36. v: this.version,
  37. }
  38. this.historyOTUpdate = {
  39. doc: this.doc_id,
  40. op: [{ textOperation: [4, 'one and a half\n', 9] }],
  41. v: this.version,
  42. meta: { source: 'random-publicId' },
  43. }
  44. this.result = ['one', 'one and a half', 'two', 'three']
  45. await DocUpdaterApp.ensureRunning()
  46. })
  47. afterEach(function () {
  48. sinon.restore()
  49. })
  50. describe('when the document is not loaded', function () {
  51. beforeEach(async function () {
  52. this.startTime = Date.now()
  53. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  54. lines: this.lines,
  55. version: this.version,
  56. })
  57. await sendUpdateAndWait(this.project_id, this.doc_id, this.update)
  58. const result = await rclientProjectHistory.get(
  59. ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
  60. project_id: this.project_id,
  61. })
  62. )
  63. this.firstOpTimestamp = parseInt(result, 10)
  64. })
  65. it('should load the document from the web API', function () {
  66. MockWebApi.getDocument
  67. .calledWith(this.project_id, this.doc_id)
  68. .should.equal(true)
  69. })
  70. it('should update the doc', async function () {
  71. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  72. doc.lines.should.deep.equal(this.result)
  73. })
  74. it('should push the applied updates to the project history changes api', function (done) {
  75. rclientProjectHistory.lrange(
  76. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  77. 0,
  78. -1,
  79. (error, updates) => {
  80. if (error != null) {
  81. throw error
  82. }
  83. JSON.parse(updates[0]).op.should.deep.equal([this.op])
  84. done()
  85. }
  86. )
  87. })
  88. it('should set the first op timestamp', function () {
  89. this.firstOpTimestamp.should.be.within(this.startTime, Date.now())
  90. })
  91. it('should yield last updated time', async function () {
  92. const { lastUpdatedAt } = await DocUpdaterClient.getProjectLastUpdatedAt(
  93. this.project_id
  94. )
  95. lastUpdatedAt.should.be.within(this.startTime, Date.now())
  96. })
  97. it('should yield no last updated time for another project', async function () {
  98. const body = await DocUpdaterClient.getProjectLastUpdatedAt(
  99. DocUpdaterClient.randomId()
  100. )
  101. body.should.deep.equal({})
  102. })
  103. it('should set the project notification timestamp in Redis', async function () {
  104. const timestamp = await rclientDU.get(
  105. Keys.projectNotificationTimestamp({ project_id: this.project_id })
  106. )
  107. expect(timestamp).to.exist
  108. const timestampValue = parseInt(timestamp, 10)
  109. timestampValue.should.be.within(this.startTime, Date.now())
  110. })
  111. describe('when sending another update', function () {
  112. beforeEach(async function () {
  113. this.timeout(10000)
  114. this.second_update = Object.assign({}, this.update)
  115. this.second_update.v = this.version + 1
  116. this.secondStartTime = Date.now()
  117. await sendUpdateAndWait(
  118. this.project_id,
  119. this.doc_id,
  120. this.second_update
  121. )
  122. })
  123. it('should update the doc', async function () {
  124. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  125. doc.lines.should.deep.equal([
  126. 'one',
  127. 'one and a half',
  128. 'one and a half',
  129. 'two',
  130. 'three',
  131. ])
  132. })
  133. it('should not change the first op timestamp', function (done) {
  134. rclientProjectHistory.get(
  135. ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
  136. project_id: this.project_id,
  137. }),
  138. (error, result) => {
  139. if (error != null) {
  140. throw error
  141. }
  142. result = parseInt(result, 10)
  143. result.should.equal(this.firstOpTimestamp)
  144. done()
  145. }
  146. )
  147. })
  148. it('should yield last updated time', async function () {
  149. const { lastUpdatedAt } =
  150. await DocUpdaterClient.getProjectLastUpdatedAt(this.project_id)
  151. lastUpdatedAt.should.be.within(this.secondStartTime, Date.now())
  152. })
  153. it('should not change the project notification timestamp', async function () {
  154. const timestamp = await rclientDU.get(
  155. Keys.projectNotificationTimestamp({ project_id: this.project_id })
  156. )
  157. expect(timestamp).to.exist
  158. const timestampValue = parseInt(timestamp, 10)
  159. // Should still be within the first update time range, not the second
  160. timestampValue.should.be.within(this.startTime, this.secondStartTime)
  161. })
  162. })
  163. describe('when another client is sending a concurrent update', function () {
  164. beforeEach(async function () {
  165. this.timeout(10000)
  166. this.otherUpdate = {
  167. doc: this.doc_id,
  168. op: [{ p: 8, i: 'two and a half\n' }],
  169. v: this.version,
  170. meta: { source: 'other-random-publicId' },
  171. }
  172. this.secondStartTime = Date.now()
  173. await sendUpdateAndWait(this.project_id, this.doc_id, this.otherUpdate)
  174. })
  175. it('should update the doc', async function () {
  176. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  177. doc.lines.should.deep.equal([
  178. 'one',
  179. 'one and a half',
  180. 'two',
  181. 'two and a half',
  182. 'three',
  183. ])
  184. })
  185. it('should not change the first op timestamp', function (done) {
  186. rclientProjectHistory.get(
  187. ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
  188. project_id: this.project_id,
  189. }),
  190. (error, result) => {
  191. if (error != null) {
  192. throw error
  193. }
  194. result = parseInt(result, 10)
  195. result.should.equal(this.firstOpTimestamp)
  196. done()
  197. }
  198. )
  199. })
  200. it('should yield last updated time', async function () {
  201. const { lastUpdatedAt } =
  202. await DocUpdaterClient.getProjectLastUpdatedAt(this.project_id)
  203. lastUpdatedAt.should.be.within(this.secondStartTime, Date.now())
  204. })
  205. })
  206. })
  207. describe('when the document is not loaded (history-ot)', function () {
  208. beforeEach(async function () {
  209. this.startTime = Date.now()
  210. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  211. lines: this.lines,
  212. version: this.version,
  213. otMigrationStage: 1,
  214. })
  215. await sendUpdateAndWait(
  216. this.project_id,
  217. this.doc_id,
  218. this.historyOTUpdate
  219. )
  220. const result = await rclientProjectHistory.get(
  221. ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
  222. project_id: this.project_id,
  223. })
  224. )
  225. this.firstOpTimestamp = parseInt(result, 10)
  226. })
  227. it('should load the document from the web API', function () {
  228. MockWebApi.getDocument
  229. .calledWith(this.project_id, this.doc_id)
  230. .should.equal(true)
  231. })
  232. it('should update the doc', async function () {
  233. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  234. doc.lines.should.deep.equal(this.result)
  235. })
  236. it('should push the applied updates to the project history changes api', function (done) {
  237. rclientProjectHistory.lrange(
  238. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  239. 0,
  240. -1,
  241. (error, updates) => {
  242. if (error != null) {
  243. throw error
  244. }
  245. JSON.parse(updates[0]).op.should.deep.equal(this.historyOTUpdate.op)
  246. JSON.parse(updates[0]).meta.pathname.should.equal('/a/b/c.tex')
  247. done()
  248. }
  249. )
  250. })
  251. it('should set the first op timestamp', function () {
  252. this.firstOpTimestamp.should.be.within(this.startTime, Date.now())
  253. })
  254. it('should yield last updated time', async function () {
  255. const { lastUpdatedAt } = await DocUpdaterClient.getProjectLastUpdatedAt(
  256. this.project_id
  257. )
  258. lastUpdatedAt.should.be.within(this.startTime, Date.now())
  259. })
  260. it('should yield no last updated time for another project', async function () {
  261. const body = await DocUpdaterClient.getProjectLastUpdatedAt(
  262. DocUpdaterClient.randomId()
  263. )
  264. body.should.deep.equal({})
  265. })
  266. it('should set the project notification timestamp in Redis', async function () {
  267. const timestamp = await rclientDU.get(
  268. Keys.projectNotificationTimestamp({ project_id: this.project_id })
  269. )
  270. expect(timestamp).to.exist
  271. const timestampValue = parseInt(timestamp, 10)
  272. timestampValue.should.be.within(this.startTime, Date.now())
  273. })
  274. describe('when sending another update', function () {
  275. beforeEach(async function () {
  276. this.timeout(10000)
  277. this.second_update = Object.assign({}, this.historyOTUpdate)
  278. this.second_update.op = [
  279. {
  280. textOperation: [4, 'one and a half\n', 24],
  281. },
  282. ]
  283. this.second_update.v = this.version + 1
  284. this.secondStartTime = Date.now()
  285. await sendUpdateAndWait(
  286. this.project_id,
  287. this.doc_id,
  288. this.second_update
  289. )
  290. })
  291. it('should update the doc', async function () {
  292. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  293. doc.lines.should.deep.equal([
  294. 'one',
  295. 'one and a half',
  296. 'one and a half',
  297. 'two',
  298. 'three',
  299. ])
  300. })
  301. it('should not change the first op timestamp', function (done) {
  302. rclientProjectHistory.get(
  303. ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
  304. project_id: this.project_id,
  305. }),
  306. (error, result) => {
  307. if (error != null) {
  308. throw error
  309. }
  310. result = parseInt(result, 10)
  311. result.should.equal(this.firstOpTimestamp)
  312. done()
  313. }
  314. )
  315. })
  316. it('should yield last updated time', async function () {
  317. const { lastUpdatedAt } =
  318. await DocUpdaterClient.getProjectLastUpdatedAt(this.project_id)
  319. lastUpdatedAt.should.be.within(this.secondStartTime, Date.now())
  320. })
  321. it('should not change the project notification timestamp', async function () {
  322. const timestamp = await rclientDU.get(
  323. Keys.projectNotificationTimestamp({ project_id: this.project_id })
  324. )
  325. expect(timestamp).to.exist
  326. const timestampValue = parseInt(timestamp, 10)
  327. // Should still be within the first update time range, not the second
  328. timestampValue.should.be.within(this.startTime, this.secondStartTime)
  329. })
  330. })
  331. describe('when another client is sending a concurrent update', function () {
  332. beforeEach(async function () {
  333. this.timeout(10000)
  334. this.otherUpdate = {
  335. doc: this.doc_id,
  336. op: [{ textOperation: [8, 'two and a half\n', 5] }],
  337. v: this.version,
  338. meta: { source: 'other-random-publicId' },
  339. }
  340. this.secondStartTime = Date.now()
  341. await sendUpdateAndWait(this.project_id, this.doc_id, this.otherUpdate)
  342. })
  343. it('should update the doc', async function () {
  344. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  345. doc.lines.should.deep.equal([
  346. 'one',
  347. 'one and a half',
  348. 'two',
  349. 'two and a half',
  350. 'three',
  351. ])
  352. })
  353. it('should not change the first op timestamp', function (done) {
  354. rclientProjectHistory.get(
  355. ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
  356. project_id: this.project_id,
  357. }),
  358. (error, result) => {
  359. if (error != null) {
  360. throw error
  361. }
  362. result = parseInt(result, 10)
  363. result.should.equal(this.firstOpTimestamp)
  364. done()
  365. }
  366. )
  367. })
  368. it('should yield last updated time', async function () {
  369. const { lastUpdatedAt } =
  370. await DocUpdaterClient.getProjectLastUpdatedAt(this.project_id)
  371. lastUpdatedAt.should.be.within(this.secondStartTime, Date.now())
  372. })
  373. })
  374. })
  375. describe('when the document is loaded', function () {
  376. beforeEach(async function () {
  377. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  378. lines: this.lines,
  379. version: this.version,
  380. })
  381. await DocUpdaterClient.preloadDoc(this.project_id, this.doc_id)
  382. sinon.resetHistory()
  383. await sendUpdateAndWait(this.project_id, this.doc_id, this.update)
  384. })
  385. it('should not need to call the web api', function () {
  386. MockWebApi.getDocument.called.should.equal(false)
  387. })
  388. it('should update the doc', async function () {
  389. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  390. doc.lines.should.deep.equal(this.result)
  391. })
  392. it('should push the applied updates to the project history changes api', function (done) {
  393. rclientProjectHistory.lrange(
  394. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  395. 0,
  396. -1,
  397. (error, updates) => {
  398. if (error) return done(error)
  399. JSON.parse(updates[0]).op.should.deep.equal([this.op])
  400. done()
  401. }
  402. )
  403. })
  404. })
  405. describe('when the document is loaded and is using project-history only', function () {
  406. beforeEach(async function () {
  407. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  408. lines: this.lines,
  409. version: this.version,
  410. })
  411. await DocUpdaterClient.preloadDoc(this.project_id, this.doc_id)
  412. sinon.resetHistory()
  413. await sendUpdateAndWait(this.project_id, this.doc_id, this.update)
  414. })
  415. it('should update the doc', async function () {
  416. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  417. doc.lines.should.deep.equal(this.result)
  418. })
  419. it('should push the applied updates to the project history changes api', function (done) {
  420. rclientProjectHistory.lrange(
  421. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  422. 0,
  423. -1,
  424. (error, updates) => {
  425. if (error) return done(error)
  426. JSON.parse(updates[0]).op.should.deep.equal([this.op])
  427. done()
  428. }
  429. )
  430. })
  431. })
  432. describe('when the document is loaded (history-ot)', function () {
  433. beforeEach(async function () {
  434. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  435. lines: this.lines,
  436. version: this.version,
  437. otMigrationStage: 1,
  438. })
  439. await DocUpdaterClient.preloadDoc(this.project_id, this.doc_id)
  440. await sendUpdateAndWait(
  441. this.project_id,
  442. this.doc_id,
  443. this.historyOTUpdate
  444. )
  445. })
  446. it('should update the doc', async function () {
  447. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  448. doc.lines.should.deep.equal(this.result)
  449. })
  450. it('should push the applied updates to the project history changes api', function (done) {
  451. rclientProjectHistory.lrange(
  452. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  453. 0,
  454. -1,
  455. (error, updates) => {
  456. if (error) return done(error)
  457. JSON.parse(updates[0]).op.should.deep.equal(this.historyOTUpdate.op)
  458. JSON.parse(updates[0]).meta.pathname.should.equal('/a/b/c.tex')
  459. done()
  460. }
  461. )
  462. })
  463. })
  464. describe('when the document has been deleted', function () {
  465. describe('when the ops come in a single linear order', function () {
  466. beforeEach(async function () {
  467. const lines = ['', '', '']
  468. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  469. lines,
  470. version: 0,
  471. })
  472. this.updates = [
  473. { doc_id: this.doc_id, v: 0, op: [{ i: 'h', p: 0 }] },
  474. { doc_id: this.doc_id, v: 1, op: [{ i: 'e', p: 1 }] },
  475. { doc_id: this.doc_id, v: 2, op: [{ i: 'l', p: 2 }] },
  476. { doc_id: this.doc_id, v: 3, op: [{ i: 'l', p: 3 }] },
  477. { doc_id: this.doc_id, v: 4, op: [{ i: 'o', p: 4 }] },
  478. { doc_id: this.doc_id, v: 5, op: [{ i: ' ', p: 5 }] },
  479. { doc_id: this.doc_id, v: 6, op: [{ i: 'w', p: 6 }] },
  480. { doc_id: this.doc_id, v: 7, op: [{ i: 'o', p: 7 }] },
  481. { doc_id: this.doc_id, v: 8, op: [{ i: 'r', p: 8 }] },
  482. { doc_id: this.doc_id, v: 9, op: [{ i: 'l', p: 9 }] },
  483. { doc_id: this.doc_id, v: 10, op: [{ i: 'd', p: 10 }] },
  484. ]
  485. this.my_result = ['hello world', '', '']
  486. for (const update of this.updates.slice(0, 6)) {
  487. await DocUpdaterClient.sendUpdate(
  488. this.project_id,
  489. this.doc_id,
  490. update
  491. )
  492. }
  493. await DocUpdaterClient.deleteDoc(this.project_id, this.doc_id)
  494. for (const update of this.updates.slice(6)) {
  495. await DocUpdaterClient.sendUpdate(
  496. this.project_id,
  497. this.doc_id,
  498. update
  499. )
  500. }
  501. await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  502. })
  503. it('should be able to continue applying updates when the project has been deleted', async function () {
  504. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  505. doc.lines.should.deep.equal(this.my_result)
  506. })
  507. it('should store the doc ops in the correct order', function (done) {
  508. rclientDU.lrange(
  509. Keys.docOps({ doc_id: this.doc_id }),
  510. 0,
  511. -1,
  512. (error, updates) => {
  513. if (error) return done(error)
  514. updates = updates.map(u => JSON.parse(u))
  515. for (let i = 0; i < this.updates.length; i++) {
  516. const appliedUpdate = this.updates[i]
  517. appliedUpdate.op.should.deep.equal(updates[i].op)
  518. }
  519. done()
  520. }
  521. )
  522. })
  523. })
  524. describe('when older ops come in after the delete', function () {
  525. beforeEach(function () {
  526. const lines = ['', '', '']
  527. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  528. lines,
  529. version: 0,
  530. })
  531. this.updates = [
  532. { doc_id: this.doc_id, v: 0, op: [{ i: 'h', p: 0 }] },
  533. { doc_id: this.doc_id, v: 1, op: [{ i: 'e', p: 1 }] },
  534. { doc_id: this.doc_id, v: 2, op: [{ i: 'l', p: 2 }] },
  535. { doc_id: this.doc_id, v: 3, op: [{ i: 'l', p: 3 }] },
  536. { doc_id: this.doc_id, v: 4, op: [{ i: 'o', p: 4 }] },
  537. { doc_id: this.doc_id, v: 0, op: [{ i: 'world', p: 1 }] },
  538. ]
  539. this.my_result = ['hello', 'world', '']
  540. })
  541. it('should be able to continue applying updates when the project has been deleted', async function () {
  542. for (const update of this.updates.slice(0, 5)) {
  543. await DocUpdaterClient.sendUpdate(
  544. this.project_id,
  545. this.doc_id,
  546. update
  547. )
  548. }
  549. await DocUpdaterClient.deleteDoc(this.project_id, this.doc_id)
  550. for (const update of this.updates.slice(5)) {
  551. await DocUpdaterClient.sendUpdate(
  552. this.project_id,
  553. this.doc_id,
  554. update
  555. )
  556. }
  557. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  558. doc.lines.should.deep.equal(this.my_result)
  559. })
  560. })
  561. })
  562. describe('with a broken update', function () {
  563. beforeEach(async function () {
  564. this.broken_update = {
  565. doc: this.doc_id,
  566. v: this.version,
  567. op: [{ d: 'not the correct content', p: 0 }],
  568. }
  569. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  570. lines: this.lines,
  571. version: this.version,
  572. })
  573. DocUpdaterClient.subscribeToAppliedOps(
  574. (this.messageCallback = sinon.stub())
  575. )
  576. await sendUpdateAndWait(this.project_id, this.doc_id, this.broken_update)
  577. })
  578. it('should not update the doc', async function () {
  579. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  580. doc.lines.should.deep.equal(this.lines)
  581. })
  582. it('should send a message with an error', function () {
  583. this.messageCallback.called.should.equal(true)
  584. const [channel, message] = this.messageCallback.args[0]
  585. channel.should.equal('applied-ops')
  586. JSON.parse(message).should.deep.include({
  587. project_id: this.project_id,
  588. doc_id: this.doc_id,
  589. error: 'Delete component does not match',
  590. })
  591. })
  592. })
  593. describe('with a broken update (history-ot)', function () {
  594. beforeEach(async function () {
  595. this.broken_update = {
  596. doc: this.doc_id,
  597. v: this.version,
  598. op: [{ textOperation: [99, -1] }],
  599. meta: { source: '42' },
  600. }
  601. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  602. lines: this.lines,
  603. version: this.version,
  604. otMigrationStage: 1,
  605. })
  606. DocUpdaterClient.subscribeToAppliedOps(
  607. (this.messageCallback = sinon.stub())
  608. )
  609. await sendUpdateAndWait(this.project_id, this.doc_id, this.broken_update)
  610. })
  611. it('should not update the doc', async function () {
  612. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  613. doc.lines.should.deep.equal(this.lines)
  614. })
  615. it('should send a message with an error', function () {
  616. this.messageCallback.called.should.equal(true)
  617. const [channel, message] = this.messageCallback.args[0]
  618. channel.should.equal('applied-ops')
  619. JSON.parse(message).should.deep.include({
  620. project_id: this.project_id,
  621. doc_id: this.doc_id,
  622. error:
  623. "The operation's base length must be equal to the string's length.",
  624. })
  625. })
  626. })
  627. describe('when mixing ot types (sharejs-text-ot -> history-ot)', function () {
  628. beforeEach(async function () {
  629. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  630. lines: this.lines,
  631. version: this.version,
  632. otMigrationStage: 0,
  633. })
  634. DocUpdaterClient.subscribeToAppliedOps(
  635. (this.messageCallback = sinon.stub())
  636. )
  637. await sendUpdateAndWait(
  638. this.project_id,
  639. this.doc_id,
  640. this.historyOTUpdate
  641. )
  642. })
  643. it('should not update the doc', async function () {
  644. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  645. doc.lines.should.deep.equal(this.lines)
  646. })
  647. it('should send a message with an error', function () {
  648. this.messageCallback.called.should.equal(true)
  649. const [channel, message] = this.messageCallback.args[0]
  650. channel.should.equal('applied-ops')
  651. JSON.parse(message).should.deep.include({
  652. project_id: this.project_id,
  653. doc_id: this.doc_id,
  654. error: 'ot type mismatch',
  655. })
  656. })
  657. })
  658. describe('when mixing ot types (history-ot -> sharejs-text-ot)', function () {
  659. beforeEach(async function () {
  660. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  661. lines: this.lines,
  662. version: this.version,
  663. otMigrationStage: 1,
  664. })
  665. DocUpdaterClient.subscribeToAppliedOps(
  666. (this.messageCallback = sinon.stub())
  667. )
  668. await sendUpdateAndWait(this.project_id, this.doc_id, this.update)
  669. })
  670. it('should not update the doc', async function () {
  671. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  672. doc.lines.should.deep.equal(this.lines)
  673. })
  674. it('should send a message with an error', function () {
  675. this.messageCallback.called.should.equal(true)
  676. const [channel, message] = this.messageCallback.args[0]
  677. channel.should.equal('applied-ops')
  678. JSON.parse(message).should.deep.include({
  679. project_id: this.project_id,
  680. doc_id: this.doc_id,
  681. error: 'ot type mismatch',
  682. })
  683. })
  684. })
  685. describe('when there is no version in Mongo', function () {
  686. beforeEach(async function () {
  687. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  688. lines: this.lines,
  689. })
  690. const update = {
  691. doc: this.doc_id,
  692. op: this.update.op,
  693. v: 0,
  694. }
  695. await sendUpdateAndWait(this.project_id, this.doc_id, update)
  696. })
  697. it('should update the doc (using version = 0)', async function () {
  698. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  699. doc.lines.should.deep.equal(this.result)
  700. })
  701. })
  702. describe('when the sending duplicate ops', function () {
  703. beforeEach(async function () {
  704. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  705. lines: this.lines,
  706. version: this.version,
  707. })
  708. DocUpdaterClient.subscribeToAppliedOps(
  709. (this.messageCallback = sinon.stub())
  710. )
  711. // One user delete 'one', the next turns it into 'once'. The second becomes a NOP.
  712. await sendUpdateAndWait(this.project_id, this.doc_id, {
  713. doc: this.doc_id,
  714. op: [
  715. {
  716. i: 'one and a half\n',
  717. p: 4,
  718. },
  719. ],
  720. v: this.version,
  721. meta: {
  722. source: 'ikHceq3yfAdQYzBo4-xZ',
  723. },
  724. })
  725. await sendUpdateAndWait(this.project_id, this.doc_id, {
  726. doc: this.doc_id,
  727. op: [
  728. {
  729. i: 'one and a half\n',
  730. p: 4,
  731. },
  732. ],
  733. v: this.version,
  734. dupIfSource: ['ikHceq3yfAdQYzBo4-xZ'],
  735. meta: {
  736. source: 'ikHceq3yfAdQYzBo4-xZ',
  737. },
  738. })
  739. })
  740. it('should update the doc', async function () {
  741. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  742. doc.lines.should.deep.equal(this.result)
  743. })
  744. it('should return a message about duplicate ops', function () {
  745. this.messageCallback.calledTwice.should.equal(true)
  746. this.messageCallback.args[0][0].should.equal('applied-ops')
  747. expect(JSON.parse(this.messageCallback.args[0][1]).op.dup).to.be.undefined
  748. this.messageCallback.args[1][0].should.equal('applied-ops')
  749. expect(JSON.parse(this.messageCallback.args[1][1]).op.dup).to.equal(true)
  750. })
  751. })
  752. describe('when sending duplicate ops (history-ot)', function () {
  753. beforeEach(async function () {
  754. MockWebApi.insertDoc(this.project_id, this.doc_id, {
  755. lines: this.lines,
  756. version: this.version,
  757. otMigrationStage: 1,
  758. })
  759. DocUpdaterClient.subscribeToAppliedOps(
  760. (this.messageCallback = sinon.stub())
  761. )
  762. // One user delete 'one', the next turns it into 'once'. The second becomes a NOP.
  763. await sendUpdateAndWait(this.project_id, this.doc_id, {
  764. doc: this.doc_id,
  765. op: [{ textOperation: [4, 'one and a half\n', 9] }],
  766. v: this.version,
  767. meta: {
  768. source: 'ikHceq3yfAdQYzBo4-xZ',
  769. },
  770. })
  771. await sendUpdateAndWait(this.project_id, this.doc_id, {
  772. doc: this.doc_id,
  773. op: [
  774. {
  775. textOperation: [4, 'one and a half\n', 9],
  776. },
  777. ],
  778. v: this.version,
  779. dupIfSource: ['ikHceq3yfAdQYzBo4-xZ'],
  780. meta: {
  781. source: 'ikHceq3yfAdQYzBo4-xZ',
  782. },
  783. })
  784. })
  785. it('should update the doc', async function () {
  786. const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
  787. doc.lines.should.deep.equal(this.result)
  788. })
  789. it('should return a message about duplicate ops', function () {
  790. this.messageCallback.calledTwice.should.equal(true)
  791. this.messageCallback.args[0][0].should.equal('applied-ops')
  792. expect(JSON.parse(this.messageCallback.args[0][1]).op.dup).to.be.undefined
  793. this.messageCallback.args[1][0].should.equal('applied-ops')
  794. expect(JSON.parse(this.messageCallback.args[1][1]).op.dup).to.equal(true)
  795. })
  796. })
  797. describe('when sending updates for a non-existing doc id', function () {
  798. beforeEach(async function () {
  799. this.non_existing = {
  800. doc: this.doc_id,
  801. v: this.version,
  802. op: [{ d: 'content', p: 0 }],
  803. }
  804. DocUpdaterClient.subscribeToAppliedOps(
  805. (this.messageCallback = sinon.stub())
  806. )
  807. await sendUpdateAndWait(this.project_id, this.doc_id, this.non_existing)
  808. })
  809. it('should not update or create a doc', async function () {
  810. await expect(DocUpdaterClient.getDoc(this.project_id, this.doc_id))
  811. .to.be.rejectedWith(RequestFailedError)
  812. .and.eventually.have.nested.property('response.status', 404)
  813. })
  814. it('should send a message with an error', function () {
  815. this.messageCallback.called.should.equal(true)
  816. const [channel, message] = this.messageCallback.args[0]
  817. channel.should.equal('applied-ops')
  818. JSON.parse(message).should.deep.include({
  819. project_id: this.project_id,
  820. doc_id: this.doc_id,
  821. error: 'doc not found',
  822. })
  823. })
  824. })
  825. })