SnapshotManagerTests.js 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119
  1. import sinon from 'sinon'
  2. import { expect } from 'chai'
  3. import { strict as esmock } from 'esmock'
  4. import Core from 'overleaf-editor-core'
  5. import * as Errors from '../../../../app/js/Errors.js'
  6. const MODULE_PATH = '../../../../app/js/SnapshotManager.js'
  7. describe('SnapshotManager', function () {
  8. beforeEach(async function () {
  9. this.HistoryStoreManager = {
  10. getBlobStore: sinon.stub(),
  11. promises: {
  12. getChunkAtVersion: sinon.stub(),
  13. getMostRecentChunk: sinon.stub(),
  14. getProjectBlobStream: sinon.stub(),
  15. },
  16. }
  17. this.WebApiManager = {
  18. promises: {
  19. getHistoryId: sinon.stub(),
  20. },
  21. }
  22. this.SnapshotManager = await esmock(MODULE_PATH, {
  23. 'overleaf-editor-core': Core,
  24. '../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager,
  25. '../../../../app/js/WebApiManager.js': this.WebApiManager,
  26. '../../../../app/js/Errors.js': Errors,
  27. })
  28. this.projectId = 'project-id-123'
  29. this.historyId = 'ol-project-id-123'
  30. this.callback = sinon.stub()
  31. })
  32. describe('getFileSnapshotStream', function () {
  33. beforeEach(function () {
  34. this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
  35. this.ranges = {
  36. comments: [],
  37. trackedChanges: [
  38. {
  39. range: { pos: 4, length: 6 },
  40. tracking: {
  41. userId: 'user-1',
  42. ts: '2024-01-01T00:00:00.000Z',
  43. type: 'delete',
  44. },
  45. },
  46. {
  47. range: { pos: 35, length: 5 },
  48. tracking: {
  49. userId: 'user-1',
  50. ts: '2024-01-01T00:00:00.000Z',
  51. type: 'insert',
  52. },
  53. },
  54. ],
  55. }
  56. this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
  57. chunk: {
  58. history: {
  59. snapshot: {
  60. files: {
  61. 'main.tex': {
  62. hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
  63. stringLength: 41,
  64. },
  65. 'file_with_ranges.tex': {
  66. hash: '5d2781d78fa5a97b7bafa849fe933dfc9dc93eba',
  67. rangesHash: '73061952d41ce54825e2fc1c36b4cf736d5fb62f',
  68. stringLength: 41,
  69. },
  70. 'binary.png': {
  71. hash: 'c6654ea913979e13e22022653d284444f284a172',
  72. byteLength: 41,
  73. },
  74. },
  75. },
  76. changes: [
  77. {
  78. operations: [
  79. {
  80. pathname: 'main.tex',
  81. textOperation: [41, '\n\nSeven eight'],
  82. },
  83. ],
  84. timestamp: '2017-12-04T10:29:17.786Z',
  85. authors: [31],
  86. },
  87. {
  88. operations: [
  89. {
  90. pathname: 'main.tex',
  91. textOperation: [54, ' nine'],
  92. },
  93. ],
  94. timestamp: '2017-12-04T10:29:22.905Z',
  95. authors: [31],
  96. },
  97. ],
  98. },
  99. startVersion: 3,
  100. authors: [
  101. {
  102. id: 31,
  103. email: 'james.allen@overleaf.com',
  104. name: 'James',
  105. },
  106. ],
  107. },
  108. })
  109. })
  110. describe('of a text file with no tracked changes', function () {
  111. beforeEach(async function () {
  112. this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
  113. getString: (this.getString = sinon.stub().resolves(
  114. `\
  115. Hello world
  116. One two three
  117. Four five six\
  118. `.replace(/^\t/g, '')
  119. )),
  120. getObject: sinon.stub().rejects(),
  121. })
  122. this.stream = await this.SnapshotManager.promises.getFileSnapshotStream(
  123. this.projectId,
  124. 5,
  125. 'main.tex'
  126. )
  127. })
  128. it('should get the overleaf id', function () {
  129. this.WebApiManager.promises.getHistoryId
  130. .calledWith(this.projectId)
  131. .should.equal(true)
  132. })
  133. it('should get the chunk', function () {
  134. this.HistoryStoreManager.promises.getChunkAtVersion
  135. .calledWith(this.projectId, this.historyId, 5)
  136. .should.equal(true)
  137. })
  138. it('should get the blob of the starting snapshot', function () {
  139. this.getString
  140. .calledWith('35c9bd86574d61dcadbce2fdd3d4a0684272c6ea')
  141. .should.equal(true)
  142. })
  143. it('should return a string stream with the text content', function () {
  144. expect(this.stream.read().toString()).to.equal(
  145. `\
  146. Hello world
  147. One two three
  148. Four five six
  149. Seven eight nine\
  150. `.replace(/^\t/g, '')
  151. )
  152. })
  153. describe('on blob store error', function () {
  154. beforeEach(function () {
  155. this.error = new Error('ESOCKETTIMEDOUT')
  156. this.HistoryStoreManager.getBlobStore
  157. .withArgs(this.historyId)
  158. .returns({
  159. getString: sinon.stub().rejects(this.error),
  160. getObject: sinon.stub().rejects(this.error),
  161. })
  162. })
  163. it('should call back with error', async function () {
  164. await expect(
  165. this.SnapshotManager.promises.getFileSnapshotStream(
  166. this.projectId,
  167. 5,
  168. 'main.tex'
  169. )
  170. ).to.be.rejectedWith(this.error)
  171. })
  172. })
  173. })
  174. describe('of a text file with tracked changes', function () {
  175. beforeEach(async function () {
  176. this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
  177. getString: (this.getString = sinon
  178. .stub()
  179. .resolves('the quick brown fox jumps over the lazy dog')),
  180. getObject: (this.getObject = sinon.stub().resolves(this.ranges)),
  181. })
  182. this.stream = await this.SnapshotManager.promises.getFileSnapshotStream(
  183. this.projectId,
  184. 5,
  185. 'file_with_ranges.tex'
  186. )
  187. })
  188. it('should get the overleaf id', function () {
  189. this.WebApiManager.promises.getHistoryId
  190. .calledWith(this.projectId)
  191. .should.equal(true)
  192. })
  193. it('should get the chunk', function () {
  194. this.HistoryStoreManager.promises.getChunkAtVersion
  195. .calledWith(this.projectId, this.historyId, 5)
  196. .should.equal(true)
  197. })
  198. it('should get the blob of the starting snapshot', function () {
  199. this.getString
  200. .calledWith('5d2781d78fa5a97b7bafa849fe933dfc9dc93eba')
  201. .should.equal(true)
  202. })
  203. it('should get the blob of the ranges', function () {
  204. this.getObject
  205. .calledWith('73061952d41ce54825e2fc1c36b4cf736d5fb62f')
  206. .should.equal(true)
  207. })
  208. it('should return a string stream with the text content without the tracked deletes', function () {
  209. expect(this.stream.read().toString()).to.equal(
  210. 'the brown fox jumps over the lazy dog'
  211. )
  212. })
  213. })
  214. describe('of a binary file', function () {
  215. beforeEach(async function () {
  216. this.HistoryStoreManager.promises.getProjectBlobStream
  217. .withArgs(this.historyId)
  218. .resolves((this.stream = 'mock-stream'))
  219. this.returnedStream =
  220. await this.SnapshotManager.promises.getFileSnapshotStream(
  221. this.projectId,
  222. 5,
  223. 'binary.png'
  224. )
  225. })
  226. it('should get the overleaf id', function () {
  227. this.WebApiManager.promises.getHistoryId
  228. .calledWith(this.projectId)
  229. .should.equal(true)
  230. })
  231. it('should get the chunk', function () {
  232. this.HistoryStoreManager.promises.getChunkAtVersion
  233. .calledWith(this.projectId, this.historyId, 5)
  234. .should.equal(true)
  235. })
  236. it('should get the blob of the starting snapshot', function () {
  237. this.HistoryStoreManager.promises.getProjectBlobStream
  238. .calledWith(
  239. this.historyId,
  240. 'c6654ea913979e13e22022653d284444f284a172'
  241. )
  242. .should.equal(true)
  243. })
  244. it('should return a stream with the blob content', function () {
  245. expect(this.returnedStream).to.equal(this.stream)
  246. })
  247. })
  248. describe("when the file doesn't exist", function () {
  249. it('should return a NotFoundError', async function () {
  250. await expect(
  251. this.SnapshotManager.promises.getFileSnapshotStream(
  252. this.projectId,
  253. 5,
  254. 'not-here.png'
  255. )
  256. ).to.be.rejectedWith(Errors.NotFoundError)
  257. })
  258. })
  259. })
  260. describe('getProjectSnapshot', function () {
  261. beforeEach(function () {
  262. this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
  263. this.ranges = {
  264. comments: [],
  265. trackedChanges: [
  266. {
  267. range: { pos: 5, length: 6 },
  268. tracking: {
  269. userId: 'user-1',
  270. ts: '2024-01-01T00:00:00.000Z',
  271. type: 'delete',
  272. },
  273. },
  274. {
  275. range: { pos: 12, length: 5 },
  276. tracking: {
  277. userId: 'user-1',
  278. ts: '2024-01-01T00:00:00.000Z',
  279. type: 'insert',
  280. },
  281. },
  282. ],
  283. }
  284. this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
  285. chunk: (this.chunk = {
  286. history: {
  287. snapshot: {
  288. files: {
  289. 'main.tex': {
  290. hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
  291. stringLength: 41,
  292. },
  293. 'unchanged.tex': {
  294. hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
  295. stringLength: 41,
  296. },
  297. 'with_ranges_unchanged.tex': {
  298. hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
  299. rangesHash: '2e59fe3dbd5310703f89236d589d0b35db169cdf',
  300. stringLength: 41,
  301. },
  302. 'with_ranges_changed.tex': {
  303. hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
  304. rangesHash: '2e59fe3dbd5310703f89236d589d0b35db169cdf',
  305. stringLength: 41,
  306. },
  307. 'binary.png': {
  308. hash: 'c6654ea913979e13e22022653d284444f284a172',
  309. byteLength: 41,
  310. },
  311. },
  312. },
  313. changes: [
  314. {
  315. operations: [
  316. {
  317. pathname: 'main.tex',
  318. textOperation: [41, '\n\nSeven eight'],
  319. },
  320. ],
  321. timestamp: '2017-12-04T10:29:17.786Z',
  322. authors: [31],
  323. },
  324. {
  325. operations: [
  326. {
  327. pathname: 'main.tex',
  328. textOperation: [54, ' nine'],
  329. },
  330. ],
  331. timestamp: '2017-12-04T10:29:22.905Z',
  332. authors: [31],
  333. },
  334. {
  335. operations: [
  336. {
  337. pathname: 'with_ranges_changed.tex',
  338. textOperation: [41, '\n\nSeven eight'],
  339. },
  340. ],
  341. timestamp: '2017-12-04T10:29:25.905Z',
  342. authors: [31],
  343. },
  344. ],
  345. },
  346. startVersion: 3,
  347. authors: [
  348. {
  349. id: 31,
  350. email: 'james.allen@overleaf.com',
  351. name: 'James',
  352. },
  353. ],
  354. }),
  355. })
  356. })
  357. describe('of project', function () {
  358. beforeEach(async function () {
  359. this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
  360. getString: (this.getString = sinon.stub().resolves(
  361. `\
  362. Hello world
  363. One two three
  364. Four five six\
  365. `
  366. )),
  367. getObject: (this.getObject = sinon.stub().resolves(this.ranges)),
  368. })
  369. this.data = await this.SnapshotManager.promises.getProjectSnapshot(
  370. this.projectId,
  371. 6
  372. )
  373. })
  374. it('should get the overleaf id', function () {
  375. this.WebApiManager.promises.getHistoryId
  376. .calledWith(this.projectId)
  377. .should.equal(true)
  378. })
  379. it('should get the chunk', function () {
  380. this.HistoryStoreManager.promises.getChunkAtVersion
  381. .calledWith(this.projectId, this.historyId, 6)
  382. .should.equal(true)
  383. })
  384. it('should get the ranges for the file with tracked changes', function () {
  385. this.getObject.calledWith('2e59fe3dbd5310703f89236d589d0b35db169cdf')
  386. })
  387. it('should produce the snapshot file data', function () {
  388. expect(this.data).to.deep.equal({
  389. files: {
  390. 'main.tex': {
  391. // files with operations in the chunk should return content only
  392. data: {
  393. content:
  394. 'Hello world\n\nOne two three\n\nFour five six\n\nSeven eight nine',
  395. },
  396. },
  397. 'unchanged.tex': {
  398. // unchanged files in the chunk should return hash only
  399. data: {
  400. hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
  401. },
  402. },
  403. 'with_ranges_changed.tex': {
  404. // files in the chunk with tracked changes should return content
  405. // without the tracked deletes
  406. data: {
  407. content:
  408. 'Hello\n\nOne two three\n\nFour five six\n\nSeven eight',
  409. },
  410. },
  411. 'with_ranges_unchanged.tex': {
  412. // files in the chunk with tracked changes should return content
  413. // without the tracked deletes, even if they are unchanged
  414. data: {
  415. content: 'Hello\n\nOne two three\n\nFour five six',
  416. },
  417. },
  418. 'binary.png': {
  419. // binary files in the chunk should return hash only
  420. data: {
  421. hash: 'c6654ea913979e13e22022653d284444f284a172',
  422. },
  423. },
  424. },
  425. projectId: 'project-id-123',
  426. })
  427. })
  428. })
  429. describe('on blob store error', function () {
  430. beforeEach(function () {
  431. this.error = new Error('ESOCKETTIMEDOUT')
  432. this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
  433. getString: sinon.stub().rejects(this.error),
  434. getObject: sinon.stub().resolves(),
  435. })
  436. })
  437. it('should call back with error', async function () {
  438. expect(
  439. this.SnapshotManager.promises.getProjectSnapshot(this.projectId, 5)
  440. ).to.be.rejectedWith(this.error.message)
  441. })
  442. })
  443. })
  444. describe('getLatestSnapshotFiles', function () {
  445. describe('for a project', function () {
  446. beforeEach(async function () {
  447. this.HistoryStoreManager.promises.getMostRecentChunk.resolves({
  448. chunk: (this.chunk = {
  449. history: {
  450. snapshot: {
  451. files: {
  452. 'main.tex': {
  453. hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
  454. stringLength: 41,
  455. },
  456. 'binary.png': {
  457. hash: 'c6654ea913979e13e22022653d284444f284a172',
  458. byteLength: 41,
  459. },
  460. },
  461. },
  462. changes: [
  463. {
  464. operations: [
  465. {
  466. pathname: 'main.tex',
  467. textOperation: [41, '\n\nSeven eight'],
  468. },
  469. ],
  470. timestamp: '2017-12-04T10:29:17.786Z',
  471. authors: [31],
  472. },
  473. {
  474. operations: [
  475. {
  476. pathname: 'main.tex',
  477. textOperation: [54, ' nine'],
  478. },
  479. ],
  480. timestamp: '2017-12-04T10:29:22.905Z',
  481. authors: [31],
  482. },
  483. ],
  484. },
  485. startVersion: 3,
  486. authors: [
  487. {
  488. id: 31,
  489. email: 'james.allen@overleaf.com',
  490. name: 'James',
  491. },
  492. ],
  493. }),
  494. })
  495. this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
  496. getString: (this.getString = sinon.stub().resolves(
  497. `\
  498. Hello world
  499. One two three
  500. Four five six\
  501. `.replace(/^\t/g, '')
  502. )),
  503. getObject: sinon.stub().rejects(),
  504. })
  505. this.data = await this.SnapshotManager.promises.getLatestSnapshotFiles(
  506. this.projectId,
  507. this.historyId
  508. )
  509. })
  510. it('should get the chunk', function () {
  511. this.HistoryStoreManager.promises.getMostRecentChunk
  512. .calledWith(this.projectId, this.historyId)
  513. .should.equal(true)
  514. })
  515. it('should produce the snapshot file data', function () {
  516. expect(this.data).to.have.all.keys(['main.tex', 'binary.png'])
  517. expect(this.data['main.tex']).to.exist
  518. expect(this.data['binary.png']).to.exist
  519. expect(this.data['main.tex'].getStringLength()).to.equal(59)
  520. expect(this.data['binary.png'].getByteLength()).to.equal(41)
  521. expect(this.data['binary.png'].getHash()).to.equal(
  522. 'c6654ea913979e13e22022653d284444f284a172'
  523. )
  524. })
  525. })
  526. describe('when the chunk is empty', function () {
  527. beforeEach(async function () {
  528. this.HistoryStoreManager.promises.getMostRecentChunk.resolves(null)
  529. expect(
  530. this.SnapshotManager.promises.getLatestSnapshotFiles(
  531. this.projectId,
  532. this.historyId
  533. )
  534. ).to.be.rejectedWith('undefined chunk')
  535. })
  536. })
  537. })
  538. describe('getRangesSnapshot', function () {
  539. beforeEach(async function () {
  540. this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
  541. this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
  542. chunk: (this.chunk = {
  543. history: {
  544. snapshot: {
  545. files: {
  546. 'main.tex': {
  547. hash: (this.fileHash =
  548. '5d2781d78fa5a97b7bafa849fe933dfc9dc93eba'),
  549. rangesHash: (this.rangesHash =
  550. '73061952d41ce54825e2fc1c36b4cf736d5fb62f'),
  551. stringLength: 41,
  552. },
  553. },
  554. },
  555. changes: [],
  556. },
  557. startVersion: 1,
  558. authors: [
  559. {
  560. id: 31,
  561. email: 'author@example.com',
  562. name: 'Author',
  563. },
  564. ],
  565. }),
  566. })
  567. this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
  568. getString: (this.getString = sinon.stub()),
  569. getObject: (this.getObject = sinon.stub()),
  570. })
  571. this.getString.resolves('the quick brown fox jumps over the lazy dog')
  572. })
  573. describe('with tracked deletes', function () {
  574. beforeEach(async function () {
  575. this.getObject.resolves({
  576. trackedChanges: [
  577. {
  578. // 'quick '
  579. range: {
  580. pos: 4,
  581. length: 6,
  582. },
  583. tracking: {
  584. type: 'delete',
  585. userId: '31',
  586. ts: '2023-01-01T00:00:00.000Z',
  587. },
  588. },
  589. {
  590. // 'fox '
  591. range: {
  592. pos: 16,
  593. length: 4,
  594. },
  595. tracking: {
  596. type: 'delete',
  597. userId: '31',
  598. ts: '2023-01-01T00:00:00.000Z',
  599. },
  600. },
  601. {
  602. // 'lazy '
  603. range: {
  604. pos: 35,
  605. length: 5,
  606. },
  607. tracking: {
  608. type: 'insert',
  609. userId: '31',
  610. ts: '2023-01-01T00:00:00.000Z',
  611. },
  612. },
  613. {
  614. // 'dog'
  615. range: {
  616. pos: 40,
  617. length: 3,
  618. },
  619. tracking: {
  620. type: 'delete',
  621. userId: '31',
  622. ts: '2023-01-01T00:00:00.000Z',
  623. },
  624. },
  625. ],
  626. })
  627. this.data = await this.SnapshotManager.promises.getRangesSnapshot(
  628. this.projectId,
  629. 1,
  630. 'main.tex'
  631. )
  632. })
  633. it("doesn't shift the tracked delete by itself", function () {
  634. expect(this.data.changes[0].op.p).to.eq(4)
  635. })
  636. it('should move subsequent tracked changes by the length of previous deletes', function () {
  637. expect(this.data.changes[1].op.p).to.eq(16 - 6)
  638. expect(this.data.changes[2].op.p).to.eq(35 - 6 - 4)
  639. })
  640. it("shouldn't move subsequent tracked changes by previous inserts", function () {
  641. expect(this.data.changes[3].op.p).to.eq(40 - 6 - 4)
  642. })
  643. })
  644. describe('with comments and tracked deletes', function () {
  645. beforeEach(async function () {
  646. this.getObject.resolves({
  647. // the quick brown fox jumps over the lazy dog
  648. trackedChanges: [
  649. {
  650. // 'e qui'
  651. range: {
  652. pos: 2,
  653. length: 5,
  654. },
  655. tracking: {
  656. type: 'delete',
  657. userId: '31',
  658. ts: '2023-01-01T00:00:00.000Z',
  659. },
  660. },
  661. {
  662. // 'r'
  663. range: {
  664. pos: 11,
  665. length: 1,
  666. },
  667. tracking: {
  668. type: 'delete',
  669. userId: '31',
  670. ts: '2023-01-01T00:00:00.000Z',
  671. },
  672. },
  673. {
  674. // 'er th'
  675. range: {
  676. pos: 28,
  677. length: 5,
  678. },
  679. tracking: {
  680. type: 'delete',
  681. userId: '31',
  682. ts: '2023-01-01T00:00:00.000Z',
  683. },
  684. },
  685. ],
  686. comments: [
  687. {
  688. id: 'comment-1',
  689. ranges: [
  690. // 'quick'
  691. {
  692. pos: 4,
  693. length: 5,
  694. },
  695. // 'brown'
  696. {
  697. pos: 10,
  698. length: 5,
  699. },
  700. // 'over'
  701. {
  702. pos: 26,
  703. length: 4,
  704. },
  705. ],
  706. resolved: false,
  707. },
  708. { id: 'comment-2', ranges: [], resolved: true },
  709. ],
  710. })
  711. this.data = await this.SnapshotManager.promises.getRangesSnapshot(
  712. this.projectId,
  713. 1,
  714. 'main.tex'
  715. )
  716. })
  717. it('should move the comment to the start of the tracked delete and remove overlapping text', function () {
  718. expect(this.data.comments[0].op.p).to.eq(2)
  719. expect(this.data.comments[0].op.c).to.eq('ck')
  720. })
  721. it('should remove overlapping text in middle of comment', function () {
  722. expect(this.data.comments[1].op.p).to.eq(5)
  723. expect(this.data.comments[1].op.c).to.eq('bown')
  724. })
  725. it('should remove overlapping text at end of comment', function () {
  726. expect(this.data.comments[2].op.p).to.eq(20)
  727. expect(this.data.comments[2].op.c).to.eq('ov')
  728. })
  729. it('should put resolved status in op', function () {
  730. expect(this.data.comments[0].op.resolved).to.be.false
  731. expect(this.data.comments[1].op.resolved).to.be.false
  732. expect(this.data.comments[2].op.resolved).to.be.false
  733. expect(this.data.comments[3].op.resolved).to.be.true
  734. })
  735. it('should include thread id', function () {
  736. expect(this.data.comments[0].op.t).to.eq('comment-1')
  737. expect(this.data.comments[1].op.t).to.eq('comment-1')
  738. expect(this.data.comments[2].op.t).to.eq('comment-1')
  739. expect(this.data.comments[3].op.t).to.eq('comment-2')
  740. })
  741. it('should translated detached comment to zero length op', function () {
  742. expect(this.data.comments[3].op.p).to.eq(0)
  743. expect(this.data.comments[3].op.c).to.eq('')
  744. })
  745. })
  746. describe('with multiple tracked changes and comments', function () {
  747. beforeEach(async function () {
  748. this.getObject.resolves({
  749. trackedChanges: [
  750. {
  751. // 'quick '
  752. range: {
  753. pos: 4,
  754. length: 6,
  755. },
  756. tracking: {
  757. type: 'delete',
  758. userId: '31',
  759. ts: '2023-01-01T00:00:00.000Z',
  760. },
  761. },
  762. {
  763. // 'brown '
  764. range: {
  765. pos: 10,
  766. length: 6,
  767. },
  768. tracking: {
  769. type: 'insert',
  770. userId: '31',
  771. ts: '2024-01-01T00:00:00.000Z',
  772. },
  773. },
  774. {
  775. // 'lazy '
  776. range: {
  777. pos: 35,
  778. length: 5,
  779. },
  780. tracking: {
  781. type: 'delete',
  782. userId: '31',
  783. ts: '2024-01-01T00:00:00.000Z',
  784. },
  785. },
  786. ],
  787. comments: [
  788. {
  789. id: 'comment-1',
  790. // 'quick', 'brown', 'lazy'
  791. ranges: [
  792. {
  793. pos: 4,
  794. length: 5,
  795. },
  796. {
  797. pos: 10,
  798. length: 5,
  799. },
  800. {
  801. pos: 35,
  802. length: 4,
  803. },
  804. ],
  805. resolved: false,
  806. },
  807. {
  808. id: 'comment-2',
  809. // 'the', 'the'
  810. ranges: [
  811. {
  812. pos: 0,
  813. length: 3,
  814. },
  815. {
  816. pos: 31,
  817. length: 3,
  818. },
  819. ],
  820. resolved: true,
  821. },
  822. ],
  823. })
  824. this.data = await this.SnapshotManager.promises.getRangesSnapshot(
  825. this.projectId,
  826. 1,
  827. 'main.tex'
  828. )
  829. })
  830. it('looks up ranges', function () {
  831. expect(this.getObject).to.have.been.calledWith(this.rangesHash)
  832. expect(this.getString).to.have.been.calledWith(this.fileHash)
  833. })
  834. it('should get the chunk', function () {
  835. expect(
  836. this.HistoryStoreManager.promises.getChunkAtVersion
  837. ).to.have.been.calledWith(this.projectId, this.historyId, 1)
  838. })
  839. it('returns the ranges with content and adjusted positions to ignore tracked deletes', function () {
  840. expect(this.data).to.deep.equal({
  841. changes: [
  842. {
  843. metadata: {
  844. ts: '2023-01-01T00:00:00.000Z',
  845. user_id: '31',
  846. },
  847. op: {
  848. d: 'quick ',
  849. p: 4,
  850. },
  851. },
  852. {
  853. metadata: {
  854. ts: '2024-01-01T00:00:00.000Z',
  855. user_id: '31',
  856. },
  857. op: {
  858. i: 'brown ',
  859. p: 4,
  860. },
  861. },
  862. {
  863. metadata: {
  864. ts: '2024-01-01T00:00:00.000Z',
  865. user_id: '31',
  866. },
  867. op: {
  868. d: 'lazy ',
  869. p: 29,
  870. },
  871. },
  872. ],
  873. comments: [
  874. {
  875. op: {
  876. c: '',
  877. p: 4,
  878. t: 'comment-1',
  879. resolved: false,
  880. },
  881. },
  882. {
  883. op: {
  884. c: 'brown',
  885. p: 4,
  886. t: 'comment-1',
  887. resolved: false,
  888. },
  889. },
  890. {
  891. op: {
  892. c: '',
  893. p: 29,
  894. t: 'comment-1',
  895. resolved: false,
  896. },
  897. },
  898. {
  899. op: {
  900. c: 'the',
  901. p: 0,
  902. t: 'comment-2',
  903. resolved: true,
  904. },
  905. },
  906. {
  907. op: {
  908. c: 'the',
  909. p: 25,
  910. t: 'comment-2',
  911. resolved: true,
  912. },
  913. },
  914. ],
  915. })
  916. })
  917. })
  918. describe('with an empty file', function () {
  919. beforeEach(async function () {
  920. this.getString.resolves('')
  921. this.getObject.resolves({})
  922. this.data = await this.SnapshotManager.promises.getRangesSnapshot(
  923. this.projectId,
  924. 1,
  925. 'main.tex'
  926. )
  927. })
  928. it('should return empty comments and changes', function () {
  929. expect(this.data).to.deep.equal({
  930. changes: [],
  931. comments: [],
  932. })
  933. })
  934. })
  935. })
  936. describe('getFileMetadataSnapshot', function () {
  937. beforeEach(function () {
  938. this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
  939. this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
  940. chunk: (this.chunk = {
  941. history: {
  942. snapshot: {
  943. files: {
  944. 'main.tex': {
  945. hash: '5d2781d78fa5a97b7bafa849fe933dfc9dc93eba',
  946. metadata: {
  947. importer_id: 'test-user-id',
  948. imported_at: '2024-01-01T00:00:00.000Z',
  949. },
  950. stringLength: 41,
  951. },
  952. 'other.tex': {
  953. hash: '5d2781d78fa5a97b7bafa849fe933dfc9dc93eba',
  954. stringLength: 41,
  955. },
  956. },
  957. },
  958. changes: [],
  959. },
  960. startVersion: 1,
  961. authors: [
  962. {
  963. id: 31,
  964. email: 'author@example.com',
  965. name: 'Author',
  966. },
  967. ],
  968. }),
  969. })
  970. })
  971. it('should return the metadata for the file', async function () {
  972. const result =
  973. await this.SnapshotManager.promises.getFileMetadataSnapshot(
  974. this.projectId,
  975. 1,
  976. 'main.tex'
  977. )
  978. expect(result).to.deep.equal({
  979. metadata: {
  980. importer_id: 'test-user-id',
  981. imported_at: '2024-01-01T00:00:00.000Z',
  982. },
  983. })
  984. })
  985. it('should return undefined when file does not have metadata', async function () {
  986. const result =
  987. await this.SnapshotManager.promises.getFileMetadataSnapshot(
  988. this.projectId,
  989. 1,
  990. 'other.tex'
  991. )
  992. expect(result).to.deep.equal({ metadata: undefined })
  993. })
  994. it('throw an error when file does not exist', async function () {
  995. await expect(
  996. this.SnapshotManager.promises.getFileMetadataSnapshot(
  997. this.projectId,
  998. 1,
  999. 'does-not-exist.tex'
  1000. )
  1001. ).to.be.rejectedWith(Error)
  1002. })
  1003. })
  1004. describe('getPathsAtVersion', function () {
  1005. beforeEach(function () {
  1006. this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
  1007. this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
  1008. chunk: (this.chunk = {
  1009. history: {
  1010. snapshot: {
  1011. files: {
  1012. 'main.tex': {
  1013. hash: (this.fileHash =
  1014. '5d2781d78fa5a97b7bafa849fe933dfc9dc93eba'),
  1015. rangesHash: (this.rangesHash =
  1016. '73061952d41ce54825e2fc1c36b4cf736d5fb62f'),
  1017. stringLength: 41,
  1018. },
  1019. 'other.tex': {
  1020. hash: (this.fileHash =
  1021. 'f572d396fae9206628714fb2ce00f72e94f2258f'),
  1022. stringLength: 6,
  1023. },
  1024. },
  1025. },
  1026. changes: [],
  1027. },
  1028. startVersion: 4,
  1029. authors: [
  1030. {
  1031. id: 31,
  1032. email: 'author@example.com',
  1033. name: 'Author',
  1034. },
  1035. ],
  1036. }),
  1037. })
  1038. })
  1039. it('should return an array of paths', async function () {
  1040. const result = await this.SnapshotManager.promises.getPathsAtVersion(
  1041. this.projectId,
  1042. 4
  1043. )
  1044. expect(result.paths).to.have.length(2)
  1045. expect(result.paths).to.include.members(['main.tex', 'other.tex'])
  1046. })
  1047. })
  1048. })