import sinon from 'sinon' import { expect } from 'chai' import { strict as esmock } from 'esmock' import Core from 'overleaf-editor-core' const MODULE_PATH = '../../../../app/js/UpdateTranslator.js' describe('UpdateTranslator', function () { beforeEach(async function () { this.UpdateTranslator = await esmock(MODULE_PATH, { 'overleaf-editor-core': Core, }) this.callback = sinon.stub() this.project_id = '59bfd450e3028c4d40a1e9aa' this.doc_id = '59bfd450e3028c4d40a1e9ab' this.file_id = '59bfd450e3028c4d40a1easd' this.user_id = '59bb9051abf6e8682a269b64' this.version = 0 this.timestamp = new Date().toJSON() this.mockBlobHash = '12345abc12345abc12345abc12345abc12345abc' }) describe('convertToChanges', function () { it('can translate doc additions', function () { const updates = [ { update: { doc: this.doc_id, pathname: '/main.tex', docLines: 'a\nb', meta: { user_id: this.user_id, ts: this.timestamp, }, }, blobHash: this.mockBlobHash, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: 'main.tex', file: { hash: this.mockBlobHash, }, }, ], v2Authors: [this.user_id], timestamp: this.timestamp, }, ]) }) it('can translate file additions', function () { const updates = [ { update: { file: this.file_id, pathname: '/test.png', url: 'filestore.example.com/test.png', meta: { user_id: this.user_id, ts: this.timestamp, }, }, blobHash: this.mockBlobHash, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: 'test.png', file: { hash: this.mockBlobHash, }, }, ], v2Authors: [this.user_id], timestamp: this.timestamp, }, ]) }) it('can translate doc renames', function () { const updates = [ { update: { doc: this.doc_id, pathname: '/main.tex', new_pathname: '/new_main.tex', meta: { user_id: this.user_id, ts: this.timestamp, }, }, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: 'main.tex', newPathname: 'new_main.tex', }, ], v2Authors: [this.user_id], timestamp: this.timestamp, }, ]) }) it('can translate file renames', function () { const updates = [ { update: { file: this.file_id, pathname: '/test.png', new_pathname: '/new_test.png', meta: { user_id: this.user_id, ts: this.timestamp, }, }, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: 'test.png', newPathname: 'new_test.png', }, ], v2Authors: [this.user_id], timestamp: this.timestamp, }, ]) }) it('can translate multiple updates with the correct versions', function () { const updates = [ { update: { doc: this.doc_id, pathname: '/main.tex', docLines: 'a\nb', meta: { user_id: this.user_id, ts: this.timestamp, }, }, blobHash: this.mockBlobHash, }, { update: { file: this.file_id, pathname: '/test.png', url: 'filestore.example.com/test.png', meta: { user_id: this.user_id, ts: this.timestamp, }, }, blobHash: this.mockBlobHash, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: 'main.tex', file: { hash: this.mockBlobHash, }, }, ], v2Authors: [this.user_id], timestamp: this.timestamp, }, { authors: [], operations: [ { pathname: 'test.png', file: { hash: this.mockBlobHash, }, }, ], v2Authors: [this.user_id], timestamp: this.timestamp, }, ]) }) it('returns an error if the update has an unknown format', function () { const updates = [ { update: { foo: 'bar', }, }, ] expect(() => this.UpdateTranslator.convertToChanges(this.project_id, updates) ).to.throw('update with unknown format') }) it('replaces backslashes with underscores in pathnames', function () { const updates = [ { update: { doc: this.doc_id, pathname: '/\\main\\foo.tex', new_pathname: '/\\new_main\\foo\\bar.tex', meta: { user_id: this.user_id, ts: this.timestamp, }, }, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: '_main_foo.tex', newPathname: '_new_main_foo_bar.tex', }, ], v2Authors: [this.user_id], timestamp: this.timestamp, }, ]) }) it('replaces leading asterisks with __ASTERISK__ in pathnames', function () { const updates = [ { update: { file: this.file_id, pathname: '/test*test.png', meta: { user_id: this.user_id, ts: this.timestamp, }, url: 'filestore.example.com/test*test.png', }, blobHash: this.mockBlobHash, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: 'test__ASTERISK__test.png', file: { hash: this.mockBlobHash, }, }, ], v2Authors: [this.user_id], timestamp: this.timestamp, }, ]) }) it('replaces a leading space for top-level files with __SPACE__', function () { const updates = [ { update: { file: this.file_id, pathname: '/ test.png', meta: { user_id: this.user_id, ts: this.timestamp, }, url: 'filestore.example.com/test.png', }, blobHash: this.mockBlobHash, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: '__SPACE__test.png', file: { hash: this.mockBlobHash, }, }, ], v2Authors: [this.user_id], timestamp: this.timestamp, }, ]) }) it('replaces leading spaces of files in subfolders with __SPACE__', function () { const updates = [ { update: { file: this.file_id, pathname: '/folder/ test.png', meta: { user_id: this.user_id, ts: this.timestamp, }, url: 'filestore.example.com/folder/test.png', }, blobHash: this.mockBlobHash, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: 'folder/__SPACE__test.png', file: { hash: this.mockBlobHash, }, }, ], v2Authors: [this.user_id], timestamp: this.timestamp, }, ]) }) it('sets a null author when user_id is "anonymous-user"', function () { const updates = [ { update: { doc: this.doc_id, pathname: '/main.tex', docLines: 'a\nb', meta: { user_id: 'anonymous-user', ts: this.timestamp, }, }, blobHash: this.mockBlobHash, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: 'main.tex', file: { hash: this.mockBlobHash, }, }, ], v2Authors: [null], timestamp: this.timestamp, }, ]) }) it('sets an empty array as author when there is no meta.user_id', function () { const updates = [ { update: { doc: this.doc_id, pathname: '/main.tex', docLines: 'a\nb', meta: { ts: this.timestamp, }, }, blobHash: this.mockBlobHash, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: 'main.tex', file: { hash: this.mockBlobHash, }, }, ], v2Authors: [], timestamp: this.timestamp, }, ]) }) describe('text updates', function () { it('can translate insertions', function () { const updates = [ { update: { doc: this.doc_id, op: [ { p: 3, i: 'foo' }, { p: 15, i: 'bar' }, ], v: this.version, meta: { user_id: this.user_id, ts: new Date(this.timestamp).getTime(), pathname: '/main.tex', doc_length: 20, source: 'some-editor-id', }, }, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: 'main.tex', textOperation: [3, 'foo', 9, 'bar', 8], }, ], v2Authors: [this.user_id], timestamp: this.timestamp, v2DocVersions: { '59bfd450e3028c4d40a1e9ab': { pathname: 'main.tex', v: 0, }, }, }, ]) }) it('can translate deletions', function () { const updates = [ { update: { doc: this.doc_id, op: [ { p: 3, d: 'lo' }, { p: 10, d: 'bar' }, ], v: this.version, meta: { user_id: this.user_id, ts: new Date(this.timestamp).getTime(), pathname: '/main.tex', doc_length: 20, }, }, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: 'main.tex', textOperation: [3, -2, 7, -3, 5], }, ], v2Authors: [this.user_id], timestamp: this.timestamp, v2DocVersions: { '59bfd450e3028c4d40a1e9ab': { pathname: 'main.tex', v: 0, }, }, }, ]) }) it('can translate insertions at the start and end (with zero retained)', function () { const updates = [ { update: { doc: this.doc_id, op: [ { p: 0, i: 'foo' }, { p: 23, i: 'bar' }, { p: 0, d: 'foo' }, { p: 20, d: 'bar' }, ], v: this.version, meta: { user_id: this.user_id, ts: new Date(this.timestamp).getTime(), pathname: '/main.tex', doc_length: 20, }, }, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: 'main.tex', textOperation: [20], }, ], v2Authors: [this.user_id], timestamp: this.timestamp, v2DocVersions: { '59bfd450e3028c4d40a1e9ab': { pathname: 'main.tex', v: 0, }, }, }, ]) }) it('can handle operations in non-linear offset order', function () { const updates = [ { update: { doc: this.doc_id, op: [ { p: 15, i: 'foo' }, { p: 3, i: 'bar' }, ], v: this.version, meta: { user_id: this.user_id, ts: this.timestamp, pathname: '/main.tex', doc_length: 20, }, }, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: 'main.tex', textOperation: [3, 'bar', 12, 'foo', 5], }, ], v2Authors: [this.user_id], timestamp: this.timestamp, v2DocVersions: { '59bfd450e3028c4d40a1e9ab': { pathname: 'main.tex', v: 0, }, }, }, ]) }) it('handles comment ops', function () { const updates = [ { update: { doc: this.doc_id, op: [ { p: 0, i: 'foo' }, { p: 3, d: 'bar' }, { p: 5, c: 'comment this', t: 'comment-id-1' }, { p: 7, c: 'another comment', t: 'comment-id-2' }, { p: 10, i: 'baz' }, ], v: this.version, meta: { user_id: this.user_id, ts: new Date(this.timestamp).getTime(), pathname: '/main.tex', doc_length: 20, }, }, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: 'main.tex', textOperation: ['foo', -3, 17], }, { pathname: 'main.tex', commentId: 'comment-id-1', ranges: [{ pos: 5, length: 12 }], resolved: false, }, { pathname: 'main.tex', commentId: 'comment-id-2', ranges: [{ pos: 7, length: 15 }], resolved: false, }, { pathname: 'main.tex', textOperation: [10, 'baz', 10], }, ], v2Authors: [this.user_id], timestamp: this.timestamp, v2DocVersions: { '59bfd450e3028c4d40a1e9ab': { pathname: 'main.tex', v: 0, }, }, }, ]) }) it('handles insertions after the end of the document', function () { const updates = [ { update: { doc: this.doc_id, op: [{ p: 3, i: '\\' }], v: this.version, meta: { user_id: this.user_id, ts: new Date(this.timestamp).getTime(), pathname: '/main.tex', doc_length: 2, }, }, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: 'main.tex', textOperation: [2, '\\'], }, ], v2Authors: [this.user_id], timestamp: this.timestamp, v2DocVersions: { '59bfd450e3028c4d40a1e9ab': { pathname: 'main.tex', v: 0, }, }, }, ]) }) it('translates external source metadata into an origin', function () { const updates = [ { update: { doc: this.doc_id, op: [{ p: 3, i: 'foo' }], v: this.version, meta: { user_id: this.user_id, ts: new Date(this.timestamp).getTime(), pathname: '/main.tex', doc_length: 20, type: 'external', source: 'dropbox', }, }, }, ] const changes = this.UpdateTranslator.convertToChanges( this.project_id, updates ).map(change => change.toRaw()) expect(changes).to.deep.equal([ { authors: [], operations: [ { pathname: 'main.tex', textOperation: [3, 'foo', 17], }, ], v2Authors: [this.user_id], timestamp: this.timestamp, v2DocVersions: { '59bfd450e3028c4d40a1e9ab': { pathname: 'main.tex', v: 0, }, }, origin: { kind: 'dropbox' }, }, ]) }) it('errors on unexpected ops', function () { const updates = [ { update: { doc: this.doc_id, op: [{ p: 5, z: 'bar' }], v: this.version, meta: { user_id: this.user_id, ts: new Date(this.timestamp).getTime(), pathname: '/main.tex', doc_length: 20, }, }, }, ] expect(() => { this.UpdateTranslator.convertToChanges(this.project_id, updates) }).to.throw('unexpected op type') }) }) }) })