mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-08 08:39:03 +02:00
339a7b91ed
GitOrigin-RevId: 20585e01dee90e691476a0d47fd5c63b0412e4a6
1272 lines
40 KiB
JavaScript
1272 lines
40 KiB
JavaScript
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
|
|
|
|
ctx.fsPath = '/tmp/path/on/disk'
|
|
ctx.blobStream = 'blob-stream'
|
|
|
|
vi.doMock('../../../../app/src/Features/Errors/Errors.js', () => ({
|
|
default: Errors,
|
|
}))
|
|
|
|
vi.doMock(
|
|
'../../../../app/src/Features/History/HistoryManager.mjs',
|
|
() => ({
|
|
default: (ctx.HistoryManager = {
|
|
promises: {
|
|
getContentAtVersion: sinon.stub().resolves({
|
|
// Raw snapshot data that will be passed to Snapshot.fromRaw
|
|
files: {
|
|
'main.tex': {
|
|
hash: 'abcdef1234567890abcdef1234567890abcdef12',
|
|
stringLength: 100,
|
|
metadata: {
|
|
editorId: 'test-editor',
|
|
},
|
|
},
|
|
'foo.tex': {
|
|
hash: 'abcdef1234567890abcdef1234567890abcdef12',
|
|
stringLength: 100,
|
|
metadata: {
|
|
editorId: 'test-editor',
|
|
},
|
|
},
|
|
'folder/file.tex': {
|
|
hash: 'abcdef1234567890abcdef1234567890abcdef12',
|
|
stringLength: 100,
|
|
metadata: {
|
|
editorId: 'test-editor',
|
|
},
|
|
},
|
|
'foo.png': {
|
|
hash: 'abcdef1234567890abcdef1234567890abcdef12',
|
|
stringLength: 100,
|
|
metadata: {
|
|
provider: 'bar',
|
|
},
|
|
},
|
|
'linkedFile.bib': {
|
|
hash: 'abcdef1234567890abcdef1234567890abcdef12',
|
|
stringLength: 100,
|
|
metadata: {
|
|
provider: 'mendeley',
|
|
},
|
|
},
|
|
'withMainTrue.tex': {
|
|
hash: 'abcdef1234567890abcdef1234567890abcdef12',
|
|
stringLength: 100,
|
|
metadata: {
|
|
main: true,
|
|
},
|
|
},
|
|
},
|
|
timestamp: new Date().toISOString(),
|
|
}),
|
|
requestBlob: sinon.stub().resolves({ stream: ctx.blobStream }),
|
|
},
|
|
}),
|
|
})
|
|
)
|
|
|
|
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: {
|
|
writeStreamToDisk: sinon.stub().resolves(ctx.fsPath),
|
|
writeContentToDisk: sinon.stub().resolves(ctx.fsPath),
|
|
},
|
|
}),
|
|
}))
|
|
|
|
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: {},
|
|
}),
|
|
})
|
|
)
|
|
|
|
vi.doMock('overleaf-editor-core', () => ({
|
|
Snapshot: {
|
|
fromRaw: sinon.stub().callsFake(snapshotData => ({
|
|
getFile: pathname => ({
|
|
getStringLength: sinon.stub().returns(100),
|
|
getByteLength: sinon.stub().returns(100),
|
|
getContent: sinon.stub().returns('foo\nbar\nbaz'),
|
|
isEditable: sinon.stub().returns(true),
|
|
getMetadata: sinon
|
|
.stub()
|
|
.returns(snapshotData?.files?.[pathname]?.metadata),
|
|
getHash: sinon.stub().returns((ctx.hash = 'somehash')),
|
|
}),
|
|
getFilePathnames: sinon
|
|
.stub()
|
|
.returns(Object.keys(snapshotData.files || {})),
|
|
getTimestamp: sinon
|
|
.stub()
|
|
.returns(
|
|
snapshotData?.timestamp
|
|
? new Date(snapshotData.timestamp)
|
|
: new Date()
|
|
),
|
|
})),
|
|
},
|
|
getDocUpdaterCompatibleRanges: (ctx.getDocUpdaterCompatibleRanges = sinon
|
|
.stub()
|
|
.returns({
|
|
changes: ctx.tracked_changes || [],
|
|
comments: ctx.comments || [],
|
|
})),
|
|
}))
|
|
|
|
ctx.RestoreManager = (await import(modulePath)).default
|
|
ctx.user_id = 'mock-user-id'
|
|
ctx.project_id = 'mock-project-id'
|
|
ctx.version = 42
|
|
|
|
// Add missing method mocks to RestoreManager
|
|
ctx.RestoreManager.promises._getUpdatesFromHistory = sinon.stub().resolves([
|
|
{
|
|
toV: ctx.version,
|
|
meta: { end_ts: new Date('2024-01-01T00:00:00.000Z') },
|
|
},
|
|
])
|
|
|
|
ctx.RestoreManager.promises._writeFileVersionToDisk = sinon
|
|
.stub()
|
|
.resolves('/tmp/mock-file-path')
|
|
})
|
|
|
|
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._findOrCreateFolder = sinon
|
|
.stub()
|
|
.resolves((ctx.folder_id = 'mock-folder-id'))
|
|
ctx.FileSystemImportManager.promises.addEntity = sinon
|
|
.stub()
|
|
.resolves((ctx.entity = 'mock-entity'))
|
|
})
|
|
|
|
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.getDocUpdaterCompatibleRanges.returns({
|
|
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)
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|
|
|
|
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,
|
|
{
|
|
getFile: sinon.stub().returns({
|
|
getMetadata: sinon.stub().returns(undefined),
|
|
getContent: sinon.stub().returns('foo\nbar\nbaz'),
|
|
isEditable: sinon.stub().returns(true),
|
|
}),
|
|
},
|
|
{
|
|
origin: {
|
|
kind: 'file-restore',
|
|
path: 'foo.tex',
|
|
timestamp: new Date(ctx.endTs).toISOString(),
|
|
version: 42,
|
|
},
|
|
}
|
|
)
|
|
})
|
|
|
|
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,
|
|
{
|
|
getFile: sinon.stub().returns({
|
|
getMetadata: sinon.stub().returns(undefined),
|
|
getContent: sinon.stub().returns('foo\nbar\nbaz'),
|
|
isEditable: sinon.stub().returns(true),
|
|
}),
|
|
},
|
|
{
|
|
origin: {
|
|
kind: 'file-restore',
|
|
path: 'foo.tex',
|
|
timestamp: new Date(ctx.endTs).toISOString(),
|
|
version: 42,
|
|
},
|
|
}
|
|
)
|
|
})
|
|
|
|
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.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.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 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 = 'linkedFile.bib'
|
|
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',
|
|
'linkedFile.bib',
|
|
ctx.fsPath,
|
|
{ provider: 'mendeley' },
|
|
{
|
|
kind: 'file-restore',
|
|
path: ctx.pathname,
|
|
version: ctx.version,
|
|
timestamp: new Date(ctx.endTs).toISOString(),
|
|
},
|
|
ctx.user_id
|
|
)
|
|
})
|
|
|
|
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 = 'withMainTrue.tex'
|
|
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 add the document', function (ctx) {
|
|
expect(
|
|
ctx.EditorController.promises.addDocWithRanges
|
|
).to.have.been.calledWith(
|
|
ctx.project_id,
|
|
ctx.folder_id,
|
|
'withMainTrue.tex',
|
|
['foo', 'bar', 'baz'],
|
|
{ changes: [], comments: [] }
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('when reverting a binary file', function () {
|
|
beforeEach(async function (ctx) {
|
|
ctx.pathname = 'foo.png'
|
|
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.HistoryManager.promises.getContentAtVersion = sinon
|
|
.stub()
|
|
.resolves({
|
|
files: {
|
|
'main.tex': {
|
|
hash: 'abcdef1234567890abcdef1234567890abcdef12',
|
|
stringLength: 100,
|
|
metadata: {
|
|
editorId: 'test-editor',
|
|
},
|
|
},
|
|
'figures/image.png': {
|
|
hash: 'abcdef1234567890abcdef1234567890abcdef12',
|
|
stringLength: 100,
|
|
metadata: {
|
|
provider: 'bar',
|
|
},
|
|
},
|
|
'since-deleted.tex': {
|
|
hash: 'abcdef1234567890abcdef1234567890abcdef12',
|
|
stringLength: 100,
|
|
metadata: {
|
|
editorId: 'test-editor',
|
|
},
|
|
},
|
|
},
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
|
|
ctx.ProjectEntityHandler.promises.getAllEntities = sinon
|
|
.stub()
|
|
.resolves({
|
|
docs: [{ path: '/main.tex' }, { path: '/new-file.tex' }],
|
|
files: [{ path: '/figures/image.png' }],
|
|
})
|
|
|
|
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'
|
|
)
|
|
})
|
|
})
|
|
})
|
|
})
|