ApplyingUpdatesToProjectStructureTests.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  1. const sinon = require('sinon')
  2. const { setTimeout } = require('node:timers/promises')
  3. const Settings = require('@overleaf/settings')
  4. const rclientProjectHistory = require('@overleaf/redis-wrapper').createClient(
  5. Settings.redis.project_history
  6. )
  7. const ProjectHistoryKeys = Settings.redis.project_history.key_schema
  8. const MockProjectHistoryApi = require('./helpers/MockProjectHistoryApi')
  9. const MockWebApi = require('./helpers/MockWebApi')
  10. const DocUpdaterClient = require('./helpers/DocUpdaterClient')
  11. const DocUpdaterApp = require('./helpers/DocUpdaterApp')
  12. async function sendProjectUpdateAndWait(projectId, docId, update, version) {
  13. await DocUpdaterClient.sendProjectUpdate(projectId, docId, update, version)
  14. // It seems that we need to wait for a little while
  15. await setTimeout(200)
  16. }
  17. describe("Applying updates to a project's structure", function () {
  18. before(function (done) {
  19. this.user_id = 'user-id-123'
  20. this.version = 1234
  21. DocUpdaterApp.ensureRunning(done)
  22. })
  23. describe('renaming a file', function () {
  24. before(async function () {
  25. this.project_id = DocUpdaterClient.randomId()
  26. this.fileUpdate = {
  27. type: 'rename-file',
  28. id: DocUpdaterClient.randomId(),
  29. pathname: '/file-path',
  30. newPathname: '/new-file-path',
  31. }
  32. this.updates = [this.fileUpdate]
  33. await DocUpdaterApp.ensureRunning()
  34. await sendProjectUpdateAndWait(
  35. this.project_id,
  36. this.user_id,
  37. this.updates,
  38. this.version
  39. )
  40. })
  41. it('should push the applied file renames to the project history api', function (done) {
  42. rclientProjectHistory.lrange(
  43. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  44. 0,
  45. -1,
  46. (error, updates) => {
  47. if (error) {
  48. return done(error)
  49. }
  50. const update = JSON.parse(updates[0])
  51. update.file.should.equal(this.fileUpdate.id)
  52. update.pathname.should.equal('/file-path')
  53. update.new_pathname.should.equal('/new-file-path')
  54. update.meta.user_id.should.equal(this.user_id)
  55. update.meta.ts.should.be.a('string')
  56. update.version.should.equal(`${this.version}.0`)
  57. done()
  58. }
  59. )
  60. })
  61. })
  62. describe('deleting a file', function () {
  63. before(async function () {
  64. this.project_id = DocUpdaterClient.randomId()
  65. this.fileUpdate = {
  66. type: 'rename-file',
  67. id: DocUpdaterClient.randomId(),
  68. pathname: '/file-path',
  69. newPathname: '',
  70. }
  71. this.updates = [this.fileUpdate]
  72. await sendProjectUpdateAndWait(
  73. this.project_id,
  74. this.user_id,
  75. this.updates,
  76. this.version
  77. )
  78. })
  79. it('should push the applied file renames to the project history api', function (done) {
  80. rclientProjectHistory.lrange(
  81. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  82. 0,
  83. -1,
  84. (error, updates) => {
  85. if (error) {
  86. return done(error)
  87. }
  88. const update = JSON.parse(updates[0])
  89. update.file.should.equal(this.fileUpdate.id)
  90. update.pathname.should.equal('/file-path')
  91. update.new_pathname.should.equal('')
  92. update.meta.user_id.should.equal(this.user_id)
  93. update.meta.ts.should.be.a('string')
  94. update.version.should.equal(`${this.version}.0`)
  95. done()
  96. }
  97. )
  98. })
  99. })
  100. describe('renaming a document', function () {
  101. before(function () {
  102. this.update = {
  103. type: 'rename-doc',
  104. id: DocUpdaterClient.randomId(),
  105. pathname: '/doc-path',
  106. newPathname: '/new-doc-path',
  107. }
  108. this.updates = [this.update]
  109. })
  110. describe('when the document is not loaded', function () {
  111. before(async function () {
  112. this.project_id = DocUpdaterClient.randomId()
  113. await sendProjectUpdateAndWait(
  114. this.project_id,
  115. this.user_id,
  116. this.updates,
  117. this.version
  118. )
  119. })
  120. it('should push the applied doc renames to the project history api', function (done) {
  121. rclientProjectHistory.lrange(
  122. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  123. 0,
  124. -1,
  125. (error, updates) => {
  126. if (error) {
  127. return done(error)
  128. }
  129. const update = JSON.parse(updates[0])
  130. update.doc.should.equal(this.update.id)
  131. update.pathname.should.equal('/doc-path')
  132. update.new_pathname.should.equal('/new-doc-path')
  133. update.meta.user_id.should.equal(this.user_id)
  134. update.meta.ts.should.be.a('string')
  135. update.version.should.equal(`${this.version}.0`)
  136. done()
  137. }
  138. )
  139. })
  140. })
  141. describe('when the document is loaded', function () {
  142. before(async function () {
  143. this.project_id = DocUpdaterClient.randomId()
  144. MockWebApi.insertDoc(this.project_id, this.update.id, {})
  145. await DocUpdaterClient.preloadDoc(this.project_id, this.update.id)
  146. sinon.spy(MockWebApi, 'getDocument')
  147. await sendProjectUpdateAndWait(
  148. this.project_id,
  149. this.user_id,
  150. this.updates,
  151. this.version
  152. )
  153. })
  154. after(function () {
  155. MockWebApi.getDocument.restore()
  156. })
  157. it('should update the doc', async function () {
  158. const doc = await DocUpdaterClient.getDoc(
  159. this.project_id,
  160. this.update.id
  161. )
  162. doc.pathname.should.equal(this.update.newPathname)
  163. })
  164. it('should push the applied doc renames to the project history api', function (done) {
  165. rclientProjectHistory.lrange(
  166. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  167. 0,
  168. -1,
  169. (error, updates) => {
  170. if (error) {
  171. return done(error)
  172. }
  173. const update = JSON.parse(updates[0])
  174. update.doc.should.equal(this.update.id)
  175. update.pathname.should.equal('/doc-path')
  176. update.new_pathname.should.equal('/new-doc-path')
  177. update.meta.user_id.should.equal(this.user_id)
  178. update.meta.ts.should.be.a('string')
  179. update.version.should.equal(`${this.version}.0`)
  180. done()
  181. }
  182. )
  183. })
  184. })
  185. })
  186. describe('renaming multiple documents and files', function () {
  187. before(function () {
  188. this.docUpdate0 = {
  189. type: 'rename-doc',
  190. id: DocUpdaterClient.randomId(),
  191. pathname: '/doc-path0',
  192. newPathname: '/new-doc-path0',
  193. }
  194. this.docUpdate1 = {
  195. type: 'rename-doc',
  196. id: DocUpdaterClient.randomId(),
  197. pathname: '/doc-path1',
  198. newPathname: '/new-doc-path1',
  199. }
  200. this.fileUpdate0 = {
  201. type: 'rename-file',
  202. id: DocUpdaterClient.randomId(),
  203. pathname: '/file-path0',
  204. newPathname: '/new-file-path0',
  205. }
  206. this.fileUpdate1 = {
  207. type: 'rename-file',
  208. id: DocUpdaterClient.randomId(),
  209. pathname: '/file-path1',
  210. newPathname: '/new-file-path1',
  211. }
  212. this.updates = [
  213. this.docUpdate0,
  214. this.docUpdate1,
  215. this.fileUpdate0,
  216. this.fileUpdate1,
  217. ]
  218. })
  219. describe('when the documents are not loaded', function () {
  220. before(async function () {
  221. this.project_id = DocUpdaterClient.randomId()
  222. await sendProjectUpdateAndWait(
  223. this.project_id,
  224. this.user_id,
  225. this.updates,
  226. this.version
  227. )
  228. })
  229. it('should push the applied doc renames to the project history api', function (done) {
  230. rclientProjectHistory.lrange(
  231. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  232. 0,
  233. -1,
  234. (error, updates) => {
  235. if (error) {
  236. return done(error)
  237. }
  238. let update = JSON.parse(updates[0])
  239. update.doc.should.equal(this.docUpdate0.id)
  240. update.pathname.should.equal('/doc-path0')
  241. update.new_pathname.should.equal('/new-doc-path0')
  242. update.meta.user_id.should.equal(this.user_id)
  243. update.meta.ts.should.be.a('string')
  244. update.version.should.equal(`${this.version}.0`)
  245. update = JSON.parse(updates[1])
  246. update.doc.should.equal(this.docUpdate1.id)
  247. update.pathname.should.equal('/doc-path1')
  248. update.new_pathname.should.equal('/new-doc-path1')
  249. update.meta.user_id.should.equal(this.user_id)
  250. update.meta.ts.should.be.a('string')
  251. update.version.should.equal(`${this.version}.1`)
  252. update = JSON.parse(updates[2])
  253. update.file.should.equal(this.fileUpdate0.id)
  254. update.pathname.should.equal('/file-path0')
  255. update.new_pathname.should.equal('/new-file-path0')
  256. update.meta.user_id.should.equal(this.user_id)
  257. update.meta.ts.should.be.a('string')
  258. update.version.should.equal(`${this.version}.2`)
  259. update = JSON.parse(updates[3])
  260. update.file.should.equal(this.fileUpdate1.id)
  261. update.pathname.should.equal('/file-path1')
  262. update.new_pathname.should.equal('/new-file-path1')
  263. update.meta.user_id.should.equal(this.user_id)
  264. update.meta.ts.should.be.a('string')
  265. update.version.should.equal(`${this.version}.3`)
  266. done()
  267. }
  268. )
  269. })
  270. })
  271. })
  272. describe('deleting a document', function () {
  273. before(function () {
  274. this.update = {
  275. type: 'rename-doc',
  276. id: DocUpdaterClient.randomId(),
  277. pathname: '/doc-path',
  278. newPathname: '',
  279. }
  280. this.updates = [this.update]
  281. })
  282. describe('when the document is not loaded', function () {
  283. before(async function () {
  284. this.project_id = DocUpdaterClient.randomId()
  285. await sendProjectUpdateAndWait(
  286. this.project_id,
  287. this.user_id,
  288. this.updates,
  289. this.version
  290. )
  291. })
  292. it('should push the applied doc update to the project history api', function (done) {
  293. rclientProjectHistory.lrange(
  294. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  295. 0,
  296. -1,
  297. (error, updates) => {
  298. if (error) {
  299. return done(error)
  300. }
  301. const update = JSON.parse(updates[0])
  302. update.doc.should.equal(this.update.id)
  303. update.pathname.should.equal('/doc-path')
  304. update.new_pathname.should.equal('')
  305. update.meta.user_id.should.equal(this.user_id)
  306. update.meta.ts.should.be.a('string')
  307. update.version.should.equal(`${this.version}.0`)
  308. done()
  309. }
  310. )
  311. })
  312. })
  313. describe('when the document is loaded', function () {
  314. before(async function () {
  315. this.project_id = DocUpdaterClient.randomId()
  316. MockWebApi.insertDoc(this.project_id, this.update.id, {})
  317. await DocUpdaterClient.preloadDoc(this.project_id, this.update.id)
  318. sinon.spy(MockWebApi, 'getDocument')
  319. await sendProjectUpdateAndWait(
  320. this.project_id,
  321. this.user_id,
  322. this.updates,
  323. this.version
  324. )
  325. })
  326. after(function () {
  327. MockWebApi.getDocument.restore()
  328. })
  329. it('should not modify the doc', async function () {
  330. const doc = await DocUpdaterClient.getDoc(
  331. this.project_id,
  332. this.update.id
  333. )
  334. doc.pathname.should.equal('/a/b/c.tex') // default pathname from MockWebApi
  335. })
  336. it('should push the applied doc update to the project history api', function (done) {
  337. rclientProjectHistory.lrange(
  338. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  339. 0,
  340. -1,
  341. (error, updates) => {
  342. if (error) {
  343. return done(error)
  344. }
  345. const update = JSON.parse(updates[0])
  346. update.doc.should.equal(this.update.id)
  347. update.pathname.should.equal('/doc-path')
  348. update.new_pathname.should.equal('')
  349. update.meta.user_id.should.equal(this.user_id)
  350. update.meta.ts.should.be.a('string')
  351. update.version.should.equal(`${this.version}.0`)
  352. done()
  353. }
  354. )
  355. })
  356. })
  357. })
  358. describe('adding a file', function () {
  359. before(async function () {
  360. this.project_id = DocUpdaterClient.randomId()
  361. this.fileUpdate = {
  362. type: 'add-file',
  363. id: DocUpdaterClient.randomId(),
  364. pathname: '/file-path',
  365. url: 'filestore.example.com',
  366. }
  367. this.updates = [this.fileUpdate]
  368. await sendProjectUpdateAndWait(
  369. this.project_id,
  370. this.user_id,
  371. this.updates,
  372. this.version
  373. )
  374. })
  375. it('should push the file addition to the project history api', function (done) {
  376. rclientProjectHistory.lrange(
  377. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  378. 0,
  379. -1,
  380. (error, updates) => {
  381. if (error) {
  382. return done(error)
  383. }
  384. const update = JSON.parse(updates[0])
  385. update.file.should.equal(this.fileUpdate.id)
  386. update.pathname.should.equal('/file-path')
  387. update.url.should.equal('filestore.example.com')
  388. update.meta.user_id.should.equal(this.user_id)
  389. update.meta.ts.should.be.a('string')
  390. update.version.should.equal(`${this.version}.0`)
  391. done()
  392. }
  393. )
  394. })
  395. })
  396. describe('adding a doc', function () {
  397. before(async function () {
  398. this.project_id = DocUpdaterClient.randomId()
  399. this.docUpdate = {
  400. type: 'add-doc',
  401. id: DocUpdaterClient.randomId(),
  402. pathname: '/file-path',
  403. docLines: 'a\nb',
  404. }
  405. this.updates = [this.docUpdate]
  406. await sendProjectUpdateAndWait(
  407. this.project_id,
  408. this.user_id,
  409. this.updates,
  410. this.version
  411. )
  412. })
  413. it('should push the doc addition to the project history api', function (done) {
  414. rclientProjectHistory.lrange(
  415. ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
  416. 0,
  417. -1,
  418. (error, updates) => {
  419. if (error) {
  420. return done(error)
  421. }
  422. const update = JSON.parse(updates[0])
  423. update.doc.should.equal(this.docUpdate.id)
  424. update.pathname.should.equal('/file-path')
  425. update.docLines.should.equal('a\nb')
  426. update.meta.user_id.should.equal(this.user_id)
  427. update.meta.ts.should.be.a('string')
  428. update.version.should.equal(`${this.version}.0`)
  429. done()
  430. }
  431. )
  432. })
  433. })
  434. describe('with enough updates to flush to the history service', function () {
  435. before(async function () {
  436. this.project_id = DocUpdaterClient.randomId()
  437. this.user_id = DocUpdaterClient.randomId()
  438. this.version0 = 12345
  439. this.version1 = this.version0 + 1
  440. const updates = []
  441. for (let v = 0; v <= 599; v++) {
  442. // Should flush after 500 ops
  443. updates.push({
  444. type: 'add-doc',
  445. id: DocUpdaterClient.randomId(),
  446. pathname: '/file-' + v,
  447. docLines: 'a\nb',
  448. })
  449. }
  450. sinon.spy(MockProjectHistoryApi, 'flushProject')
  451. // Send updates in chunks to causes multiple flushes
  452. const projectId = this.project_id
  453. const userId = this.project_id
  454. await DocUpdaterClient.sendProjectUpdate(
  455. projectId,
  456. userId,
  457. updates.slice(0, 250),
  458. this.version0
  459. )
  460. await DocUpdaterClient.sendProjectUpdate(
  461. projectId,
  462. userId,
  463. updates.slice(250),
  464. this.version1
  465. )
  466. await setTimeout(200)
  467. })
  468. after(function () {
  469. MockProjectHistoryApi.flushProject.restore()
  470. })
  471. it('should flush project history', function () {
  472. MockProjectHistoryApi.flushProject
  473. .calledWith(this.project_id)
  474. .should.equal(true)
  475. })
  476. })
  477. describe('with too few updates to flush to the history service', function () {
  478. before(async function () {
  479. this.project_id = DocUpdaterClient.randomId()
  480. this.user_id = DocUpdaterClient.randomId()
  481. this.version0 = 12345
  482. this.version1 = this.version0 + 1
  483. const updates = []
  484. for (let v = 0; v <= 42; v++) {
  485. // Should flush after 500 ops
  486. updates.push({
  487. type: 'add-doc',
  488. id: DocUpdaterClient.randomId(),
  489. pathname: '/file-' + v,
  490. docLines: 'a\nb',
  491. })
  492. }
  493. sinon.spy(MockProjectHistoryApi, 'flushProject')
  494. // Send updates in chunks
  495. const projectId = this.project_id
  496. const userId = this.project_id
  497. await DocUpdaterClient.sendProjectUpdate(
  498. projectId,
  499. userId,
  500. updates.slice(0, 10),
  501. this.version0
  502. )
  503. await DocUpdaterClient.sendProjectUpdate(
  504. projectId,
  505. userId,
  506. updates.slice(10),
  507. this.version1
  508. )
  509. await setTimeout(200)
  510. })
  511. after(function () {
  512. MockProjectHistoryApi.flushProject.restore()
  513. })
  514. it('should not flush project history', function () {
  515. MockProjectHistoryApi.flushProject
  516. .calledWith(this.project_id)
  517. .should.equal(false)
  518. })
  519. })
  520. })