|
|
@@ -0,0 +1,1169 @@
|
|
|
+import { vi, expect } from 'vitest'
|
|
|
+import sinon from 'sinon'
|
|
|
+import Errors from '../../../../app/src/Features/Errors/Errors.js'
|
|
|
+import tk from 'timekeeper'
|
|
|
+import moment from 'moment'
|
|
|
+
|
|
|
+const modulePath = '../../../../app/src/Features/History/RestoreManager'
|
|
|
+
|
|
|
+function nestedMapWithSetToObject(m) {
|
|
|
+ return Object.fromEntries(
|
|
|
+ Array.from(m.entries()).map(([key, set]) => [key, Array.from(set)])
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+describe('RestoreManager', function () {
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ tk.freeze(Date.now()) // freeze the time for these tests
|
|
|
+
|
|
|
+ vi.doMock('../../../../app/src/Features/Errors/Errors.js', () => ({
|
|
|
+ default: Errors,
|
|
|
+ }))
|
|
|
+
|
|
|
+ vi.doMock('../../../../app/src/infrastructure/Metrics.js', () => ({
|
|
|
+ default: {
|
|
|
+ revertFileDurationSeconds: {
|
|
|
+ startTimer: sinon.stub().returns(sinon.stub()),
|
|
|
+ },
|
|
|
+ revertProjectDurationSeconds: {
|
|
|
+ startTimer: sinon.stub().returns(sinon.stub()),
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }))
|
|
|
+
|
|
|
+ vi.doMock('@overleaf/settings', () => ({
|
|
|
+ default: {},
|
|
|
+ }))
|
|
|
+
|
|
|
+ vi.doMock('../../../../app/src/infrastructure/FileWriter', () => ({
|
|
|
+ default: (ctx.FileWriter = { promises: {} }),
|
|
|
+ }))
|
|
|
+
|
|
|
+ vi.doMock(
|
|
|
+ '../../../../app/src/Features/Uploads/FileSystemImportManager',
|
|
|
+ () => ({
|
|
|
+ default: (ctx.FileSystemImportManager = {
|
|
|
+ promises: {},
|
|
|
+ }),
|
|
|
+ })
|
|
|
+ )
|
|
|
+
|
|
|
+ vi.doMock('../../../../app/src/Features/Editor/EditorController', () => ({
|
|
|
+ default: (ctx.EditorController = {
|
|
|
+ promises: {},
|
|
|
+ }),
|
|
|
+ }))
|
|
|
+
|
|
|
+ vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({
|
|
|
+ default: (ctx.ProjectLocator = { promises: {} }),
|
|
|
+ }))
|
|
|
+
|
|
|
+ vi.doMock(
|
|
|
+ '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler',
|
|
|
+ () => ({
|
|
|
+ default: (ctx.DocumentUpdaterHandler = {
|
|
|
+ promises: { flushProjectToMongo: sinon.stub().resolves() },
|
|
|
+ }),
|
|
|
+ })
|
|
|
+ )
|
|
|
+
|
|
|
+ vi.doMock('../../../../app/src/Features/Docstore/DocstoreManager', () => ({
|
|
|
+ default: (ctx.DocstoreManager = {
|
|
|
+ promises: { getCommentThreadIds: sinon.stub().resolves({}) },
|
|
|
+ }),
|
|
|
+ }))
|
|
|
+
|
|
|
+ vi.doMock('../../../../app/src/Features/Chat/ChatApiHandler', () => ({
|
|
|
+ default: (ctx.ChatApiHandler = { promises: {} }),
|
|
|
+ }))
|
|
|
+
|
|
|
+ vi.doMock('../../../../app/src/Features/Chat/ChatManager', () => ({
|
|
|
+ default: (ctx.ChatManager = { promises: {} }),
|
|
|
+ }))
|
|
|
+
|
|
|
+ vi.doMock(
|
|
|
+ '../../../../app/src/Features/Editor/EditorRealTimeController',
|
|
|
+ () => ({
|
|
|
+ default: (ctx.EditorRealTimeController = {}),
|
|
|
+ })
|
|
|
+ )
|
|
|
+
|
|
|
+ vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
|
|
|
+ default: (ctx.ProjectGetter = { promises: {} }),
|
|
|
+ }))
|
|
|
+
|
|
|
+ vi.doMock(
|
|
|
+ '../../../../app/src/Features/Project/ProjectEntityHandler',
|
|
|
+ () => ({
|
|
|
+ default: (ctx.ProjectEntityHandler = {
|
|
|
+ promises: {},
|
|
|
+ }),
|
|
|
+ })
|
|
|
+ )
|
|
|
+
|
|
|
+ ctx.RestoreManager = (await import(modulePath)).default
|
|
|
+ ctx.user_id = 'mock-user-id'
|
|
|
+ ctx.project_id = 'mock-project-id'
|
|
|
+ ctx.version = 42
|
|
|
+ })
|
|
|
+
|
|
|
+ afterEach(function () {
|
|
|
+ tk.reset()
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('restoreFileFromV2', function () {
|
|
|
+ beforeEach(function (ctx) {
|
|
|
+ ctx.RestoreManager.promises._writeFileVersionToDisk = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves((ctx.fsPath = '/tmp/path/on/disk'))
|
|
|
+ ctx.RestoreManager.promises._findOrCreateFolder = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves((ctx.folder_id = 'mock-folder-id'))
|
|
|
+ ctx.FileSystemImportManager.promises.addEntity = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves((ctx.entity = 'mock-entity'))
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('with a file not in a folder', function () {
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ ctx.pathname = 'foo.tex'
|
|
|
+ ctx.result = await ctx.RestoreManager.promises.restoreFileFromV2(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ ctx.pathname
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should write the file version to disk', function (ctx) {
|
|
|
+ ctx.RestoreManager.promises._writeFileVersionToDisk
|
|
|
+ .calledWith(ctx.project_id, ctx.version, ctx.pathname)
|
|
|
+ .should.equal(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should find the root folder', function (ctx) {
|
|
|
+ ctx.RestoreManager.promises._findOrCreateFolder
|
|
|
+ .calledWith(ctx.project_id, '', ctx.user_id)
|
|
|
+ .should.equal(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should add the entity', function (ctx) {
|
|
|
+ ctx.FileSystemImportManager.promises.addEntity
|
|
|
+ .calledWith(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.folder_id,
|
|
|
+ 'foo.tex',
|
|
|
+ ctx.fsPath,
|
|
|
+ false
|
|
|
+ )
|
|
|
+ .should.equal(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should return the entity', function (ctx) {
|
|
|
+ expect(ctx.result).to.equal(ctx.entity)
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('with a file in a folder', function () {
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ ctx.pathname = 'foo/bar.tex'
|
|
|
+ await ctx.RestoreManager.promises.restoreFileFromV2(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ ctx.pathname
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should find the folder', function (ctx) {
|
|
|
+ ctx.RestoreManager.promises._findOrCreateFolder
|
|
|
+ .calledWith(ctx.project_id, 'foo', ctx.user_id)
|
|
|
+ .should.equal(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should add the entity by its basename', function (ctx) {
|
|
|
+ ctx.FileSystemImportManager.promises.addEntity
|
|
|
+ .calledWith(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.folder_id,
|
|
|
+ 'bar.tex',
|
|
|
+ ctx.fsPath,
|
|
|
+ false
|
|
|
+ )
|
|
|
+ .should.equal(true)
|
|
|
+ })
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('_findOrCreateFolder', function () {
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ ctx.EditorController.promises.mkdirp = sinon.stub().resolves({
|
|
|
+ newFolders: [],
|
|
|
+ lastFolder: { _id: (ctx.folder_id = 'mock-folder-id') },
|
|
|
+ })
|
|
|
+ ctx.result = await ctx.RestoreManager.promises._findOrCreateFolder(
|
|
|
+ ctx.project_id,
|
|
|
+ 'folder/name',
|
|
|
+ ctx.user_id
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should look up or create the folder', function (ctx) {
|
|
|
+ ctx.EditorController.promises.mkdirp
|
|
|
+ .calledWith(ctx.project_id, 'folder/name', ctx.user_id)
|
|
|
+ .should.equal(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should return the folder_id', function (ctx) {
|
|
|
+ expect(ctx.result).to.equal(ctx.folder_id)
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('_addEntityWithUniqueName', function () {
|
|
|
+ beforeEach(function (ctx) {
|
|
|
+ ctx.addEntityWithName = sinon.stub()
|
|
|
+ ctx.filename = 'foo.tex'
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('with a valid name', function () {
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ ctx.addEntityWithName.resolves((ctx.entity = 'mock-entity'))
|
|
|
+ ctx.result = await ctx.RestoreManager.promises._addEntityWithUniqueName(
|
|
|
+ ctx.addEntityWithName,
|
|
|
+ ctx.filename
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should add the entity', function (ctx) {
|
|
|
+ ctx.addEntityWithName.calledWith(ctx.filename).should.equal(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should return the entity', function (ctx) {
|
|
|
+ expect(ctx.result).to.equal(ctx.entity)
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('with a duplicate name', function () {
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ ctx.addEntityWithName.rejects(new Errors.DuplicateNameError())
|
|
|
+ ctx.addEntityWithName
|
|
|
+ .onSecondCall()
|
|
|
+ .resolves((ctx.entity = 'mock-entity'))
|
|
|
+ ctx.result = await ctx.RestoreManager.promises._addEntityWithUniqueName(
|
|
|
+ ctx.addEntityWithName,
|
|
|
+ ctx.filename
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should try to add the entity with its original name', function (ctx) {
|
|
|
+ ctx.addEntityWithName.calledWith('foo.tex').should.equal(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should try to add the entity with a unique name', function (ctx) {
|
|
|
+ const date = moment(new Date()).format('Do MMM YY H:mm:ss')
|
|
|
+ ctx.addEntityWithName
|
|
|
+ .calledWith(`foo (Restored on ${date}).tex`)
|
|
|
+ .should.equal(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should return the entity', function (ctx) {
|
|
|
+ expect(ctx.result).to.equal(ctx.entity)
|
|
|
+ })
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('revertFile', function () {
|
|
|
+ beforeEach(function (ctx) {
|
|
|
+ ctx.ProjectGetter.promises.getProject = sinon.stub()
|
|
|
+ ctx.ProjectGetter.promises.getProject.withArgs(ctx.project_id).resolves({
|
|
|
+ overleaf: { history: { rangesSupportEnabled: true } },
|
|
|
+ rootDoc_id: 'root-doc-id',
|
|
|
+ })
|
|
|
+ ctx.RestoreManager.promises._writeFileVersionToDisk = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves((ctx.fsPath = '/tmp/path/on/disk'))
|
|
|
+ ctx.RestoreManager.promises._findOrCreateFolder = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves((ctx.folder_id = 'mock-folder-id'))
|
|
|
+ ctx.FileSystemImportManager.promises.addEntity = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves((ctx.entity = 'mock-entity'))
|
|
|
+ ctx.RestoreManager.promises._getRangesFromHistory = sinon.stub().rejects()
|
|
|
+ ctx.RestoreManager.promises._getMetadataFromHistory = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ metadata: undefined })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('reverting a project without ranges support', function () {
|
|
|
+ beforeEach(function (ctx) {
|
|
|
+ ctx.ProjectGetter.promises.getProject = sinon.stub().resolves({
|
|
|
+ overleaf: { history: { rangesSupportEnabled: false } },
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should throw an error', async function (ctx) {
|
|
|
+ await expect(
|
|
|
+ ctx.RestoreManager.promises.revertFile(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ ctx.pathname
|
|
|
+ )
|
|
|
+ ).to.eventually.be.rejectedWith('project does not have ranges support')
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('reverting a document with ranges', function () {
|
|
|
+ beforeEach(function (ctx) {
|
|
|
+ ctx.pathname = 'foo.tex'
|
|
|
+ ctx.comments = [
|
|
|
+ {
|
|
|
+ id: 'comment-in-other-doc',
|
|
|
+ op: { t: 'comment-in-other-doc', p: 0, c: 'foo' },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'single-comment',
|
|
|
+ op: { t: 'single-comment', p: 10, c: 'bar' },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'deleted-comment',
|
|
|
+ op: { t: 'deleted-comment', p: 20, c: 'baz' },
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ ctx.remappedComments = [
|
|
|
+ {
|
|
|
+ id: 'duplicate-comment',
|
|
|
+ op: { t: 'duplicate-comment', p: 0, c: 'foo' },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'single-comment',
|
|
|
+ op: { t: 'single-comment', p: 10, c: 'bar' },
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ ctx.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
|
|
|
+ ctx.DocstoreManager.promises.getCommentThreadIds = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ 'other-doc': [ctx.comments[0].op.t] })
|
|
|
+ ctx.ChatApiHandler.promises.duplicateCommentThreads = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({
|
|
|
+ newThreads: {
|
|
|
+ 'comment-in-other-doc': {
|
|
|
+ duplicateId: 'duplicate-comment',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ })
|
|
|
+ ctx.ChatApiHandler.promises.generateThreadData = sinon.stub().resolves(
|
|
|
+ (ctx.threadData = {
|
|
|
+ 'single-comment': {
|
|
|
+ messages: [
|
|
|
+ {
|
|
|
+ content: 'message-content',
|
|
|
+ timestamp: '2024-01-01T00:00:00.000Z',
|
|
|
+ user_id: 'user-1',
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ 'duplicate-comment': {
|
|
|
+ messages: [
|
|
|
+ {
|
|
|
+ content: 'another message',
|
|
|
+ timestamp: '2024-01-01T00:00:00.000Z',
|
|
|
+ user_id: 'user-1',
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ })
|
|
|
+ )
|
|
|
+ ctx.ChatManager.promises.injectUserInfoIntoThreads = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves(ctx.threadData)
|
|
|
+
|
|
|
+ ctx.EditorRealTimeController.emitToRoom = sinon.stub()
|
|
|
+ ctx.tracked_changes = [
|
|
|
+ {
|
|
|
+ op: { pos: 4, i: 'bar' },
|
|
|
+ metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-1' },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ op: { pos: 8, d: 'qux' },
|
|
|
+ metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-2' },
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ ctx.FileSystemImportManager.promises.importFile = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
|
|
|
+ ctx.RestoreManager.promises._getRangesFromHistory = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({
|
|
|
+ changes: ctx.tracked_changes,
|
|
|
+ comments: ctx.comments,
|
|
|
+ })
|
|
|
+ ctx.RestoreManager.promises._getUpdatesFromHistory = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves([
|
|
|
+ { toV: ctx.version, meta: { end_ts: (ctx.endTs = new Date()) } },
|
|
|
+ ])
|
|
|
+ ctx.EditorController.promises.addDocWithRanges = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves((ctx.addedFile = { _id: 'mock-doc', type: 'doc' }))
|
|
|
+ })
|
|
|
+
|
|
|
+ describe("when reverting a file that doesn't current exist", function () {
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ ctx.data = await ctx.RestoreManager.promises.revertFile(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ ctx.pathname
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should flush the document before fetching ranges', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.DocumentUpdaterHandler.promises.flushProjectToMongo
|
|
|
+ ).to.have.been.calledBefore(
|
|
|
+ ctx.DocstoreManager.promises.getCommentThreadIds
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should import the file', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.EditorController.promises.addDocWithRanges
|
|
|
+ ).to.have.been.calledWith(
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.folder_id,
|
|
|
+ 'foo.tex',
|
|
|
+ ['foo', 'bar', 'baz'],
|
|
|
+ { changes: ctx.tracked_changes, comments: ctx.remappedComments }
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should return the created entity', function (ctx) {
|
|
|
+ expect(ctx.data).to.deep.equal(ctx.addedFile)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should look up ranges', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.RestoreManager.promises._getRangesFromHistory
|
|
|
+ ).to.have.been.calledWith(ctx.project_id, ctx.version, ctx.pathname)
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('with an existing file in the current project', function () {
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ ctx.ProjectGetter.promises.getProject = sinon.stub()
|
|
|
+ ctx.ProjectGetter.promises.getProject
|
|
|
+ .withArgs(ctx.project_id)
|
|
|
+ .resolves({
|
|
|
+ overleaf: { history: { rangesSupportEnabled: true } },
|
|
|
+ rootDoc_id: 'root-doc-id',
|
|
|
+ })
|
|
|
+ ctx.ProjectLocator.promises.findElementByPath = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ type: 'file', element: { _id: 'mock-file-id' } })
|
|
|
+ ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
|
|
|
+
|
|
|
+ ctx.data = await ctx.RestoreManager.promises.revertFile(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ ctx.pathname
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should delete the existing file', async function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.EditorController.promises.deleteEntity
|
|
|
+ ).to.have.been.calledWith(
|
|
|
+ ctx.project_id,
|
|
|
+ 'mock-file-id',
|
|
|
+ 'file',
|
|
|
+ {
|
|
|
+ kind: 'file-restore',
|
|
|
+ path: ctx.pathname,
|
|
|
+ version: ctx.version,
|
|
|
+ timestamp: new Date(ctx.endTs).toISOString(),
|
|
|
+ },
|
|
|
+ ctx.user_id
|
|
|
+ )
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('with an existing document in the current project', function () {
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ ctx.ProjectGetter.promises.getProject = sinon.stub()
|
|
|
+ ctx.ProjectGetter.promises.getProject
|
|
|
+ .withArgs(ctx.project_id)
|
|
|
+ .resolves({
|
|
|
+ overleaf: { history: { rangesSupportEnabled: true } },
|
|
|
+ rootDoc_id: 'root-doc-id',
|
|
|
+ })
|
|
|
+ ctx.ProjectLocator.promises.findElementByPath = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
|
|
|
+ ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
|
|
|
+
|
|
|
+ ctx.data = await ctx.RestoreManager.promises.revertFile(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ ctx.pathname
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should delete the existing document', async function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.EditorController.promises.deleteEntity
|
|
|
+ ).to.have.been.calledWith(
|
|
|
+ ctx.project_id,
|
|
|
+ 'mock-file-id',
|
|
|
+ 'doc',
|
|
|
+ {
|
|
|
+ kind: 'file-restore',
|
|
|
+ path: ctx.pathname,
|
|
|
+ version: ctx.version,
|
|
|
+ timestamp: new Date(ctx.endTs).toISOString(),
|
|
|
+ },
|
|
|
+ ctx.user_id
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should flush the document before fetching ranges', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.DocumentUpdaterHandler.promises.flushProjectToMongo
|
|
|
+ ).to.have.been.calledBefore(
|
|
|
+ ctx.DocstoreManager.promises.getCommentThreadIds
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should import the file', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.EditorController.promises.addDocWithRanges
|
|
|
+ ).to.have.been.calledWith(
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.folder_id,
|
|
|
+ 'foo.tex',
|
|
|
+ ['foo', 'bar', 'baz'],
|
|
|
+ { changes: ctx.tracked_changes, comments: ctx.remappedComments },
|
|
|
+ {
|
|
|
+ kind: 'file-restore',
|
|
|
+ path: ctx.pathname,
|
|
|
+ version: ctx.version,
|
|
|
+ timestamp: new Date(ctx.endTs).toISOString(),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should return the created entity', function (ctx) {
|
|
|
+ expect(ctx.data).to.deep.equal(ctx.addedFile)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should look up ranges', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.RestoreManager.promises._getRangesFromHistory
|
|
|
+ ).to.have.been.calledWith(ctx.project_id, ctx.version, ctx.pathname)
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('with comments in same doc', function () {
|
|
|
+ // copy of the above, addition: inject and later inspect threadIds set
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ ctx.ProjectGetter.promises.getProject = sinon.stub()
|
|
|
+ ctx.ProjectGetter.promises.getProject
|
|
|
+ .withArgs(ctx.project_id)
|
|
|
+ .resolves({
|
|
|
+ overleaf: { history: { rangesSupportEnabled: true } },
|
|
|
+ rootDoc_id: 'root-doc-id',
|
|
|
+ })
|
|
|
+ ctx.ProjectLocator.promises.findElementByPath = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
|
|
|
+ ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
|
|
|
+ ctx.ChatApiHandler.promises.generateThreadData = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves(
|
|
|
+ (ctx.threadData = {
|
|
|
+ [ctx.comments[0].op.t]: {
|
|
|
+ messages: [
|
|
|
+ {
|
|
|
+ content: 'message',
|
|
|
+ timestamp: '2024-01-01T00:00:00.000Z',
|
|
|
+ user_id: 'user-1',
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ [ctx.comments[1].op.t]: {
|
|
|
+ messages: [
|
|
|
+ {
|
|
|
+ content: 'other message',
|
|
|
+ timestamp: '2024-01-01T00:00:00.000Z',
|
|
|
+ user_id: 'user-1',
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ })
|
|
|
+ )
|
|
|
+
|
|
|
+ ctx.threadIds = new Map([
|
|
|
+ [
|
|
|
+ 'mock-file-id',
|
|
|
+ new Set([ctx.comments[0].op.t, ctx.comments[1].op.t]),
|
|
|
+ ],
|
|
|
+ ])
|
|
|
+ // Comments are updated in-place. Look up threads before reverting.
|
|
|
+ ctx.afterThreadIds = {
|
|
|
+ // mock-file-id removed
|
|
|
+ [ctx.addedFile._id]: [ctx.comments[0].op.t, ctx.comments[1].op.t],
|
|
|
+ }
|
|
|
+ ctx.data = await ctx.RestoreManager.promises._revertSingleFile(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ ctx.pathname,
|
|
|
+ ctx.threadIds
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should import the file with original comments minus the deleted one', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.EditorController.promises.addDocWithRanges
|
|
|
+ ).to.have.been.calledWith(
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.folder_id,
|
|
|
+ 'foo.tex',
|
|
|
+ ['foo', 'bar', 'baz'],
|
|
|
+ {
|
|
|
+ changes: ctx.tracked_changes,
|
|
|
+ comments: ctx.comments.slice(0, 2),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ kind: 'file-restore',
|
|
|
+ path: ctx.pathname,
|
|
|
+ version: ctx.version,
|
|
|
+ timestamp: new Date(ctx.endTs).toISOString(),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should add the seen thread ids to the map', function (ctx) {
|
|
|
+ expect(nestedMapWithSetToObject(ctx.threadIds)).to.deep.equal(
|
|
|
+ ctx.afterThreadIds
|
|
|
+ )
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('with remapped comments during revertProject', function () {
|
|
|
+ // copy of the above, addition: inject and later inspect threadIds set
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ ctx.ProjectGetter.promises.getProject = sinon.stub()
|
|
|
+ ctx.ProjectGetter.promises.getProject
|
|
|
+ .withArgs(ctx.project_id)
|
|
|
+ .resolves({
|
|
|
+ overleaf: { history: { rangesSupportEnabled: true } },
|
|
|
+ rootDoc_id: 'root-doc-id',
|
|
|
+ })
|
|
|
+ ctx.ProjectLocator.promises.findElementByPath = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
|
|
|
+ ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
|
|
|
+
|
|
|
+ ctx.threadIds = new Map([
|
|
|
+ ['other-doc', new Set([ctx.comments[0].op.t])],
|
|
|
+ ])
|
|
|
+ // Comments are updated in-place. Look up threads before reverting.
|
|
|
+ ctx.afterThreadIds = {
|
|
|
+ // mock-file-id removed
|
|
|
+ 'other-doc': [ctx.comments[0].op.t],
|
|
|
+ [ctx.addedFile._id]: [
|
|
|
+ ctx.remappedComments[0].op.t,
|
|
|
+ ctx.remappedComments[1].op.t,
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ ctx.data = await ctx.RestoreManager.promises._revertSingleFile(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ ctx.pathname,
|
|
|
+ ctx.threadIds
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should import the file', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.EditorController.promises.addDocWithRanges
|
|
|
+ ).to.have.been.calledWith(
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.folder_id,
|
|
|
+ 'foo.tex',
|
|
|
+ ['foo', 'bar', 'baz'],
|
|
|
+ { changes: ctx.tracked_changes, comments: ctx.remappedComments },
|
|
|
+ {
|
|
|
+ kind: 'file-restore',
|
|
|
+ path: ctx.pathname,
|
|
|
+ version: ctx.version,
|
|
|
+ timestamp: new Date(ctx.endTs).toISOString(),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should add the seen thread ids to the map', function (ctx) {
|
|
|
+ expect(nestedMapWithSetToObject(ctx.threadIds)).to.deep.equal(
|
|
|
+ ctx.afterThreadIds
|
|
|
+ )
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('when restored file has the same id as root doc', function () {
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ ctx.ProjectGetter.promises.getProject = sinon.stub()
|
|
|
+ ctx.ProjectGetter.promises.getProject
|
|
|
+ .withArgs(ctx.project_id)
|
|
|
+ .resolves({
|
|
|
+ overleaf: { history: { rangesSupportEnabled: true } },
|
|
|
+ rootDoc_id: 'root-doc-id',
|
|
|
+ })
|
|
|
+ ctx.ProjectLocator.promises.findElementByPath = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ type: 'doc', element: { _id: 'root-doc-id' } })
|
|
|
+ ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
|
|
|
+ ctx.EditorController.promises.addDocWithRanges = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves((ctx.addedFile = { _id: 'new-doc-id', type: 'doc' }))
|
|
|
+ ctx.EditorController.promises.setRootDoc = sinon.stub().resolves()
|
|
|
+
|
|
|
+ ctx.data = await ctx.RestoreManager.promises.revertFile(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ ctx.pathname
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should delete the existing root document', async function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.EditorController.promises.deleteEntity
|
|
|
+ ).to.have.been.calledWith(
|
|
|
+ ctx.project_id,
|
|
|
+ 'root-doc-id',
|
|
|
+ 'doc',
|
|
|
+ {
|
|
|
+ kind: 'file-restore',
|
|
|
+ path: ctx.pathname,
|
|
|
+ version: ctx.version,
|
|
|
+ timestamp: new Date(ctx.endTs).toISOString(),
|
|
|
+ },
|
|
|
+ ctx.user_id
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should import the file', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.EditorController.promises.addDocWithRanges
|
|
|
+ ).to.have.been.calledWith(
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.folder_id,
|
|
|
+ 'foo.tex',
|
|
|
+ ['foo', 'bar', 'baz'],
|
|
|
+ { changes: ctx.tracked_changes, comments: ctx.remappedComments },
|
|
|
+ {
|
|
|
+ kind: 'file-restore',
|
|
|
+ path: ctx.pathname,
|
|
|
+ version: ctx.version,
|
|
|
+ timestamp: new Date(ctx.endTs).toISOString(),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should return the created entity with root doc id', function (ctx) {
|
|
|
+ expect(ctx.data).to.deep.equal(ctx.addedFile)
|
|
|
+ expect(ctx.data._id).to.equal('new-doc-id')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should set the restored document as the new root doc', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.EditorController.promises.setRootDoc
|
|
|
+ ).to.have.been.calledWith(ctx.project_id, ctx.addedFile._id)
|
|
|
+ })
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('reverting a file or document with metadata', function () {
|
|
|
+ beforeEach(function (ctx) {
|
|
|
+ ctx.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
|
|
|
+ ctx.EditorController.promises.addDocWithRanges = sinon.stub()
|
|
|
+ ctx.RestoreManager.promises._getUpdatesFromHistory = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves([
|
|
|
+ { toV: ctx.version, meta: { end_ts: (ctx.endTs = new Date()) } },
|
|
|
+ ])
|
|
|
+
|
|
|
+ ctx.EditorController.promises.upsertFile = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ _id: 'mock-file-id', type: 'file' })
|
|
|
+ ctx.RestoreManager.promises._getRangesFromHistory = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({
|
|
|
+ changes: [],
|
|
|
+ comments: [],
|
|
|
+ })
|
|
|
+ ctx.EditorController.promises.addDocWithRanges = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves((ctx.addedFile = { _id: 'mock-doc-id', type: 'doc' }))
|
|
|
+
|
|
|
+ ctx.DocstoreManager.promises.getCommentThreadIds = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({})
|
|
|
+ ctx.ChatApiHandler.promises.generateThreadData = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({})
|
|
|
+ ctx.ChatManager.promises.injectUserInfoIntoThreads = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({})
|
|
|
+ ctx.EditorRealTimeController.emitToRoom = sinon.stub()
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('when reverting a linked file', function () {
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ ctx.pathname = 'foo.png'
|
|
|
+ ctx.FileSystemImportManager.promises.importFile = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ type: 'file' })
|
|
|
+ ctx.RestoreManager.promises._getMetadataFromHistory = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ metadata: { provider: 'bar' } })
|
|
|
+ ctx.result = await ctx.RestoreManager.promises.revertFile(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ ctx.pathname
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should revert it as a file', function (ctx) {
|
|
|
+ expect(ctx.result).to.deep.equal({
|
|
|
+ _id: 'mock-file-id',
|
|
|
+ type: 'file',
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should upload to the project as a file', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.EditorController.promises.upsertFile
|
|
|
+ ).to.have.been.calledWith(
|
|
|
+ ctx.project_id,
|
|
|
+ 'mock-folder-id',
|
|
|
+ 'foo.png',
|
|
|
+ ctx.fsPath,
|
|
|
+ { provider: 'bar' },
|
|
|
+ {
|
|
|
+ kind: 'file-restore',
|
|
|
+ path: ctx.pathname,
|
|
|
+ version: ctx.version,
|
|
|
+ timestamp: new Date(ctx.endTs).toISOString(),
|
|
|
+ },
|
|
|
+ ctx.user_id
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should not look up ranges', function (ctx) {
|
|
|
+ expect(ctx.RestoreManager.promises._getRangesFromHistory).to.not.have
|
|
|
+ .been.called
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should not try to add a document', function (ctx) {
|
|
|
+ expect(ctx.EditorController.promises.addDocWithRanges).to.not.have
|
|
|
+ .been.called
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('when reverting a linked document with provider', function () {
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ ctx.pathname = 'foo.tex'
|
|
|
+ ctx.FileSystemImportManager.promises.importFile = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
|
|
|
+ ctx.RestoreManager.promises._getMetadataFromHistory = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ metadata: { provider: 'bar' } })
|
|
|
+ ctx.result = await ctx.RestoreManager.promises.revertFile(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ ctx.pathname
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should revert it as a file', function (ctx) {
|
|
|
+ expect(ctx.result).to.deep.equal({
|
|
|
+ _id: 'mock-file-id',
|
|
|
+ type: 'file',
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should upload to the project as a file', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.EditorController.promises.upsertFile
|
|
|
+ ).to.have.been.calledWith(
|
|
|
+ ctx.project_id,
|
|
|
+ 'mock-folder-id',
|
|
|
+ 'foo.tex',
|
|
|
+ ctx.fsPath,
|
|
|
+ { provider: 'bar' },
|
|
|
+ {
|
|
|
+ kind: 'file-restore',
|
|
|
+ path: ctx.pathname,
|
|
|
+ version: ctx.version,
|
|
|
+ timestamp: new Date(ctx.endTs).toISOString(),
|
|
|
+ },
|
|
|
+ ctx.user_id
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should not look up ranges', function (ctx) {
|
|
|
+ expect(ctx.RestoreManager.promises._getRangesFromHistory).to.not.have
|
|
|
+ .been.called
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should not try to add a document', function (ctx) {
|
|
|
+ expect(ctx.EditorController.promises.addDocWithRanges).to.not.have
|
|
|
+ .been.called
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('when reverting a linked document with { main: true }', function () {
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ ctx.pathname = 'foo.tex'
|
|
|
+ ctx.FileSystemImportManager.promises.importFile = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
|
|
|
+ ctx.RestoreManager.promises._getMetadataFromHistory = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ metadata: { main: true } })
|
|
|
+ ctx.result = await ctx.RestoreManager.promises.revertFile(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ ctx.pathname
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should revert it as a document', function (ctx) {
|
|
|
+ expect(ctx.result).to.deep.equal({
|
|
|
+ _id: 'mock-doc-id',
|
|
|
+ type: 'doc',
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should not upload to the project as a file', function (ctx) {
|
|
|
+ expect(ctx.EditorController.promises.upsertFile).to.not.have.been
|
|
|
+ .called
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should look up ranges', function (ctx) {
|
|
|
+ expect(ctx.RestoreManager.promises._getRangesFromHistory).to.have.been
|
|
|
+ .called
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should add the document', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.EditorController.promises.addDocWithRanges
|
|
|
+ ).to.have.been.calledWith(
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.folder_id,
|
|
|
+ 'foo.tex',
|
|
|
+ ['foo', 'bar', 'baz'],
|
|
|
+ { changes: [], comments: [] }
|
|
|
+ )
|
|
|
+ })
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('when reverting a binary file', function () {
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ ctx.pathname = 'foo.png'
|
|
|
+ ctx.FileSystemImportManager.promises.importFile = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ type: 'file' })
|
|
|
+ ctx.EditorController.promises.upsertFile = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ _id: 'mock-file-id', type: 'file' })
|
|
|
+ ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
|
|
|
+ ctx.RestoreManager.promises._getUpdatesFromHistory = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves([{ toV: ctx.version, meta: { end_ts: Date.now() } }])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should return the created entity if file exists', async function (ctx) {
|
|
|
+ ctx.ProjectLocator.promises.findElementByPath = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ type: 'file', element: { _id: 'existing-file-id' } })
|
|
|
+
|
|
|
+ const revertRes = await ctx.RestoreManager.promises.revertFile(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ ctx.pathname
|
|
|
+ )
|
|
|
+
|
|
|
+ expect(revertRes).to.deep.equal({ _id: 'mock-file-id', type: 'file' })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should return the created entity if file does not exists', async function (ctx) {
|
|
|
+ ctx.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
|
|
|
+
|
|
|
+ const revertRes = await ctx.RestoreManager.promises.revertFile(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ ctx.pathname
|
|
|
+ )
|
|
|
+
|
|
|
+ expect(revertRes).to.deep.equal({ _id: 'mock-file-id', type: 'file' })
|
|
|
+ })
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('revertProject', function () {
|
|
|
+ beforeEach(function (ctx) {
|
|
|
+ ctx.ProjectGetter.promises.getProject = sinon.stub()
|
|
|
+ ctx.ProjectGetter.promises.getProject
|
|
|
+ .withArgs(ctx.project_id)
|
|
|
+ .resolves({ overleaf: { history: { rangesSupportEnabled: true } } })
|
|
|
+ ctx.RestoreManager.promises._revertSingleFile = sinon.stub().resolves({
|
|
|
+ _id: 'mock-doc-id',
|
|
|
+ type: 'doc',
|
|
|
+ })
|
|
|
+ ctx.RestoreManager.promises._getProjectPathsAtVersion = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves([])
|
|
|
+ ctx.ProjectEntityHandler.promises.getAllEntities = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({ docs: [], files: [] })
|
|
|
+ ctx.EditorController.promises.deleteEntityWithPath = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves()
|
|
|
+ ctx.RestoreManager.promises._getUpdatesFromHistory = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves([
|
|
|
+ { toV: ctx.version, meta: { end_ts: (ctx.end_ts = Date.now()) } },
|
|
|
+ ])
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('reverting a project without ranges support', function () {
|
|
|
+ beforeEach(function (ctx) {
|
|
|
+ ctx.ProjectGetter.promises.getProject = sinon.stub().resolves({
|
|
|
+ overleaf: { history: { rangesSupportEnabled: false } },
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should throw an error', async function (ctx) {
|
|
|
+ await expect(
|
|
|
+ ctx.RestoreManager.promises.revertProject(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version
|
|
|
+ )
|
|
|
+ ).to.eventually.be.rejectedWith('project does not have ranges support')
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe('for a project with overlap in current files and old files', function () {
|
|
|
+ beforeEach(async function (ctx) {
|
|
|
+ ctx.ProjectEntityHandler.promises.getAllEntities = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves({
|
|
|
+ docs: [{ path: '/main.tex' }, { path: '/new-file.tex' }],
|
|
|
+ files: [{ path: '/figures/image.png' }],
|
|
|
+ })
|
|
|
+ ctx.RestoreManager.promises._getProjectPathsAtVersion = sinon
|
|
|
+ .stub()
|
|
|
+ .resolves(['main.tex', 'figures/image.png', 'since-deleted.tex'])
|
|
|
+
|
|
|
+ await ctx.RestoreManager.promises.revertProject(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version
|
|
|
+ )
|
|
|
+ ctx.origin = {
|
|
|
+ kind: 'project-restore',
|
|
|
+ version: ctx.version,
|
|
|
+ timestamp: new Date(ctx.end_ts).toISOString(),
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should delete the old files', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.EditorController.promises.deleteEntityWithPath
|
|
|
+ ).to.have.been.calledWith(
|
|
|
+ ctx.project_id,
|
|
|
+ 'new-file.tex',
|
|
|
+ ctx.origin,
|
|
|
+ ctx.user_id
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should not delete the current files', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.EditorController.promises.deleteEntityWithPath
|
|
|
+ ).to.not.have.been.calledWith(
|
|
|
+ ctx.project_id,
|
|
|
+ 'main.tex',
|
|
|
+ ctx.origin,
|
|
|
+ ctx.user_id
|
|
|
+ )
|
|
|
+
|
|
|
+ expect(
|
|
|
+ ctx.EditorController.promises.deleteEntityWithPath
|
|
|
+ ).to.not.have.been.calledWith(
|
|
|
+ ctx.project_id,
|
|
|
+ 'figures/image.png',
|
|
|
+ ctx.origin,
|
|
|
+ ctx.user_id
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should revert the old files', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.RestoreManager.promises._revertSingleFile
|
|
|
+ ).to.have.been.calledWith(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ 'main.tex'
|
|
|
+ )
|
|
|
+
|
|
|
+ expect(
|
|
|
+ ctx.RestoreManager.promises._revertSingleFile
|
|
|
+ ).to.have.been.calledWith(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ 'figures/image.png'
|
|
|
+ )
|
|
|
+
|
|
|
+ expect(
|
|
|
+ ctx.RestoreManager.promises._revertSingleFile
|
|
|
+ ).to.have.been.calledWith(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ 'since-deleted.tex'
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should not revert the current files', function (ctx) {
|
|
|
+ expect(
|
|
|
+ ctx.RestoreManager.promises._revertSingleFile
|
|
|
+ ).to.not.have.been.calledWith(
|
|
|
+ ctx.user_id,
|
|
|
+ ctx.project_id,
|
|
|
+ ctx.version,
|
|
|
+ 'new-file.tex'
|
|
|
+ )
|
|
|
+ })
|
|
|
+ })
|
|
|
+ })
|
|
|
+})
|