Files
overleaf-cep/services/document-updater/test/unit/js/DocumentManager/DocumentManagerTests.js
T
KristinaandJimmy Domagala-Tang 9c005e4e54 [web/document-updater] display preview for accepted/rejected tracked changes (#36240)
* [web] add preview payload to track-change notification types

* [doc-updater] pass accepted/rejected change data through to web for previews

* [web] snapshot change preview onto accepted/rejected notifications

* [web] render resolved change previews instead of accepted/rejected count lines

* [web] add unit coverage for accepted/rejected change preview flow

* fix: notification carries multiple previews per accept/reject batch; extract shared group helper

* fix: one preview per accept/reject action, drop adjacency grouping

* fix: move buildResolvedChangePreviews out of the email builder class

* fix: type error, make tests more explicit

* fix: render resolver below preview

* fix: overflow text, alignment

* fix: test error

* fix: resolved count

* [document-updater/web] send sparse change preview instead of full doc lines

* fix: store sparse preview, hydrate at render, drop CE gate

* fix: advance slice cursor past tracked-delete text in sparse preview

* fix: inline buildChangeGroupPreview

* fix: changePreviewBuilder -> sparseChangePreview

* fix: rm change_contributors

* fix: copilot flagged testing issues

* fix: hydrate and truncate change preview at write time

* fix: avoid materializing full doc text when slicing preview window

* fix: update reject preview test to hydrated shape

* fix: accepted delete removing context

* [document-updater/web] split resolved change previews into one per change cluster

* fix: narrow cluster userIds to string[] for typecheck

* feat: updating to use zod schema for previews

---------

Co-authored-by: Jimmy Domagala-Tang <jimmy.domagala-tang@overleaf.com>
GitOrigin-RevId: 0740713c715a61a220180f353e497b27da6a5b0f
2026-09-17 11:58:14 +00:00

1671 lines
51 KiB
JavaScript

const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = '../../../../app/js/DocumentManager.js'
const SandboxedModule = require('sandboxed-module')
const Errors = require('../../../../app/js/Errors')
const tk = require('timekeeper')
describe('DocumentManager', function () {
beforeEach(function () {
tk.freeze(new Date())
this.Metrics = {
Timer: class Timer {},
inc: sinon.stub(),
}
this.Metrics.Timer.prototype.done = sinon.stub()
this.RedisManager = {
promises: {
clearUnflushedTime: sinon.stub().resolves(),
getDoc: sinon.stub(),
getPreviousDocOps: sinon.stub(),
putDocInMemory: sinon.stub().resolves(),
removeDocFromMemory: sinon.stub().resolves(),
renameDoc: sinon.stub().resolves(),
updateCommentState: sinon.stub().resolves(),
updateDocument: sinon.stub().resolves(),
},
}
this.ProjectHistoryRedisManager = {
promises: {
queueOps: sinon.stub().resolves(),
queueResyncDocContent: sinon.stub().resolves(),
},
}
this.PersistenceManager = {
promises: {
getDoc: sinon.stub(),
setDoc: sinon.stub().resolves(),
},
}
this.HistoryManager = {
flushProjectChangesAsync: sinon.stub(),
}
this.DiffCodec = {
diffAsShareJsOp: sinon.stub(),
}
this.UpdateManager = {
promises: {
applyUpdate: sinon.stub().resolves(),
},
}
this.HistoryOTUpdateManager = {
applyUpdate: sinon.stub().resolves(),
}
this.RangesManager = {
acceptChanges: sinon.stub(),
deleteComment: sinon.stub(),
}
this.Settings = {
max_doc_length: 2 * 1024 * 1024, // 2mb
maxUnflushedAgeMs: 300 * 1000, // 5 minutes
}
this.buildSparseChangePreviews = sinon.stub().returns([])
this.DocumentManager = SandboxedModule.require(modulePath, {
requires: {
'./RedisManager': this.RedisManager,
'./ProjectHistoryRedisManager': this.ProjectHistoryRedisManager,
'./PersistenceManager': this.PersistenceManager,
'./HistoryManager': this.HistoryManager,
'./Metrics': this.Metrics,
'./DiffCodec': this.DiffCodec,
'./UpdateManager': this.UpdateManager,
'./HistoryOTUpdateManager': this.HistoryOTUpdateManager,
'./RangesManager': this.RangesManager,
'./Errors': Errors,
'./TrackedChangePreview': {
buildSparseChangePreviews: this.buildSparseChangePreviews,
},
'@overleaf/settings': this.Settings,
},
})
this.project_id = 'project-id-123'
this.projectHistoryId = 'history-id-123'
this.doc_id = 'doc-id-123'
this.user_id = 1234
this.lines = ['one', 'two', 'three']
this.version = 42
this.ranges = { comments: 'mock', entries: 'mock' }
this.resolvedCommentIds = ['comment-1']
this.pathname = '/a/b/c.tex'
this.unflushedTime = Date.now()
this.lastUpdatedAt = Date.now()
this.lastUpdatedBy = 'last-author-id'
this.source = 'external-source'
this.historyRangesSupport = false
})
afterEach(function () {
tk.reset()
})
describe('flushAndDeleteDoc', function () {
describe('successfully', function () {
beforeEach(async function () {
this.DocumentManager.promises.flushDocIfLoaded = sinon.stub().resolves()
await this.DocumentManager.promises.flushAndDeleteDoc(
this.project_id,
this.doc_id,
{}
)
})
it('should flush the doc', function () {
this.DocumentManager.promises.flushDocIfLoaded
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should remove the doc from redis', function () {
this.RedisManager.promises.removeDocFromMemory
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
})
describe('when a flush error occurs', function () {
beforeEach(async function () {
this.DocumentManager.promises.flushDocIfLoaded = sinon
.stub()
.rejects(new Error('boom!'))
await expect(
this.DocumentManager.promises.flushAndDeleteDoc(
this.project_id,
this.doc_id,
{}
)
).to.be.rejected
})
it('should not remove the doc from redis', function () {
this.RedisManager.promises.removeDocFromMemory.called.should.equal(
false
)
})
describe('when ignoring flush errors', function () {
it('should remove the doc from redis', async function () {
await this.DocumentManager.promises.flushAndDeleteDoc(
this.project_id,
this.doc_id,
{ ignoreFlushErrors: true }
)
this.RedisManager.promises.removeDocFromMemory.called.should.equal(
true
)
})
})
})
})
describe('flushDocIfLoaded', function () {
describe('when the doc is in Redis', function () {
beforeEach(async function () {
this.RedisManager.promises.getDoc.resolves({
lines: this.lines,
version: this.version,
ranges: this.ranges,
pathname: this.pathname,
projectHistoryId: this.projectHistoryId,
unflushedTime: this.unflushedTime,
lastUpdatedAt: this.lastUpdatedAt,
lastUpdatedBy: this.lastUpdatedBy,
})
await this.DocumentManager.promises.flushDocIfLoaded(
this.project_id,
this.doc_id
)
})
it('should get the doc from redis', function () {
this.RedisManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should write the doc lines to the persistence layer', function () {
this.PersistenceManager.promises.setDoc.should.have.been.calledWith(
this.project_id,
this.doc_id,
this.lines,
this.version,
this.ranges,
this.lastUpdatedAt,
this.lastUpdatedBy
)
})
})
describe('when the document is not in Redis', function () {
beforeEach(async function () {
this.RedisManager.promises.getDoc.resolves({
lines: null,
version: null,
ranges: null,
})
await this.DocumentManager.promises.flushDocIfLoaded(
this.project_id,
this.doc_id
)
})
it('should get the doc from redis', function () {
this.RedisManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should not write anything to the persistence layer', function () {
this.PersistenceManager.promises.setDoc.called.should.equal(false)
})
})
})
describe('getDocAndRecentOps', function () {
describe('with a previous version specified', function () {
beforeEach(async function () {
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.lines,
version: this.version,
ranges: this.ranges,
pathname: this.pathname,
projectHistoryId: this.projectHistoryId,
type: 'sharejs-text-ot',
})
this.RedisManager.promises.getPreviousDocOps.resolves(this.ops)
this.result = await this.DocumentManager.promises.getDocAndRecentOps(
this.project_id,
this.doc_id,
this.fromVersion
)
})
it('should get the doc', function () {
this.DocumentManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should get the doc ops', function () {
this.RedisManager.promises.getPreviousDocOps
.calledWith(this.doc_id, this.fromVersion, this.version)
.should.equal(true)
})
it('should return the doc info', function () {
expect(this.result).to.deep.equal({
lines: this.lines,
version: this.version,
ops: this.ops,
ranges: this.ranges,
pathname: this.pathname,
projectHistoryId: this.projectHistoryId,
type: 'sharejs-text-ot',
})
})
})
describe('with no previous version specified', function () {
beforeEach(async function () {
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.lines,
version: this.version,
ranges: this.ranges,
pathname: this.pathname,
projectHistoryId: this.projectHistoryId,
type: 'sharejs-text-ot',
})
this.RedisManager.promises.getPreviousDocOps.resolves(this.ops)
this.result = await this.DocumentManager.promises.getDocAndRecentOps(
this.project_id,
this.doc_id,
-1
)
})
it('should get the doc', function () {
this.DocumentManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should not need to get the doc ops', function () {
this.RedisManager.promises.getPreviousDocOps.called.should.equal(false)
})
it('should return the doc info', function () {
expect(this.result).to.deep.equal({
lines: this.lines,
version: this.version,
ops: [],
ranges: this.ranges,
pathname: this.pathname,
projectHistoryId: this.projectHistoryId,
type: 'sharejs-text-ot',
})
})
})
})
describe('getDoc', function () {
describe('when the doc exists in Redis', function () {
beforeEach(async function () {
this.RedisManager.promises.getDoc.resolves({
lines: this.lines,
version: this.version,
ranges: this.ranges,
resolvedCommentIds: this.resolvedCommentIds,
pathname: this.pathname,
projectHistoryId: this.projectHistoryId,
unflushedTime: this.unflushedTime,
lastUpdatedAt: this.lastUpdatedAt,
lastUpdatedBy: this.lastUpdatedBy,
historyRangesSupport: this.historyRangesSupport,
})
this.result = await this.DocumentManager.promises.getDoc(
this.project_id,
this.doc_id
)
})
it('should get the doc from Redis', function () {
this.RedisManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should return the doc info', function () {
expect(this.result).to.deep.equal({
lines: this.lines,
version: this.version,
ranges: this.ranges,
resolvedCommentIds: this.resolvedCommentIds,
pathname: this.pathname,
projectHistoryId: this.projectHistoryId,
unflushedTime: this.unflushedTime,
alreadyLoaded: true,
historyRangesSupport: this.historyRangesSupport,
type: 'sharejs-text-ot',
})
})
})
describe('when the doc does not exist in Redis', function () {
beforeEach(async function () {
this.RedisManager.promises.getDoc.resolves({
lines: null,
version: null,
ranges: null,
pathname: null,
projectHistoryId: null,
})
this.PersistenceManager.promises.getDoc.resolves({
lines: this.lines,
version: this.version,
ranges: this.ranges,
resolvedCommentIds: this.resolvedCommentIds,
pathname: this.pathname,
projectHistoryId: this.projectHistoryId,
historyRangesSupport: this.historyRangesSupport,
})
this.result = await this.DocumentManager.promises.getDoc(
this.project_id,
this.doc_id
)
})
it('should try to get the doc from Redis', function () {
this.RedisManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should get the doc from the PersistenceManager', function () {
this.PersistenceManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should set the doc in Redis', function () {
this.RedisManager.promises.putDocInMemory
.calledWith(
this.project_id,
this.doc_id,
this.lines,
this.version,
this.ranges,
this.resolvedCommentIds,
this.pathname,
this.projectHistoryId,
this.historyRangesSupport
)
.should.equal(true)
})
it('should return doc info', function () {
expect(this.result).to.deep.equal({
lines: this.lines,
version: this.version,
ranges: this.ranges,
resolvedCommentIds: this.resolvedCommentIds,
pathname: this.pathname,
projectHistoryId: this.projectHistoryId,
unflushedTime: null,
alreadyLoaded: false,
historyRangesSupport: this.historyRangesSupport,
type: 'sharejs-text-ot',
})
})
})
})
describe('setDoc', function () {
describe('with plain tex lines', function () {
beforeEach(function () {
this.beforeLines = ['before', 'lines']
this.afterLines = ['after', 'lines']
this.ops = [
{ i: 'foo', p: 4 },
{ d: 'bar', p: 42 },
]
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.beforeLines,
version: this.version,
ranges: this.ranges,
resolvedCommentIds: this.resolvedCommentIds,
pathname: this.pathname,
projectHistoryId: this.projectHistoryId,
unflushedTime: this.unflushedTime,
alreadyLoaded: true,
})
this.DiffCodec.diffAsShareJsOp.returns(this.ops)
this.DocumentManager.promises.flushDocIfLoaded = sinon.stub().resolves()
this.DocumentManager.promises.flushAndDeleteDoc = sinon
.stub()
.resolves()
})
describe('when not loaded but with the same content', function () {
beforeEach(async function () {
this.DiffCodec.diffAsShareJsOp.returns([])
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.beforeLines,
version: this.version,
ranges: this.ranges,
resolvedCommentIds: this.resolvedCommentIds,
pathname: this.pathname,
projectHistoryId: this.projectHistoryId,
unflushedTime: this.unflushedTime,
alreadyLoaded: false,
})
await this.DocumentManager.promises.setDoc(
this.project_id,
this.doc_id,
this.beforeLines,
this.source,
this.user_id,
false,
true
)
})
it('should not apply the diff as a ShareJS op', function () {
this.UpdateManager.promises.applyUpdate.called.should.equal(false)
})
it('should increment the external update metric', function () {
this.Metrics.inc
.calledWith('external-update', 1, {
status: 'noop',
method: 'evict',
path: this.source,
})
.should.equal(true)
})
it('should flush and delete the doc from redis', function () {
this.DocumentManager.promises.flushAndDeleteDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
})
describe('when already loaded with the same content', function () {
beforeEach(async function () {
this.DiffCodec.diffAsShareJsOp.returns([])
await this.DocumentManager.promises.setDoc(
this.project_id,
this.doc_id,
this.beforeLines,
this.source,
this.user_id,
false,
true
)
})
it('should not apply the diff as a ShareJS op', function () {
this.UpdateManager.promises.applyUpdate.called.should.equal(false)
})
it('should increment the external update metric', function () {
this.Metrics.inc
.calledWith('external-update', 1, {
status: 'noop',
method: 'flush',
path: this.source,
})
.should.equal(true)
})
it('should flush the doc to Mongo', function () {
this.DocumentManager.promises.flushDocIfLoaded
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
})
describe('when already loaded', function () {
beforeEach(async function () {
await this.DocumentManager.promises.setDoc(
this.project_id,
this.doc_id,
this.afterLines,
this.source,
this.user_id,
false,
true
)
})
it('should get the current doc lines', function () {
this.DocumentManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should return a diff of the old and new lines', function () {
this.DiffCodec.diffAsShareJsOp
.calledWith(this.beforeLines, this.afterLines)
.should.equal(true)
})
it('should apply the diff as a ShareJS op', function () {
this.UpdateManager.promises.applyUpdate
.calledWith(this.project_id, this.doc_id, {
doc: this.doc_id,
v: this.version,
op: this.ops,
meta: {
type: 'external',
source: this.source,
user_id: this.user_id,
},
})
.should.equal(true)
})
it('should increment the external update metric', function () {
this.Metrics.inc
.calledWith('external-update', 1, {
status: 'diff',
method: 'flush',
path: this.source,
})
.should.equal(true)
})
it('should flush the doc to Mongo', function () {
this.DocumentManager.promises.flushDocIfLoaded
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should not flush the project history', function () {
this.HistoryManager.flushProjectChangesAsync.called.should.equal(
false
)
})
})
describe('with track changes enabled', function () {
beforeEach(async function () {
await this.DocumentManager.promises.setDoc(
this.project_id,
this.doc_id,
this.afterLines,
this.source,
this.user_id,
false,
true,
true
)
})
it('should apply the diff with a track changes id seed', function () {
this.UpdateManager.promises.applyUpdate.should.have.been.calledWith(
this.project_id,
this.doc_id,
sinon.match({
doc: this.doc_id,
v: this.version,
op: this.ops,
meta: sinon.match({
type: 'external',
source: this.source,
user_id: this.user_id,
tc: sinon.match(/^[0-9a-f]{18}$/),
}),
})
)
})
})
describe('when not already loaded', function () {
beforeEach(async function () {
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.beforeLines,
version: this.version,
pathname: this.pathname,
unflushedTime: null,
alreadyLoaded: false,
})
await this.DocumentManager.promises.setDoc(
this.project_id,
this.doc_id,
this.afterLines,
this.source,
this.user_id,
false,
true
)
})
it('should flush and delete the doc from the doc updater', function () {
this.DocumentManager.promises.flushAndDeleteDoc
.calledWith(this.project_id, this.doc_id, {})
.should.equal(true)
})
it('should increment the external update metric', function () {
this.Metrics.inc
.calledWith('external-update', 1, {
status: 'diff',
method: 'evict',
path: this.source,
})
.should.equal(true)
})
it('should not flush the project history', function () {
this.HistoryManager.flushProjectChangesAsync
.calledWithExactly(this.project_id)
.should.equal(true)
})
})
describe('without new lines', function () {
beforeEach(async function () {
await expect(
this.DocumentManager.promises.setDoc(
this.project_id,
this.doc_id,
null,
this.source,
this.user_id,
false,
true
)
).to.be.rejectedWith('No lines were provided to setDoc')
})
it('should not try to get the doc lines', function () {
this.DocumentManager.promises.getDoc.called.should.equal(false)
})
})
describe('with the undoing flag', function () {
beforeEach(async function () {
// Copy ops so we don't interfere with other tests
this.ops = [
{ i: 'foo', p: 4 },
{ d: 'bar', p: 42 },
]
this.DiffCodec.diffAsShareJsOp.returns(this.ops)
await this.DocumentManager.promises.setDoc(
this.project_id,
this.doc_id,
this.afterLines,
this.source,
this.user_id,
true,
true
)
})
it('should set the undo flag on each op', function () {
this.ops.map(op => op.u.should.equal(true))
})
})
describe('with the external flag', function () {
beforeEach(async function () {
this.undoing = false
// Copy ops so we don't interfere with other tests
this.ops = [
{ i: 'foo', p: 4 },
{ d: 'bar', p: 42 },
]
this.DiffCodec.diffAsShareJsOp.returns(this.ops)
await this.DocumentManager.promises.setDoc(
this.project_id,
this.doc_id,
this.afterLines,
this.source,
this.user_id,
this.undoing,
true
)
})
it('should add the external type to update metadata', function () {
this.UpdateManager.promises.applyUpdate
.calledWith(this.project_id, this.doc_id, {
doc: this.doc_id,
v: this.version,
op: this.ops,
meta: {
type: 'external',
source: this.source,
user_id: this.user_id,
},
})
.should.equal(true)
})
})
describe('without the external flag', function () {
beforeEach(async function () {
this.undoing = false
// Copy ops so we don't interfere with other tests
this.ops = [
{ i: 'foo', p: 4 },
{ d: 'bar', p: 42 },
]
this.DiffCodec.diffAsShareJsOp.returns(this.ops)
await this.DocumentManager.promises.setDoc(
this.project_id,
this.doc_id,
this.afterLines,
this.source,
this.user_id,
this.undoing,
false
)
})
it('should not add the external type to update metadata', function () {
this.UpdateManager.promises.applyUpdate
.calledWith(this.project_id, this.doc_id, {
doc: this.doc_id,
v: this.version,
op: this.ops,
meta: {
source: this.source,
user_id: this.user_id,
},
})
.should.equal(true)
})
})
})
})
describe('acceptChanges', function () {
beforeEach(function () {
this.change_id = 'mock-change-id'
this.change_ids = [
'mock-change-id-1',
'mock-change-id-2',
'mock-change-id-3',
'mock-change-id-4',
]
this.version = 34
this.lines = ['original', 'lines']
this.ranges = {
entries: 'mock',
comments: 'mock',
changes: [
{ id: 'mock-change-id', metadata: { user_id: 'mock-user-id-0' } },
{ id: 'mock-change-id-1', metadata: { user_id: 'mock-user-id-1' } },
{ id: 'mock-change-id-2', metadata: { user_id: 'mock-user-id-2' } },
{ id: 'mock-change-id-3', metadata: { user_id: 'mock-user-id-3' } },
{ id: 'mock-change-id-4', metadata: { user_id: 'mock-user-id-4' } },
{ id: 'other-change-id', metadata: { user_id: 'other-user-id' } },
],
}
this.updated_ranges = { entries: 'updated', comments: 'updated' }
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.lines,
version: this.version,
ranges: this.ranges,
})
this.RangesManager.acceptChanges.returns(this.updated_ranges)
})
describe('successfully with a single change', function () {
beforeEach(async function () {
this.result = await this.DocumentManager.promises.acceptChanges(
this.project_id,
this.doc_id,
[this.change_id]
)
})
it("should get the document's current ranges", function () {
this.DocumentManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should apply the accept change to the ranges', function () {
this.RangesManager.acceptChanges.should.have.been.calledWith(
this.project_id,
this.doc_id,
[this.change_id],
this.ranges
)
})
it('should save the updated ranges', function () {
this.RedisManager.promises.updateDocument
.calledWith(
this.project_id,
this.doc_id,
this.lines,
this.version,
[],
this.updated_ranges,
{}
)
.should.equal(true)
})
it('should return the change contributors', function () {
expect(this.result.changeContributors).to.deep.equal(['mock-user-id-0'])
})
})
describe('successfully with multiple changes', function () {
beforeEach(async function () {
this.result = await this.DocumentManager.promises.acceptChanges(
this.project_id,
this.doc_id,
this.change_ids
)
})
it('should apply the accept change to the ranges', function () {
this.RangesManager.acceptChanges
.calledWith(
this.project_id,
this.doc_id,
this.change_ids,
this.ranges
)
.should.equal(true)
})
it('should return the change contributors', function () {
expect(this.result.changeContributors).to.deep.equal([
'mock-user-id-1',
'mock-user-id-2',
'mock-user-id-3',
'mock-user-id-4',
])
})
})
describe('preview building', function () {
beforeEach(async function () {
this.mockPreviews = [
{
sectionPath: ['Intro'],
startLine: 1,
changes: [{ i: 'x', p: 0 }],
slice: 'x',
sliceStart: 0,
userIds: ['mock-user-id-0'],
},
]
this.buildSparseChangePreviews.returns(this.mockPreviews)
this.result = await this.DocumentManager.promises.acceptChanges(
this.project_id,
this.doc_id,
[this.change_id]
)
})
it('should call buildSparseChangePreviews with the accepted changes and doc lines', function () {
this.buildSparseChangePreviews.should.have.been.calledWith({
changes: [
{ id: 'mock-change-id', metadata: { user_id: 'mock-user-id-0' } },
],
lines: this.lines,
})
})
it('should return the previews from the helper', function () {
expect(this.result.previews).to.equal(this.mockPreviews)
})
})
describe('when the doc is not found', function () {
beforeEach(async function () {
this.DocumentManager.promises.getDoc = sinon
.stub()
.resolves({ lines: null, version: null, ranges: null })
await expect(
this.DocumentManager.promises.acceptChanges(
this.project_id,
this.doc_id,
[this.change_id]
)
).to.be.rejectedWith(Errors.NotFoundError)
})
it('should not save anything', function () {
this.RedisManager.promises.updateDocument.called.should.equal(false)
})
})
})
describe('getComment', function () {
beforeEach(function () {
this.ranges.comments = [
{
id: 'mock-comment-id-1',
},
{
id: 'mock-comment-id-2',
},
]
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.lines,
version: this.version,
ranges: this.ranges,
})
})
describe('when comment exists', function () {
beforeEach(async function () {
await expect(
this.DocumentManager.promises.getComment(
this.project_id,
this.doc_id,
'mock-comment-id-1'
)
).to.eventually.deep.equal({ id: 'mock-comment-id-1' })
})
it("should get the document's current ranges", function () {
this.DocumentManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
})
describe('when comment doesnt exists', function () {
beforeEach(async function () {
await expect(
this.DocumentManager.promises.getComment(
this.project_id,
this.doc_id,
'mock-comment-id-x'
)
).to.be.rejectedWith(Errors.NotFoundError)
})
it("should get the document's current ranges", function () {
this.DocumentManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
})
describe('when the doc is not found', function () {
beforeEach(async function () {
this.DocumentManager.promises.getDoc = sinon
.stub()
.resolves({ lines: null, version: null, ranges: null })
await expect(
this.DocumentManager.promises.acceptChanges(
this.project_id,
this.doc_id,
[this.change_id]
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('when the doc uses history-ot', function () {
beforeEach(function () {
this.lines = {
content: 'the quick brown fox',
comments: [
{
id: 'mock-comment-id-1',
ranges: [{ pos: 4, length: 5 }],
resolved: true,
},
],
}
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.lines,
version: this.version,
ranges: {},
type: 'history-ot',
})
})
describe('when comment exists', function () {
it('should return the comment in doc-updater format', async function () {
await expect(
this.DocumentManager.promises.getComment(
this.project_id,
this.doc_id,
'mock-comment-id-1'
)
).to.eventually.deep.equal({
id: 'mock-comment-id-1',
op: { p: 4, c: 'quick', t: 'mock-comment-id-1', resolved: true },
})
})
})
describe('when comment doesnt exists', function () {
it('should throw a NotFoundError', async function () {
await expect(
this.DocumentManager.promises.getComment(
this.project_id,
this.doc_id,
'mock-comment-id-x'
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
})
})
describe('deleteComment', function () {
beforeEach(function () {
this.comment_id = 'mock-comment-id'
this.version = 34
this.lines = ['original', 'lines']
this.ranges = { comments: ['one', 'two', 'three'] }
this.resolvedCommentIds = ['comment1']
this.updated_ranges = { comments: ['one', 'three'] }
this.historyRangesSupport = true
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.lines,
version: this.version,
ranges: this.ranges,
resolvedCommentIds: this.resolvedCommentIds,
pathname: this.pathname,
projectHistoryId: this.projectHistoryId,
unflushedTime: Date.now() - 1e9,
alreadyLoaded: true,
historyRangesSupport: this.historyRangesSupport,
})
this.RangesManager.deleteComment.returns(this.updated_ranges)
})
describe('successfully', function () {
beforeEach(async function () {
await this.DocumentManager.promises.deleteComment(
this.project_id,
this.doc_id,
this.comment_id,
this.user_id
)
})
it("should get the document's current ranges", function () {
this.DocumentManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should delete the comment from the ranges', function () {
this.RangesManager.deleteComment
.calledWith(this.comment_id, this.ranges)
.should.equal(true)
})
it('should save the updated ranges', function () {
this.RedisManager.promises.updateDocument
.calledWith(
this.project_id,
this.doc_id,
this.lines,
this.version,
[],
this.updated_ranges,
{}
)
.should.equal(true)
})
it('should unset the comment resolved state', function () {
this.RedisManager.promises.updateCommentState.should.have.been.calledWith(
this.doc_id,
this.comment_id,
false
)
})
it('should queue the delete comment operation', function () {
this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWith(
this.project_id,
JSON.stringify({
pathname: this.pathname,
deleteComment: this.comment_id,
meta: {
ts: new Date(),
user_id: this.user_id,
},
})
)
})
})
describe('when the doc uses history-ot', function () {
beforeEach(async function () {
this.lines = {
content: 'the quick brown fox',
comments: [{ id: this.comment_id, ranges: [{ pos: 4, length: 5 }] }],
}
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.lines,
version: this.version,
ranges: {},
pathname: this.pathname,
historyRangesSupport: true,
type: 'history-ot',
})
await this.DocumentManager.promises.deleteComment(
this.project_id,
this.doc_id,
this.comment_id,
this.user_id
)
})
it('should apply a deleteComment operation', function () {
this.HistoryOTUpdateManager.applyUpdate.should.have.been.calledWith(
this.project_id,
this.doc_id,
{
doc: this.doc_id,
op: [{ deleteComment: this.comment_id }],
v: this.version,
meta: { user_id: this.user_id },
}
)
})
it('should not update the document in redis directly', function () {
this.RangesManager.deleteComment.called.should.equal(false)
this.RedisManager.promises.updateDocument.called.should.equal(false)
this.RedisManager.promises.updateCommentState.called.should.equal(false)
this.ProjectHistoryRedisManager.promises.queueOps.called.should.equal(
false
)
})
})
describe('when the doc is not found', function () {
beforeEach(async function () {
this.DocumentManager.promises.getDoc = sinon
.stub()
.resolves({ lines: null, version: null, ranges: null })
await expect(
this.DocumentManager.promises.acceptChanges(
this.project_id,
this.doc_id,
[this.comment_id]
)
).to.be.rejectedWith(Errors.NotFoundError)
})
it('should not save anything', function () {
this.RedisManager.promises.updateDocument.called.should.equal(false)
})
})
})
describe('updateCommentState', function () {
beforeEach(function () {
this.comment_id = 'mock-comment-id'
this.version = 34
})
describe('with sharejs-text-ot and history ranges support', function () {
beforeEach(async function () {
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.lines,
version: this.version,
ranges: this.ranges,
pathname: this.pathname,
historyRangesSupport: true,
type: 'sharejs-text-ot',
})
await this.DocumentManager.promises.updateCommentState(
this.project_id,
this.doc_id,
this.comment_id,
this.user_id,
true
)
})
it('should update the comment state in redis', function () {
this.RedisManager.promises.updateCommentState.should.have.been.calledWith(
this.doc_id,
this.comment_id,
true
)
})
it('should queue the comment state operation', function () {
this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWith(
this.project_id,
JSON.stringify({
pathname: this.pathname,
commentId: this.comment_id,
resolved: true,
meta: {
ts: new Date(),
user_id: this.user_id,
},
})
)
})
})
describe('when the doc uses history-ot', function () {
beforeEach(async function () {
this.lines = {
content: 'the quick brown fox',
comments: [{ id: this.comment_id, ranges: [{ pos: 4, length: 5 }] }],
}
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.lines,
version: this.version,
ranges: {},
pathname: this.pathname,
historyRangesSupport: true,
type: 'history-ot',
})
await this.DocumentManager.promises.updateCommentState(
this.project_id,
this.doc_id,
this.comment_id,
this.user_id,
true
)
})
it('should apply a commentState operation', function () {
this.HistoryOTUpdateManager.applyUpdate.should.have.been.calledWith(
this.project_id,
this.doc_id,
{
doc: this.doc_id,
op: [{ resolved: true, commentId: this.comment_id }],
v: this.version,
meta: { user_id: this.user_id },
}
)
})
it('should not update the comment state in redis directly', function () {
this.RedisManager.promises.updateCommentState.called.should.equal(false)
this.ProjectHistoryRedisManager.promises.queueOps.called.should.equal(
false
)
})
})
describe('when the doc is not found', function () {
beforeEach(async function () {
this.DocumentManager.promises.getDoc = sinon
.stub()
.resolves({ lines: null, version: null, ranges: null })
await expect(
this.DocumentManager.promises.updateCommentState(
this.project_id,
this.doc_id,
this.comment_id,
this.user_id,
true
)
).to.be.rejectedWith(Errors.NotFoundError)
})
it('should not update anything', function () {
this.RedisManager.promises.updateCommentState.called.should.equal(false)
this.ProjectHistoryRedisManager.promises.queueOps.called.should.equal(
false
)
})
})
})
describe('getDocAndFlushIfOld', function () {
beforeEach(function () {
this.DocumentManager.promises.flushDocIfLoaded = sinon.stub().resolves()
})
describe('when the doc is in Redis', function () {
describe('and has changes to be flushed', function () {
beforeEach(async function () {
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.lines,
version: this.version,
ranges: this.ranges,
projectHistoryId: this.projectHistoryId,
pathname: this.pathname,
unflushedTime: Date.now() - 2 * this.Settings.maxUnflushedAgeMs, // document is older than max unflushed age
alreadyLoaded: true,
})
this.result = await this.DocumentManager.promises.getDocAndFlushIfOld(
this.project_id,
this.doc_id
)
})
it('should get the doc', function () {
this.DocumentManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should flush the doc', function () {
this.DocumentManager.promises.flushDocIfLoaded
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should return the lines and versions', function () {
expect(this.result).to.deep.equal({
lines: this.lines,
version: this.version,
})
})
})
describe("and has only changes that don't need to be flushed", function () {
beforeEach(async function () {
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.lines,
version: this.version,
ranges: this.ranges,
pathname: this.pathname,
unflushedTime: Date.now() - 0.5 * this.Settings.maxUnflushedAgeMs, // document is not old enough to trigger flush
alreadyLoaded: true,
})
this.result = await this.DocumentManager.promises.getDocAndFlushIfOld(
this.project_id,
this.doc_id
)
})
it('should get the doc', function () {
this.DocumentManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should not flush the doc', function () {
this.DocumentManager.promises.flushDocIfLoaded.called.should.equal(
false
)
})
it('should return the lines and versions', function () {
expect(this.result).to.deep.equal({
lines: this.lines,
version: this.version,
})
})
})
})
describe('when the doc is not in Redis', function () {
beforeEach(async function () {
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.lines,
version: this.version,
ranges: this.ranges,
alreadyLoaded: false,
})
this.result = await this.DocumentManager.promises.getDocAndFlushIfOld(
this.project_id,
this.doc_id
)
})
it('should get the doc', function () {
this.DocumentManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should not flush the doc', function () {
this.DocumentManager.promises.flushDocIfLoaded.called.should.equal(
false
)
})
it('should return the lines and versions', function () {
expect(this.result).to.deep.equal({
lines: this.lines,
version: this.version,
})
})
})
})
describe('renameDoc', function () {
beforeEach(function () {
this.update = 'some-update'
})
describe('successfully', function () {
beforeEach(async function () {
await this.DocumentManager.promises.renameDoc(
this.project_id,
this.doc_id,
this.user_id,
this.update,
this.projectHistoryId
)
})
it('should rename the document', function () {
this.RedisManager.promises.renameDoc
.calledWith(
this.project_id,
this.doc_id,
this.user_id,
this.update,
this.projectHistoryId
)
.should.equal(true)
})
})
})
describe('resyncDocContents', function () {
describe('when doc is loaded in redis', function () {
beforeEach(async function () {
this.pathnameFromProjectStructureUpdate = '/foo/bar.tex'
this.RedisManager.promises.getDoc.resolves({
lines: this.lines,
version: this.version,
ranges: this.ranges,
resolvedCommentIds: this.resolvedCommentIds,
pathname: this.pathname,
projectHistoryId: this.projectHistoryId,
historyRangesSupport: this.historyRangesSupport,
})
await this.DocumentManager.promises.resyncDocContents(
this.project_id,
this.doc_id,
this.pathnameFromProjectStructureUpdate
)
})
it('gets the doc contents from redis', function () {
this.RedisManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('queues a resync doc content update', function () {
this.ProjectHistoryRedisManager.promises.queueResyncDocContent
.calledWith(
this.project_id,
this.projectHistoryId,
this.doc_id,
this.lines,
this.ranges,
this.resolvedCommentIds,
this.version,
this.pathnameFromProjectStructureUpdate,
this.historyRangesSupport
)
.should.equal(true)
})
})
describe('when doc is not loaded in redis', function () {
beforeEach(async function () {
this.pathnameFromProjectStructureUpdate = '/foo/bar.tex'
this.RedisManager.promises.getDoc.resolves({})
this.PersistenceManager.promises.getDoc.resolves({
lines: this.lines,
version: this.version,
ranges: this.ranges,
resolvedCommentIds: this.resolvedCommentIds,
pathname: this.pathname,
projectHistoryId: this.projectHistoryId,
historyRangesSupport: this.historyRangesSupport,
})
await this.DocumentManager.promises.resyncDocContents(
this.project_id,
this.doc_id,
this.pathnameFromProjectStructureUpdate
)
})
it('tries to get the doc contents from redis', function () {
this.RedisManager.promises.getDoc
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('gets the doc contents from web', function () {
this.PersistenceManager.promises.getDoc
.calledWith(this.project_id, this.doc_id, { peek: true })
.should.equal(true)
})
it('queues a resync doc content update', function () {
this.ProjectHistoryRedisManager.promises.queueResyncDocContent
.calledWith(
this.project_id,
this.projectHistoryId,
this.doc_id,
this.lines,
this.ranges,
this.resolvedCommentIds,
this.version,
this.pathnameFromProjectStructureUpdate,
this.historyRangesSupport
)
.should.equal(true)
})
})
describe('when a doc has no ranges in docstore', function () {
beforeEach(async function () {
this.pathnameFromProjectStructureUpdate = '/foo/bar.tex'
this.RedisManager.promises.getDoc.resolves({})
this.PersistenceManager.promises.getDoc.resolves({
lines: this.lines,
version: this.version,
ranges: undefined,
resolvedCommentIds: [],
pathname: this.pathname,
projectHistoryId: this.projectHistoryId,
historyRangesSupport: this.historyRangesSupport,
})
await this.DocumentManager.promises.resyncDocContents(
this.project_id,
this.doc_id,
this.pathnameFromProjectStructureUpdate
)
})
it('gets the doc contents from web', function () {
this.PersistenceManager.promises.getDoc
.calledWith(this.project_id, this.doc_id, { peek: true })
.should.equal(true)
})
it('queues a resync doc content update with an empty ranges object', function () {
this.ProjectHistoryRedisManager.promises.queueResyncDocContent
.calledWith(
this.project_id,
this.projectHistoryId,
this.doc_id,
this.lines,
{},
[],
this.version,
this.pathnameFromProjectStructureUpdate,
this.historyRangesSupport
)
.should.equal(true)
})
})
})
describe('appendToDoc', function () {
describe('sucessfully', function () {
beforeEach(async function () {
this.lines = ['one', 'two', 'three']
this.DocumentManager.promises.setDoc = sinon
.stub()
.resolves({ rev: '123' })
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.lines,
})
this.result = await this.DocumentManager.promises.appendToDoc(
this.project_id,
this.doc_id,
['four', 'five', 'six'],
this.source,
this.user_id
)
})
it('should call setDoc with concatenated lines', function () {
this.DocumentManager.promises.setDoc
.calledWith(
this.project_id,
this.doc_id,
['one', 'two', 'three', 'four', 'five', 'six'],
this.source,
this.user_id,
false,
false
)
.should.equal(true)
})
it('should return output from setDoc', function () {
this.result.should.deep.equal({ rev: '123' })
})
})
describe('when doc would become too big', function () {
beforeEach(async function () {
this.Settings.max_doc_length = 100
this.lines = ['one', 'two', 'three']
this.DocumentManager.promises.setDoc = sinon
.stub()
.resolves({ rev: '123' })
this.DocumentManager.promises.getDoc = sinon.stub().resolves({
lines: this.lines,
})
})
it('should fail with FileTooLarge error', async function () {
expect(
this.DocumentManager.promises.appendToDoc(
this.project_id,
this.doc_id,
['x'.repeat(1000)],
this.source,
this.user_id
)
).to.eventually.be.rejectedWith(Errors.FileTooLargeError)
})
})
})
})