ProjectHistoryRedisManagerTests.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  1. const sinon = require('sinon')
  2. const { expect } = require('chai')
  3. const modulePath = '../../../../app/js/ProjectHistoryRedisManager.js'
  4. const SandboxedModule = require('sandboxed-module')
  5. const tk = require('timekeeper')
  6. describe('ProjectHistoryRedisManager', function () {
  7. beforeEach(function () {
  8. this.project_id = 'project-id-123'
  9. this.projectHistoryId = 'history-id-123'
  10. this.user_id = 'user-id-123'
  11. this.rclient = {}
  12. this.source = 'editor'
  13. tk.freeze(new Date())
  14. this.Limits = {
  15. docIsTooLarge: sinon.stub().returns(false),
  16. stringFileDataContentIsTooLarge: sinon.stub().returns(false),
  17. }
  18. this.ProjectHistoryRedisManager = SandboxedModule.require(modulePath, {
  19. requires: {
  20. '@overleaf/settings': (this.settings = {
  21. max_doc_length: 123,
  22. redis: {
  23. project_history: {
  24. key_schema: {
  25. projectHistoryOps({ project_id: projectId }) {
  26. return `ProjectHistory:Ops:${projectId}`
  27. },
  28. projectHistoryFirstOpTimestamp({ project_id: projectId }) {
  29. return `ProjectHistory:FirstOpTimestamp:${projectId}`
  30. },
  31. },
  32. },
  33. },
  34. }),
  35. '@overleaf/redis-wrapper': {
  36. createClient: () => this.rclient,
  37. },
  38. './Metrics': (this.metrics = { summary: sinon.stub() }),
  39. './Limits': this.Limits,
  40. },
  41. })
  42. })
  43. afterEach(function () {
  44. tk.reset()
  45. })
  46. describe('queueOps', function () {
  47. beforeEach(async function () {
  48. this.ops = ['mock-op-1', 'mock-op-2']
  49. this.multi = { exec: sinon.stub().resolves([1]) }
  50. this.multi.rpush = sinon.stub()
  51. this.multi.setnx = sinon.stub()
  52. this.rclient.multi = () => this.multi
  53. await this.ProjectHistoryRedisManager.promises.queueOps(
  54. this.project_id,
  55. ...this.ops
  56. )
  57. })
  58. it('should queue an update', function () {
  59. this.multi.rpush.should.have.been.calledWithExactly(
  60. `ProjectHistory:Ops:${this.project_id}`,
  61. this.ops[0],
  62. this.ops[1]
  63. )
  64. })
  65. it('should set the queue timestamp if not present', function () {
  66. this.multi.setnx.should.have.been.calledWithExactly(
  67. `ProjectHistory:FirstOpTimestamp:${this.project_id}`,
  68. Date.now()
  69. )
  70. })
  71. })
  72. describe('queueRenameEntity', function () {
  73. beforeEach(async function () {
  74. this.file_id = 1234
  75. this.rawUpdate = {
  76. pathname: (this.pathname = '/old'),
  77. newPathname: (this.newPathname = '/new'),
  78. version: (this.version = 2),
  79. }
  80. this.ProjectHistoryRedisManager.promises.queueOps = sinon
  81. .stub()
  82. .resolves()
  83. await this.ProjectHistoryRedisManager.promises.queueRenameEntity(
  84. this.project_id,
  85. this.projectHistoryId,
  86. 'file',
  87. this.file_id,
  88. this.user_id,
  89. this.rawUpdate,
  90. this.source
  91. )
  92. })
  93. it('should queue an update', function () {
  94. const update = {
  95. pathname: this.pathname,
  96. new_pathname: this.newPathname,
  97. meta: {
  98. user_id: this.user_id,
  99. ts: new Date(),
  100. source: this.source,
  101. },
  102. version: this.version,
  103. projectHistoryId: this.projectHistoryId,
  104. file: this.file_id,
  105. }
  106. this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWithExactly(
  107. this.project_id,
  108. JSON.stringify(update)
  109. )
  110. })
  111. })
  112. describe('queueAddEntity', function () {
  113. beforeEach(function () {
  114. this.doc_id = 1234
  115. this.rawUpdate = {
  116. pathname: (this.pathname = '/old'),
  117. docLines: (this.docLines = 'a\nb'),
  118. version: (this.version = 2),
  119. }
  120. this.ProjectHistoryRedisManager.promises.queueOps = sinon
  121. .stub()
  122. .resolves()
  123. })
  124. it('should queue an update', async function () {
  125. this.rawUpdate.url = this.url = 'filestore.example.com'
  126. await this.ProjectHistoryRedisManager.promises.queueAddEntity(
  127. this.project_id,
  128. this.projectHistoryId,
  129. 'doc',
  130. this.doc_id,
  131. this.user_id,
  132. this.rawUpdate,
  133. this.source
  134. )
  135. const update = {
  136. pathname: this.pathname,
  137. docLines: this.docLines,
  138. url: this.url,
  139. meta: {
  140. user_id: this.user_id,
  141. ts: new Date(),
  142. source: this.source,
  143. },
  144. version: this.version,
  145. projectHistoryId: this.projectHistoryId,
  146. createdBlob: false,
  147. doc: this.doc_id,
  148. }
  149. this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWithExactly(
  150. this.project_id,
  151. JSON.stringify(update)
  152. )
  153. })
  154. it('should queue an update with file metadata', async function () {
  155. const metadata = {
  156. importedAt: '2024-07-30T09:14:45.928Z',
  157. provider: 'references-provider',
  158. }
  159. const projectId = 'project-id'
  160. const fileId = 'file-id'
  161. const url = `http://filestore/project/${projectId}/file/${fileId}`
  162. await this.ProjectHistoryRedisManager.promises.queueAddEntity(
  163. projectId,
  164. this.projectHistoryId,
  165. 'file',
  166. fileId,
  167. this.user_id,
  168. {
  169. pathname: 'foo.png',
  170. url,
  171. version: 42,
  172. hash: '1337',
  173. metadata,
  174. },
  175. this.source
  176. )
  177. const update = {
  178. pathname: 'foo.png',
  179. docLines: undefined,
  180. url,
  181. meta: {
  182. user_id: this.user_id,
  183. ts: new Date(),
  184. source: this.source,
  185. },
  186. version: 42,
  187. hash: '1337',
  188. metadata,
  189. projectHistoryId: this.projectHistoryId,
  190. createdBlob: false,
  191. file: fileId,
  192. }
  193. expect(
  194. this.ProjectHistoryRedisManager.promises.queueOps.args[0][1]
  195. ).to.equal(JSON.stringify(update))
  196. this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWithExactly(
  197. projectId,
  198. JSON.stringify(update)
  199. )
  200. })
  201. it('should forward history compatible ranges if history ranges support is enabled', async function () {
  202. this.rawUpdate.historyRangesSupport = true
  203. this.docLines = 'the quick fox jumps over the lazy dog'
  204. const ranges = {
  205. changes: [
  206. {
  207. op: { p: 4, i: 'quick' },
  208. metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-1' },
  209. },
  210. {
  211. op: { p: 9, d: ' brown' },
  212. metadata: { ts: '2024-02-01T00:00:00.000Z', user_id: 'user-1' },
  213. },
  214. {
  215. op: { p: 14, i: 'jumps' },
  216. metadata: { ts: '2024-02-01T00:00:00.000Z', user_id: 'user-1' },
  217. },
  218. ],
  219. comments: [
  220. {
  221. op: { p: 29, c: 'lazy', t: 'comment-1' },
  222. metadata: { resolved: false },
  223. },
  224. ],
  225. }
  226. this.rawUpdate.ranges = ranges
  227. this.rawUpdate.docLines = this.docLines
  228. await this.ProjectHistoryRedisManager.promises.queueAddEntity(
  229. this.project_id,
  230. this.projectHistoryId,
  231. 'doc',
  232. this.doc_id,
  233. this.user_id,
  234. this.rawUpdate,
  235. this.source
  236. )
  237. const historyCompatibleRanges = {
  238. comments: [
  239. {
  240. op: { p: 29, c: 'lazy', t: 'comment-1', hpos: 35 },
  241. metadata: { resolved: false },
  242. },
  243. ],
  244. changes: [
  245. {
  246. op: { p: 4, i: 'quick' },
  247. metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-1' },
  248. },
  249. {
  250. op: { p: 9, d: ' brown' },
  251. metadata: { ts: '2024-02-01T00:00:00.000Z', user_id: 'user-1' },
  252. },
  253. {
  254. op: { p: 14, i: 'jumps', hpos: 20 },
  255. metadata: { ts: '2024-02-01T00:00:00.000Z', user_id: 'user-1' },
  256. },
  257. ],
  258. }
  259. const update = {
  260. pathname: this.pathname,
  261. docLines: 'the quick brown fox jumps over the lazy dog',
  262. meta: {
  263. user_id: this.user_id,
  264. ts: new Date(),
  265. source: this.source,
  266. },
  267. version: this.version,
  268. projectHistoryId: this.projectHistoryId,
  269. createdBlob: false,
  270. ranges: historyCompatibleRanges,
  271. doc: this.doc_id,
  272. }
  273. expect(
  274. this.ProjectHistoryRedisManager.promises.queueOps
  275. ).to.have.been.calledWithExactly(this.project_id, JSON.stringify(update))
  276. })
  277. it('should not forward ranges if history ranges support is disabled', async function () {
  278. this.rawUpdate.historyRangesSupport = false
  279. const ranges = {
  280. changes: [
  281. {
  282. op: { p: 0, i: 'foo' },
  283. metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-1' },
  284. },
  285. {
  286. op: { p: 7, d: ' baz' },
  287. metadata: { ts: '2024-02-01T00:00:00.000Z', user_id: 'user-1' },
  288. },
  289. ],
  290. comments: [
  291. {
  292. op: { p: 4, c: 'bar', t: 'comment-1' },
  293. metadata: { resolved: false },
  294. },
  295. ],
  296. }
  297. this.rawUpdate.ranges = ranges
  298. await this.ProjectHistoryRedisManager.promises.queueAddEntity(
  299. this.project_id,
  300. this.projectHistoryId,
  301. 'doc',
  302. this.doc_id,
  303. this.user_id,
  304. this.rawUpdate,
  305. this.source
  306. )
  307. const update = {
  308. pathname: this.pathname,
  309. docLines: this.docLines,
  310. meta: {
  311. user_id: this.user_id,
  312. ts: new Date(),
  313. source: this.source,
  314. },
  315. version: this.version,
  316. projectHistoryId: this.projectHistoryId,
  317. createdBlob: false,
  318. doc: this.doc_id,
  319. }
  320. this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWithExactly(
  321. this.project_id,
  322. JSON.stringify(update)
  323. )
  324. })
  325. it('should not forward ranges if history ranges support is undefined', async function () {
  326. this.rawUpdate.historyRangesSupport = false
  327. const ranges = {
  328. changes: [
  329. {
  330. op: { p: 0, i: 'foo' },
  331. metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-1' },
  332. },
  333. {
  334. op: { p: 7, d: ' baz' },
  335. metadata: { ts: '2024-02-01T00:00:00.000Z', user_id: 'user-1' },
  336. },
  337. ],
  338. comments: [
  339. {
  340. op: { p: 4, c: 'bar', t: 'comment-1' },
  341. metadata: { resolved: false },
  342. },
  343. ],
  344. }
  345. this.rawUpdate.ranges = ranges
  346. await this.ProjectHistoryRedisManager.promises.queueAddEntity(
  347. this.project_id,
  348. this.projectHistoryId,
  349. 'doc',
  350. this.doc_id,
  351. this.user_id,
  352. this.rawUpdate,
  353. this.source
  354. )
  355. const update = {
  356. pathname: this.pathname,
  357. docLines: this.docLines,
  358. meta: {
  359. user_id: this.user_id,
  360. ts: new Date(),
  361. source: this.source,
  362. },
  363. version: this.version,
  364. projectHistoryId: this.projectHistoryId,
  365. createdBlob: false,
  366. doc: this.doc_id,
  367. }
  368. this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWithExactly(
  369. this.project_id,
  370. JSON.stringify(update)
  371. )
  372. })
  373. it('should pass "false" as the createdBlob field if not provided', async function () {
  374. await this.ProjectHistoryRedisManager.promises.queueAddEntity(
  375. this.project_id,
  376. this.projectHistoryId,
  377. 'doc',
  378. this.doc_id,
  379. this.user_id,
  380. this.rawUpdate,
  381. this.source
  382. )
  383. const update = {
  384. pathname: this.pathname,
  385. docLines: this.docLines,
  386. meta: {
  387. user_id: this.user_id,
  388. ts: new Date(),
  389. source: this.source,
  390. },
  391. version: this.version,
  392. projectHistoryId: this.projectHistoryId,
  393. createdBlob: false,
  394. doc: this.doc_id,
  395. }
  396. this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWithExactly(
  397. this.project_id,
  398. JSON.stringify(update)
  399. )
  400. })
  401. it('should pass through the value of the createdBlob field', async function () {
  402. this.rawUpdate.createdBlob = true
  403. await this.ProjectHistoryRedisManager.promises.queueAddEntity(
  404. this.project_id,
  405. this.projectHistoryId,
  406. 'doc',
  407. this.doc_id,
  408. this.user_id,
  409. this.rawUpdate,
  410. this.source
  411. )
  412. const update = {
  413. pathname: this.pathname,
  414. docLines: this.docLines,
  415. meta: {
  416. user_id: this.user_id,
  417. ts: new Date(),
  418. source: this.source,
  419. },
  420. version: this.version,
  421. projectHistoryId: this.projectHistoryId,
  422. createdBlob: true,
  423. doc: this.doc_id,
  424. }
  425. this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWithExactly(
  426. this.project_id,
  427. JSON.stringify(update)
  428. )
  429. })
  430. })
  431. describe('queueResyncProjectStructure', function () {
  432. it('should queue an update', function () {})
  433. })
  434. describe('queueResyncDocContent', function () {
  435. beforeEach(function () {
  436. this.doc_id = 1234
  437. this.lines = ['one', 'two']
  438. this.ranges = {
  439. changes: [{ op: { i: 'ne', p: 1 } }, { op: { d: 'deleted', p: 3 } }],
  440. }
  441. this.resolvedCommentIds = ['comment-1']
  442. this.version = 2
  443. this.pathname = '/path'
  444. this.ProjectHistoryRedisManager.promises.queueOps = sinon
  445. .stub()
  446. .resolves()
  447. })
  448. describe('with a good doc', function () {
  449. beforeEach(async function () {
  450. this.update = {
  451. resyncDocContent: {
  452. version: this.version,
  453. content: 'one\ntwo',
  454. },
  455. projectHistoryId: this.projectHistoryId,
  456. path: this.pathname,
  457. doc: this.doc_id,
  458. meta: { ts: new Date() },
  459. }
  460. await this.ProjectHistoryRedisManager.promises.queueResyncDocContent(
  461. this.project_id,
  462. this.projectHistoryId,
  463. this.doc_id,
  464. this.lines,
  465. this.ranges,
  466. this.resolvedCommentIds,
  467. this.version,
  468. this.pathname,
  469. false
  470. )
  471. })
  472. it('should check if the doc is too large', function () {
  473. this.Limits.docIsTooLarge.should.have.been.calledWith(
  474. JSON.stringify(this.update).length,
  475. this.lines,
  476. this.settings.max_doc_length
  477. )
  478. })
  479. it('should queue an update', function () {
  480. this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWithExactly(
  481. this.project_id,
  482. JSON.stringify(this.update)
  483. )
  484. })
  485. })
  486. describe('with a doc that is too large', function () {
  487. beforeEach(async function () {
  488. this.Limits.docIsTooLarge.returns(true)
  489. await expect(
  490. this.ProjectHistoryRedisManager.promises.queueResyncDocContent(
  491. this.project_id,
  492. this.projectHistoryId,
  493. this.doc_id,
  494. this.lines,
  495. this.ranges,
  496. this.resolvedCommentIds,
  497. this.version,
  498. this.pathname,
  499. false
  500. )
  501. ).to.be.rejected
  502. })
  503. it('should not queue an update if the doc is too large', function () {
  504. this.ProjectHistoryRedisManager.promises.queueOps.should.not.have.been
  505. .called
  506. })
  507. })
  508. describe('when history ranges support is enabled', function () {
  509. beforeEach(async function () {
  510. this.update = {
  511. resyncDocContent: {
  512. version: this.version,
  513. ranges: this.ranges,
  514. resolvedCommentIds: this.resolvedCommentIds,
  515. content: 'onedeleted\ntwo',
  516. },
  517. projectHistoryId: this.projectHistoryId,
  518. path: this.pathname,
  519. doc: this.doc_id,
  520. meta: { ts: new Date() },
  521. }
  522. await this.ProjectHistoryRedisManager.promises.queueResyncDocContent(
  523. this.project_id,
  524. this.projectHistoryId,
  525. this.doc_id,
  526. this.lines,
  527. this.ranges,
  528. this.resolvedCommentIds,
  529. this.version,
  530. this.pathname,
  531. true
  532. )
  533. })
  534. it('should include tracked deletes in the update', function () {
  535. this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWithExactly(
  536. this.project_id,
  537. JSON.stringify(this.update)
  538. )
  539. })
  540. it('should check the doc length without tracked deletes', function () {
  541. this.Limits.docIsTooLarge.should.have.been.calledWith(
  542. JSON.stringify(this.update).length,
  543. this.lines,
  544. this.settings.max_doc_length
  545. )
  546. })
  547. it('should queue an update', function () {
  548. this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWithExactly(
  549. this.project_id,
  550. JSON.stringify(this.update)
  551. )
  552. })
  553. })
  554. describe('history-ot', function () {
  555. beforeEach(async function () {
  556. this.lines = {
  557. content: 'onedeleted\ntwo',
  558. comments: [{ id: 'id1', ranges: [{ pos: 0, length: 3 }] }],
  559. trackedChanges: [
  560. {
  561. range: { pos: 3, length: 7 },
  562. tracking: {
  563. type: 'delete',
  564. userId: 'user-id',
  565. ts: '2025-06-16T14:31:44.910Z',
  566. },
  567. },
  568. ],
  569. }
  570. this.update = {
  571. resyncDocContent: {
  572. version: this.version,
  573. historyOTRanges: {
  574. comments: this.lines.comments,
  575. trackedChanges: this.lines.trackedChanges,
  576. },
  577. content: this.lines.content,
  578. },
  579. projectHistoryId: this.projectHistoryId,
  580. path: this.pathname,
  581. doc: this.doc_id,
  582. meta: { ts: new Date() },
  583. }
  584. await this.ProjectHistoryRedisManager.promises.queueResyncDocContent(
  585. this.project_id,
  586. this.projectHistoryId,
  587. this.doc_id,
  588. this.lines,
  589. this.ranges,
  590. this.resolvedCommentIds,
  591. this.version,
  592. this.pathname,
  593. true
  594. )
  595. })
  596. it('should include tracked deletes in the update', function () {
  597. this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWithExactly(
  598. this.project_id,
  599. JSON.stringify(this.update)
  600. )
  601. })
  602. it('should check the doc length without tracked deletes', function () {
  603. this.Limits.stringFileDataContentIsTooLarge.should.have.been.calledWith(
  604. this.lines,
  605. this.settings.max_doc_length
  606. )
  607. })
  608. it('should queue an update', function () {
  609. this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWithExactly(
  610. this.project_id,
  611. JSON.stringify(this.update)
  612. )
  613. })
  614. })
  615. })
  616. })