overleaf/services/project-history/test/unit/js/SnapshotManager/SnapshotManagerTests.js

640 lines
19 KiB
JavaScript
Raw Normal View History

/* eslint-disable
no-return-assign,
no-undef,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import sinon from 'sinon'
import { expect } from 'chai'
import { strict as esmock } from 'esmock'
import Core from 'overleaf-editor-core'
import BPromise from 'bluebird'
import * as Errors from '../../../../app/js/Errors.js'
const MODULE_PATH = '../../../../app/js/SnapshotManager.js'
describe('SnapshotManager', function () {
beforeEach(async function () {
this.HistoryStoreManager = {
getBlobStore: sinon.stub(),
getChunkAtVersion: sinon.stub(),
getMostRecentChunk: sinon.stub(),
getProjectBlobStream: sinon.stub(),
}
this.WebApiManager = {
getHistoryId: sinon.stub(),
}
this.SnapshotManager = await esmock(MODULE_PATH, {
'overleaf-editor-core': Core,
'../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager,
'../../../../app/js/WebApiManager.js': this.WebApiManager,
'../../../../app/js/Errors.js': Errors,
})
this.projectId = 'project-id-123'
this.historyId = 'ol-project-id-123'
return (this.callback = sinon.stub())
})
describe('getFileSnapshotStream', function () {
beforeEach(function () {
this.WebApiManager.getHistoryId.yields(null, this.historyId)
this.ranges = {
comments: [],
trackedChanges: [
{
range: { pos: 4, length: 6 },
tracking: {
userId: 'user-1',
ts: '2024-01-01T00:00:00.000Z',
type: 'delete',
},
},
{
range: { pos: 35, length: 5 },
tracking: {
userId: 'user-1',
ts: '2024-01-01T00:00:00.000Z',
type: 'insert',
},
},
],
}
this.HistoryStoreManager.getChunkAtVersion.yields(null, {
chunk: {
history: {
snapshot: {
files: {
'main.tex': {
hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
stringLength: 41,
},
'file_with_ranges.tex': {
hash: '5d2781d78fa5a97b7bafa849fe933dfc9dc93eba',
rangesHash: '73061952d41ce54825e2fc1c36b4cf736d5fb62f',
stringLength: 41,
},
'binary.png': {
hash: 'c6654ea913979e13e22022653d284444f284a172',
byteLength: 41,
},
},
},
changes: [
{
operations: [
{
pathname: 'main.tex',
textOperation: [41, '\n\nSeven eight'],
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
{
operations: [
{
pathname: 'main.tex',
textOperation: [54, ' nine'],
},
],
timestamp: '2017-12-04T10:29:22.905Z',
authors: [31],
},
],
},
startVersion: 3,
authors: [
{
id: 31,
email: 'james.allen@overleaf.com',
name: 'James',
},
],
},
})
})
describe('of a text file with no tracked changes', function () {
beforeEach(function (done) {
this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
getString: (this.getString = sinon.stub().resolves(
`\
Hello world
One two three
Four five six\
`.replace(/^\t/g, '')
)),
getObject: sinon.stub().rejects(),
})
this.SnapshotManager.getFileSnapshotStream(
this.projectId,
5,
'main.tex',
(error, stream) => {
this.stream = stream
return done(error)
}
)
})
it('should get the overleaf id', function () {
return this.WebApiManager.getHistoryId
.calledWith(this.projectId)
.should.equal(true)
})
it('should get the chunk', function () {
return this.HistoryStoreManager.getChunkAtVersion
.calledWith(this.projectId, this.historyId, 5)
.should.equal(true)
})
it('should get the blob of the starting snapshot', function () {
return this.getString
.calledWith('35c9bd86574d61dcadbce2fdd3d4a0684272c6ea')
.should.equal(true)
})
it('should return a string stream with the text content', function () {
return expect(this.stream.read().toString()).to.equal(
`\
Hello world
One two three
Four five six
Seven eight nine\
`.replace(/^\t/g, '')
)
})
describe('on blob store error', function () {
beforeEach(function () {
this.error = new Error('ESOCKETTIMEDOUT')
this.HistoryStoreManager.getBlobStore
.withArgs(this.historyId)
.returns({
getString: sinon.stub().rejects(this.error),
getObject: sinon.stub().rejects(this.error),
})
})
it('should call back with error', function (done) {
this.SnapshotManager.getFileSnapshotStream(
this.projectId,
5,
'main.tex',
error => {
expect(error).to.exist
expect(error.name).to.equal(this.error.name)
done()
}
)
})
})
})
describe('of a text file with tracked changes', function () {
beforeEach(function (done) {
this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
getString: (this.getString = sinon
.stub()
.resolves('the quick brown fox jumps over the lazy dog')),
getObject: (this.getObject = sinon.stub().resolves(this.ranges)),
})
this.SnapshotManager.getFileSnapshotStream(
this.projectId,
5,
'file_with_ranges.tex',
(error, stream) => {
this.stream = stream
done(error)
}
)
})
it('should get the overleaf id', function () {
this.WebApiManager.getHistoryId
.calledWith(this.projectId)
.should.equal(true)
})
it('should get the chunk', function () {
this.HistoryStoreManager.getChunkAtVersion
.calledWith(this.projectId, this.historyId, 5)
.should.equal(true)
})
it('should get the blob of the starting snapshot', function () {
this.getString
.calledWith('5d2781d78fa5a97b7bafa849fe933dfc9dc93eba')
.should.equal(true)
})
it('should get the blob of the ranges', function () {
this.getObject
.calledWith('73061952d41ce54825e2fc1c36b4cf736d5fb62f')
.should.equal(true)
})
it('should return a string stream with the text content without the tracked deletes', function () {
expect(this.stream.read().toString()).to.equal(
'the brown fox jumps over the lazy dog'
)
})
})
describe('of a binary file', function () {
beforeEach(function (done) {
this.HistoryStoreManager.getProjectBlobStream
.withArgs(this.historyId)
.yields(null, (this.stream = 'mock-stream'))
return this.SnapshotManager.getFileSnapshotStream(
this.projectId,
5,
'binary.png',
(error, returnedStream) => {
this.returnedStream = returnedStream
return done(error)
}
)
})
it('should get the overleaf id', function () {
return this.WebApiManager.getHistoryId
.calledWith(this.projectId)
.should.equal(true)
})
it('should get the chunk', function () {
return this.HistoryStoreManager.getChunkAtVersion
.calledWith(this.projectId, this.historyId, 5)
.should.equal(true)
})
it('should get the blob of the starting snapshot', function () {
return this.HistoryStoreManager.getProjectBlobStream
.calledWith(
this.historyId,
'c6654ea913979e13e22022653d284444f284a172'
)
.should.equal(true)
})
return it('should return a stream with the blob content', function () {
return expect(this.returnedStream).to.equal(this.stream)
})
})
return describe("when the file doesn't exist", function () {
beforeEach(function (done) {
return this.SnapshotManager.getFileSnapshotStream(
this.projectId,
5,
'not-here.png',
(error, returnedStream) => {
this.error = error
this.returnedStream = returnedStream
return done()
}
)
})
return it('should return a NotFoundError', function () {
expect(this.error).to.exist
expect(this.error.message).to.equal('not-here.png not found')
return expect(this.error).to.be.an.instanceof(Errors.NotFoundError)
})
})
})
describe('getProjectSnapshot', function () {
beforeEach(function () {
this.WebApiManager.getHistoryId.yields(null, this.historyId)
this.ranges = {
comments: [],
trackedChanges: [
{
range: { pos: 5, length: 6 },
tracking: {
userId: 'user-1',
ts: '2024-01-01T00:00:00.000Z',
type: 'delete',
},
},
{
range: { pos: 12, length: 5 },
tracking: {
userId: 'user-1',
ts: '2024-01-01T00:00:00.000Z',
type: 'insert',
},
},
],
}
return this.HistoryStoreManager.getChunkAtVersion.yields(null, {
chunk: (this.chunk = {
history: {
snapshot: {
files: {
'main.tex': {
hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
stringLength: 41,
},
'unchanged.tex': {
hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
stringLength: 41,
},
'with_ranges_unchanged.tex': {
hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
rangesHash: '2e59fe3dbd5310703f89236d589d0b35db169cdf',
stringLength: 41,
},
'with_ranges_changed.tex': {
hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
rangesHash: '2e59fe3dbd5310703f89236d589d0b35db169cdf',
stringLength: 41,
},
'binary.png': {
hash: 'c6654ea913979e13e22022653d284444f284a172',
byteLength: 41,
},
},
},
changes: [
{
operations: [
{
pathname: 'main.tex',
textOperation: [41, '\n\nSeven eight'],
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
{
operations: [
{
pathname: 'main.tex',
textOperation: [54, ' nine'],
},
],
timestamp: '2017-12-04T10:29:22.905Z',
authors: [31],
},
{
operations: [
{
pathname: 'with_ranges_changed.tex',
textOperation: [41, '\n\nSeven eight'],
},
],
timestamp: '2017-12-04T10:29:25.905Z',
authors: [31],
},
],
},
startVersion: 3,
authors: [
{
id: 31,
email: 'james.allen@overleaf.com',
name: 'James',
},
],
}),
})
})
describe('of project', function () {
beforeEach(function (done) {
this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
getString: (this.getString = sinon.stub().resolves(
`\
Hello world
One two three
Four five six\
`
)),
getObject: (this.getObject = sinon.stub().resolves(this.ranges)),
})
this.SnapshotManager.getProjectSnapshot(
this.projectId,
6,
(error, data) => {
this.data = data
done(error)
}
)
})
it('should get the overleaf id', function () {
this.WebApiManager.getHistoryId
.calledWith(this.projectId)
.should.equal(true)
})
it('should get the chunk', function () {
this.HistoryStoreManager.getChunkAtVersion
.calledWith(this.projectId, this.historyId, 6)
.should.equal(true)
})
it('should get the ranges for the file with tracked changes', function () {
this.getObject.calledWith('2e59fe3dbd5310703f89236d589d0b35db169cdf')
})
it('should produce the snapshot file data', function () {
expect(this.data).to.deep.equal({
files: {
'main.tex': {
// files with operations in the chunk should return content only
data: {
content:
'Hello world\n\nOne two three\n\nFour five six\n\nSeven eight nine',
},
},
'unchanged.tex': {
// unchanged files in the chunk should return hash only
data: {
hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
},
},
'with_ranges_changed.tex': {
// files in the chunk with tracked changes should return content
// without the tracked deletes
data: {
content:
'Hello\n\nOne two three\n\nFour five six\n\nSeven eight',
},
},
'with_ranges_unchanged.tex': {
// files in the chunk with tracked changes should return content
// without the tracked deletes, even if they are unchanged
data: {
content: 'Hello\n\nOne two three\n\nFour five six',
},
},
'binary.png': {
// binary files in the chunk should return hash only
data: {
hash: 'c6654ea913979e13e22022653d284444f284a172',
},
},
},
projectId: 'project-id-123',
})
})
})
describe('on blob store error', function () {
beforeEach(function () {
this.error = new Error('ESOCKETTIMEDOUT')
this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
getString: sinon.stub().rejects(this.error),
getObject: sinon.stub().resolves(),
})
})
it('should call back with error', function (done) {
this.SnapshotManager.getProjectSnapshot(this.projectId, 5, error => {
expect(error).to.exist
expect(error.message).to.equal(this.error.message)
done()
})
})
})
})
return describe('getLatestSnapshot', function () {
describe('for a project', function () {
beforeEach(function (done) {
this.HistoryStoreManager.getMostRecentChunk.yields(null, {
chunk: (this.chunk = {
history: {
snapshot: {
files: {
'main.tex': {
hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
stringLength: 41,
},
'binary.png': {
hash: 'c6654ea913979e13e22022653d284444f284a172',
byteLength: 41,
},
},
},
changes: [
{
operations: [
{
pathname: 'main.tex',
textOperation: [41, '\n\nSeven eight'],
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
{
operations: [
{
pathname: 'main.tex',
textOperation: [54, ' nine'],
},
],
timestamp: '2017-12-04T10:29:22.905Z',
authors: [31],
},
],
},
startVersion: 3,
authors: [
{
id: 31,
email: 'james.allen@overleaf.com',
name: 'James',
},
],
}),
})
this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
getString: (this.getString = sinon.stub().resolves(
`\
Hello world
One two three
Four five six\
`.replace(/^\t/g, '')
)),
getObject: sinon.stub().rejects(),
})
this.SnapshotManager.getLatestSnapshot(
this.projectId,
this.historyId,
(error, data) => {
this.data = data
done(error)
}
)
})
it('should get the chunk', function () {
return this.HistoryStoreManager.getMostRecentChunk
.calledWith(this.projectId, this.historyId)
.should.equal(true)
})
return it('should produce the snapshot file data', function () {
expect(this.data).to.have.all.keys(['main.tex', 'binary.png'])
expect(this.data['main.tex']).to.exist
expect(this.data['binary.png']).to.exist
expect(this.data['main.tex'].getStringLength()).to.equal(59)
expect(this.data['binary.png'].getByteLength()).to.equal(41)
return expect(this.data['binary.png'].getHash()).to.equal(
'c6654ea913979e13e22022653d284444f284a172'
)
})
})
return describe('when the chunk is empty', function () {
beforeEach(function (done) {
this.HistoryStoreManager.getMostRecentChunk.yields(null)
return this.SnapshotManager.getLatestSnapshot(
this.projectId,
this.historyId,
(error, data) => {
this.error = error
this.data = data
return done()
}
)
})
it('should get the chunk', function () {
return this.HistoryStoreManager.getMostRecentChunk
.calledWith(this.projectId, this.historyId)
.should.equal(true)
})
return it('return an error', function () {
expect(this.error).to.exist
return expect(this.error.message).to.equal('undefined chunk')
})
})
})
})