mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
1074 lines
33 KiB
JavaScript
1074 lines
33 KiB
JavaScript
|
import sinon from 'sinon'
|
||
|
import { expect } from 'chai'
|
||
|
import { ObjectId } from 'mongodb'
|
||
|
import tk from 'timekeeper'
|
||
|
import { strict as esmock } from 'esmock'
|
||
|
|
||
|
const MODULE_PATH = '../../../../app/js/SyncManager.js'
|
||
|
|
||
|
const timestamp = new Date()
|
||
|
|
||
|
const resyncProjectStructureUpdate = (docs, files) => ({
|
||
|
resyncProjectStructure: { docs, files },
|
||
|
|
||
|
meta: {
|
||
|
ts: timestamp,
|
||
|
},
|
||
|
})
|
||
|
|
||
|
const docContentSyncUpdate = (doc, content) => ({
|
||
|
path: doc.path,
|
||
|
doc: doc.doc,
|
||
|
|
||
|
resyncDocContent: {
|
||
|
content,
|
||
|
},
|
||
|
|
||
|
meta: {
|
||
|
ts: timestamp,
|
||
|
},
|
||
|
})
|
||
|
|
||
|
describe('SyncManager', function () {
|
||
|
beforeEach(async function () {
|
||
|
this.now = new Date()
|
||
|
tk.freeze(this.now)
|
||
|
this.projectId = new ObjectId().toString()
|
||
|
this.historyId = 'mock-overleaf-id'
|
||
|
this.syncState = { origin: { kind: 'history-resync' } }
|
||
|
this.db = {
|
||
|
projectHistorySyncState: {
|
||
|
findOne: sinon.stub().yields(null, this.syncState),
|
||
|
updateOne: sinon.stub().yields(),
|
||
|
},
|
||
|
}
|
||
|
this.callback = sinon.stub()
|
||
|
this.extendLock = sinon.stub().yields()
|
||
|
this.LockManager = {
|
||
|
runWithLock: sinon.spy((key, runner, callback) => {
|
||
|
runner(this.extendLock, callback)
|
||
|
}),
|
||
|
}
|
||
|
this.UpdateCompressor = {
|
||
|
diffAsShareJsOps: sinon.stub(),
|
||
|
}
|
||
|
this.UpdateTranslator = {
|
||
|
isTextUpdate: sinon.stub(),
|
||
|
_convertPathname: sinon.stub(),
|
||
|
}
|
||
|
this.WebApiManager = {
|
||
|
getHistoryId: sinon.stub(),
|
||
|
requestResync: sinon.stub().yields(),
|
||
|
}
|
||
|
this.WebApiManager.getHistoryId
|
||
|
.withArgs(this.projectId)
|
||
|
.yields(null, this.historyId)
|
||
|
this.ErrorRecorder = {
|
||
|
record: sinon.stub().yields(),
|
||
|
recordSyncStart: sinon.stub().yields(),
|
||
|
}
|
||
|
this.RedisManager = {}
|
||
|
this.SnapshotManager = {
|
||
|
getLatestSnapshot: sinon.stub(),
|
||
|
}
|
||
|
this.HistoryStoreManager = {
|
||
|
getBlobStore: sinon.stub(),
|
||
|
_getBlobHashFromString: sinon.stub().returns('random-hash'),
|
||
|
}
|
||
|
this.HashManager = {
|
||
|
_getBlobHashFromString: sinon.stub(),
|
||
|
}
|
||
|
this.Metrics = { inc: sinon.stub() }
|
||
|
this.Settings = {
|
||
|
redis: {
|
||
|
lock: {
|
||
|
key_schema: {
|
||
|
projectHistoryLock({ project_id: projectId }) {
|
||
|
return `ProjectHistoryLock:${projectId}`
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
}
|
||
|
this.SyncManager = await esmock(MODULE_PATH, {
|
||
|
'../../../../app/js/LockManager.js': this.LockManager,
|
||
|
'../../../../app/js/UpdateCompressor.js': this.UpdateCompressor,
|
||
|
'../../../../app/js/UpdateTranslator.js': this.UpdateTranslator,
|
||
|
'../../../../app/js/mongodb.js': { ObjectId, db: this.db },
|
||
|
'../../../../app/js/WebApiManager.js': this.WebApiManager,
|
||
|
'../../../../app/js/ErrorRecorder.js': this.ErrorRecorder,
|
||
|
'../../../../app/js/RedisManager.js': this.RedisManager,
|
||
|
'../../../../app/js/SnapshotManager.js': this.SnapshotManager,
|
||
|
'../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager,
|
||
|
'../../../../app/js/HashManager.js': this.HashManager,
|
||
|
'@overleaf/metrics': this.Metrics,
|
||
|
'@overleaf/settings': this.Settings,
|
||
|
})
|
||
|
})
|
||
|
|
||
|
afterEach(function () {
|
||
|
tk.reset()
|
||
|
})
|
||
|
|
||
|
describe('startResync', function () {
|
||
|
describe('if a sync is not in progress', function () {
|
||
|
beforeEach(function () {
|
||
|
this.db.projectHistorySyncState.findOne.yields(null, {})
|
||
|
this.SyncManager.startResync(this.projectId, this.callback)
|
||
|
})
|
||
|
|
||
|
it('takes the project lock', function () {
|
||
|
expect(this.LockManager.runWithLock).to.have.been.calledWith(
|
||
|
`ProjectHistoryLock:${this.projectId}`
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('gets the sync state from mongo', function () {
|
||
|
expect(this.db.projectHistorySyncState.findOne).to.have.been.calledWith(
|
||
|
{ project_id: ObjectId(this.projectId) }
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('requests a resync from web', function () {
|
||
|
expect(this.WebApiManager.requestResync).to.have.been.calledWith(
|
||
|
this.projectId
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('sets the sync state in mongo and prevents it expiring', function () {
|
||
|
expect(
|
||
|
this.db.projectHistorySyncState.updateOne
|
||
|
).to.have.been.calledWith(
|
||
|
{
|
||
|
project_id: ObjectId(this.projectId),
|
||
|
},
|
||
|
sinon.match({
|
||
|
$set: {
|
||
|
resyncProjectStructure: true,
|
||
|
resyncDocContents: [],
|
||
|
origin: { kind: 'history-resync' },
|
||
|
},
|
||
|
$currentDate: { lastUpdated: true },
|
||
|
$inc: { resyncCount: 1 },
|
||
|
$unset: { expiresAt: true },
|
||
|
}),
|
||
|
{
|
||
|
upsert: true,
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('calls the callback', function () {
|
||
|
expect(this.callback).to.have.been.called
|
||
|
})
|
||
|
})
|
||
|
|
||
|
describe('if project structure sync is in progress', function () {
|
||
|
beforeEach(function () {
|
||
|
const syncState = { resyncProjectStructure: true }
|
||
|
this.db.projectHistorySyncState.findOne.yields(null, syncState)
|
||
|
this.SyncManager.startResync(this.projectId, this.callback)
|
||
|
})
|
||
|
|
||
|
it('returns an error if already syncing', function () {
|
||
|
expect(this.callback).to.have.been.calledWithMatch({
|
||
|
message: 'sync ongoing',
|
||
|
})
|
||
|
})
|
||
|
})
|
||
|
|
||
|
describe('if doc content sync in is progress', function () {
|
||
|
beforeEach(function () {
|
||
|
const syncState = { resyncDocContents: ['/foo.tex'] }
|
||
|
this.db.projectHistorySyncState.findOne.yields(null, syncState)
|
||
|
this.SyncManager.startResync(this.projectId, this.callback)
|
||
|
})
|
||
|
|
||
|
it('returns an error if already syncing', function () {
|
||
|
expect(this.callback).to.have.been.calledWithMatch({
|
||
|
message: 'sync ongoing',
|
||
|
})
|
||
|
})
|
||
|
})
|
||
|
})
|
||
|
|
||
|
describe('setResyncState', function () {
|
||
|
describe('when the sync is starting', function () {
|
||
|
beforeEach(function () {
|
||
|
this.syncState = {
|
||
|
toRaw() {
|
||
|
return {
|
||
|
resyncProjectStructure: true,
|
||
|
resyncDocContents: [],
|
||
|
origin: { kind: 'history-resync' },
|
||
|
}
|
||
|
},
|
||
|
isSyncOngoing: sinon.stub().returns(true),
|
||
|
}
|
||
|
})
|
||
|
|
||
|
it('sets the sync state in mongo and prevents it expiring', function (done) {
|
||
|
// SyncState is a private class of SyncManager
|
||
|
// we know the interface however:
|
||
|
this.SyncManager.setResyncState(
|
||
|
this.projectId,
|
||
|
this.syncState,
|
||
|
error => {
|
||
|
expect(error).to.be.undefined
|
||
|
expect(
|
||
|
this.db.projectHistorySyncState.updateOne
|
||
|
).to.have.been.calledWith(
|
||
|
{
|
||
|
project_id: ObjectId(this.projectId),
|
||
|
},
|
||
|
sinon.match({
|
||
|
$set: this.syncState.toRaw(),
|
||
|
$currentDate: { lastUpdated: true },
|
||
|
$inc: { resyncCount: 1 },
|
||
|
$unset: { expiresAt: true },
|
||
|
}),
|
||
|
{
|
||
|
upsert: true,
|
||
|
}
|
||
|
)
|
||
|
done()
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
})
|
||
|
|
||
|
describe('when the sync is ending', function () {
|
||
|
beforeEach(function () {
|
||
|
this.syncState = {
|
||
|
toRaw() {
|
||
|
return {
|
||
|
resyncProjectStructure: false,
|
||
|
resyncDocContents: [],
|
||
|
origin: { kind: 'history-resync' },
|
||
|
}
|
||
|
},
|
||
|
isSyncOngoing: sinon.stub().returns(false),
|
||
|
}
|
||
|
})
|
||
|
|
||
|
it('sets the sync state entry in mongo to expire', function (done) {
|
||
|
this.SyncManager.setResyncState(
|
||
|
this.projectId,
|
||
|
this.syncState,
|
||
|
error => {
|
||
|
expect(error).to.be.undefined
|
||
|
expect(
|
||
|
this.db.projectHistorySyncState.updateOne
|
||
|
).to.have.been.calledWith(
|
||
|
{
|
||
|
project_id: ObjectId(this.projectId),
|
||
|
},
|
||
|
sinon.match({
|
||
|
$set: {
|
||
|
resyncProjectStructure: false,
|
||
|
resyncDocContents: [],
|
||
|
origin: { kind: 'history-resync' },
|
||
|
expiresAt: new Date(
|
||
|
this.now.getTime() + 90 * 24 * 3600 * 1000
|
||
|
),
|
||
|
},
|
||
|
$currentDate: { lastUpdated: true },
|
||
|
}),
|
||
|
{
|
||
|
upsert: true,
|
||
|
}
|
||
|
)
|
||
|
done()
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
})
|
||
|
|
||
|
describe('when the new sync state is null', function () {
|
||
|
it('does not update the sync state in mongo', function (done) {
|
||
|
// SyncState is a private class of SyncManager
|
||
|
// we know the interface however:
|
||
|
this.SyncManager.setResyncState(this.projectId, null, error => {
|
||
|
expect(error).to.be.undefined
|
||
|
expect(this.db.projectHistorySyncState.updateOne).to.not.have.been
|
||
|
.called
|
||
|
done()
|
||
|
})
|
||
|
})
|
||
|
})
|
||
|
})
|
||
|
|
||
|
describe('skipUpdatesDuringSync', function () {
|
||
|
describe('if a sync is not in progress', function () {
|
||
|
beforeEach(function () {
|
||
|
this.db.projectHistorySyncState.findOne.yields(null, {})
|
||
|
this.updates = ['some', 'mock', 'updates']
|
||
|
this.SyncManager.skipUpdatesDuringSync(
|
||
|
this.projectId,
|
||
|
this.updates,
|
||
|
this.callback
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('returns all updates', function () {
|
||
|
expect(this.callback).to.have.been.calledWith(null, this.updates)
|
||
|
})
|
||
|
|
||
|
it('should not return any newSyncState', function () {
|
||
|
expect(this.callback).to.have.been.calledWithExactly(null, this.updates)
|
||
|
})
|
||
|
})
|
||
|
|
||
|
describe('if a sync in is progress', function () {
|
||
|
beforeEach(function () {
|
||
|
this.renameUpdate = {
|
||
|
pathname: 'old.tex',
|
||
|
newPathname: 'new.tex',
|
||
|
}
|
||
|
this.projectStructureSyncUpdate = {
|
||
|
resyncProjectStructure: {
|
||
|
docs: [{ path: 'new.tex' }],
|
||
|
files: [],
|
||
|
},
|
||
|
}
|
||
|
this.textUpdate = {
|
||
|
doc: new ObjectId(),
|
||
|
op: [{ i: 'a', p: 4 }],
|
||
|
meta: {
|
||
|
pathname: 'new.tex',
|
||
|
doc_length: 4,
|
||
|
},
|
||
|
}
|
||
|
this.docContentSyncUpdate = {
|
||
|
path: 'new.tex',
|
||
|
resyncDocContent: {
|
||
|
content: 'a',
|
||
|
},
|
||
|
}
|
||
|
this.UpdateTranslator.isTextUpdate
|
||
|
.withArgs(this.renameUpdate)
|
||
|
.returns(false)
|
||
|
this.UpdateTranslator.isTextUpdate
|
||
|
.withArgs(this.projectStructureSyncUpdate)
|
||
|
.returns(false)
|
||
|
this.UpdateTranslator.isTextUpdate
|
||
|
.withArgs(this.docContentSyncUpdate)
|
||
|
.returns(false)
|
||
|
this.UpdateTranslator.isTextUpdate
|
||
|
.withArgs(this.textUpdate)
|
||
|
.returns(true)
|
||
|
|
||
|
const syncState = {
|
||
|
resyncProjectStructure: true,
|
||
|
resyncDocContents: [],
|
||
|
origin: { kind: 'history-resync' },
|
||
|
}
|
||
|
this.db.projectHistorySyncState.findOne.yields(null, syncState)
|
||
|
})
|
||
|
|
||
|
it('remove updates before a project structure sync update', function () {
|
||
|
const updates = [
|
||
|
this.renameUpdate,
|
||
|
this.textUpdate,
|
||
|
this.projectStructureSyncUpdate,
|
||
|
]
|
||
|
this.SyncManager.skipUpdatesDuringSync(
|
||
|
this.projectId,
|
||
|
updates,
|
||
|
(error, filteredUpdates, syncState) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(filteredUpdates).to.deep.equal([
|
||
|
this.projectStructureSyncUpdate,
|
||
|
])
|
||
|
expect(syncState.toRaw()).to.deep.equal({
|
||
|
resyncProjectStructure: false,
|
||
|
resyncDocContents: ['new.tex'],
|
||
|
origin: { kind: 'history-resync' },
|
||
|
})
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('allow project structure updates after project structure sync update', function () {
|
||
|
const updates = [this.projectStructureSyncUpdate, this.renameUpdate]
|
||
|
this.SyncManager.skipUpdatesDuringSync(
|
||
|
this.projectId,
|
||
|
updates,
|
||
|
(error, filteredUpdates, syncState) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(filteredUpdates).to.deep.equal([
|
||
|
this.projectStructureSyncUpdate,
|
||
|
this.renameUpdate,
|
||
|
])
|
||
|
expect(syncState.toRaw()).to.deep.equal({
|
||
|
resyncProjectStructure: false,
|
||
|
resyncDocContents: ['new.tex'],
|
||
|
origin: { kind: 'history-resync' },
|
||
|
})
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('remove text updates for a doc before doc sync update', function () {
|
||
|
const updates = [
|
||
|
this.projectStructureSyncUpdate,
|
||
|
this.textUpdate,
|
||
|
this.docContentSyncUpdate,
|
||
|
]
|
||
|
this.SyncManager.skipUpdatesDuringSync(
|
||
|
this.projectId,
|
||
|
updates,
|
||
|
(error, filteredUpdates, syncState) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(filteredUpdates).to.deep.equal([
|
||
|
this.projectStructureSyncUpdate,
|
||
|
this.docContentSyncUpdate,
|
||
|
])
|
||
|
expect(syncState.toRaw()).to.deep.equal({
|
||
|
resyncProjectStructure: false,
|
||
|
resyncDocContents: [],
|
||
|
origin: { kind: 'history-resync' },
|
||
|
})
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('allow text updates for a doc after doc sync update', function () {
|
||
|
const updates = [
|
||
|
this.projectStructureSyncUpdate,
|
||
|
this.docContentSyncUpdate,
|
||
|
this.textUpdate,
|
||
|
]
|
||
|
this.SyncManager.skipUpdatesDuringSync(
|
||
|
this.projectId,
|
||
|
updates,
|
||
|
(error, filteredUpdates, syncState) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(filteredUpdates).to.deep.equal([
|
||
|
this.projectStructureSyncUpdate,
|
||
|
this.docContentSyncUpdate,
|
||
|
this.textUpdate,
|
||
|
])
|
||
|
expect(syncState.toRaw()).to.deep.equal({
|
||
|
resyncProjectStructure: false,
|
||
|
resyncDocContents: [],
|
||
|
origin: { kind: 'history-resync' },
|
||
|
})
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
})
|
||
|
})
|
||
|
|
||
|
describe('expandSyncUpdates', function () {
|
||
|
beforeEach(function () {
|
||
|
this.persistedDoc = {
|
||
|
doc: { data: { hash: 'abcdef' } },
|
||
|
path: 'main.tex',
|
||
|
content: 'asdf',
|
||
|
}
|
||
|
this.persistedFile = {
|
||
|
file: { data: { hash: '123456789a' } },
|
||
|
path: '1.png',
|
||
|
}
|
||
|
this.fileMap = {
|
||
|
'main.tex': {
|
||
|
isEditable: sinon.stub().returns(true),
|
||
|
content: this.persistedDoc.content,
|
||
|
},
|
||
|
'1.png': {
|
||
|
isEditable: sinon.stub().returns(false),
|
||
|
data: { hash: this.persistedFile.file.data.hash },
|
||
|
},
|
||
|
}
|
||
|
this.UpdateTranslator._convertPathname
|
||
|
.withArgs('main.tex')
|
||
|
.returns('main.tex')
|
||
|
this.UpdateTranslator._convertPathname
|
||
|
.withArgs('/main.tex')
|
||
|
.returns('main.tex')
|
||
|
this.UpdateTranslator._convertPathname
|
||
|
.withArgs('another.tex')
|
||
|
.returns('another.tex')
|
||
|
this.UpdateTranslator._convertPathname.withArgs('1.png').returns('1.png')
|
||
|
this.UpdateTranslator._convertPathname.withArgs('2.png').returns('2.png')
|
||
|
this.SnapshotManager.getLatestSnapshot.yields(null, this.fileMap)
|
||
|
})
|
||
|
|
||
|
it('returns updates if no sync updates are queued', function () {
|
||
|
const updates = ['some', 'mock', 'updates']
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(expandedUpdates).to.equal(updates)
|
||
|
expect(this.SnapshotManager.getLatestSnapshot).to.not.have.been.called
|
||
|
expect(this.extendLock).to.not.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
describe('expanding project structure sync updates', function () {
|
||
|
it('queues nothing for expected docs and files', function () {
|
||
|
const updates = [
|
||
|
resyncProjectStructureUpdate(
|
||
|
[this.persistedDoc],
|
||
|
[this.persistedFile]
|
||
|
),
|
||
|
]
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(expandedUpdates).to.deep.equal([])
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('queues file removes for unexpected files', function () {
|
||
|
const updates = [resyncProjectStructureUpdate([this.persistedDoc], [])]
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(expandedUpdates).to.deep.equal([
|
||
|
{
|
||
|
pathname: this.persistedFile.path,
|
||
|
new_pathname: '',
|
||
|
meta: {
|
||
|
resync: true,
|
||
|
ts: timestamp,
|
||
|
origin: { kind: 'history-resync' },
|
||
|
},
|
||
|
},
|
||
|
])
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('queues doc removes for unexpected docs', function () {
|
||
|
const updates = [resyncProjectStructureUpdate([], [this.persistedFile])]
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(expandedUpdates).to.deep.equal([
|
||
|
{
|
||
|
pathname: this.persistedDoc.path,
|
||
|
new_pathname: '',
|
||
|
meta: {
|
||
|
resync: true,
|
||
|
ts: timestamp,
|
||
|
origin: { kind: 'history-resync' },
|
||
|
},
|
||
|
},
|
||
|
])
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('queues file additions for missing files', function () {
|
||
|
const newFile = {
|
||
|
path: '2.png',
|
||
|
file: {},
|
||
|
url: 'filestore/2.png',
|
||
|
}
|
||
|
const updates = [
|
||
|
resyncProjectStructureUpdate(
|
||
|
[this.persistedDoc],
|
||
|
[this.persistedFile, newFile]
|
||
|
),
|
||
|
]
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(expandedUpdates).to.deep.equal([
|
||
|
{
|
||
|
pathname: newFile.path,
|
||
|
file: newFile.file,
|
||
|
url: newFile.url,
|
||
|
meta: {
|
||
|
resync: true,
|
||
|
ts: timestamp,
|
||
|
origin: { kind: 'history-resync' },
|
||
|
},
|
||
|
},
|
||
|
])
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('queues blank doc additions for missing docs', function () {
|
||
|
const newDoc = {
|
||
|
path: 'another.tex',
|
||
|
doc: ObjectId().toString(),
|
||
|
}
|
||
|
const updates = [
|
||
|
resyncProjectStructureUpdate(
|
||
|
[this.persistedDoc, newDoc],
|
||
|
[this.persistedFile]
|
||
|
),
|
||
|
]
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(expandedUpdates).to.deep.equal([
|
||
|
{
|
||
|
pathname: newDoc.path,
|
||
|
doc: newDoc.doc,
|
||
|
docLines: '',
|
||
|
meta: {
|
||
|
resync: true,
|
||
|
ts: timestamp,
|
||
|
origin: { kind: 'history-resync' },
|
||
|
},
|
||
|
},
|
||
|
])
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('removes and re-adds files if whether they are binary differs', function () {
|
||
|
const fileWichWasADoc = {
|
||
|
path: this.persistedDoc.path,
|
||
|
url: 'filestore/2.png',
|
||
|
_hash: 'other-hash',
|
||
|
}
|
||
|
|
||
|
const updates = [
|
||
|
resyncProjectStructureUpdate(
|
||
|
[],
|
||
|
[fileWichWasADoc, this.persistedFile]
|
||
|
),
|
||
|
]
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(expandedUpdates).to.deep.equal([
|
||
|
{
|
||
|
pathname: fileWichWasADoc.path,
|
||
|
new_pathname: '',
|
||
|
meta: {
|
||
|
resync: true,
|
||
|
ts: timestamp,
|
||
|
origin: { kind: 'history-resync' },
|
||
|
},
|
||
|
},
|
||
|
{
|
||
|
pathname: fileWichWasADoc.path,
|
||
|
file: fileWichWasADoc.file,
|
||
|
url: fileWichWasADoc.url,
|
||
|
meta: {
|
||
|
resync: true,
|
||
|
ts: timestamp,
|
||
|
origin: { kind: 'history-resync' },
|
||
|
},
|
||
|
},
|
||
|
])
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it("does not remove and re-add files if the expected file doesn't have a hash", function () {
|
||
|
const fileWichWasADoc = {
|
||
|
path: this.persistedDoc.path,
|
||
|
url: 'filestore/2.png',
|
||
|
}
|
||
|
|
||
|
const updates = [
|
||
|
resyncProjectStructureUpdate(
|
||
|
[],
|
||
|
[fileWichWasADoc, this.persistedFile]
|
||
|
),
|
||
|
]
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(expandedUpdates).to.deep.equal([])
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('does not remove and re-add editable files if there is a binary file with same hash', function () {
|
||
|
const binaryFile = {
|
||
|
file: Object().toString(),
|
||
|
// The paths in the resyncProjectStructureUpdate must have a leading slash ('/')
|
||
|
// The other unit tests in this file are incorrectly missing the leading slash.
|
||
|
// The leading slash is present in web where the paths are created with
|
||
|
// ProjectEntityHandler.getAllEntitiesFromProject in ProjectEntityUpdateHandler.resyncProjectHistory.
|
||
|
path: '/' + this.persistedDoc.path,
|
||
|
url: 'filestore/12345',
|
||
|
_hash: 'abcdef',
|
||
|
}
|
||
|
this.fileMap['main.tex'].data = { hash: 'abcdef' }
|
||
|
|
||
|
const updates = [
|
||
|
resyncProjectStructureUpdate([], [binaryFile, this.persistedFile]),
|
||
|
]
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(expandedUpdates).to.deep.equal([])
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('removes and re-adds binary files if they do not have same hash', function () {
|
||
|
const persistedFileWithNewContent = {
|
||
|
_hash: 'anotherhashvalue',
|
||
|
hello: 'world',
|
||
|
path: '1.png',
|
||
|
url: 'filestore-new-url',
|
||
|
}
|
||
|
const updates = [
|
||
|
resyncProjectStructureUpdate(
|
||
|
[this.persistedDoc],
|
||
|
[persistedFileWithNewContent]
|
||
|
),
|
||
|
]
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(expandedUpdates).to.deep.equal([
|
||
|
{
|
||
|
pathname: persistedFileWithNewContent.path,
|
||
|
new_pathname: '',
|
||
|
meta: {
|
||
|
resync: true,
|
||
|
ts: timestamp,
|
||
|
origin: { kind: 'history-resync' },
|
||
|
},
|
||
|
},
|
||
|
{
|
||
|
pathname: persistedFileWithNewContent.path,
|
||
|
file: persistedFileWithNewContent.file,
|
||
|
url: persistedFileWithNewContent.url,
|
||
|
meta: {
|
||
|
resync: true,
|
||
|
ts: timestamp,
|
||
|
origin: { kind: 'history-resync' },
|
||
|
},
|
||
|
},
|
||
|
])
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('preserves other updates', function () {
|
||
|
const update = 'mock-update'
|
||
|
const updates = [
|
||
|
update,
|
||
|
resyncProjectStructureUpdate(
|
||
|
[this.persistedDoc],
|
||
|
[this.persistedFile]
|
||
|
),
|
||
|
]
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(expandedUpdates).to.deep.equal([update])
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
})
|
||
|
|
||
|
describe('expanding doc contents sync updates', function () {
|
||
|
it('returns errors from diffAsShareJsOps', function () {
|
||
|
const diffError = new Error('test')
|
||
|
this.UpdateCompressor.diffAsShareJsOps.throws(diffError)
|
||
|
const updates = [
|
||
|
resyncProjectStructureUpdate(
|
||
|
[this.persistedDoc],
|
||
|
[this.persistedFile]
|
||
|
),
|
||
|
docContentSyncUpdate(this.persistedDoc, this.persistedDoc.content),
|
||
|
]
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.equal(diffError)
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('handles an update for a file that is missing from the snapshot', function () {
|
||
|
const updates = [docContentSyncUpdate('not-in-snapshot.txt', 'test')]
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.exist
|
||
|
expect(error.message).to.equal('unrecognised file: not in snapshot')
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('queues nothing for in docs whose contents is in sync', function () {
|
||
|
const updates = [
|
||
|
resyncProjectStructureUpdate(
|
||
|
[this.persistedDoc],
|
||
|
[this.persistedFile]
|
||
|
),
|
||
|
docContentSyncUpdate(this.persistedDoc, this.persistedDoc.content),
|
||
|
]
|
||
|
this.UpdateCompressor.diffAsShareJsOps.returns([])
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(expandedUpdates).to.deep.equal([])
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('queues text updates for docs whose contents is out of sync', function () {
|
||
|
const updates = [
|
||
|
resyncProjectStructureUpdate(
|
||
|
[this.persistedDoc],
|
||
|
[this.persistedFile]
|
||
|
),
|
||
|
docContentSyncUpdate(this.persistedDoc, 'a'),
|
||
|
]
|
||
|
this.UpdateCompressor.diffAsShareJsOps.returns([{ d: 'sdf', p: 1 }])
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(expandedUpdates).to.deep.equal([
|
||
|
{
|
||
|
doc: this.persistedDoc.doc,
|
||
|
op: [{ d: 'sdf', p: 1 }],
|
||
|
meta: {
|
||
|
pathname: this.persistedDoc.path,
|
||
|
doc_length: 4,
|
||
|
resync: true,
|
||
|
ts: timestamp,
|
||
|
origin: { kind: 'history-resync' },
|
||
|
},
|
||
|
},
|
||
|
])
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('queues text updates for docs created by project structure sync', function () {
|
||
|
this.UpdateCompressor.diffAsShareJsOps.returns([{ i: 'a', p: 0 }])
|
||
|
const newDoc = {
|
||
|
path: 'another.tex',
|
||
|
doc: ObjectId().toString(),
|
||
|
content: 'a',
|
||
|
}
|
||
|
const updates = [
|
||
|
resyncProjectStructureUpdate(
|
||
|
[this.persistedDoc, newDoc],
|
||
|
[this.persistedFile]
|
||
|
),
|
||
|
docContentSyncUpdate(newDoc, newDoc.content),
|
||
|
]
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(expandedUpdates).to.deep.equal([
|
||
|
{
|
||
|
pathname: newDoc.path,
|
||
|
doc: newDoc.doc,
|
||
|
docLines: '',
|
||
|
meta: {
|
||
|
resync: true,
|
||
|
ts: timestamp,
|
||
|
origin: { kind: 'history-resync' },
|
||
|
},
|
||
|
},
|
||
|
{
|
||
|
doc: newDoc.doc,
|
||
|
op: [{ i: 'a', p: 0 }],
|
||
|
meta: {
|
||
|
pathname: newDoc.path,
|
||
|
doc_length: 0,
|
||
|
resync: true,
|
||
|
ts: timestamp,
|
||
|
origin: { kind: 'history-resync' },
|
||
|
},
|
||
|
},
|
||
|
])
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('skips text updates for docs when hashes match', function () {
|
||
|
this.fileMap['main.tex'].getHash = sinon.stub().returns('special-hash')
|
||
|
this.HashManager._getBlobHashFromString.returns('special-hash')
|
||
|
const updates = [
|
||
|
resyncProjectStructureUpdate(
|
||
|
[this.persistedDoc],
|
||
|
[this.persistedFile]
|
||
|
),
|
||
|
docContentSyncUpdate(this.persistedDoc, 'hello'),
|
||
|
]
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(expandedUpdates).to.deep.equal([])
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('computes text updates for docs when hashes differ', function () {
|
||
|
this.fileMap['main.tex'].getHash = sinon.stub().returns('first-hash')
|
||
|
this.HashManager._getBlobHashFromString.returns('second-hash')
|
||
|
this.UpdateCompressor.diffAsShareJsOps.returns([
|
||
|
{ i: 'test diff', p: 0 },
|
||
|
])
|
||
|
const updates = [
|
||
|
resyncProjectStructureUpdate(
|
||
|
[this.persistedDoc],
|
||
|
[this.persistedFile]
|
||
|
),
|
||
|
docContentSyncUpdate(this.persistedDoc, 'hello'),
|
||
|
]
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
expect(error).to.be.null
|
||
|
expect(expandedUpdates).to.deep.equal([
|
||
|
{
|
||
|
doc: this.persistedDoc.doc,
|
||
|
op: [{ i: 'test diff', p: 0 }],
|
||
|
meta: {
|
||
|
pathname: this.persistedDoc.path,
|
||
|
doc_length: 4,
|
||
|
resync: true,
|
||
|
ts: timestamp,
|
||
|
origin: { kind: 'history-resync' },
|
||
|
},
|
||
|
},
|
||
|
])
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
describe('for docs whose contents is out of sync', function () {
|
||
|
beforeEach(function (done) {
|
||
|
const updates = [
|
||
|
resyncProjectStructureUpdate(
|
||
|
[this.persistedDoc],
|
||
|
[this.persistedFile]
|
||
|
),
|
||
|
docContentSyncUpdate(this.persistedDoc, 'a'),
|
||
|
]
|
||
|
const file = { getContent: sinon.stub().returns('stored content') }
|
||
|
this.fileMap['main.tex'].load = sinon.stub().resolves(file)
|
||
|
this.UpdateCompressor.diffAsShareJsOps.returns([{ d: 'sdf', p: 1 }])
|
||
|
this.SyncManager.expandSyncUpdates(
|
||
|
this.projectId,
|
||
|
this.historyId,
|
||
|
updates,
|
||
|
this.extendLock,
|
||
|
(error, expandedUpdates) => {
|
||
|
this.error = error
|
||
|
this.expandedUpdates = expandedUpdates
|
||
|
done()
|
||
|
}
|
||
|
)
|
||
|
})
|
||
|
|
||
|
it('loads content from the history service when needed', function () {
|
||
|
expect(this.error).to.be.null
|
||
|
expect(this.expandedUpdates).to.deep.equal([
|
||
|
{
|
||
|
doc: this.persistedDoc.doc,
|
||
|
op: [{ d: 'sdf', p: 1 }],
|
||
|
meta: {
|
||
|
pathname: this.persistedDoc.path,
|
||
|
doc_length: 'stored content'.length,
|
||
|
resync: true,
|
||
|
ts: timestamp,
|
||
|
origin: { kind: 'history-resync' },
|
||
|
},
|
||
|
},
|
||
|
])
|
||
|
expect(this.extendLock).to.have.been.called
|
||
|
})
|
||
|
})
|
||
|
})
|
||
|
})
|
||
|
})
|