RestoreManager.test.mjs 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268
  1. import { vi, expect } from 'vitest'
  2. import sinon from 'sinon'
  3. import Errors from '../../../../app/src/Features/Errors/Errors.js'
  4. import tk from 'timekeeper'
  5. import moment from 'moment'
  6. const modulePath = '../../../../app/src/Features/History/RestoreManager'
  7. function nestedMapWithSetToObject(m) {
  8. return Object.fromEntries(
  9. Array.from(m.entries()).map(([key, set]) => [key, Array.from(set)])
  10. )
  11. }
  12. describe('RestoreManager', function () {
  13. beforeEach(async function (ctx) {
  14. tk.freeze(Date.now()) // freeze the time for these tests
  15. ctx.fsPath = '/tmp/path/on/disk'
  16. ctx.blobStream = 'blob-stream'
  17. vi.doMock('../../../../app/src/Features/Errors/Errors.js', () => ({
  18. default: Errors,
  19. }))
  20. vi.doMock('../../../../app/src/Features/History/HistoryManager.js', () => ({
  21. default: (ctx.HistoryManager = {
  22. promises: {
  23. getContentAtVersion: sinon.stub().resolves({
  24. // Raw snapshot data that will be passed to Snapshot.fromRaw
  25. files: {
  26. 'main.tex': {
  27. hash: 'abcdef1234567890abcdef1234567890abcdef12',
  28. stringLength: 100,
  29. metadata: {
  30. editorId: 'test-editor',
  31. },
  32. },
  33. 'foo.tex': {
  34. hash: 'abcdef1234567890abcdef1234567890abcdef12',
  35. stringLength: 100,
  36. metadata: {
  37. editorId: 'test-editor',
  38. },
  39. },
  40. 'folder/file.tex': {
  41. hash: 'abcdef1234567890abcdef1234567890abcdef12',
  42. stringLength: 100,
  43. metadata: {
  44. editorId: 'test-editor',
  45. },
  46. },
  47. 'foo.png': {
  48. hash: 'abcdef1234567890abcdef1234567890abcdef12',
  49. stringLength: 100,
  50. metadata: {
  51. provider: 'bar',
  52. },
  53. },
  54. 'linkedFile.bib': {
  55. hash: 'abcdef1234567890abcdef1234567890abcdef12',
  56. stringLength: 100,
  57. metadata: {
  58. provider: 'mendeley',
  59. },
  60. },
  61. 'withMainTrue.tex': {
  62. hash: 'abcdef1234567890abcdef1234567890abcdef12',
  63. stringLength: 100,
  64. metadata: {
  65. main: true,
  66. },
  67. },
  68. },
  69. timestamp: new Date().toISOString(),
  70. }),
  71. requestBlob: sinon.stub().resolves({ stream: ctx.blobStream }),
  72. },
  73. }),
  74. }))
  75. vi.doMock('../../../../app/src/infrastructure/Metrics.js', () => ({
  76. default: {
  77. revertFileDurationSeconds: {
  78. startTimer: sinon.stub().returns(sinon.stub()),
  79. },
  80. revertProjectDurationSeconds: {
  81. startTimer: sinon.stub().returns(sinon.stub()),
  82. },
  83. },
  84. }))
  85. vi.doMock('@overleaf/settings', () => ({
  86. default: {},
  87. }))
  88. vi.doMock('../../../../app/src/infrastructure/FileWriter', () => ({
  89. default: (ctx.FileWriter = {
  90. promises: {
  91. writeStreamToDisk: sinon.stub().resolves(ctx.fsPath),
  92. writeContentToDisk: sinon.stub().resolves(ctx.fsPath),
  93. },
  94. }),
  95. }))
  96. vi.doMock(
  97. '../../../../app/src/Features/Uploads/FileSystemImportManager',
  98. () => ({
  99. default: (ctx.FileSystemImportManager = {
  100. promises: {},
  101. }),
  102. })
  103. )
  104. vi.doMock('../../../../app/src/Features/Editor/EditorController', () => ({
  105. default: (ctx.EditorController = {
  106. promises: {},
  107. }),
  108. }))
  109. vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({
  110. default: (ctx.ProjectLocator = { promises: {} }),
  111. }))
  112. vi.doMock(
  113. '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler',
  114. () => ({
  115. default: (ctx.DocumentUpdaterHandler = {
  116. promises: { flushProjectToMongo: sinon.stub().resolves() },
  117. }),
  118. })
  119. )
  120. vi.doMock('../../../../app/src/Features/Docstore/DocstoreManager', () => ({
  121. default: (ctx.DocstoreManager = {
  122. promises: { getCommentThreadIds: sinon.stub().resolves({}) },
  123. }),
  124. }))
  125. vi.doMock('../../../../app/src/Features/Chat/ChatApiHandler', () => ({
  126. default: (ctx.ChatApiHandler = { promises: {} }),
  127. }))
  128. vi.doMock('../../../../app/src/Features/Chat/ChatManager', () => ({
  129. default: (ctx.ChatManager = { promises: {} }),
  130. }))
  131. vi.doMock(
  132. '../../../../app/src/Features/Editor/EditorRealTimeController',
  133. () => ({
  134. default: (ctx.EditorRealTimeController = {}),
  135. })
  136. )
  137. vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
  138. default: (ctx.ProjectGetter = { promises: {} }),
  139. }))
  140. vi.doMock(
  141. '../../../../app/src/Features/Project/ProjectEntityHandler',
  142. () => ({
  143. default: (ctx.ProjectEntityHandler = {
  144. promises: {},
  145. }),
  146. })
  147. )
  148. vi.doMock('overleaf-editor-core', () => ({
  149. Snapshot: {
  150. fromRaw: sinon.stub().callsFake(snapshotData => ({
  151. getFile: pathname => ({
  152. getStringLength: sinon.stub().returns(100),
  153. getByteLength: sinon.stub().returns(100),
  154. getContent: sinon.stub().returns('foo\nbar\nbaz'),
  155. isEditable: sinon.stub().returns(true),
  156. getMetadata: sinon
  157. .stub()
  158. .returns(snapshotData?.files?.[pathname]?.metadata),
  159. getHash: sinon.stub().returns((ctx.hash = 'somehash')),
  160. }),
  161. getFilePathnames: sinon
  162. .stub()
  163. .returns(Object.keys(snapshotData.files || {})),
  164. getTimestamp: sinon
  165. .stub()
  166. .returns(
  167. snapshotData?.timestamp
  168. ? new Date(snapshotData.timestamp)
  169. : new Date()
  170. ),
  171. })),
  172. },
  173. getDocUpdaterCompatibleRanges: (ctx.getDocUpdaterCompatibleRanges = sinon
  174. .stub()
  175. .returns({
  176. changes: ctx.tracked_changes || [],
  177. comments: ctx.comments || [],
  178. })),
  179. }))
  180. ctx.RestoreManager = (await import(modulePath)).default
  181. ctx.user_id = 'mock-user-id'
  182. ctx.project_id = 'mock-project-id'
  183. ctx.version = 42
  184. // Add missing method mocks to RestoreManager
  185. ctx.RestoreManager.promises._getUpdatesFromHistory = sinon.stub().resolves([
  186. {
  187. toV: ctx.version,
  188. meta: { end_ts: new Date('2024-01-01T00:00:00.000Z') },
  189. },
  190. ])
  191. ctx.RestoreManager.promises._writeFileVersionToDisk = sinon
  192. .stub()
  193. .resolves('/tmp/mock-file-path')
  194. })
  195. afterEach(function () {
  196. tk.reset()
  197. })
  198. describe('restoreFileFromV2', function () {
  199. beforeEach(function (ctx) {
  200. ctx.RestoreManager.promises._writeFileVersionToDisk = sinon
  201. .stub()
  202. .resolves((ctx.fsPath = '/tmp/path/on/disk'))
  203. ctx.RestoreManager.promises._findOrCreateFolder = sinon
  204. .stub()
  205. .resolves((ctx.folder_id = 'mock-folder-id'))
  206. ctx.FileSystemImportManager.promises.addEntity = sinon
  207. .stub()
  208. .resolves((ctx.entity = 'mock-entity'))
  209. })
  210. describe('with a file not in a folder', function () {
  211. beforeEach(async function (ctx) {
  212. ctx.pathname = 'foo.tex'
  213. ctx.result = await ctx.RestoreManager.promises.restoreFileFromV2(
  214. ctx.user_id,
  215. ctx.project_id,
  216. ctx.version,
  217. ctx.pathname
  218. )
  219. })
  220. it('should write the file version to disk', function (ctx) {
  221. ctx.RestoreManager.promises._writeFileVersionToDisk
  222. .calledWith(ctx.project_id, ctx.version, ctx.pathname)
  223. .should.equal(true)
  224. })
  225. it('should find the root folder', function (ctx) {
  226. ctx.RestoreManager.promises._findOrCreateFolder
  227. .calledWith(ctx.project_id, '', ctx.user_id)
  228. .should.equal(true)
  229. })
  230. it('should add the entity', function (ctx) {
  231. ctx.FileSystemImportManager.promises.addEntity
  232. .calledWith(
  233. ctx.user_id,
  234. ctx.project_id,
  235. ctx.folder_id,
  236. 'foo.tex',
  237. ctx.fsPath,
  238. false
  239. )
  240. .should.equal(true)
  241. })
  242. it('should return the entity', function (ctx) {
  243. expect(ctx.result).to.equal(ctx.entity)
  244. })
  245. })
  246. describe('with a file in a folder', function () {
  247. beforeEach(async function (ctx) {
  248. ctx.pathname = 'foo/bar.tex'
  249. await ctx.RestoreManager.promises.restoreFileFromV2(
  250. ctx.user_id,
  251. ctx.project_id,
  252. ctx.version,
  253. ctx.pathname
  254. )
  255. })
  256. it('should find the folder', function (ctx) {
  257. ctx.RestoreManager.promises._findOrCreateFolder
  258. .calledWith(ctx.project_id, 'foo', ctx.user_id)
  259. .should.equal(true)
  260. })
  261. it('should add the entity by its basename', function (ctx) {
  262. ctx.FileSystemImportManager.promises.addEntity
  263. .calledWith(
  264. ctx.user_id,
  265. ctx.project_id,
  266. ctx.folder_id,
  267. 'bar.tex',
  268. ctx.fsPath,
  269. false
  270. )
  271. .should.equal(true)
  272. })
  273. })
  274. })
  275. describe('_findOrCreateFolder', function () {
  276. beforeEach(async function (ctx) {
  277. ctx.EditorController.promises.mkdirp = sinon.stub().resolves({
  278. newFolders: [],
  279. lastFolder: { _id: (ctx.folder_id = 'mock-folder-id') },
  280. })
  281. ctx.result = await ctx.RestoreManager.promises._findOrCreateFolder(
  282. ctx.project_id,
  283. 'folder/name',
  284. ctx.user_id
  285. )
  286. })
  287. it('should look up or create the folder', function (ctx) {
  288. ctx.EditorController.promises.mkdirp
  289. .calledWith(ctx.project_id, 'folder/name', ctx.user_id)
  290. .should.equal(true)
  291. })
  292. it('should return the folder_id', function (ctx) {
  293. expect(ctx.result).to.equal(ctx.folder_id)
  294. })
  295. })
  296. describe('_addEntityWithUniqueName', function () {
  297. beforeEach(function (ctx) {
  298. ctx.addEntityWithName = sinon.stub()
  299. ctx.filename = 'foo.tex'
  300. })
  301. describe('with a valid name', function () {
  302. beforeEach(async function (ctx) {
  303. ctx.addEntityWithName.resolves((ctx.entity = 'mock-entity'))
  304. ctx.result = await ctx.RestoreManager.promises._addEntityWithUniqueName(
  305. ctx.addEntityWithName,
  306. ctx.filename
  307. )
  308. })
  309. it('should add the entity', function (ctx) {
  310. ctx.addEntityWithName.calledWith(ctx.filename).should.equal(true)
  311. })
  312. it('should return the entity', function (ctx) {
  313. expect(ctx.result).to.equal(ctx.entity)
  314. })
  315. })
  316. describe('with a duplicate name', function () {
  317. beforeEach(async function (ctx) {
  318. ctx.addEntityWithName.rejects(new Errors.DuplicateNameError())
  319. ctx.addEntityWithName
  320. .onSecondCall()
  321. .resolves((ctx.entity = 'mock-entity'))
  322. ctx.result = await ctx.RestoreManager.promises._addEntityWithUniqueName(
  323. ctx.addEntityWithName,
  324. ctx.filename
  325. )
  326. })
  327. it('should try to add the entity with its original name', function (ctx) {
  328. ctx.addEntityWithName.calledWith('foo.tex').should.equal(true)
  329. })
  330. it('should try to add the entity with a unique name', function (ctx) {
  331. const date = moment(new Date()).format('Do MMM YY H:mm:ss')
  332. ctx.addEntityWithName
  333. .calledWith(`foo (Restored on ${date}).tex`)
  334. .should.equal(true)
  335. })
  336. it('should return the entity', function (ctx) {
  337. expect(ctx.result).to.equal(ctx.entity)
  338. })
  339. })
  340. })
  341. describe('revertFile', function () {
  342. beforeEach(function (ctx) {
  343. ctx.ProjectGetter.promises.getProject = sinon.stub()
  344. ctx.ProjectGetter.promises.getProject.withArgs(ctx.project_id).resolves({
  345. overleaf: { history: { rangesSupportEnabled: true } },
  346. rootDoc_id: 'root-doc-id',
  347. })
  348. ctx.RestoreManager.promises._findOrCreateFolder = sinon
  349. .stub()
  350. .resolves((ctx.folder_id = 'mock-folder-id'))
  351. ctx.FileSystemImportManager.promises.addEntity = sinon
  352. .stub()
  353. .resolves((ctx.entity = 'mock-entity'))
  354. })
  355. describe('reverting a project without ranges support', function () {
  356. beforeEach(function (ctx) {
  357. ctx.ProjectGetter.promises.getProject = sinon.stub().resolves({
  358. overleaf: { history: { rangesSupportEnabled: false } },
  359. })
  360. })
  361. it('should throw an error', async function (ctx) {
  362. await expect(
  363. ctx.RestoreManager.promises.revertFile(
  364. ctx.user_id,
  365. ctx.project_id,
  366. ctx.version,
  367. ctx.pathname
  368. )
  369. ).to.eventually.be.rejectedWith('project does not have ranges support')
  370. })
  371. })
  372. describe('reverting a document with ranges', function () {
  373. beforeEach(function (ctx) {
  374. ctx.pathname = 'foo.tex'
  375. ctx.comments = [
  376. {
  377. id: 'comment-in-other-doc',
  378. op: { t: 'comment-in-other-doc', p: 0, c: 'foo' },
  379. },
  380. {
  381. id: 'single-comment',
  382. op: { t: 'single-comment', p: 10, c: 'bar' },
  383. },
  384. {
  385. id: 'deleted-comment',
  386. op: { t: 'deleted-comment', p: 20, c: 'baz' },
  387. },
  388. ]
  389. ctx.remappedComments = [
  390. {
  391. id: 'duplicate-comment',
  392. op: { t: 'duplicate-comment', p: 0, c: 'foo' },
  393. },
  394. {
  395. id: 'single-comment',
  396. op: { t: 'single-comment', p: 10, c: 'bar' },
  397. },
  398. ]
  399. ctx.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
  400. ctx.DocstoreManager.promises.getCommentThreadIds = sinon
  401. .stub()
  402. .resolves({ 'other-doc': [ctx.comments[0].op.t] })
  403. ctx.ChatApiHandler.promises.duplicateCommentThreads = sinon
  404. .stub()
  405. .resolves({
  406. newThreads: {
  407. 'comment-in-other-doc': {
  408. duplicateId: 'duplicate-comment',
  409. },
  410. },
  411. })
  412. ctx.ChatApiHandler.promises.generateThreadData = sinon.stub().resolves(
  413. (ctx.threadData = {
  414. 'single-comment': {
  415. messages: [
  416. {
  417. content: 'message-content',
  418. timestamp: '2024-01-01T00:00:00.000Z',
  419. user_id: 'user-1',
  420. },
  421. ],
  422. },
  423. 'duplicate-comment': {
  424. messages: [
  425. {
  426. content: 'another message',
  427. timestamp: '2024-01-01T00:00:00.000Z',
  428. user_id: 'user-1',
  429. },
  430. ],
  431. },
  432. })
  433. )
  434. ctx.ChatManager.promises.injectUserInfoIntoThreads = sinon
  435. .stub()
  436. .resolves(ctx.threadData)
  437. ctx.EditorRealTimeController.emitToRoom = sinon.stub()
  438. ctx.tracked_changes = [
  439. {
  440. op: { pos: 4, i: 'bar' },
  441. metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-1' },
  442. },
  443. {
  444. op: { pos: 8, d: 'qux' },
  445. metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-2' },
  446. },
  447. ]
  448. ctx.getDocUpdaterCompatibleRanges.returns({
  449. changes: ctx.tracked_changes,
  450. comments: ctx.comments,
  451. })
  452. ctx.RestoreManager.promises._getUpdatesFromHistory = sinon
  453. .stub()
  454. .resolves([
  455. { toV: ctx.version, meta: { end_ts: (ctx.endTs = new Date()) } },
  456. ])
  457. ctx.EditorController.promises.addDocWithRanges = sinon
  458. .stub()
  459. .resolves((ctx.addedFile = { _id: 'mock-doc', type: 'doc' }))
  460. })
  461. describe("when reverting a file that doesn't current exist", function () {
  462. beforeEach(async function (ctx) {
  463. ctx.data = await ctx.RestoreManager.promises.revertFile(
  464. ctx.user_id,
  465. ctx.project_id,
  466. ctx.version,
  467. ctx.pathname
  468. )
  469. })
  470. it('should flush the document before fetching ranges', function (ctx) {
  471. expect(
  472. ctx.DocumentUpdaterHandler.promises.flushProjectToMongo
  473. ).to.have.been.calledBefore(
  474. ctx.DocstoreManager.promises.getCommentThreadIds
  475. )
  476. })
  477. it('should import the file', function (ctx) {
  478. expect(
  479. ctx.EditorController.promises.addDocWithRanges
  480. ).to.have.been.calledWith(
  481. ctx.project_id,
  482. ctx.folder_id,
  483. 'foo.tex',
  484. ['foo', 'bar', 'baz'],
  485. { changes: ctx.tracked_changes, comments: ctx.remappedComments }
  486. )
  487. })
  488. it('should return the created entity', function (ctx) {
  489. expect(ctx.data).to.deep.equal(ctx.addedFile)
  490. })
  491. })
  492. describe('with an existing file in the current project', function () {
  493. beforeEach(async function (ctx) {
  494. ctx.ProjectGetter.promises.getProject = sinon.stub()
  495. ctx.ProjectGetter.promises.getProject
  496. .withArgs(ctx.project_id)
  497. .resolves({
  498. overleaf: { history: { rangesSupportEnabled: true } },
  499. rootDoc_id: 'root-doc-id',
  500. })
  501. ctx.ProjectLocator.promises.findElementByPath = sinon
  502. .stub()
  503. .resolves({ type: 'file', element: { _id: 'mock-file-id' } })
  504. ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
  505. ctx.data = await ctx.RestoreManager.promises.revertFile(
  506. ctx.user_id,
  507. ctx.project_id,
  508. ctx.version,
  509. ctx.pathname
  510. )
  511. })
  512. it('should delete the existing file', async function (ctx) {
  513. expect(
  514. ctx.EditorController.promises.deleteEntity
  515. ).to.have.been.calledWith(
  516. ctx.project_id,
  517. 'mock-file-id',
  518. 'file',
  519. {
  520. kind: 'file-restore',
  521. path: ctx.pathname,
  522. version: ctx.version,
  523. timestamp: new Date(ctx.endTs).toISOString(),
  524. },
  525. ctx.user_id
  526. )
  527. })
  528. })
  529. describe('with an existing document in the current project', function () {
  530. beforeEach(async function (ctx) {
  531. ctx.ProjectGetter.promises.getProject = sinon.stub()
  532. ctx.ProjectGetter.promises.getProject
  533. .withArgs(ctx.project_id)
  534. .resolves({
  535. overleaf: { history: { rangesSupportEnabled: true } },
  536. rootDoc_id: 'root-doc-id',
  537. })
  538. ctx.ProjectLocator.promises.findElementByPath = sinon
  539. .stub()
  540. .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
  541. ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
  542. ctx.data = await ctx.RestoreManager.promises.revertFile(
  543. ctx.user_id,
  544. ctx.project_id,
  545. ctx.version,
  546. ctx.pathname
  547. )
  548. })
  549. it('should delete the existing document', async function (ctx) {
  550. expect(
  551. ctx.EditorController.promises.deleteEntity
  552. ).to.have.been.calledWith(
  553. ctx.project_id,
  554. 'mock-file-id',
  555. 'doc',
  556. {
  557. kind: 'file-restore',
  558. path: ctx.pathname,
  559. version: ctx.version,
  560. timestamp: new Date(ctx.endTs).toISOString(),
  561. },
  562. ctx.user_id
  563. )
  564. })
  565. it('should flush the document before fetching ranges', function (ctx) {
  566. expect(
  567. ctx.DocumentUpdaterHandler.promises.flushProjectToMongo
  568. ).to.have.been.calledBefore(
  569. ctx.DocstoreManager.promises.getCommentThreadIds
  570. )
  571. })
  572. it('should import the file', function (ctx) {
  573. expect(
  574. ctx.EditorController.promises.addDocWithRanges
  575. ).to.have.been.calledWith(
  576. ctx.project_id,
  577. ctx.folder_id,
  578. 'foo.tex',
  579. ['foo', 'bar', 'baz'],
  580. { changes: ctx.tracked_changes, comments: ctx.remappedComments },
  581. {
  582. kind: 'file-restore',
  583. path: ctx.pathname,
  584. version: ctx.version,
  585. timestamp: new Date(ctx.endTs).toISOString(),
  586. }
  587. )
  588. })
  589. it('should return the created entity', function (ctx) {
  590. expect(ctx.data).to.deep.equal(ctx.addedFile)
  591. })
  592. })
  593. describe('with comments in same doc', function () {
  594. // copy of the above, addition: inject and later inspect threadIds set
  595. beforeEach(async function (ctx) {
  596. ctx.ProjectGetter.promises.getProject = sinon.stub()
  597. ctx.ProjectGetter.promises.getProject
  598. .withArgs(ctx.project_id)
  599. .resolves({
  600. overleaf: { history: { rangesSupportEnabled: true } },
  601. rootDoc_id: 'root-doc-id',
  602. })
  603. ctx.ProjectLocator.promises.findElementByPath = sinon
  604. .stub()
  605. .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
  606. ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
  607. ctx.ChatApiHandler.promises.generateThreadData = sinon
  608. .stub()
  609. .resolves(
  610. (ctx.threadData = {
  611. [ctx.comments[0].op.t]: {
  612. messages: [
  613. {
  614. content: 'message',
  615. timestamp: '2024-01-01T00:00:00.000Z',
  616. user_id: 'user-1',
  617. },
  618. ],
  619. },
  620. [ctx.comments[1].op.t]: {
  621. messages: [
  622. {
  623. content: 'other message',
  624. timestamp: '2024-01-01T00:00:00.000Z',
  625. user_id: 'user-1',
  626. },
  627. ],
  628. },
  629. })
  630. )
  631. ctx.threadIds = new Map([
  632. [
  633. 'mock-file-id',
  634. new Set([ctx.comments[0].op.t, ctx.comments[1].op.t]),
  635. ],
  636. ])
  637. // Comments are updated in-place. Look up threads before reverting.
  638. ctx.afterThreadIds = {
  639. // mock-file-id removed
  640. [ctx.addedFile._id]: [ctx.comments[0].op.t, ctx.comments[1].op.t],
  641. }
  642. ctx.data = await ctx.RestoreManager.promises._revertSingleFile(
  643. ctx.user_id,
  644. ctx.project_id,
  645. ctx.version,
  646. ctx.pathname,
  647. ctx.threadIds,
  648. {
  649. getFile: sinon.stub().returns({
  650. getMetadata: sinon.stub().returns(undefined),
  651. getContent: sinon.stub().returns('foo\nbar\nbaz'),
  652. isEditable: sinon.stub().returns(true),
  653. }),
  654. },
  655. {
  656. origin: {
  657. kind: 'file-restore',
  658. path: 'foo.tex',
  659. timestamp: new Date(ctx.endTs).toISOString(),
  660. version: 42,
  661. },
  662. }
  663. )
  664. })
  665. it('should import the file with original comments minus the deleted one', function (ctx) {
  666. expect(
  667. ctx.EditorController.promises.addDocWithRanges
  668. ).to.have.been.calledWith(
  669. ctx.project_id,
  670. ctx.folder_id,
  671. 'foo.tex',
  672. ['foo', 'bar', 'baz'],
  673. {
  674. changes: ctx.tracked_changes,
  675. comments: ctx.comments.slice(0, 2),
  676. },
  677. {
  678. kind: 'file-restore',
  679. path: ctx.pathname,
  680. version: ctx.version,
  681. timestamp: new Date(ctx.endTs).toISOString(),
  682. }
  683. )
  684. })
  685. it('should add the seen thread ids to the map', function (ctx) {
  686. expect(nestedMapWithSetToObject(ctx.threadIds)).to.deep.equal(
  687. ctx.afterThreadIds
  688. )
  689. })
  690. })
  691. describe('with remapped comments during revertProject', function () {
  692. // copy of the above, addition: inject and later inspect threadIds set
  693. beforeEach(async function (ctx) {
  694. ctx.ProjectGetter.promises.getProject = sinon.stub()
  695. ctx.ProjectGetter.promises.getProject
  696. .withArgs(ctx.project_id)
  697. .resolves({
  698. overleaf: { history: { rangesSupportEnabled: true } },
  699. rootDoc_id: 'root-doc-id',
  700. })
  701. ctx.ProjectLocator.promises.findElementByPath = sinon
  702. .stub()
  703. .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
  704. ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
  705. ctx.threadIds = new Map([
  706. ['other-doc', new Set([ctx.comments[0].op.t])],
  707. ])
  708. // Comments are updated in-place. Look up threads before reverting.
  709. ctx.afterThreadIds = {
  710. // mock-file-id removed
  711. 'other-doc': [ctx.comments[0].op.t],
  712. [ctx.addedFile._id]: [
  713. ctx.remappedComments[0].op.t,
  714. ctx.remappedComments[1].op.t,
  715. ],
  716. }
  717. ctx.data = await ctx.RestoreManager.promises._revertSingleFile(
  718. ctx.user_id,
  719. ctx.project_id,
  720. ctx.version,
  721. ctx.pathname,
  722. ctx.threadIds,
  723. {
  724. getFile: sinon.stub().returns({
  725. getMetadata: sinon.stub().returns(undefined),
  726. getContent: sinon.stub().returns('foo\nbar\nbaz'),
  727. isEditable: sinon.stub().returns(true),
  728. }),
  729. },
  730. {
  731. origin: {
  732. kind: 'file-restore',
  733. path: 'foo.tex',
  734. timestamp: new Date(ctx.endTs).toISOString(),
  735. version: 42,
  736. },
  737. }
  738. )
  739. })
  740. it('should import the file', function (ctx) {
  741. expect(
  742. ctx.EditorController.promises.addDocWithRanges
  743. ).to.have.been.calledWith(
  744. ctx.project_id,
  745. ctx.folder_id,
  746. 'foo.tex',
  747. ['foo', 'bar', 'baz'],
  748. { changes: ctx.tracked_changes, comments: ctx.remappedComments },
  749. {
  750. kind: 'file-restore',
  751. path: ctx.pathname,
  752. version: ctx.version,
  753. timestamp: new Date(ctx.endTs).toISOString(),
  754. }
  755. )
  756. })
  757. it('should add the seen thread ids to the map', function (ctx) {
  758. expect(nestedMapWithSetToObject(ctx.threadIds)).to.deep.equal(
  759. ctx.afterThreadIds
  760. )
  761. })
  762. })
  763. describe('when restored file has the same id as root doc', function () {
  764. beforeEach(async function (ctx) {
  765. ctx.ProjectGetter.promises.getProject = sinon.stub()
  766. ctx.ProjectGetter.promises.getProject
  767. .withArgs(ctx.project_id)
  768. .resolves({
  769. overleaf: { history: { rangesSupportEnabled: true } },
  770. rootDoc_id: 'root-doc-id',
  771. })
  772. ctx.ProjectLocator.promises.findElementByPath = sinon
  773. .stub()
  774. .resolves({ type: 'doc', element: { _id: 'root-doc-id' } })
  775. ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
  776. ctx.EditorController.promises.addDocWithRanges = sinon
  777. .stub()
  778. .resolves((ctx.addedFile = { _id: 'new-doc-id', type: 'doc' }))
  779. ctx.EditorController.promises.setRootDoc = sinon.stub().resolves()
  780. ctx.data = await ctx.RestoreManager.promises.revertFile(
  781. ctx.user_id,
  782. ctx.project_id,
  783. ctx.version,
  784. ctx.pathname
  785. )
  786. })
  787. it('should delete the existing root document', async function (ctx) {
  788. expect(
  789. ctx.EditorController.promises.deleteEntity
  790. ).to.have.been.calledWith(
  791. ctx.project_id,
  792. 'root-doc-id',
  793. 'doc',
  794. {
  795. kind: 'file-restore',
  796. path: ctx.pathname,
  797. version: ctx.version,
  798. timestamp: new Date(ctx.endTs).toISOString(),
  799. },
  800. ctx.user_id
  801. )
  802. })
  803. it('should import the file', function (ctx) {
  804. expect(
  805. ctx.EditorController.promises.addDocWithRanges
  806. ).to.have.been.calledWith(
  807. ctx.project_id,
  808. ctx.folder_id,
  809. 'foo.tex',
  810. ['foo', 'bar', 'baz'],
  811. { changes: ctx.tracked_changes, comments: ctx.remappedComments },
  812. {
  813. kind: 'file-restore',
  814. path: ctx.pathname,
  815. version: ctx.version,
  816. timestamp: new Date(ctx.endTs).toISOString(),
  817. }
  818. )
  819. })
  820. it('should return the created entity with root doc id', function (ctx) {
  821. expect(ctx.data).to.deep.equal(ctx.addedFile)
  822. expect(ctx.data._id).to.equal('new-doc-id')
  823. })
  824. it('should set the restored document as the new root doc', function (ctx) {
  825. expect(
  826. ctx.EditorController.promises.setRootDoc
  827. ).to.have.been.calledWith(ctx.project_id, ctx.addedFile._id)
  828. })
  829. })
  830. })
  831. describe('reverting a file or document with metadata', function () {
  832. beforeEach(function (ctx) {
  833. ctx.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
  834. ctx.EditorController.promises.addDocWithRanges = sinon.stub()
  835. ctx.RestoreManager.promises._getUpdatesFromHistory = sinon
  836. .stub()
  837. .resolves([
  838. { toV: ctx.version, meta: { end_ts: (ctx.endTs = new Date()) } },
  839. ])
  840. ctx.EditorController.promises.upsertFile = sinon
  841. .stub()
  842. .resolves({ _id: 'mock-file-id', type: 'file' })
  843. ctx.EditorController.promises.addDocWithRanges = sinon
  844. .stub()
  845. .resolves((ctx.addedFile = { _id: 'mock-doc-id', type: 'doc' }))
  846. ctx.DocstoreManager.promises.getCommentThreadIds = sinon
  847. .stub()
  848. .resolves({})
  849. ctx.ChatApiHandler.promises.generateThreadData = sinon
  850. .stub()
  851. .resolves({})
  852. ctx.ChatManager.promises.injectUserInfoIntoThreads = sinon
  853. .stub()
  854. .resolves({})
  855. ctx.EditorRealTimeController.emitToRoom = sinon.stub()
  856. })
  857. describe('when reverting a linked file', function () {
  858. beforeEach(async function (ctx) {
  859. ctx.pathname = 'foo.png'
  860. ctx.result = await ctx.RestoreManager.promises.revertFile(
  861. ctx.user_id,
  862. ctx.project_id,
  863. ctx.version,
  864. ctx.pathname
  865. )
  866. })
  867. it('should revert it as a file', function (ctx) {
  868. expect(ctx.result).to.deep.equal({
  869. _id: 'mock-file-id',
  870. type: 'file',
  871. })
  872. })
  873. it('should upload to the project as a file', function (ctx) {
  874. expect(
  875. ctx.EditorController.promises.upsertFile
  876. ).to.have.been.calledWith(
  877. ctx.project_id,
  878. 'mock-folder-id',
  879. 'foo.png',
  880. ctx.fsPath,
  881. { provider: 'bar' },
  882. {
  883. kind: 'file-restore',
  884. path: ctx.pathname,
  885. version: ctx.version,
  886. timestamp: new Date(ctx.endTs).toISOString(),
  887. },
  888. ctx.user_id
  889. )
  890. })
  891. it('should not try to add a document', function (ctx) {
  892. expect(ctx.EditorController.promises.addDocWithRanges).to.not.have
  893. .been.called
  894. })
  895. })
  896. describe('when reverting a linked document with provider', function () {
  897. beforeEach(async function (ctx) {
  898. ctx.pathname = 'linkedFile.bib'
  899. ctx.result = await ctx.RestoreManager.promises.revertFile(
  900. ctx.user_id,
  901. ctx.project_id,
  902. ctx.version,
  903. ctx.pathname
  904. )
  905. })
  906. it('should revert it as a file', function (ctx) {
  907. expect(ctx.result).to.deep.equal({
  908. _id: 'mock-file-id',
  909. type: 'file',
  910. })
  911. })
  912. it('should upload to the project as a file', function (ctx) {
  913. expect(
  914. ctx.EditorController.promises.upsertFile
  915. ).to.have.been.calledWith(
  916. ctx.project_id,
  917. 'mock-folder-id',
  918. 'linkedFile.bib',
  919. ctx.fsPath,
  920. { provider: 'mendeley' },
  921. {
  922. kind: 'file-restore',
  923. path: ctx.pathname,
  924. version: ctx.version,
  925. timestamp: new Date(ctx.endTs).toISOString(),
  926. },
  927. ctx.user_id
  928. )
  929. })
  930. it('should not try to add a document', function (ctx) {
  931. expect(ctx.EditorController.promises.addDocWithRanges).to.not.have
  932. .been.called
  933. })
  934. })
  935. describe('when reverting a linked document with { main: true }', function () {
  936. beforeEach(async function (ctx) {
  937. ctx.pathname = 'withMainTrue.tex'
  938. ctx.result = await ctx.RestoreManager.promises.revertFile(
  939. ctx.user_id,
  940. ctx.project_id,
  941. ctx.version,
  942. ctx.pathname
  943. )
  944. })
  945. it('should revert it as a document', function (ctx) {
  946. expect(ctx.result).to.deep.equal({
  947. _id: 'mock-doc-id',
  948. type: 'doc',
  949. })
  950. })
  951. it('should not upload to the project as a file', function (ctx) {
  952. expect(ctx.EditorController.promises.upsertFile).to.not.have.been
  953. .called
  954. })
  955. it('should add the document', function (ctx) {
  956. expect(
  957. ctx.EditorController.promises.addDocWithRanges
  958. ).to.have.been.calledWith(
  959. ctx.project_id,
  960. ctx.folder_id,
  961. 'withMainTrue.tex',
  962. ['foo', 'bar', 'baz'],
  963. { changes: [], comments: [] }
  964. )
  965. })
  966. })
  967. })
  968. describe('when reverting a binary file', function () {
  969. beforeEach(async function (ctx) {
  970. ctx.pathname = 'foo.png'
  971. ctx.EditorController.promises.upsertFile = sinon
  972. .stub()
  973. .resolves({ _id: 'mock-file-id', type: 'file' })
  974. ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
  975. ctx.RestoreManager.promises._getUpdatesFromHistory = sinon
  976. .stub()
  977. .resolves([{ toV: ctx.version, meta: { end_ts: Date.now() } }])
  978. })
  979. it('should return the created entity if file exists', async function (ctx) {
  980. ctx.ProjectLocator.promises.findElementByPath = sinon
  981. .stub()
  982. .resolves({ type: 'file', element: { _id: 'existing-file-id' } })
  983. const revertRes = await ctx.RestoreManager.promises.revertFile(
  984. ctx.user_id,
  985. ctx.project_id,
  986. ctx.version,
  987. ctx.pathname
  988. )
  989. expect(revertRes).to.deep.equal({ _id: 'mock-file-id', type: 'file' })
  990. })
  991. it('should return the created entity if file does not exists', async function (ctx) {
  992. ctx.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
  993. const revertRes = await ctx.RestoreManager.promises.revertFile(
  994. ctx.user_id,
  995. ctx.project_id,
  996. ctx.version,
  997. ctx.pathname
  998. )
  999. expect(revertRes).to.deep.equal({ _id: 'mock-file-id', type: 'file' })
  1000. })
  1001. })
  1002. })
  1003. describe('revertProject', function () {
  1004. beforeEach(function (ctx) {
  1005. ctx.ProjectGetter.promises.getProject = sinon.stub()
  1006. ctx.ProjectGetter.promises.getProject
  1007. .withArgs(ctx.project_id)
  1008. .resolves({ overleaf: { history: { rangesSupportEnabled: true } } })
  1009. ctx.RestoreManager.promises._revertSingleFile = sinon.stub().resolves({
  1010. _id: 'mock-doc-id',
  1011. type: 'doc',
  1012. })
  1013. ctx.RestoreManager.promises._getProjectPathsAtVersion = sinon
  1014. .stub()
  1015. .resolves([])
  1016. ctx.ProjectEntityHandler.promises.getAllEntities = sinon
  1017. .stub()
  1018. .resolves({ docs: [], files: [] })
  1019. ctx.EditorController.promises.deleteEntityWithPath = sinon
  1020. .stub()
  1021. .resolves()
  1022. ctx.RestoreManager.promises._getUpdatesFromHistory = sinon
  1023. .stub()
  1024. .resolves([
  1025. { toV: ctx.version, meta: { end_ts: (ctx.end_ts = Date.now()) } },
  1026. ])
  1027. })
  1028. describe('reverting a project without ranges support', function () {
  1029. beforeEach(function (ctx) {
  1030. ctx.ProjectGetter.promises.getProject = sinon.stub().resolves({
  1031. overleaf: { history: { rangesSupportEnabled: false } },
  1032. })
  1033. })
  1034. it('should throw an error', async function (ctx) {
  1035. await expect(
  1036. ctx.RestoreManager.promises.revertProject(
  1037. ctx.user_id,
  1038. ctx.project_id,
  1039. ctx.version
  1040. )
  1041. ).to.eventually.be.rejectedWith('project does not have ranges support')
  1042. })
  1043. })
  1044. describe('for a project with overlap in current files and old files', function () {
  1045. beforeEach(async function (ctx) {
  1046. ctx.HistoryManager.promises.getContentAtVersion = sinon
  1047. .stub()
  1048. .resolves({
  1049. files: {
  1050. 'main.tex': {
  1051. hash: 'abcdef1234567890abcdef1234567890abcdef12',
  1052. stringLength: 100,
  1053. metadata: {
  1054. editorId: 'test-editor',
  1055. },
  1056. },
  1057. 'figures/image.png': {
  1058. hash: 'abcdef1234567890abcdef1234567890abcdef12',
  1059. stringLength: 100,
  1060. metadata: {
  1061. provider: 'bar',
  1062. },
  1063. },
  1064. 'since-deleted.tex': {
  1065. hash: 'abcdef1234567890abcdef1234567890abcdef12',
  1066. stringLength: 100,
  1067. metadata: {
  1068. editorId: 'test-editor',
  1069. },
  1070. },
  1071. },
  1072. timestamp: new Date().toISOString(),
  1073. })
  1074. ctx.ProjectEntityHandler.promises.getAllEntities = sinon
  1075. .stub()
  1076. .resolves({
  1077. docs: [{ path: '/main.tex' }, { path: '/new-file.tex' }],
  1078. files: [{ path: '/figures/image.png' }],
  1079. })
  1080. await ctx.RestoreManager.promises.revertProject(
  1081. ctx.user_id,
  1082. ctx.project_id,
  1083. ctx.version
  1084. )
  1085. ctx.origin = {
  1086. kind: 'project-restore',
  1087. version: ctx.version,
  1088. timestamp: new Date(ctx.end_ts).toISOString(),
  1089. }
  1090. })
  1091. it('should delete the old files', function (ctx) {
  1092. expect(
  1093. ctx.EditorController.promises.deleteEntityWithPath
  1094. ).to.have.been.calledWith(
  1095. ctx.project_id,
  1096. 'new-file.tex',
  1097. ctx.origin,
  1098. ctx.user_id
  1099. )
  1100. })
  1101. it('should not delete the current files', function (ctx) {
  1102. expect(
  1103. ctx.EditorController.promises.deleteEntityWithPath
  1104. ).to.not.have.been.calledWith(
  1105. ctx.project_id,
  1106. 'main.tex',
  1107. ctx.origin,
  1108. ctx.user_id
  1109. )
  1110. expect(
  1111. ctx.EditorController.promises.deleteEntityWithPath
  1112. ).to.not.have.been.calledWith(
  1113. ctx.project_id,
  1114. 'figures/image.png',
  1115. ctx.origin,
  1116. ctx.user_id
  1117. )
  1118. })
  1119. it('should revert the old files', function (ctx) {
  1120. expect(
  1121. ctx.RestoreManager.promises._revertSingleFile
  1122. ).to.have.been.calledWith(
  1123. ctx.user_id,
  1124. ctx.project_id,
  1125. ctx.version,
  1126. 'main.tex'
  1127. )
  1128. expect(
  1129. ctx.RestoreManager.promises._revertSingleFile
  1130. ).to.have.been.calledWith(
  1131. ctx.user_id,
  1132. ctx.project_id,
  1133. ctx.version,
  1134. 'figures/image.png'
  1135. )
  1136. expect(
  1137. ctx.RestoreManager.promises._revertSingleFile
  1138. ).to.have.been.calledWith(
  1139. ctx.user_id,
  1140. ctx.project_id,
  1141. ctx.version,
  1142. 'since-deleted.tex'
  1143. )
  1144. })
  1145. it('should not revert the current files', function (ctx) {
  1146. expect(
  1147. ctx.RestoreManager.promises._revertSingleFile
  1148. ).to.not.have.been.calledWith(
  1149. ctx.user_id,
  1150. ctx.project_id,
  1151. ctx.version,
  1152. 'new-file.tex'
  1153. )
  1154. })
  1155. })
  1156. })
  1157. })