first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
import { expect } from 'chai'
import nock from 'nock'
import mongodb from 'mongodb-legacy'
import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
const { ObjectId } = mongodb
const MockHistoryStore = () => nock('http://127.0.0.1:3100')
const MockWeb = () => nock('http://127.0.0.1:3000')
const fixture = path => new URL(`../fixtures/${path}`, import.meta.url)
describe('Deleting project', function () {
beforeEach(function (done) {
this.projectId = new ObjectId().toString()
this.historyId = new ObjectId().toString()
MockWeb()
.get(`/project/${this.projectId}/details`)
.reply(200, {
name: 'Test Project',
overleaf: { history: { id: this.historyId } },
})
MockHistoryStore()
.get(`/api/projects/${this.historyId}/latest/history`)
.replyWithFile(200, fixture('chunks/0-3.json'))
MockHistoryStore().delete(`/api/projects/${this.historyId}`).reply(204)
ProjectHistoryApp.ensureRunning(done)
})
describe('when the project has no pending updates', function (done) {
it('successfully deletes the project', function (done) {
ProjectHistoryClient.deleteProject(this.projectId, done)
})
})
describe('when the project has pending updates', function (done) {
beforeEach(function (done) {
ProjectHistoryClient.pushRawUpdate(
this.projectId,
{
pathname: '/main.tex',
docLines: 'hello',
doc: this.docId,
meta: { userId: this.userId, ts: new Date() },
},
err => {
if (err) {
return done(err)
}
ProjectHistoryClient.setFirstOpTimestamp(
this.projectId,
Date.now(),
err => {
if (err) {
return done(err)
}
ProjectHistoryClient.deleteProject(this.projectId, done)
}
)
}
)
})
it('clears pending updates', function (done) {
ProjectHistoryClient.getDump(this.projectId, (err, dump) => {
if (err) {
return done(err)
}
expect(dump.updates).to.deep.equal([])
done()
})
})
it('clears the first op timestamp', function (done) {
ProjectHistoryClient.getFirstOpTimestamp(this.projectId, (err, ts) => {
if (err) {
return done(err)
}
expect(ts).to.be.null
done()
})
})
})
})

View File

@@ -0,0 +1,415 @@
import { expect } from 'chai'
import request from 'request'
import crypto from 'node:crypto'
import mongodb from 'mongodb-legacy'
import nock from 'nock'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
const { ObjectId } = mongodb
const MockHistoryStore = () => nock('http://127.0.0.1:3100')
const MockWeb = () => nock('http://127.0.0.1:3000')
function createMockBlob(historyId, content) {
const sha = crypto.createHash('sha1').update(content).digest('hex')
MockHistoryStore()
.get(`/api/projects/${historyId}/blobs/${sha}`)
.reply(200, content)
.persist()
return sha
}
describe('Diffs', function () {
beforeEach(function (done) {
ProjectHistoryApp.ensureRunning(error => {
if (error) {
throw error
}
this.historyId = new ObjectId().toString()
this.projectId = new ObjectId().toString()
MockHistoryStore().post('/api/projects').reply(200, {
projectId: this.historyId,
})
MockWeb()
.get(`/project/${this.projectId}/details`)
.reply(200, {
name: 'Test Project',
overleaf: { history: { id: this.historyId } },
})
ProjectHistoryClient.initializeProject(this.historyId, error => {
if (error) {
return done(error)
}
done()
})
})
})
afterEach(function () {
nock.cleanAll()
})
it('should return a diff of the updates to a doc from a single chunk', function (done) {
this.blob = 'one two three five'
this.sha = createMockBlob(this.historyId, this.blob)
this.v2AuthorId = '123456789'
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/6/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {
'foo.tex': {
hash: this.sha,
stringLength: this.blob.length,
},
},
},
changes: [
{
operations: [
{
pathname: 'foo.tex',
textOperation: [13, ' four', 5],
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
{
operations: [
{
pathname: 'foo.tex',
textOperation: [4, -4, 15],
},
],
timestamp: '2017-12-04T10:29:22.905Z',
authors: [31],
},
{
operations: [
{
pathname: 'foo.tex',
textOperation: [19, ' six'],
},
],
timestamp: '2017-12-04T10:29:26.120Z',
v2Authors: [this.v2AuthorId],
},
],
},
startVersion: 3,
},
authors: [31],
})
ProjectHistoryClient.getDiff(
this.projectId,
'foo.tex',
3,
6,
(error, diff) => {
if (error) {
throw error
}
expect(diff).to.deep.equal({
diff: [
{
u: 'one ',
},
{
d: 'two ',
meta: {
users: [31],
start_ts: 1512383362905,
end_ts: 1512383362905,
},
},
{
u: 'three',
},
{
i: ' four',
meta: {
users: [31],
start_ts: 1512383357786,
end_ts: 1512383357786,
},
},
{
u: ' five',
},
{
i: ' six',
meta: {
users: [this.v2AuthorId],
start_ts: 1512383366120,
end_ts: 1512383366120,
},
},
],
})
done()
}
)
})
it('should return a diff of the updates to a doc across multiple chunks', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/5/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {
'foo.tex': {
hash: createMockBlob(this.historyId, 'one two three five'),
stringLength: 'one three four five'.length,
},
},
},
changes: [
{
operations: [
{
pathname: 'foo.tex',
textOperation: [13, ' four', 5],
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
{
operations: [
{
pathname: 'foo.tex',
textOperation: [4, -4, 15],
},
],
timestamp: '2017-12-04T10:29:22.905Z',
authors: [31],
},
],
},
startVersion: 3,
},
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/6/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {
'foo.tex': {
hash: createMockBlob(this.historyId, 'one three four five'),
stringLength: 'one three four five'.length,
},
},
},
changes: [
{
operations: [
{
pathname: 'foo.tex',
textOperation: [19, ' six'],
},
],
timestamp: '2017-12-04T10:29:26.120Z',
authors: [31],
},
{
operations: [
{
pathname: 'foo.tex',
textOperation: [23, ' seven'],
},
],
timestamp: '2017-12-04T10:29:26.120Z',
authors: [31],
},
],
},
startVersion: 5,
},
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
ProjectHistoryClient.getDiff(
this.projectId,
'foo.tex',
4,
6,
(error, diff) => {
if (error) {
throw error
}
expect(diff).to.deep.equal({
diff: [
{
u: 'one ',
},
{
d: 'two ',
meta: {
users: [31],
start_ts: 1512383362905,
end_ts: 1512383362905,
},
},
{
u: 'three four five',
},
{
i: ' six',
meta: {
users: [31],
start_ts: 1512383366120,
end_ts: 1512383366120,
},
},
],
})
done()
}
)
})
it('should return a 404 when there are no changes for the file in the range', function (done) {
this.blob = 'one two three five'
this.sha = createMockBlob(this.historyId, this.blob)
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/6/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {
'foo.tex': {
hash: this.sha,
stringLength: this.blob.length,
},
},
},
changes: [
{
operations: [
{
pathname: 'foo.tex',
textOperation: [13, ' four', 5],
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
],
},
startVersion: 3,
},
authors: [31],
})
request.get(
{
url: `http://127.0.0.1:3054/project/${this.projectId}/diff`,
qs: {
pathname: 'not_here.tex',
from: 3,
to: 6,
},
json: true,
},
(error, res, body) => {
if (error) {
throw error
}
expect(res.statusCode).to.equal(404)
done()
}
)
})
it('should return a binary flag with a diff of a binary file', function (done) {
this.blob = 'one two three five'
this.sha = createMockBlob(this.historyId, this.blob)
this.binaryBlob = Buffer.from([1, 2, 3, 4])
this.binarySha = createMockBlob(this.historyId, this.binaryBlob)
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/6/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {
'binary.tex': {
hash: this.binarySha,
byteLength: this.binaryBlob.length, // Indicates binary
},
'foo.tex': {
hash: this.sha,
stringLength: this.blob.length, // Indicates binary
},
},
},
changes: [
{
operations: [
{
pathname: 'foo.tex',
textOperation: [13, ' four', 5],
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
{
operations: [
{
pathname: 'foo.tex',
textOperation: [4, -4, 15],
},
],
timestamp: '2017-12-04T10:29:22.905Z',
authors: [31],
},
{
operations: [
{
pathname: 'foo.tex',
textOperation: [19, ' six'],
},
],
timestamp: '2017-12-04T10:29:26.120Z',
authors: [31],
},
],
},
startVersion: 3,
},
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
ProjectHistoryClient.getDiff(
this.projectId,
'binary.tex',
3,
6,
(error, diff) => {
if (error) {
throw error
}
expect(diff).to.deep.equal({
diff: {
binary: true,
},
})
done()
}
)
})
})

View File

@@ -0,0 +1,73 @@
/* eslint-disable
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
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import async from 'async'
import sinon from 'sinon'
import { expect } from 'chai'
import Settings from '@overleaf/settings'
import assert from 'node:assert'
import mongodb from 'mongodb-legacy'
import nock from 'nock'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
const { ObjectId } = mongodb
const MockHistoryStore = () => nock('http://127.0.0.1:3100')
const MockWeb = () => nock('http://127.0.0.1:3000')
describe('DiscardingUpdates', function () {
beforeEach(function (done) {
this.timestamp = new Date()
return ProjectHistoryApp.ensureRunning(error => {
if (error != null) {
throw error
}
this.user_id = new ObjectId().toString()
this.project_id = new ObjectId().toString()
this.doc_id = new ObjectId().toString()
MockHistoryStore().post('/api/projects').reply(200, {
projectId: 0,
})
MockWeb()
.get(`/project/${this.project_id}/details`)
.reply(200, { name: 'Test Project' })
return ProjectHistoryClient.initializeProject(this.project_id, done)
})
})
return it('should discard updates', function (done) {
return async.series(
[
cb => {
const update = {
pathname: '/main.tex',
docLines: 'a\nb',
doc: this.doc_id,
meta: { user_id: this.user_id, ts: new Date() },
}
return ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
},
cb => {
return ProjectHistoryClient.flushProject(this.project_id, cb)
},
],
error => {
if (error != null) {
throw error
}
return done()
}
)
})
})

View File

@@ -0,0 +1,880 @@
/* eslint-disable
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
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import sinon from 'sinon'
import { expect } from 'chai'
import Settings from '@overleaf/settings'
import request from 'request'
import assert from 'node:assert'
import Path from 'node:path'
import crypto from 'node:crypto'
import mongodb from 'mongodb-legacy'
import nock from 'nock'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
import * as HistoryId from './helpers/HistoryId.js'
const { ObjectId } = mongodb
const MockHistoryStore = () => nock('http://127.0.0.1:3100')
const MockFileStore = () => nock('http://127.0.0.1:3009')
const MockWeb = () => nock('http://127.0.0.1:3000')
const sha = data => crypto.createHash('sha1').update(data).digest('hex')
describe('FileTree Diffs', function () {
beforeEach(function (done) {
return ProjectHistoryApp.ensureRunning(error => {
if (error != null) {
throw error
}
this.historyId = new ObjectId().toString()
this.projectId = new ObjectId().toString()
MockHistoryStore().post('/api/projects').reply(200, {
projectId: this.historyId,
})
MockWeb()
.get(`/project/${this.projectId}/details`)
.reply(200, {
name: 'Test Project',
overleaf: { history: { id: this.historyId } },
})
return ProjectHistoryClient.initializeProject(
this.historyId,
(error, olProject) => {
if (error != null) {
throw error
}
return done()
}
)
})
})
afterEach(function () {
return nock.cleanAll()
})
it('should return a diff of the updates to a doc from a single chunk', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/7/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {
'foo.tex': {
hash: sha('mock-sha-foo'),
stringLength: 42,
},
'renamed.tex': {
hash: sha('mock-sha-renamed'),
stringLength: 42,
},
'deleted.tex': {
hash: sha('mock-sha-deleted'),
stringLength: 42,
},
},
},
changes: [
{
operations: [
{
pathname: 'renamed.tex',
newPathname: 'newName.tex',
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
{
operations: [
{
pathname: 'foo.tex',
textOperation: ['lorem ipsum'],
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
{
operations: [
{
pathname: 'deleted.tex',
newPathname: '',
},
],
timestamp: '2017-12-04T10:29:22.905Z',
authors: [31],
},
{
operations: [
{
file: {
hash: sha('new-sha'),
stringLength: 42,
},
pathname: 'added.tex',
},
],
timestamp: '2017-12-04T10:29:22.905Z',
authors: [31],
},
],
},
startVersion: 3,
},
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
return ProjectHistoryClient.getFileTreeDiff(
this.projectId,
3,
7,
(error, diff) => {
if (error != null) {
throw error
}
expect(diff).to.deep.equal({
diff: [
{
pathname: 'foo.tex',
operation: 'edited',
},
{
pathname: 'deleted.tex',
operation: 'removed',
deletedAtV: 5,
editable: true,
},
{
newPathname: 'newName.tex',
pathname: 'renamed.tex',
operation: 'renamed',
editable: true,
},
{
pathname: 'added.tex',
operation: 'added',
editable: true,
},
],
})
return done()
}
)
})
it('should return a diff of the updates to a doc across multiple chunks', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/5/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {
'foo.tex': {
// Updated in this chunk
hash: sha('mock-sha-foo'),
stringLength: 42,
},
'bar.tex': {
// Updated in the next chunk
hash: sha('mock-sha-bar'),
stringLength: 42,
},
'baz.tex': {
// Not updated
hash: sha('mock-sha-bar'),
stringLength: 42,
},
'renamed.tex': {
hash: sha('mock-sha-renamed'),
stringLength: 42,
},
'deleted.tex': {
hash: sha('mock-sha-deleted'),
stringLength: 42,
},
},
},
changes: [
{
operations: [
{
pathname: 'renamed.tex',
newPathname: 'newName.tex',
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
{
operations: [
{
pathname: 'foo.tex',
textOperation: ['lorem ipsum'],
},
],
timestamp: '2017-12-04T10:29:19.786Z',
authors: [31],
},
{
operations: [
{
pathname: 'deleted.tex',
newPathname: '',
},
],
timestamp: '2017-12-04T10:29:22.905Z',
authors: [31],
},
],
},
startVersion: 2,
},
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/7/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {
'foo.tex': {
hash: sha('mock-sha-foo'),
stringLength: 42,
},
'baz.tex': {
hash: sha('mock-sha-bar'),
stringLength: 42,
},
'newName.tex': {
hash: sha('mock-sha-renamed'),
stringLength: 42,
},
},
},
changes: [
{
operations: [
{
file: {
hash: sha('new-sha'),
stringLength: 42,
},
pathname: 'added.tex',
},
],
timestamp: '2017-12-04T10:29:22.905Z',
authors: [31],
},
{
operations: [
{
pathname: 'bar.tex',
textOperation: ['lorem ipsum'],
},
],
timestamp: '2017-12-04T10:29:23.786Z',
authors: [31],
},
],
},
startVersion: 5,
},
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
return ProjectHistoryClient.getFileTreeDiff(
this.projectId,
2,
7,
(error, diff) => {
if (error != null) {
throw error
}
expect(diff).to.deep.equal({
diff: [
{
pathname: 'foo.tex',
operation: 'edited',
},
{
pathname: 'bar.tex',
operation: 'edited',
},
{
pathname: 'baz.tex',
editable: true,
},
{
pathname: 'deleted.tex',
operation: 'removed',
deletedAtV: 4,
editable: true,
},
{
newPathname: 'newName.tex',
pathname: 'renamed.tex',
operation: 'renamed',
editable: true,
},
{
pathname: 'added.tex',
operation: 'added',
editable: true,
},
],
})
return done()
}
)
})
it('should return a diff that includes multiple renames', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/5/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {
'one.tex': {
hash: sha('mock-sha'),
stringLength: 42,
},
},
},
changes: [
{
operations: [
{
pathname: 'one.tex',
newPathname: 'two.tex',
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
{
operations: [
{
pathname: 'two.tex',
newPathname: 'three.tex',
},
],
timestamp: '2017-12-04T10:29:22.905Z',
authors: [31],
},
],
},
startVersion: 3,
},
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
return ProjectHistoryClient.getFileTreeDiff(
this.projectId,
3,
5,
(error, diff) => {
if (error != null) {
throw error
}
expect(diff).to.deep.equal({
diff: [
{
newPathname: 'three.tex',
pathname: 'one.tex',
operation: 'renamed',
editable: true,
},
],
})
return done()
}
)
})
it('should handle deleting then re-adding a file', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/5/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {
'one.tex': {
hash: sha('mock-sha'),
stringLength: 42,
},
},
},
changes: [
{
operations: [
{
pathname: 'one.tex',
newPathname: '',
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
{
operations: [
{
pathname: 'one.tex',
file: {
hash: sha('mock-sha'),
},
},
],
timestamp: '2017-12-04T10:29:22.905Z',
authors: [31],
},
],
},
startVersion: 3,
},
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
return ProjectHistoryClient.getFileTreeDiff(
this.projectId,
3,
5,
(error, diff) => {
if (error != null) {
throw error
}
expect(diff).to.deep.equal({
diff: [
{
pathname: 'one.tex',
operation: 'added',
editable: null,
},
],
})
return done()
}
)
})
it('should handle deleting the renaming a file to the same place', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/5/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {
'one.tex': {
hash: sha('mock-sha-one'),
stringLength: 42,
},
'two.tex': {
hash: sha('mock-sha-two'),
stringLength: 42,
},
},
},
changes: [
{
operations: [
{
pathname: 'one.tex',
newPathname: '',
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
{
operations: [
{
pathname: 'two.tex',
newPathname: 'one.tex',
},
],
timestamp: '2017-12-04T10:29:22.905Z',
authors: [31],
},
],
},
startVersion: 3,
},
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
return ProjectHistoryClient.getFileTreeDiff(
this.projectId,
3,
5,
(error, diff) => {
if (error != null) {
throw error
}
expect(diff).to.deep.equal({
diff: [
{
pathname: 'two.tex',
newPathname: 'one.tex',
operation: 'renamed',
editable: true,
},
],
})
return done()
}
)
})
it('should handle adding then renaming a file', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/5/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {},
},
changes: [
{
operations: [
{
pathname: 'one.tex',
file: {
hash: sha('mock-sha'),
stringLength: 42,
},
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
{
operations: [
{
pathname: 'one.tex',
newPathname: 'two.tex',
},
],
timestamp: '2017-12-04T10:29:22.905Z',
authors: [31],
},
],
},
startVersion: 3,
},
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
return ProjectHistoryClient.getFileTreeDiff(
this.projectId,
3,
5,
(error, diff) => {
if (error != null) {
throw error
}
expect(diff).to.deep.equal({
diff: [
{
pathname: 'two.tex',
operation: 'added',
editable: true,
},
],
})
return done()
}
)
})
it('should return 422 with a chunk with an invalid rename', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/6/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {
'foo.tex': {
hash: sha('mock-sha-foo'),
stringLength: 42,
},
'bar.tex': {
hash: sha('mock-sha-bar'),
stringLength: 42,
},
},
},
changes: [
{
operations: [
{
pathname: 'foo.tex',
newPathname: 'bar.tex',
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
],
},
startVersion: 5,
},
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
return ProjectHistoryClient.getFileTreeDiff(
this.projectId,
5,
6,
(error, diff, statusCode) => {
if (error != null) {
throw error
}
expect(statusCode).to.equal(422)
return done()
}
)
})
it('should return 200 with a chunk with an invalid add', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/6/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {
'foo.tex': {
hash: sha('mock-sha-foo'),
stringLength: 42,
},
},
},
changes: [
{
operations: [
{
file: {
hash: sha('new-sha'),
},
pathname: 'foo.tex',
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
],
},
startVersion: 5,
},
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
return ProjectHistoryClient.getFileTreeDiff(
this.projectId,
5,
6,
(error, diff, statusCode) => {
if (error != null) {
throw error
}
expect(diff).to.deep.equal({
diff: [
{
pathname: 'foo.tex',
operation: 'added',
editable: null,
},
],
})
expect(statusCode).to.equal(200)
return done()
}
)
})
it('should handle edits of missing/invalid files ', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/5/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {},
},
changes: [
{
operations: [
{
pathname: 'new.tex',
textOperation: ['lorem ipsum'],
},
],
timestamp: '2017-12-04T10:29:18.786Z',
authors: [31],
},
{
operations: [
{
pathname: '',
textOperation: ['lorem ipsum'],
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
],
},
startVersion: 3,
},
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
return ProjectHistoryClient.getFileTreeDiff(
this.projectId,
3,
5,
(error, diff) => {
if (error != null) {
throw error
}
expect(diff).to.deep.equal({
diff: [
{
operation: 'edited',
pathname: 'new.tex',
},
],
})
return done()
}
)
})
it('should handle deletions of missing/invalid files ', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/5/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {},
},
changes: [
{
operations: [
{
pathname: 'missing.tex',
newPathname: '',
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
{
operations: [
{
pathname: '',
newPathname: '',
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
],
},
startVersion: 3,
},
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
return ProjectHistoryClient.getFileTreeDiff(
this.projectId,
3,
5,
(error, diff) => {
if (error != null) {
throw error
}
expect(diff).to.deep.equal({
diff: [],
})
return done()
}
)
})
return it('should handle renames of missing/invalid files ', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/5/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {},
},
changes: [
{
operations: [
{
pathname: 'missing.tex',
newPathname: 'missing-renamed.tex',
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
{
operations: [
{
pathname: '',
newPathname: 'missing-renamed-other.tex',
},
],
timestamp: '2017-12-04T10:29:17.786Z',
authors: [31],
},
],
},
startVersion: 3,
},
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
return ProjectHistoryClient.getFileTreeDiff(
this.projectId,
3,
5,
(error, diff) => {
if (error != null) {
throw error
}
expect(diff).to.deep.equal({
diff: [],
})
return done()
}
)
})
})

View File

@@ -0,0 +1,242 @@
import async from 'async'
import nock from 'nock'
import { expect } from 'chai'
import request from 'request'
import assert from 'node:assert'
import mongodb from 'mongodb-legacy'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
const { ObjectId } = mongodb
const MockHistoryStore = () => nock('http://127.0.0.1:3100')
const MockWeb = () => nock('http://127.0.0.1:3000')
describe('Flushing old queues', function () {
const historyId = new ObjectId().toString()
beforeEach(function (done) {
this.timestamp = new Date()
ProjectHistoryApp.ensureRunning(error => {
if (error) {
throw error
}
this.projectId = new ObjectId().toString()
this.docId = new ObjectId().toString()
this.fileId = new ObjectId().toString()
MockHistoryStore().post('/api/projects').reply(200, {
projectId: historyId,
})
MockWeb()
.get(`/project/${this.projectId}/details`)
.reply(200, {
name: 'Test Project',
overleaf: {
history: {
id: historyId,
},
},
})
MockHistoryStore()
.get(`/api/projects/${historyId}/latest/history`)
.reply(200, {
chunk: {
startVersion: 0,
history: {
changes: [],
},
},
})
ProjectHistoryClient.initializeProject(historyId, done)
})
})
afterEach(function () {
nock.cleanAll()
})
describe('retrying an unflushed project', function () {
describe('when the update is older than the cutoff', function () {
beforeEach(function (done) {
this.flushCall = MockHistoryStore()
.put(
`/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
)
.reply(201)
.post(`/api/projects/${historyId}/legacy_changes?end_version=0`)
.reply(200)
const update = {
pathname: '/main.tex',
docLines: 'a\nb',
doc: this.docId,
meta: { user_id: this.user_id, ts: new Date() },
}
async.series(
[
cb =>
ProjectHistoryClient.pushRawUpdate(this.projectId, update, cb),
cb =>
ProjectHistoryClient.setFirstOpTimestamp(
this.projectId,
Date.now() - 24 * 3600 * 1000,
cb
),
],
done
)
})
it('flushes the project history queue', function (done) {
request.post(
{
url: 'http://127.0.0.1:3054/flush/old?maxAge=10800',
},
(error, res, body) => {
if (error) {
return done(error)
}
expect(res.statusCode).to.equal(200)
assert(
this.flushCall.isDone(),
'made calls to history service to store updates'
)
done()
}
)
})
it('flushes the project history queue in the background when requested', function (done) {
request.post(
{
url: 'http://127.0.0.1:3054/flush/old?maxAge=10800&background=1',
},
(error, res, body) => {
if (error) {
return done(error)
}
expect(res.statusCode).to.equal(200)
expect(body).to.equal('{"message":"running flush in background"}')
assert(
!this.flushCall.isDone(),
'did not make calls to history service to store updates in the foreground'
)
setTimeout(() => {
assert(
this.flushCall.isDone(),
'made calls to history service to store updates in the background'
)
done()
}, 100)
}
)
})
})
describe('when the update is newer than the cutoff', function () {
beforeEach(function (done) {
this.flushCall = MockHistoryStore()
.put(
`/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
)
.reply(201)
.post(`/api/projects/${historyId}/legacy_changes?end_version=0`)
.reply(200)
const update = {
pathname: '/main.tex',
docLines: 'a\nb',
doc: this.docId,
meta: { user_id: this.user_id, ts: new Date() },
}
async.series(
[
cb =>
ProjectHistoryClient.pushRawUpdate(this.projectId, update, cb),
cb =>
ProjectHistoryClient.setFirstOpTimestamp(
this.projectId,
Date.now() - 60 * 1000,
cb
),
],
done
)
})
it('does not flush the project history queue', function (done) {
request.post(
{
url: `http://127.0.0.1:3054/flush/old?maxAge=${3 * 3600}`,
},
(error, res, body) => {
if (error) {
return done(error)
}
expect(res.statusCode).to.equal(200)
assert(
!this.flushCall.isDone(),
'did not make calls to history service to store updates'
)
done()
}
)
})
})
describe('when the update does not have a timestamp', function () {
beforeEach(function (done) {
this.flushCall = MockHistoryStore()
.put(
`/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
)
.reply(201)
.post(`/api/projects/${historyId}/legacy_changes?end_version=0`)
.reply(200)
const update = {
pathname: '/main.tex',
docLines: 'a\nb',
doc: this.docId,
meta: { user_id: this.user_id, ts: new Date() },
}
this.startDate = Date.now()
async.series(
[
cb =>
ProjectHistoryClient.pushRawUpdate(this.projectId, update, cb),
cb =>
ProjectHistoryClient.clearFirstOpTimestamp(this.projectId, cb),
],
done
)
})
it('flushes the project history queue anyway', function (done) {
request.post(
{
url: `http://127.0.0.1:3054/flush/old?maxAge=${3 * 3600}`,
},
(error, res, body) => {
if (error) {
return done(error)
}
expect(res.statusCode).to.equal(200)
assert(
this.flushCall.isDone(),
'made calls to history service to store updates'
)
ProjectHistoryClient.getFirstOpTimestamp(
this.projectId,
(err, result) => {
if (err) {
return done(err)
}
expect(result).to.be.null
done()
}
)
}
)
})
})
})
})

View File

@@ -0,0 +1,158 @@
import { expect } from 'chai'
import mongodb from 'mongodb-legacy'
import nock from 'nock'
import Core from 'overleaf-editor-core'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
import latestChunk from '../fixtures/chunks/7-8.json' with { type: 'json' }
import previousChunk from '../fixtures/chunks/4-6.json' with { type: 'json' }
import firstChunk from '../fixtures/chunks/0-3.json' with { type: 'json' }
const { ObjectId } = mongodb
const MockHistoryStore = () => nock('http://127.0.0.1:3100')
const MockWeb = () => nock('http://127.0.0.1:3000')
const fixture = path => new URL(`../fixtures/${path}`, import.meta.url)
describe('GetChangesInChunkSince', function () {
let projectId, historyId
beforeEach(function (done) {
projectId = new ObjectId().toString()
historyId = new ObjectId().toString()
ProjectHistoryApp.ensureRunning(error => {
if (error) throw error
MockHistoryStore().post('/api/projects').reply(200, {
projectId: historyId,
})
ProjectHistoryClient.initializeProject(historyId, (error, olProject) => {
if (error) throw error
MockWeb()
.get(`/project/${projectId}/details`)
.reply(200, {
name: 'Test Project',
overleaf: { history: { id: olProject.id } },
})
MockHistoryStore()
.get(`/api/projects/${historyId}/latest/history`)
.replyWithFile(200, fixture('chunks/7-8.json'))
MockHistoryStore()
.get(`/api/projects/${historyId}/versions/7/history`)
.replyWithFile(200, fixture('chunks/7-8.json'))
MockHistoryStore()
.get(`/api/projects/${historyId}/versions/6/history`)
.replyWithFile(200, fixture('chunks/7-8.json'))
MockHistoryStore()
.get(`/api/projects/${historyId}/versions/5/history`)
.replyWithFile(200, fixture('chunks/4-6.json'))
MockHistoryStore()
.get(`/api/projects/${historyId}/versions/4/history`)
.replyWithFile(200, fixture('chunks/4-6.json'))
MockHistoryStore()
.get(`/api/projects/${historyId}/versions/3/history`)
.replyWithFile(200, fixture('chunks/4-6.json'))
MockHistoryStore()
.get(`/api/projects/${historyId}/versions/2/history`)
.replyWithFile(200, fixture('chunks/0-3.json'))
MockHistoryStore()
.get(`/api/projects/${historyId}/versions/1/history`)
.replyWithFile(200, fixture('chunks/0-3.json'))
MockHistoryStore()
.get(`/api/projects/${historyId}/versions/0/history`)
.replyWithFile(200, fixture('chunks/0-3.json'))
done()
})
})
})
afterEach(function () {
nock.cleanAll()
})
function expectChangesSince(version, n, changes, done) {
ProjectHistoryClient.getChangesInChunkSince(
projectId,
version,
{},
(error, got) => {
if (error) throw error
expect(got.latestStartVersion).to.equal(6)
expect(got.changes).to.have.length(n)
expect(got.changes.map(c => Core.Change.fromRaw(c))).to.deep.equal(
changes.map(c => Core.Change.fromRaw(c))
)
done()
}
)
}
const cases = {
8: {
name: 'when up-to-date, return zero changes',
n: 0,
changes: [],
},
7: {
name: 'when one version behind, return one change',
n: 1,
changes: latestChunk.chunk.history.changes.slice(1),
},
6: {
name: 'when at current chunk boundary, return latest chunk in full',
n: 2,
changes: latestChunk.chunk.history.changes,
},
5: {
name: 'when one version behind last chunk, return one change',
n: 1,
changes: previousChunk.chunk.history.changes.slice(2),
},
4: {
name: 'when in last chunk, return two changes',
n: 2,
changes: previousChunk.chunk.history.changes.slice(1),
},
3: {
name: 'when at previous chunk boundary, return just the previous chunk',
n: 3,
changes: previousChunk.chunk.history.changes,
},
2: {
name: 'when at end of first chunk, return one change',
n: 1,
changes: firstChunk.chunk.history.changes.slice(2),
},
1: {
name: 'when in first chunk, return two changes',
n: 2,
changes: firstChunk.chunk.history.changes.slice(1),
},
0: {
name: 'when from zero, return just the first chunk',
n: 3,
changes: firstChunk.chunk.history.changes,
},
}
for (const [since, { name, n, changes }] of Object.entries(cases)) {
it(name, function (done) {
expectChangesSince(since, n, changes, done)
})
}
it('should return an error when past the end version', function (done) {
ProjectHistoryClient.getChangesInChunkSince(
projectId,
9,
{ allowErrors: true },
(error, _body, statusCode) => {
if (error) throw error
expect(statusCode).to.equal(400)
done()
}
)
})
})

View File

@@ -0,0 +1,76 @@
/* eslint-disable
no-undef,
*/
// 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
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import { expect } from 'chai'
import settings from '@overleaf/settings'
import request from 'request'
import mongodb from 'mongodb-legacy'
import nock from 'nock'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
const { ObjectId } = mongodb
const MockHistoryStore = () => nock('http://127.0.0.1:3100')
const MockWeb = () => nock('http://127.0.0.1:3000')
describe('Health Check', function () {
beforeEach(function (done) {
const projectId = new ObjectId()
const historyId = new ObjectId().toString()
settings.history.healthCheck = { project_id: projectId }
return ProjectHistoryApp.ensureRunning(error => {
if (error != null) {
throw error
}
MockHistoryStore().post('/api/projects').reply(200, {
projectId: historyId,
})
MockHistoryStore()
.get(`/api/projects/${historyId}/latest/history`)
.reply(200, {
chunk: {
startVersion: 0,
history: {
snapshot: {},
changes: [],
},
},
})
MockWeb()
.get(`/project/${projectId}/details`)
.reply(200, {
name: 'Test Project',
overleaf: {
history: {
id: historyId,
},
},
})
return ProjectHistoryClient.initializeProject(historyId, done)
})
})
return it('should respond to the health check', function (done) {
return request.get(
{
url: 'http://127.0.0.1:3054/health_check',
},
(error, res, body) => {
if (error != null) {
return callback(error)
}
expect(res.statusCode).to.equal(200)
return done()
}
)
})
})

View File

@@ -0,0 +1,282 @@
import { expect } from 'chai'
import mongodb from 'mongodb-legacy'
import nock from 'nock'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
const { ObjectId } = mongodb
const MockHistoryStore = () => nock('http://127.0.0.1:3100')
const MockWeb = () => nock('http://127.0.0.1:3000')
const fixture = path => new URL(`../fixtures/${path}`, import.meta.url)
describe('Labels', function () {
beforeEach(function (done) {
ProjectHistoryApp.ensureRunning(error => {
if (error != null) {
throw error
}
this.historyId = new ObjectId().toString()
MockHistoryStore().post('/api/projects').reply(200, {
projectId: this.historyId,
})
ProjectHistoryClient.initializeProject(
this.historyId,
(error, olProject) => {
if (error != null) {
throw error
}
this.project_id = new ObjectId().toString()
MockWeb()
.get(`/project/${this.project_id}/details`)
.reply(200, {
name: 'Test Project',
overleaf: { history: { id: olProject.id } },
})
MockHistoryStore()
.get(`/api/projects/${this.historyId}/latest/history`)
.replyWithFile(200, fixture('chunks/7-8.json'))
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/7/history`)
.replyWithFile(200, fixture('chunks/7-8.json'))
.persist()
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/8/history`)
.replyWithFile(200, fixture('chunks/7-8.json'))
.persist()
this.comment = 'a saved version comment'
this.comment2 = 'another saved version comment'
this.user_id = new ObjectId().toString()
this.created_at = new Date(1)
done()
}
)
})
})
afterEach(function () {
nock.cleanAll()
})
it('can create and get labels', function (done) {
ProjectHistoryClient.createLabel(
this.project_id,
this.user_id,
7,
this.comment,
this.created_at,
(error, label) => {
if (error != null) {
throw error
}
ProjectHistoryClient.getLabels(this.project_id, (error, labels) => {
if (error != null) {
throw error
}
expect(labels).to.deep.equal([label])
done()
})
}
)
})
it('can create and get labels with no user id', function (done) {
const userId = undefined
ProjectHistoryClient.createLabel(
this.project_id,
userId,
7,
this.comment,
this.created_at,
(error, label) => {
if (error != null) {
throw error
}
ProjectHistoryClient.getLabels(this.project_id, (error, labels) => {
if (error != null) {
throw error
}
expect(labels).to.deep.equal([label])
done()
})
}
)
})
it('can delete labels', function (done) {
ProjectHistoryClient.createLabel(
this.project_id,
this.user_id,
7,
this.comment,
this.created_at,
(error, label) => {
if (error != null) {
throw error
}
ProjectHistoryClient.deleteLabel(this.project_id, label.id, error => {
if (error != null) {
throw error
}
ProjectHistoryClient.getLabels(this.project_id, (error, labels) => {
if (error != null) {
throw error
}
expect(labels).to.deep.equal([])
done()
})
})
}
)
})
it('can delete labels for the current user', function (done) {
ProjectHistoryClient.createLabel(
this.project_id,
this.user_id,
7,
this.comment,
this.created_at,
(error, label) => {
if (error != null) {
throw error
}
ProjectHistoryClient.deleteLabelForUser(
this.project_id,
this.user_id,
label.id,
error => {
if (error != null) {
throw error
}
ProjectHistoryClient.getLabels(this.project_id, (error, labels) => {
if (error != null) {
throw error
}
expect(labels).to.deep.equal([])
done()
})
}
)
}
)
})
it('can transfer ownership of labels', function (done) {
const fromUser = new ObjectId().toString()
const toUser = new ObjectId().toString()
ProjectHistoryClient.createLabel(
this.project_id,
fromUser,
7,
this.comment,
this.created_at,
(error, label) => {
if (error != null) {
throw error
}
ProjectHistoryClient.createLabel(
this.project_id,
fromUser,
7,
this.comment2,
this.created_at,
(error, label2) => {
if (error != null) {
throw error
}
ProjectHistoryClient.transferLabelOwnership(
fromUser,
toUser,
error => {
if (error != null) {
throw error
}
ProjectHistoryClient.getLabels(
this.project_id,
(error, labels) => {
if (error != null) {
throw error
}
expect(labels).to.deep.equal([
{
id: label.id,
comment: label.comment,
version: label.version,
created_at: label.created_at,
user_id: toUser,
},
{
id: label2.id,
comment: label2.comment,
version: label2.version,
created_at: label2.created_at,
user_id: toUser,
},
])
done()
}
)
}
)
}
)
}
)
})
it('should return labels with summarized updates', function (done) {
ProjectHistoryClient.createLabel(
this.project_id,
this.user_id,
8,
this.comment,
this.created_at,
(error, label) => {
if (error != null) {
throw error
}
ProjectHistoryClient.getSummarizedUpdates(
this.project_id,
{ min_count: 1 },
(error, updates) => {
if (error != null) {
throw error
}
expect(updates).to.deep.equal({
nextBeforeTimestamp: 6,
updates: [
{
fromV: 6,
toV: 8,
meta: {
users: ['5a5637efdac84e81b71014c4', 31],
start_ts: 1512383567277,
end_ts: 1512383572877,
},
pathnames: ['bar.tex', 'main.tex'],
project_ops: [],
labels: [
{
id: label.id.toString(),
comment: this.comment,
version: 8,
user_id: this.user_id,
created_at: this.created_at.toISOString(),
},
],
},
],
})
done()
}
)
}
)
})
})

View File

@@ -0,0 +1,78 @@
import { expect } from 'chai'
import mongodb from 'mongodb-legacy'
import nock from 'nock'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
const { ObjectId } = mongodb
const MockHistoryStore = () => nock('http://127.0.0.1:3100')
const MockWeb = () => nock('http://127.0.0.1:3000')
const fixture = path => new URL(`../fixtures/${path}`, import.meta.url)
describe('LatestSnapshot', function () {
beforeEach(function (done) {
ProjectHistoryApp.ensureRunning(error => {
if (error) {
throw error
}
this.historyId = new ObjectId().toString()
MockHistoryStore().post('/api/projects').reply(200, {
projectId: this.historyId,
})
ProjectHistoryClient.initializeProject(
this.historyId,
(error, v1Project) => {
if (error) {
throw error
}
this.projectId = new ObjectId().toString()
MockWeb()
.get(`/project/${this.projectId}/details`)
.reply(200, {
name: 'Test Project',
overleaf: { history: { id: v1Project.id } },
})
done()
}
)
})
})
afterEach(function () {
nock.cleanAll()
})
it('should return the snapshot with applied changes, metadata and without full content', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/latest/history`)
.replyWithFile(200, fixture('chunks/0-3.json'))
ProjectHistoryClient.getLatestSnapshot(this.projectId, (error, body) => {
if (error) {
throw error
}
expect(body).to.deep.equal({
snapshot: {
files: {
'main.tex': {
hash: 'f28571f561d198b87c24cc6a98b78e87b665e22d',
stringLength: 20649,
operations: [{ textOperation: [1912, 'Hello world', 18726] }],
metadata: { main: true },
},
'foo.tex': {
hash: '4f785a4c192155b240e3042b3a7388b47603f423',
stringLength: 41,
operations: [{ textOperation: [26, '\n\nFour five six'] }],
},
},
},
version: 3,
})
done()
})
})
})

View File

@@ -0,0 +1,298 @@
import { expect } from 'chai'
import mongodb from 'mongodb-legacy'
import nock from 'nock'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
const { ObjectId } = mongodb
const MockHistoryStore = () => nock('http://127.0.0.1:3100')
const MockWeb = () => nock('http://127.0.0.1:3000')
const fixture = path => new URL(`../fixtures/${path}`, import.meta.url)
describe('ReadSnapshot', function () {
beforeEach(function (done) {
ProjectHistoryApp.ensureRunning(error => {
if (error) {
throw error
}
this.historyId = new ObjectId().toString()
MockHistoryStore().post('/api/projects').reply(200, {
projectId: this.historyId,
})
ProjectHistoryClient.initializeProject(
this.historyId,
(error, v1Project) => {
if (error) {
throw error
}
this.projectId = new ObjectId().toString()
MockWeb()
.get(`/project/${this.projectId}/details`)
.reply(200, {
name: 'Test Project',
overleaf: { history: { id: v1Project.id } },
})
done()
}
)
})
})
afterEach(function () {
nock.cleanAll()
})
describe('of a text file', function () {
it('should return the snapshot of a doc at the given version', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/5/history`)
.replyWithFile(200, fixture('chunks/4-6.json'))
MockHistoryStore()
.get(
`/api/projects/${this.historyId}/blobs/c6654ea913979e13e22022653d284444f284a172`
)
.replyWithFile(
200,
fixture('blobs/c6654ea913979e13e22022653d284444f284a172')
)
ProjectHistoryClient.getSnapshot(
this.projectId,
'foo.tex',
5,
(error, body) => {
if (error) {
throw error
}
expect(body).to.deep.equal(
`\
Hello world
One two three
Four five six
Seven eight nine\
`.replace(/^\t/g, '')
)
done()
}
)
})
it('should return the snapshot of a doc at a different version', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/4/history`)
.replyWithFile(200, fixture('chunks/4-6.json'))
MockHistoryStore()
.get(
`/api/projects/${this.historyId}/blobs/c6654ea913979e13e22022653d284444f284a172`
)
.replyWithFile(
200,
fixture('blobs/c6654ea913979e13e22022653d284444f284a172')
)
ProjectHistoryClient.getSnapshot(
this.projectId,
'foo.tex',
4,
(error, body) => {
if (error) {
throw error
}
expect(body).to.deep.equal(
`\
Hello world
One two three
Four five six
Seven eight nince\
`.replace(/^\t/g, '')
)
done()
}
)
})
it('should return the snapshot of a doc after a rename version', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/6/history`)
.replyWithFile(200, fixture('chunks/4-6.json'))
MockHistoryStore()
.get(
`/api/projects/${this.historyId}/blobs/c6654ea913979e13e22022653d284444f284a172`
)
.replyWithFile(
200,
fixture('blobs/c6654ea913979e13e22022653d284444f284a172')
)
ProjectHistoryClient.getSnapshot(
this.projectId,
'bar.tex',
6,
(error, body) => {
if (error) {
throw error
}
expect(body).to.deep.equal(
`\
Hello world
One two three
Four five six
Seven eight nine\
`.replace(/^\t/g, '')
)
done()
}
)
})
})
describe('of a binary file', function () {
beforeEach(function () {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/4/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {
binary_file: {
hash: 'c6654ea913979e13e22022653d284444f284a172',
byteLength: 41,
},
},
},
changes: [],
},
startVersion: 3,
},
authors: [],
})
})
it('should return the snapshot of the file at the given version', function (done) {
MockHistoryStore()
.get(
`/api/projects/${this.historyId}/blobs/c6654ea913979e13e22022653d284444f284a172`
)
.replyWithFile(
200,
fixture('blobs/c6654ea913979e13e22022653d284444f284a172')
)
ProjectHistoryClient.getSnapshot(
this.projectId,
'binary_file',
4,
(error, body) => {
if (error) {
throw error
}
expect(body).to.deep.equal(
`\
Hello world
One two three
Four five six\
`.replace(/^\t/g, '')
)
done()
}
)
})
it("should return an error when the blob doesn't exist", function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/4/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {
binary_file: {
hash: 'c6654ea913979e13e22022653d284444f284a172',
byteLength: 41,
},
},
},
changes: [],
},
startVersion: 3,
},
authors: [],
})
MockHistoryStore()
.get(
`/api/projects/${this.historyId}/blobs/c6654ea913979e13e22022653d284444f284a172`
)
.reply(404)
ProjectHistoryClient.getSnapshot(
this.projectId,
'binary_file',
4,
{ allowErrors: true },
(error, body, statusCode) => {
if (error) {
throw error
}
expect(statusCode).to.equal(500)
done()
}
)
})
it('should return an error when the blob request errors', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/4/history`)
.reply(200, {
chunk: {
history: {
snapshot: {
files: {
binary_file: {
hash: 'c6654ea913979e13e22022653d284444f284a172',
byteLength: 41,
},
},
},
changes: [],
},
startVersion: 3,
},
authors: [],
})
MockHistoryStore()
.get(
`/api/projects/${this.historyId}/blobs/c6654ea913979e13e22022653d284444f284a172`
)
.replyWithError('oh no!')
ProjectHistoryClient.getSnapshot(
this.projectId,
'binary_file',
4,
{ allowErrors: true },
(error, body, statusCode) => {
if (error) {
throw error
}
expect(statusCode).to.equal(500)
done()
}
)
})
})
})

View File

@@ -0,0 +1,194 @@
import async from 'async'
import nock from 'nock'
import { expect } from 'chai'
import request from 'request'
import assert from 'node:assert'
import mongodb from 'mongodb-legacy'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
const { ObjectId } = mongodb
const MockHistoryStore = () => nock('http://127.0.0.1:3100')
const MockWeb = () => nock('http://127.0.0.1:3000')
const MockCallback = () => nock('http://127.0.0.1')
describe('Retrying failed projects', function () {
const historyId = new ObjectId().toString()
beforeEach(function (done) {
this.timestamp = new Date()
ProjectHistoryApp.ensureRunning(error => {
if (error) {
throw error
}
this.project_id = new ObjectId().toString()
this.doc_id = new ObjectId().toString()
this.file_id = new ObjectId().toString()
MockHistoryStore().post('/api/projects').reply(200, {
projectId: historyId,
})
MockWeb()
.get(`/project/${this.project_id}/details`)
.reply(200, {
name: 'Test Project',
overleaf: {
history: {
id: historyId,
},
},
})
MockHistoryStore()
.get(`/api/projects/${historyId}/latest/history`)
.reply(200, {
chunk: {
startVersion: 0,
history: {
changes: [],
},
},
})
ProjectHistoryClient.initializeProject(historyId, done)
})
})
afterEach(function () {
nock.cleanAll()
})
describe('retrying project history', function () {
describe('when there is a soft failure', function () {
beforeEach(function (done) {
this.flushCall = MockHistoryStore()
.put(
`/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
)
.reply(201)
.post(`/api/projects/${historyId}/legacy_changes?end_version=0`)
.reply(200)
const update = {
pathname: '/main.tex',
docLines: 'a\nb',
doc: this.doc_id,
meta: { user_id: this.user_id, ts: new Date() },
}
async.series(
[
cb =>
ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb),
cb =>
ProjectHistoryClient.setFailure(
{
project_id: this.project_id,
attempts: 1,
error: 'soft-error',
},
cb
),
],
done
)
})
it('flushes the project history queue', function (done) {
request.post(
{
url: 'http://127.0.0.1:3054/retry/failures?failureType=soft&limit=1&timeout=10000',
},
(error, res, body) => {
if (error) {
return done(error)
}
expect(res.statusCode).to.equal(200)
assert(
this.flushCall.isDone(),
'made calls to history service to store updates'
)
done()
}
)
})
it('retries in the background when requested', function (done) {
this.callback = MockCallback()
.matchHeader('Authorization', '123')
.get('/ping')
.reply(200)
request.post(
{
url: 'http://127.0.0.1:3054/retry/failures?failureType=soft&limit=1&timeout=10000&callbackUrl=http%3A%2F%2F127.0.0.1%2Fping',
headers: {
'X-CALLBACK-Authorization': '123',
},
},
(error, res, body) => {
if (error) {
return done(error)
}
expect(res.statusCode).to.equal(200)
expect(body).to.equal(
'{"retryStatus":"running retryFailures in background"}'
)
assert(
!this.flushCall.isDone(),
'did not make calls to history service to store updates in the foreground'
)
setTimeout(() => {
assert(
this.flushCall.isDone(),
'made calls to history service to store updates in the background'
)
assert(this.callback.isDone(), 'hit the callback url')
done()
}, 100)
}
)
})
})
describe('when there is a hard failure', function () {
beforeEach(function (done) {
MockWeb()
.get(`/project/${this.project_id}/details`)
.reply(200, {
name: 'Test Project',
overleaf: {
history: {
id: historyId,
},
},
})
ProjectHistoryClient.setFailure(
{
project_id: this.project_id,
attempts: 100,
error: 'hard-error',
},
done
)
})
it('calls web to resync the project', function (done) {
const resyncCall = MockWeb()
.post(`/project/${this.project_id}/history/resync`)
.reply(200)
request.post(
{
url: 'http://127.0.0.1:3054/retry/failures?failureType=hard&limit=1&timeout=10000',
},
(error, res, body) => {
if (error) {
return done(error)
}
expect(res.statusCode).to.equal(200)
assert(resyncCall.isDone(), 'made a call to web to resync project')
done()
}
)
})
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,249 @@
/* eslint-disable
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
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import sinon from 'sinon'
import { expect } from 'chai'
import Settings from '@overleaf/settings'
import request from 'request'
import assert from 'node:assert'
import mongodb from 'mongodb-legacy'
import nock from 'nock'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
const { ObjectId } = mongodb
const MockHistoryStore = () => nock('http://127.0.0.1:3100')
const MockFileStore = () => nock('http://127.0.0.1:3009')
const MockWeb = () => nock('http://127.0.0.1:3000')
const fixture = path => new URL(`../fixtures/${path}`, import.meta.url)
describe('Summarized updates', function () {
beforeEach(function (done) {
this.projectId = new ObjectId().toString()
this.historyId = new ObjectId().toString()
return ProjectHistoryApp.ensureRunning(error => {
if (error != null) {
throw error
}
MockHistoryStore().post('/api/projects').reply(200, {
projectId: this.historyId,
})
return ProjectHistoryClient.initializeProject(
this.historyId,
(error, olProject) => {
if (error != null) {
throw error
}
MockWeb()
.get(`/project/${this.projectId}/details`)
.reply(200, {
name: 'Test Project',
overleaf: { history: { id: olProject.id } },
})
MockHistoryStore()
.get(`/api/projects/${this.historyId}/latest/history`)
.replyWithFile(200, fixture('chunks/7-8.json'))
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/6/history`)
.replyWithFile(200, fixture('chunks/4-6.json'))
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/3/history`)
.replyWithFile(200, fixture('chunks/0-3.json'))
return done()
}
)
})
})
afterEach(function () {
return nock.cleanAll()
})
it('should return the latest summarized updates from a single chunk', function (done) {
return ProjectHistoryClient.getSummarizedUpdates(
this.projectId,
{ min_count: 1 },
(error, updates) => {
if (error != null) {
throw error
}
expect(updates).to.deep.equal({
nextBeforeTimestamp: 6,
updates: [
{
fromV: 6,
toV: 8,
meta: {
users: ['5a5637efdac84e81b71014c4', 31],
start_ts: 1512383567277,
end_ts: 1512383572877,
},
pathnames: ['bar.tex', 'main.tex'],
project_ops: [],
labels: [],
},
],
})
return done()
}
)
})
it('should return the latest summarized updates, with min_count spanning multiple chunks', function (done) {
return ProjectHistoryClient.getSummarizedUpdates(
this.projectId,
{ min_count: 5 },
(error, updates) => {
if (error != null) {
throw error
}
expect(updates).to.deep.equal({
updates: [
{
fromV: 6,
toV: 8,
meta: {
users: ['5a5637efdac84e81b71014c4', 31],
start_ts: 1512383567277,
end_ts: 1512383572877,
},
pathnames: ['bar.tex', 'main.tex'],
project_ops: [],
labels: [],
},
{
fromV: 5,
toV: 6,
meta: {
users: [31],
start_ts: 1512383366120,
end_ts: 1512383366120,
},
pathnames: [],
project_ops: [
{
atV: 5,
rename: {
pathname: 'foo.tex',
newPathname: 'bar.tex',
},
},
],
labels: [],
},
{
fromV: 2,
toV: 5,
meta: {
users: [31],
start_ts: 1512383313724,
end_ts: 1512383362905,
},
pathnames: ['foo.tex'],
project_ops: [],
labels: [],
},
{
fromV: 1,
toV: 2,
meta: {
users: [31],
start_ts: 1512383246874,
end_ts: 1512383246874,
},
pathnames: [],
project_ops: [
{
atV: 1,
rename: {
pathname: 'bar.tex',
newPathname: 'foo.tex',
},
},
],
labels: [],
},
{
fromV: 0,
toV: 1,
meta: {
users: [31],
start_ts: 1512383015633,
end_ts: 1512383015633,
},
pathnames: ['main.tex'],
project_ops: [],
labels: [],
},
],
})
return done()
}
)
})
it('should return the summarized updates from a before version at the start of a chunk', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/4/history`)
.replyWithFile(200, fixture('chunks/4-6.json'))
return ProjectHistoryClient.getSummarizedUpdates(
this.projectId,
{ before: 4 },
(error, updates) => {
if (error != null) {
throw error
}
expect(updates.updates[0].toV).to.equal(4)
return done()
}
)
})
it('should return the summarized updates from a before version in the middle of a chunk', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/5/history`)
.replyWithFile(200, fixture('chunks/4-6.json'))
return ProjectHistoryClient.getSummarizedUpdates(
this.projectId,
{ before: 5 },
(error, updates) => {
if (error != null) {
throw error
}
expect(updates.updates[0].toV).to.equal(5)
return done()
}
)
})
return it('should return the summarized updates from a before version at the end of a chunk', function (done) {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/6/history`)
.replyWithFile(200, fixture('chunks/4-6.json'))
return ProjectHistoryClient.getSummarizedUpdates(
this.projectId,
{ before: 6 },
(error, updates) => {
if (error != null) {
throw error
}
expect(updates.updates[0].toV).to.equal(6)
return done()
}
)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
// TODO: This file was created by bulk-decaffeinate.
// Sanity-check the conversion and remove this comment.
let id = 0
export function nextId() {
return id++
}

View File

@@ -0,0 +1,41 @@
/* eslint-disable
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
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import { expect } from 'chai'
import request from 'request'
import Settings from '@overleaf/settings'
export function getLatestContent(olProjectId, callback) {
if (callback == null) {
callback = function () {}
}
return request.get(
{
url: `${Settings.overleaf.history.host}/projects/${olProjectId}/latest/content`,
auth: {
user: Settings.overleaf.history.user,
pass: Settings.overleaf.history.pass,
sendImmediately: true,
},
},
(error, res, body) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
callback(
new Error(
`history store a non-success status code: ${res.statusCode}`
)
)
}
return callback(error, JSON.parse(body))
}
)
}

View File

@@ -0,0 +1,41 @@
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS205: Consider reworking code to avoid use of IIFEs
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import { app } from '../../../../app/js/server.js'
let running = false
let initing = false
const callbacks = []
export function ensureRunning(callback) {
if (callback == null) {
callback = function () {}
}
if (running) {
return callback()
} else if (initing) {
return callbacks.push(callback)
}
initing = true
callbacks.push(callback)
app.listen(3054, '127.0.0.1', error => {
if (error != null) {
throw error
}
running = true
return (() => {
const result = []
for (callback of Array.from(callbacks)) {
result.push(callback())
}
return result
})()
})
}

View File

@@ -0,0 +1,354 @@
import { expect } from 'chai'
import request from 'request'
import Settings from '@overleaf/settings'
import RedisWrapper from '@overleaf/redis-wrapper'
import { db } from '../../../../app/js/mongodb.js'
const rclient = RedisWrapper.createClient(Settings.redis.project_history)
const Keys = Settings.redis.project_history.key_schema
export function resetDatabase(callback) {
rclient.flushdb(callback)
}
export function initializeProject(historyId, callback) {
request.post(
{
url: 'http://127.0.0.1:3054/project',
json: { historyId },
},
(error, res, body) => {
if (error) {
return callback(error)
}
expect(res.statusCode).to.equal(200)
callback(null, body.project)
}
)
}
export function flushProject(projectId, options, callback) {
if (typeof options === 'function') {
callback = options
options = null
}
if (!options) {
options = { allowErrors: false }
}
request.post(
{
url: `http://127.0.0.1:3054/project/${projectId}/flush`,
},
(error, res, body) => {
if (error) {
return callback(error)
}
if (!options.allowErrors) {
expect(res.statusCode).to.equal(204)
}
callback(error, res)
}
)
}
export function getSummarizedUpdates(projectId, query, callback) {
request.get(
{
url: `http://127.0.0.1:3054/project/${projectId}/updates`,
qs: query,
json: true,
},
(error, res, body) => {
if (error) {
return callback(error)
}
expect(res.statusCode).to.equal(200)
callback(error, body)
}
)
}
export function getDiff(projectId, pathname, from, to, callback) {
request.get(
{
url: `http://127.0.0.1:3054/project/${projectId}/diff`,
qs: {
pathname,
from,
to,
},
json: true,
},
(error, res, body) => {
if (error) {
return callback(error)
}
expect(res.statusCode).to.equal(200)
callback(error, body)
}
)
}
export function getFileTreeDiff(projectId, from, to, callback) {
request.get(
{
url: `http://127.0.0.1:3054/project/${projectId}/filetree/diff`,
qs: {
from,
to,
},
json: true,
},
(error, res, body) => {
if (error) {
return callback(error)
}
callback(error, body, res.statusCode)
}
)
}
export function getChangesInChunkSince(projectId, since, options, callback) {
request.get(
{
url: `http://127.0.0.1:3054/project/${projectId}/changes-in-chunk`,
qs: {
since,
},
json: true,
},
(error, res, body) => {
if (error) return callback(error)
if (!options.allowErrors) {
expect(res.statusCode).to.equal(200)
}
callback(null, body, res.statusCode)
}
)
}
export function getLatestSnapshot(projectId, callback) {
request.get(
{
url: `http://127.0.0.1:3054/project/${projectId}/snapshot`,
json: true,
},
(error, res, body) => {
if (error) {
return callback(error)
}
expect(res.statusCode).to.equal(200)
callback(null, body)
}
)
}
export function getSnapshot(projectId, pathname, version, options, callback) {
if (typeof options === 'function') {
callback = options
options = null
}
if (!options) {
options = { allowErrors: false }
}
request.get(
{
url: `http://127.0.0.1:3054/project/${projectId}/version/${version}/${encodeURIComponent(
pathname
)}`,
},
(error, res, body) => {
if (error) {
return callback(error)
}
if (!options.allowErrors) {
expect(res.statusCode).to.equal(200)
}
callback(error, body, res.statusCode)
}
)
}
export function pushRawUpdate(projectId, update, callback) {
rclient.rpush(
Keys.projectHistoryOps({ project_id: projectId }),
JSON.stringify(update),
callback
)
}
export function setFirstOpTimestamp(projectId, timestamp, callback) {
rclient.set(
Keys.projectHistoryFirstOpTimestamp({ project_id: projectId }),
timestamp,
callback
)
}
export function getFirstOpTimestamp(projectId, callback) {
rclient.get(
Keys.projectHistoryFirstOpTimestamp({ project_id: projectId }),
callback
)
}
export function clearFirstOpTimestamp(projectId, callback) {
rclient.del(
Keys.projectHistoryFirstOpTimestamp({ project_id: projectId }),
callback
)
}
export function getQueueLength(projectId, callback) {
rclient.llen(Keys.projectHistoryOps({ project_id: projectId }), callback)
}
export function getQueueCounts(callback) {
return request.get(
{
url: 'http://127.0.0.1:3054/status/queue',
json: true,
},
callback
)
}
export function resyncHistory(projectId, callback) {
request.post(
{
url: `http://127.0.0.1:3054/project/${projectId}/resync`,
json: true,
body: { origin: { kind: 'test-origin' } },
},
(error, res, body) => {
if (error) {
return callback(error)
}
expect(res.statusCode).to.equal(204)
callback(error)
}
)
}
export function createLabel(
projectId,
userId,
version,
comment,
createdAt,
callback
) {
request.post(
{
url: `http://127.0.0.1:3054/project/${projectId}/labels`,
json: { comment, version, created_at: createdAt, user_id: userId },
},
(error, res, body) => {
if (error) {
return callback(error)
}
expect(res.statusCode).to.equal(200)
callback(null, body)
}
)
}
export function getLabels(projectId, callback) {
request.get(
{
url: `http://127.0.0.1:3054/project/${projectId}/labels`,
json: true,
},
(error, res, body) => {
if (error) {
return callback(error)
}
expect(res.statusCode).to.equal(200)
callback(null, body)
}
)
}
export function deleteLabelForUser(projectId, userId, labelId, callback) {
request.delete(
{
url: `http://127.0.0.1:3054/project/${projectId}/user/${userId}/labels/${labelId}`,
},
(error, res, body) => {
if (error) {
return callback(error)
}
expect(res.statusCode).to.equal(204)
callback(null, body)
}
)
}
export function deleteLabel(projectId, labelId, callback) {
request.delete(
{
url: `http://127.0.0.1:3054/project/${projectId}/labels/${labelId}`,
},
(error, res, body) => {
if (error) {
return callback(error)
}
expect(res.statusCode).to.equal(204)
callback(null, body)
}
)
}
export function setFailure(failureEntry, callback) {
db.projectHistoryFailures.deleteOne(
{ project_id: { $exists: true } },
(err, result) => {
if (err) {
return callback(err)
}
db.projectHistoryFailures.insertOne(failureEntry, callback)
}
)
}
export function getFailure(projectId, callback) {
db.projectHistoryFailures.findOne({ project_id: projectId }, callback)
}
export function transferLabelOwnership(fromUser, toUser, callback) {
request.post(
{
url: `http://127.0.0.1:3054/user/${fromUser}/labels/transfer/${toUser}`,
},
(error, res, body) => {
if (error) {
return callback(error)
}
expect(res.statusCode).to.equal(204)
callback(null, body)
}
)
}
export function getDump(projectId, callback) {
request.get(
`http://127.0.0.1:3054/project/${projectId}/dump`,
(err, res, body) => {
if (err) {
return callback(err)
}
expect(res.statusCode).to.equal(200)
callback(null, JSON.parse(body))
}
)
}
export function deleteProject(projectId, callback) {
request.delete(`http://127.0.0.1:3054/project/${projectId}`, (err, res) => {
if (err) {
return callback(err)
}
expect(res.statusCode).to.equal(204)
callback()
})
}