SnapshotManagerTests.js 32 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103
  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 the la'
  675. range: {
  676. pos: 28,
  677. length: 9,
  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. // 'lazy'
  706. {
  707. pos: 35,
  708. length: 4,
  709. },
  710. ],
  711. resolved: false,
  712. },
  713. { id: 'comment-2', ranges: [], resolved: true },
  714. {
  715. id: 'comment-3',
  716. ranges: [
  717. // 'q'
  718. { pos: 4, length: 1 },
  719. ],
  720. resolved: true,
  721. },
  722. ],
  723. })
  724. this.data = await this.SnapshotManager.promises.getRangesSnapshot(
  725. this.projectId,
  726. 1,
  727. 'main.tex'
  728. )
  729. })
  730. it('should move the comment to the start of the tracked delete and remove overlapping text', function () {
  731. expect(this.data.comments[0].op.p).to.eq(2)
  732. expect(this.data.comments[0].op.c).to.eq('ck bown fox jumps ovzy')
  733. })
  734. it('should put resolved status in op', function () {
  735. expect(this.data.comments[0].op.resolved).to.be.false
  736. expect(this.data.comments[1].op.resolved).to.be.true
  737. expect(this.data.comments[2].op.resolved).to.be.true
  738. })
  739. it('should include thread id', function () {
  740. expect(this.data.comments[0].op.t).to.eq('comment-1')
  741. expect(this.data.comments[1].op.t).to.eq('comment-2')
  742. expect(this.data.comments[2].op.t).to.eq('comment-3')
  743. })
  744. it('should translate detached comment to zero length op', function () {
  745. expect(this.data.comments[1].op.p).to.eq(0)
  746. expect(this.data.comments[1].op.c).to.eq('')
  747. })
  748. it('should position a comment entirely in a tracked delete next to the tracked delete', function () {
  749. expect(this.data.comments[2].op.p).to.eq(2)
  750. expect(this.data.comments[2].op.c).to.eq('')
  751. })
  752. })
  753. describe('with multiple tracked changes and comments', function () {
  754. beforeEach(async function () {
  755. this.getObject.resolves({
  756. trackedChanges: [
  757. {
  758. // 'quick '
  759. range: {
  760. pos: 4,
  761. length: 6,
  762. },
  763. tracking: {
  764. type: 'delete',
  765. userId: '31',
  766. ts: '2023-01-01T00:00:00.000Z',
  767. },
  768. },
  769. {
  770. // 'brown '
  771. range: {
  772. pos: 10,
  773. length: 6,
  774. },
  775. tracking: {
  776. type: 'insert',
  777. userId: '31',
  778. ts: '2024-01-01T00:00:00.000Z',
  779. },
  780. },
  781. {
  782. // 'lazy '
  783. range: {
  784. pos: 35,
  785. length: 5,
  786. },
  787. tracking: {
  788. type: 'delete',
  789. userId: '31',
  790. ts: '2024-01-01T00:00:00.000Z',
  791. },
  792. },
  793. ],
  794. comments: [
  795. {
  796. id: 'comment-1',
  797. // 'quick', 'brown', 'lazy'
  798. ranges: [
  799. {
  800. pos: 4,
  801. length: 5,
  802. },
  803. {
  804. pos: 10,
  805. length: 5,
  806. },
  807. {
  808. pos: 35,
  809. length: 4,
  810. },
  811. ],
  812. resolved: false,
  813. },
  814. {
  815. id: 'comment-2',
  816. // 'the', 'the'
  817. ranges: [
  818. {
  819. pos: 0,
  820. length: 3,
  821. },
  822. {
  823. pos: 31,
  824. length: 3,
  825. },
  826. ],
  827. resolved: true,
  828. },
  829. ],
  830. })
  831. this.data = await this.SnapshotManager.promises.getRangesSnapshot(
  832. this.projectId,
  833. 1,
  834. 'main.tex'
  835. )
  836. })
  837. it('looks up ranges', function () {
  838. expect(this.getObject).to.have.been.calledWith(this.rangesHash)
  839. expect(this.getString).to.have.been.calledWith(this.fileHash)
  840. })
  841. it('should get the chunk', function () {
  842. expect(
  843. this.HistoryStoreManager.promises.getChunkAtVersion
  844. ).to.have.been.calledWith(this.projectId, this.historyId, 1)
  845. })
  846. it('returns the ranges with content and adjusted positions to ignore tracked deletes', function () {
  847. expect(this.data).to.deep.equal({
  848. changes: [
  849. {
  850. metadata: {
  851. ts: '2023-01-01T00:00:00.000Z',
  852. user_id: '31',
  853. },
  854. op: {
  855. d: 'quick ',
  856. p: 4,
  857. },
  858. },
  859. {
  860. metadata: {
  861. ts: '2024-01-01T00:00:00.000Z',
  862. user_id: '31',
  863. },
  864. op: {
  865. i: 'brown ',
  866. p: 4,
  867. },
  868. },
  869. {
  870. metadata: {
  871. ts: '2024-01-01T00:00:00.000Z',
  872. user_id: '31',
  873. },
  874. op: {
  875. d: 'lazy ',
  876. p: 29,
  877. },
  878. },
  879. ],
  880. comments: [
  881. {
  882. op: {
  883. c: 'brown fox jumps over the ',
  884. p: 4,
  885. t: 'comment-1',
  886. resolved: false,
  887. },
  888. id: 'comment-1',
  889. },
  890. {
  891. op: {
  892. c: 'the brown fox jumps over the',
  893. p: 0,
  894. t: 'comment-2',
  895. resolved: true,
  896. },
  897. id: 'comment-2',
  898. },
  899. ],
  900. })
  901. })
  902. })
  903. describe('with an empty file', function () {
  904. beforeEach(async function () {
  905. this.getString.resolves('')
  906. this.getObject.resolves({})
  907. this.data = await this.SnapshotManager.promises.getRangesSnapshot(
  908. this.projectId,
  909. 1,
  910. 'main.tex'
  911. )
  912. })
  913. it('should return empty comments and changes', function () {
  914. expect(this.data).to.deep.equal({
  915. changes: [],
  916. comments: [],
  917. })
  918. })
  919. })
  920. })
  921. describe('getFileMetadataSnapshot', function () {
  922. beforeEach(function () {
  923. this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
  924. this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
  925. chunk: (this.chunk = {
  926. history: {
  927. snapshot: {
  928. files: {
  929. 'main.tex': {
  930. hash: '5d2781d78fa5a97b7bafa849fe933dfc9dc93eba',
  931. metadata: {
  932. importer_id: 'test-user-id',
  933. imported_at: '2024-01-01T00:00:00.000Z',
  934. },
  935. stringLength: 41,
  936. },
  937. 'other.tex': {
  938. hash: '5d2781d78fa5a97b7bafa849fe933dfc9dc93eba',
  939. stringLength: 41,
  940. },
  941. },
  942. },
  943. changes: [],
  944. },
  945. startVersion: 1,
  946. authors: [
  947. {
  948. id: 31,
  949. email: 'author@example.com',
  950. name: 'Author',
  951. },
  952. ],
  953. }),
  954. })
  955. })
  956. it('should return the metadata for the file', async function () {
  957. const result =
  958. await this.SnapshotManager.promises.getFileMetadataSnapshot(
  959. this.projectId,
  960. 1,
  961. 'main.tex'
  962. )
  963. expect(result).to.deep.equal({
  964. metadata: {
  965. importer_id: 'test-user-id',
  966. imported_at: '2024-01-01T00:00:00.000Z',
  967. },
  968. })
  969. })
  970. it('should return undefined when file does not have metadata', async function () {
  971. const result =
  972. await this.SnapshotManager.promises.getFileMetadataSnapshot(
  973. this.projectId,
  974. 1,
  975. 'other.tex'
  976. )
  977. expect(result).to.deep.equal({ metadata: undefined })
  978. })
  979. it('throw an error when file does not exist', async function () {
  980. await expect(
  981. this.SnapshotManager.promises.getFileMetadataSnapshot(
  982. this.projectId,
  983. 1,
  984. 'does-not-exist.tex'
  985. )
  986. ).to.be.rejectedWith(Error)
  987. })
  988. })
  989. describe('getPathsAtVersion', function () {
  990. beforeEach(function () {
  991. this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
  992. this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
  993. chunk: (this.chunk = {
  994. history: {
  995. snapshot: {
  996. files: {
  997. 'main.tex': {
  998. hash: (this.fileHash =
  999. '5d2781d78fa5a97b7bafa849fe933dfc9dc93eba'),
  1000. rangesHash: (this.rangesHash =
  1001. '73061952d41ce54825e2fc1c36b4cf736d5fb62f'),
  1002. stringLength: 41,
  1003. },
  1004. 'other.tex': {
  1005. hash: (this.fileHash =
  1006. 'f572d396fae9206628714fb2ce00f72e94f2258f'),
  1007. stringLength: 6,
  1008. },
  1009. },
  1010. },
  1011. changes: [],
  1012. },
  1013. startVersion: 4,
  1014. authors: [
  1015. {
  1016. id: 31,
  1017. email: 'author@example.com',
  1018. name: 'Author',
  1019. },
  1020. ],
  1021. }),
  1022. })
  1023. })
  1024. it('should return an array of paths', async function () {
  1025. const result = await this.SnapshotManager.promises.getPathsAtVersion(
  1026. this.projectId,
  1027. 4
  1028. )
  1029. expect(result.paths).to.have.length(2)
  1030. expect(result.paths).to.include.members(['main.tex', 'other.tex'])
  1031. })
  1032. })
  1033. })