diff --git a/services/document-updater/app.coffee b/services/document-updater/app.coffee index 004b9f77bc..20170cdd34 100644 --- a/services/document-updater/app.coffee +++ b/services/document-updater/app.coffee @@ -45,6 +45,7 @@ app.post '/project/:project_id/doc/:doc_id/flush', HttpController.flushDocIfLo app.delete '/project/:project_id/doc/:doc_id', HttpController.flushAndDeleteDoc app.delete '/project/:project_id', HttpController.deleteProject app.post '/project/:project_id/flush', HttpController.flushProject +app.post '/project/:project_id/doc/:doc_id/change/:change_id/accept', HttpController.acceptChange app.get '/total', (req, res)-> timer = new Metrics.Timer("http.allDocList") diff --git a/services/document-updater/app/coffee/DocumentManager.coffee b/services/document-updater/app/coffee/DocumentManager.coffee index ebbdc3a66e..4f02bbcf5c 100644 --- a/services/document-updater/app/coffee/DocumentManager.coffee +++ b/services/document-updater/app/coffee/DocumentManager.coffee @@ -3,7 +3,10 @@ PersistenceManager = require "./PersistenceManager" DiffCodec = require "./DiffCodec" logger = require "logger-sharelatex" Metrics = require "./Metrics" -TrackChangesManager = require "./TrackChangesManager" +HistoryManager = require "./HistoryManager" +WebRedisManager = require "./WebRedisManager" +Errors = require "./Errors" +RangesManager = require "./RangesManager" module.exports = DocumentManager = getDoc: (project_id, doc_id, _callback = (error, lines, version, alreadyLoaded) ->) -> @@ -12,33 +15,33 @@ module.exports = DocumentManager = timer.done() _callback(args...) - RedisManager.getDoc project_id, doc_id, (error, lines, version) -> + RedisManager.getDoc project_id, doc_id, (error, lines, version, ranges) -> return callback(error) if error? if !lines? or !version? - logger.log project_id: project_id, doc_id: doc_id, "doc not in redis so getting from persistence API" - PersistenceManager.getDoc project_id, doc_id, (error, lines, version) -> + logger.log {project_id, doc_id}, "doc not in redis so getting from persistence API" + PersistenceManager.getDoc project_id, doc_id, (error, lines, version, ranges) -> return callback(error) if error? - logger.log project_id: project_id, doc_id: doc_id, lines: lines, version: version, "got doc from persistence API" - RedisManager.putDocInMemory project_id, doc_id, lines, version, (error) -> + logger.log {project_id, doc_id, lines, version}, "got doc from persistence API" + RedisManager.putDocInMemory project_id, doc_id, lines, version, ranges, (error) -> return callback(error) if error? - callback null, lines, version, false + callback null, lines, version, ranges, false else - callback null, lines, version, true + callback null, lines, version, ranges, true - getDocAndRecentOps: (project_id, doc_id, fromVersion, _callback = (error, lines, version, recentOps) ->) -> + getDocAndRecentOps: (project_id, doc_id, fromVersion, _callback = (error, lines, version, recentOps, ranges) ->) -> timer = new Metrics.Timer("docManager.getDocAndRecentOps") callback = (args...) -> timer.done() _callback(args...) - DocumentManager.getDoc project_id, doc_id, (error, lines, version) -> + DocumentManager.getDoc project_id, doc_id, (error, lines, version, ranges) -> return callback(error) if error? if fromVersion == -1 - callback null, lines, version, [] + callback null, lines, version, [], ranges else RedisManager.getPreviousDocOps doc_id, fromVersion, version, (error, ops) -> return callback(error) if error? - callback null, lines, version, ops + callback null, lines, version, ops, ranges setDoc: (project_id, doc_id, newLines, source, user_id, _callback = (error) ->) -> timer = new Metrics.Timer("docManager.setDoc") @@ -50,7 +53,7 @@ module.exports = DocumentManager = return callback(new Error("No lines were provided to setDoc")) UpdateManager = require "./UpdateManager" - DocumentManager.getDoc project_id, doc_id, (error, oldLines, version, alreadyLoaded) -> + DocumentManager.getDoc project_id, doc_id, (error, oldLines, version, ranges, alreadyLoaded) -> return callback(error) if error? if oldLines? and oldLines.length > 0 and oldLines[0].text? @@ -82,21 +85,20 @@ module.exports = DocumentManager = DocumentManager.flushAndDeleteDoc project_id, doc_id, (error) -> return callback(error) if error? callback null - flushDocIfLoaded: (project_id, doc_id, _callback = (error) ->) -> timer = new Metrics.Timer("docManager.flushDocIfLoaded") callback = (args...) -> timer.done() _callback(args...) - RedisManager.getDoc project_id, doc_id, (error, lines, version) -> + RedisManager.getDoc project_id, doc_id, (error, lines, version, ranges) -> return callback(error) if error? if !lines? or !version? logger.log project_id: project_id, doc_id: doc_id, "doc is not loaded so not flushing" callback null # TODO: return a flag to bail out, as we go on to remove doc from memory? else logger.log project_id: project_id, doc_id: doc_id, version: version, "flushing doc" - PersistenceManager.setDoc project_id, doc_id, lines, version, (error) -> + PersistenceManager.setDoc project_id, doc_id, lines, version, ranges, (error) -> return callback(error) if error? callback null @@ -111,13 +113,29 @@ module.exports = DocumentManager = # Flush in the background since it requires and http request # to track changes - TrackChangesManager.flushDocChanges project_id, doc_id, (err) -> + HistoryManager.flushDocChanges project_id, doc_id, (err) -> if err? logger.err {err, project_id, doc_id}, "error flushing to track changes" RedisManager.removeDocFromMemory project_id, doc_id, (error) -> return callback(error) if error? callback null + + acceptChange: (project_id, doc_id, change_id, _callback = (error) ->) -> + timer = new Metrics.Timer("docManager.acceptChange") + callback = (args...) -> + timer.done() + _callback(args...) + + DocumentManager.getDoc project_id, doc_id, (error, lines, version, ranges) -> + return callback(error) if error? + if !lines? or !version? + return callback(new Errors.NotFoundError("document not found: #{doc_id}")) + RangesManager.acceptChange change_id, ranges, (error, new_ranges) -> + return callback(error) if error? + RedisManager.updateDocument doc_id, lines, version, [], new_ranges, (error) -> + return callback(error) if error? + callback() getDocWithLock: (project_id, doc_id, callback = (error, lines, version) ->) -> UpdateManager = require "./UpdateManager" @@ -138,3 +156,7 @@ module.exports = DocumentManager = flushAndDeleteDocWithLock: (project_id, doc_id, callback = (error) ->) -> UpdateManager = require "./UpdateManager" UpdateManager.lockUpdatesAndDo DocumentManager.flushAndDeleteDoc, project_id, doc_id, callback + + acceptChangeWithLock: (project_id, doc_id, change_id, callback = (error) ->) -> + UpdateManager = require "./UpdateManager" + UpdateManager.lockUpdatesAndDo DocumentManager.acceptChange, project_id, doc_id, change_id, callback diff --git a/services/document-updater/app/coffee/TrackChangesManager.coffee b/services/document-updater/app/coffee/HistoryManager.coffee similarity index 87% rename from services/document-updater/app/coffee/TrackChangesManager.coffee rename to services/document-updater/app/coffee/HistoryManager.coffee index 9aa1c0ad47..637fd2cb5f 100644 --- a/services/document-updater/app/coffee/TrackChangesManager.coffee +++ b/services/document-updater/app/coffee/HistoryManager.coffee @@ -4,7 +4,7 @@ logger = require "logger-sharelatex" async = require "async" WebRedisManager = require "./WebRedisManager" -module.exports = TrackChangesManager = +module.exports = HistoryManager = flushDocChanges: (project_id, doc_id, callback = (error) ->) -> if !settings.apis?.trackchanges? logger.warn doc_id: doc_id, "track changes API is not configured, so not flushing" @@ -32,13 +32,13 @@ module.exports = TrackChangesManager = # ops. If we've changed, then we've gone over a multiple of 50 and should flush. # (Most of the time, we will only hit 50 and then flushing will put us back to 0) previousLength = length - ops.length - prevBlock = Math.floor(previousLength / TrackChangesManager.FLUSH_EVERY_N_OPS) - newBlock = Math.floor(length / TrackChangesManager.FLUSH_EVERY_N_OPS) + prevBlock = Math.floor(previousLength / HistoryManager.FLUSH_EVERY_N_OPS) + newBlock = Math.floor(length / HistoryManager.FLUSH_EVERY_N_OPS) if newBlock != prevBlock # Do this in the background since it uses HTTP and so may be too # slow to wait for when processing a doc update. logger.log length: length, doc_id: doc_id, project_id: project_id, "flushing track changes api" - TrackChangesManager.flushDocChanges project_id, doc_id, (error) -> + HistoryManager.flushDocChanges project_id, doc_id, (error) -> if error? logger.error err: error, doc_id: doc_id, project_id: project_id, "error flushing doc to track changes api" callback() \ No newline at end of file diff --git a/services/document-updater/app/coffee/HttpController.coffee b/services/document-updater/app/coffee/HttpController.coffee index ee6359b104..683b94230f 100644 --- a/services/document-updater/app/coffee/HttpController.coffee +++ b/services/document-updater/app/coffee/HttpController.coffee @@ -18,7 +18,7 @@ module.exports = HttpController = else fromVersion = -1 - DocumentManager.getDocAndRecentOpsWithLock project_id, doc_id, fromVersion, (error, lines, version, ops) -> + DocumentManager.getDocAndRecentOpsWithLock project_id, doc_id, fromVersion, (error, lines, version, ops, ranges) -> timer.done() return next(error) if error? logger.log project_id: project_id, doc_id: doc_id, "got doc via http" @@ -29,6 +29,7 @@ module.exports = HttpController = lines: lines version: version ops: ops + ranges: ranges _getTotalSizeOfLines: (lines) -> size = 0 @@ -96,3 +97,15 @@ module.exports = HttpController = return next(error) if error? logger.log project_id: project_id, "deleted project via http" res.send 204 # No Content + + acceptChange: (req, res, next = (error) ->) -> + {project_id, doc_id, change_id} = req.params + logger.log {project_id, doc_id, change_id}, "accepting change via http" + timer = new Metrics.Timer("http.acceptChange") + DocumentManager.acceptChangeWithLock project_id, doc_id, change_id, (error) -> + timer.done() + return next(error) if error? + logger.log {project_id, doc_id, change_id}, "accepted change via http" + res.send 204 # No Content + + diff --git a/services/document-updater/app/coffee/PersistenceManager.coffee b/services/document-updater/app/coffee/PersistenceManager.coffee index 8be268a6e8..457627982f 100644 --- a/services/document-updater/app/coffee/PersistenceManager.coffee +++ b/services/document-updater/app/coffee/PersistenceManager.coffee @@ -10,7 +10,7 @@ logger = require "logger-sharelatex" MAX_HTTP_REQUEST_LENGTH = 5000 # 5 seconds module.exports = PersistenceManager = - getDoc: (project_id, doc_id, _callback = (error, lines, version) ->) -> + getDoc: (project_id, doc_id, _callback = (error, lines, version, ranges) ->) -> timer = new Metrics.Timer("persistenceManager.getDoc") callback = (args...) -> timer.done() @@ -39,13 +39,13 @@ module.exports = PersistenceManager = return callback(new Error("web API response had no doc lines")) if !body.version? or not body.version instanceof Number return callback(new Error("web API response had no valid doc version")) - return callback null, body.lines, body.version + return callback null, body.lines, body.version, body.ranges else if res.statusCode == 404 return callback(new Errors.NotFoundError("doc not not found: #{url}")) else return callback(new Error("error accessing web API: #{url} #{res.statusCode}")) - setDoc: (project_id, doc_id, lines, version, _callback = (error) ->) -> + setDoc: (project_id, doc_id, lines, version, ranges, _callback = (error) ->) -> timer = new Metrics.Timer("persistenceManager.setDoc") callback = (args...) -> timer.done() @@ -55,11 +55,10 @@ module.exports = PersistenceManager = request { url: url method: "POST" - body: JSON.stringify + json: lines: lines + ranges: ranges version: version - headers: - "content-type": "application/json" auth: user: Settings.apis.web.user pass: Settings.apis.web.pass diff --git a/services/document-updater/app/coffee/ProjectManager.coffee b/services/document-updater/app/coffee/ProjectManager.coffee index f0f62b6d1b..cd4c66ae8d 100644 --- a/services/document-updater/app/coffee/ProjectManager.coffee +++ b/services/document-updater/app/coffee/ProjectManager.coffee @@ -56,5 +56,3 @@ module.exports = ProjectManager = callback new Error("Errors deleting docs. See log for details") else callback(null) - - diff --git a/services/document-updater/app/coffee/RangesManager.coffee b/services/document-updater/app/coffee/RangesManager.coffee new file mode 100644 index 0000000000..25da4ec9db --- /dev/null +++ b/services/document-updater/app/coffee/RangesManager.coffee @@ -0,0 +1,44 @@ +RangesTracker = require "./RangesTracker" +logger = require "logger-sharelatex" + +module.exports = RangesManager = + MAX_COMMENTS: 500 + MAX_CHANGES: 500 + + applyUpdate: (project_id, doc_id, entries = {}, updates = [], callback = (error, new_entries) ->) -> + {changes, comments} = entries + logger.log {changes, comments, updates}, "applying updates to ranges" + rangesTracker = new RangesTracker(changes, comments) + for update in updates + rangesTracker.track_changes = !!update.meta.tc + if !!update.meta.tc + rangesTracker.setIdSeed(update.meta.tc) + for op in update.op + rangesTracker.applyOp(op, { user_id: update.meta?.user_id }) + + if rangesTracker.changes?.length > RangesManager.MAX_CHANGES or rangesTracker.comments?.length > RangesManager.MAX_COMMENTS + return callback new Error("too many comments or tracked changes") + + response = RangesManager._getRanges rangesTracker + logger.log {response}, "applied updates to ranges" + callback null, response + + acceptChange: (change_id, ranges, callback = (error, ranges) ->) -> + {changes, comments} = ranges + logger.log {changes, comments, change_id}, "accepting change in ranges" + rangesTracker = new RangesTracker(changes, comments) + rangesTracker.removeChangeId(change_id) + response = RangesManager._getRanges(rangesTracker) + callback null, response + + _getRanges: (rangesTracker) -> + # Return the minimal data structure needed, since most documents won't have any + # changes or comments + response = {} + if rangesTracker.changes?.length > 0 + response ?= {} + response.changes = rangesTracker.changes + if rangesTracker.comments?.length > 0 + response ?= {} + response.comments = rangesTracker.comments + return response \ No newline at end of file diff --git a/services/document-updater/app/coffee/RangesTracker.coffee b/services/document-updater/app/coffee/RangesTracker.coffee new file mode 100644 index 0000000000..722eab1aa5 --- /dev/null +++ b/services/document-updater/app/coffee/RangesTracker.coffee @@ -0,0 +1,480 @@ +load = (EventEmitter) -> + class RangesTracker extends EventEmitter + # The purpose of this class is to track a set of inserts and deletes to a document, like + # track changes in Word. We store these as a set of ShareJs style ranges: + # {i: "foo", p: 42} # Insert 'foo' at offset 42 + # {d: "bar", p: 37} # Delete 'bar' at offset 37 + # We only track the inserts and deletes, not the whole document, but by being given all + # updates that are applied to a document, we can update these appropriately. + # + # Note that the set of inserts and deletes we store applies to the document as-is at the moment. + # So inserts correspond to text which is in the document, while deletes correspond to text which + # is no longer there, so their lengths do not affect the position of later offsets. + # E.g. + # this is the current text of the document + # |-----| | + # {i: "current ", p:12} -^ ^- {d: "old ", p: 31} + # + # Track changes rules (should be consistent with Word): + # * When text is inserted at a delete, the text goes to the left of the delete + # I.e. "foo|bar" -> "foobaz|bar", where | is the delete, and 'baz' is inserted + # * Deleting content flagged as 'inserted' does not create a new delete marker, it only + # removes the insert marker. E.g. + # * "abdefghijkl" -> "abfghijkl" when 'de' is deleted. No delete marker added + # |---| <- inserted |-| <- inserted + # * Deletes overlapping regular text and inserted text will insert a delete marker for the + # regular text: + # "abcdefghijkl" -> "abcdejkl" when 'fghi' is deleted + # |----| |--|| + # ^- inserted 'bcdefg' \ ^- deleted 'hi' + # \--inserted 'bcde' + # * Deletes overlapping other deletes are merged. E.g. + # "abcghijkl" -> "ahijkl" when 'bcg is deleted' + # | <- delete 'def' | <- delete 'bcdefg' + # * Deletes by another user will consume deletes by the first user + # * Inserts by another user will not combine with inserts by the first user. If they are in the + # middle of a previous insert by the first user, the original insert will be split into two. + constructor: (@changes = [], @comments = []) -> + @setIdSeed(RangesTracker.generateIdSeed()) + + getIdSeed: () -> + return @id_seed + + setIdSeed: (seed) -> + @id_seed = seed + @id_increment = 0 + + @generateIdSeed: () -> + # Generate a the first 18 characters of Mongo ObjectId, leaving 6 for the increment part + # Reference: https://github.com/dreampulse/ObjectId.js/blob/master/src/main/javascript/Objectid.js + pid = Math.floor(Math.random() * (32767)).toString(16) + machine = Math.floor(Math.random() * (16777216)).toString(16) + timestamp = Math.floor(new Date().valueOf() / 1000).toString(16) + return '00000000'.substr(0, 8 - timestamp.length) + timestamp + + '000000'.substr(0, 6 - machine.length) + machine + + '0000'.substr(0, 4 - pid.length) + pid + + @generateId: () -> + @generateIdSeed() + "000001" + + newId: () -> + @id_increment++ + increment = @id_increment.toString(16) + id = @id_seed + '000000'.substr(0, 6 - increment.length) + increment; + return id + + getComment: (comment_id) -> + comment = null + for c in @comments + if c.id == comment_id + comment = c + break + return comment + + removeCommentId: (comment_id) -> + comment = @getComment(comment_id) + return if !comment? + @comments = @comments.filter (c) -> c.id != comment_id + @emit "comment:removed", comment + + getChange: (change_id) -> + change = null + for c in @changes + if c.id == change_id + change = c + break + return change + + removeChangeId: (change_id) -> + change = @getChange(change_id) + return if !change? + @_removeChange(change) + + applyOp: (op, metadata = {}) -> + metadata.ts ?= new Date() + # Apply an op that has been applied to the document to our changes to keep them up to date + if op.i? + @applyInsertToChanges(op, metadata) + @applyInsertToComments(op) + else if op.d? + @applyDeleteToChanges(op, metadata) + @applyDeleteToComments(op) + else if op.c? + @addComment(op, metadata) + else + throw new Error("unknown op type") + + addComment: (op, metadata) -> + # TODO: Don't allow overlapping comments? + @comments.push comment = { + id: @newId() + op: # Copy because we'll modify in place + c: op.c + p: op.p + t: op.t + metadata + } + @emit "comment:added", comment + return comment + + applyInsertToComments: (op) -> + for comment in @comments + if op.p <= comment.op.p + comment.op.p += op.i.length + @emit "comment:moved", comment + else if op.p < comment.op.p + comment.op.c.length + offset = op.p - comment.op.p + comment.op.c = comment.op.c[0..(offset-1)] + op.i + comment.op.c[offset...] + @emit "comment:moved", comment + + applyDeleteToComments: (op) -> + op_start = op.p + op_length = op.d.length + op_end = op.p + op_length + for comment in @comments + comment_start = comment.op.p + comment_end = comment.op.p + comment.op.c.length + comment_length = comment_end - comment_start + if op_end <= comment_start + # delete is fully before comment + comment.op.p -= op_length + @emit "comment:moved", comment + else if op_start >= comment_end + # delete is fully after comment, nothing to do + else + # delete and comment overlap + if op_start <= comment_start + remaining_before = "" + else + remaining_before = comment.op.c.slice(0, op_start - comment_start) + if op_end >= comment_end + remaining_after = "" + else + remaining_after = comment.op.c.slice(op_end - comment_start) + + # Check deleted content matches delete op + deleted_comment = comment.op.c.slice(remaining_before.length, comment_length - remaining_after.length) + offset = Math.max(0, comment_start - op_start) + deleted_op_content = op.d.slice(offset).slice(0, deleted_comment.length) + if deleted_comment != deleted_op_content + throw new Error("deleted content does not match comment content") + + comment.op.p = Math.min(comment_start, op_start) + comment.op.c = remaining_before + remaining_after + @emit "comment:moved", comment + + applyInsertToChanges: (op, metadata) -> + op_start = op.p + op_length = op.i.length + op_end = op.p + op_length + + already_merged = false + previous_change = null + moved_changes = [] + remove_changes = [] + new_changes = [] + for change in @changes + change_start = change.op.p + + if change.op.d? + # Shift any deletes after this along by the length of this insert + if op_start < change_start + change.op.p += op_length + moved_changes.push change + else if op_start == change_start + # If the insert matches the start of the delete, just remove it from the delete instead + if change.op.d.length >= op.i.length and change.op.d.slice(0, op.i.length) == op.i + change.op.d = change.op.d.slice(op.i.length) + change.op.p += op.i.length + if change.op.d == "" + remove_changes.push change + else + moved_changes.push change + already_merged = true + else + change.op.p += op_length + moved_changes.push change + else if change.op.i? + change_end = change_start + change.op.i.length + is_change_overlapping = (op_start >= change_start and op_start <= change_end) + + # Only merge inserts if they are from the same user + is_same_user = metadata.user_id == change.metadata.user_id + + # If there is a delete at the start of the insert, and we're inserting + # at the start, we SHOULDN'T merge since the delete acts as a partition. + # The previous op will be the delete, but it's already been shifted by this insert + # + # I.e. + # Originally: |-- existing insert --| + # | <- existing delete at same offset + # + # Now: |-- existing insert --| <- not shifted yet + # |-- this insert --|| <- existing delete shifted along to end of this op + # + # After: |-- existing insert --| + # |-- this insert --|| <- existing delete + # + # Without the delete, the inserts would be merged. + is_insert_blocked_by_delete = (previous_change? and previous_change.op.d? and previous_change.op.p == op_end) + + # If the insert is overlapping another insert, either at the beginning in the middle or touching the end, + # then we merge them into one. + if @track_changes and + is_change_overlapping and + !is_insert_blocked_by_delete and + !already_merged and + is_same_user + offset = op_start - change_start + change.op.i = change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset) + change.metadata.ts = metadata.ts + already_merged = true + moved_changes.push change + else if op_start <= change_start + # If we're fully before the other insert we can just shift the other insert by our length. + # If they are touching, and should have been merged, they will have been above. + # If not merged above, then it must be blocked by a delete, and will be after this insert, so we shift it along as well + change.op.p += op_length + moved_changes.push change + else if (!is_same_user or !@track_changes) and change_start < op_start < change_end + # This user is inserting inside a change by another user, so we need to split the + # other user's change into one before and after this one. + offset = op_start - change_start + before_content = change.op.i.slice(0, offset) + after_content = change.op.i.slice(offset) + + # The existing change can become the 'before' change + change.op.i = before_content + moved_changes.push change + + # Create a new op afterwards + after_change = { + op: { + i: after_content + p: change_start + offset + op_length + } + metadata: {} + } + after_change.metadata[key] = value for key, value of change.metadata + new_changes.push after_change + + previous_change = change + + if @track_changes and !already_merged + @_addOp op, metadata + for {op, metadata} in new_changes + @_addOp op, metadata + + for change in remove_changes + @_removeChange change + + if moved_changes.length > 0 + @emit "changes:moved", moved_changes + + applyDeleteToChanges: (op, metadata) -> + op_start = op.p + op_length = op.d.length + op_end = op.p + op_length + remove_changes = [] + moved_changes = [] + + # We might end up modifying our delete op if it merges with existing deletes, or cancels out + # with an existing insert. Since we might do multiple modifications, we record them and do + # all the modifications after looping through the existing changes, so as not to mess up the + # offset indexes as we go. + op_modifications = [] + for change in @changes + if change.op.i? + change_start = change.op.p + change_end = change_start + change.op.i.length + if op_end <= change_start + # Shift ops after us back by our length + change.op.p -= op_length + moved_changes.push change + else if op_start >= change_end + # Delete is after insert, nothing to do + else + # When the new delete overlaps an insert, we should remove the part of the insert that + # is now deleted, and also remove the part of the new delete that overlapped. I.e. + # the two cancel out where they overlap. + if op_start >= change_start + # |-- existing insert --| + # insert_remaining_before -> |.....||-- new delete --| + delete_remaining_before = "" + insert_remaining_before = change.op.i.slice(0, op_start - change_start) + else + # delete_remaining_before -> |.....||-- existing insert --| + # |-- new delete --| + delete_remaining_before = op.d.slice(0, change_start - op_start) + insert_remaining_before = "" + + if op_end <= change_end + # |-- existing insert --| + # |-- new delete --||.....| <- insert_remaining_after + delete_remaining_after = "" + insert_remaining_after = change.op.i.slice(op_end - change_start) + else + # |-- existing insert --||.....| <- delete_remaining_after + # |-- new delete --| + delete_remaining_after = op.d.slice(change_end - op_start) + insert_remaining_after = "" + + insert_remaining = insert_remaining_before + insert_remaining_after + if insert_remaining.length > 0 + change.op.i = insert_remaining + change.op.p = Math.min(change_start, op_start) + change.metadata.ts = metadata.ts + moved_changes.push change + else + remove_changes.push change + + # We know what we want to preserve of our delete op before (delete_remaining_before) and what we want to preserve + # afterwards (delete_remaining_before). Now we need to turn that into a modification which deletes the + # chunk in the middle not covered by these. + delete_removed_length = op.d.length - delete_remaining_before.length - delete_remaining_after.length + delete_removed_start = delete_remaining_before.length + modification = { + d: op.d.slice(delete_removed_start, delete_removed_start + delete_removed_length) + p: delete_removed_start + } + if modification.d.length > 0 + op_modifications.push modification + else if change.op.d? + change_start = change.op.p + if op_end < change_start or (!@track_changes and op_end == change_start) + # Shift ops after us back by our length. + # If we're tracking changes, it must be strictly before, since we'll merge + # below if they are touching. Otherwise, touching is fine. + change.op.p -= op_length + moved_changes.push change + else if op_start <= change_start <= op_end + if @track_changes + # If we overlap a delete, add it in our content, and delete the existing change. + # It's easier to do it this way, rather than modifying the existing delete in case + # we overlap many deletes and we'd need to track that. We have a workaround to + # update the delete in place if possible below. + offset = change_start - op_start + op_modifications.push { i: change.op.d, p: offset } + remove_changes.push change + else + change.op.p = op_start + moved_changes.push change + + # Copy rather than modify because we still need to apply it to comments + op = { + p: op.p + d: @_applyOpModifications(op.d, op_modifications) + } + + for change in remove_changes + # This is a bit of hack to avoid removing one delete and replacing it with another. + # If we don't do this, it causes the UI to flicker + if op.d.length > 0 and change.op.d? and op.p <= change.op.p <= op.p + op.d.length + change.op.p = op.p + change.op.d = op.d + change.metadata = metadata + moved_changes.push change + op.d = "" # stop it being added + else + @_removeChange change + + if @track_changes and op.d.length > 0 + @_addOp op, metadata + else + # It's possible that we deleted an insert between two other inserts. I.e. + # If we delete 'user_2 insert' in: + # |-- user_1 insert --||-- user_2 insert --||-- user_1 insert --| + # it becomes: + # |-- user_1 insert --||-- user_1 insert --| + # We need to merge these together again + results = @_scanAndMergeAdjacentUpdates() + moved_changes = moved_changes.concat(results.moved_changes) + for change in results.remove_changes + @_removeChange change + moved_changes = moved_changes.filter (c) -> c != change + + if moved_changes.length > 0 + @emit "changes:moved", moved_changes + + _addOp: (op, metadata) -> + change = { + id: @newId() + op: op + metadata: metadata + } + @changes.push change + + # Keep ops in order of offset, with deletes before inserts + @changes.sort (c1, c2) -> + result = c1.op.p - c2.op.p + if result != 0 + return result + else if c1.op.i? and c2.op.d? + return 1 + else + return -1 + + if op.d? + @emit "delete:added", change + else if op.i? + @emit "insert:added", change + + _removeChange: (change) -> + @changes = @changes.filter (c) -> c.id != change.id + if change.op.d? + @emit "delete:removed", change + else if change.op.i? + @emit "insert:removed", change + + _applyOpModifications: (content, op_modifications) -> + # Put in descending position order, with deleting first if at the same offset + # (Inserting first would modify the content that the delete will delete) + op_modifications.sort (a, b) -> + result = b.p - a.p + if result != 0 + return result + else if a.i? and b.d? + return 1 + else + return -1 + + for modification in op_modifications + if modification.i? + content = content.slice(0, modification.p) + modification.i + content.slice(modification.p) + else if modification.d? + if content.slice(modification.p, modification.p + modification.d.length) != modification.d + throw new Error("deleted content does not match. content: #{JSON.stringify(content)}; modification: #{JSON.stringify(modification)}") + content = content.slice(0, modification.p) + content.slice(modification.p + modification.d.length) + return content + + _scanAndMergeAdjacentUpdates: () -> + # This should only need calling when deleting an update between two + # other updates. There's no other way to get two adjacent updates from the + # same user, since they would be merged on insert. + previous_change = null + remove_changes = [] + moved_changes = [] + for change in @changes + if previous_change?.op.i? and change.op.i? + previous_change_end = previous_change.op.p + previous_change.op.i.length + previous_change_user_id = previous_change.metadata.user_id + change_start = change.op.p + change_user_id = change.metadata.user_id + if previous_change_end == change_start and previous_change_user_id == change_user_id + remove_changes.push change + previous_change.op.i += change.op.i + moved_changes.push previous_change + else if previous_change?.op.d? and change.op.d? and previous_change.op.p == change.op.p + # Merge adjacent deletes + previous_change.op.d += change.op.d + remove_changes.push change + moved_changes.push previous_change + else # Only update to the current change if we haven't removed it. + previous_change = change + return { moved_changes, remove_changes } + +if define? + define ["utils/EventEmitter"], load +else + EventEmitter = require("events").EventEmitter + module.exports = load(EventEmitter) \ No newline at end of file diff --git a/services/document-updater/app/coffee/RedisKeyBuilder.coffee b/services/document-updater/app/coffee/RedisKeyBuilder.coffee index 0e9e59e8f1..1b5e548809 100644 --- a/services/document-updater/app/coffee/RedisKeyBuilder.coffee +++ b/services/document-updater/app/coffee/RedisKeyBuilder.coffee @@ -34,6 +34,8 @@ module.exports = RedisKeyBuilder = return (key_schema) -> key_schema.uncompressedHistoryOp({doc_id}) pendingUpdates: ({doc_id}) -> return (key_schema) -> key_schema.pendingUpdates({doc_id}) + ranges: ({doc_id}) -> + return (key_schema) -> key_schema.ranges({doc_id}) docsInProject: ({project_id}) -> return (key_schema) -> key_schema.docsInProject({project_id}) docsWithHistoryOps: ({project_id}) -> diff --git a/services/document-updater/app/coffee/RedisManager.coffee b/services/document-updater/app/coffee/RedisManager.coffee index 87e31b7826..be5166b94c 100644 --- a/services/document-updater/app/coffee/RedisManager.coffee +++ b/services/document-updater/app/coffee/RedisManager.coffee @@ -13,16 +13,21 @@ minutes = 60 # seconds for Redis expire module.exports = RedisManager = rclient: rclient - putDocInMemory : (project_id, doc_id, docLines, version, _callback)-> + putDocInMemory : (project_id, doc_id, docLines, version, ranges, _callback)-> timer = new metrics.Timer("redis.put-doc") callback = (error) -> timer.done() _callback(error) logger.log project_id:project_id, doc_id:doc_id, version: version, "putting doc in redis" + ranges = RedisManager._serializeRanges(ranges) multi = rclient.multi() multi.set keys.docLines(doc_id:doc_id), JSON.stringify(docLines) multi.set keys.projectKey({doc_id:doc_id}), project_id multi.set keys.docVersion(doc_id:doc_id), version + if ranges? + multi.set keys.ranges(doc_id:doc_id), ranges + else + multi.del keys.ranges(doc_id:doc_id) multi.exec (error) -> return callback(error) if error? rclient.sadd keys.docsInProject(project_id:project_id), doc_id, callback @@ -41,30 +46,33 @@ module.exports = RedisManager = multi.del keys.docLines(doc_id:doc_id) multi.del keys.projectKey(doc_id:doc_id) multi.del keys.docVersion(doc_id:doc_id) + multi.del keys.ranges(doc_id:doc_id) multi.exec (error) -> return callback(error) if error? rclient.srem keys.docsInProject(project_id:project_id), doc_id, callback - getDoc : (project_id, doc_id, callback = (error, lines, version, project_id) ->)-> + getDoc : (project_id, doc_id, callback = (error, lines, version, ranges) ->)-> timer = new metrics.Timer("redis.get-doc") multi = rclient.multi() multi.get keys.docLines(doc_id:doc_id) multi.get keys.docVersion(doc_id:doc_id) multi.get keys.projectKey(doc_id:doc_id) - multi.exec (error, result)-> + multi.get keys.ranges(doc_id:doc_id) + multi.exec (error, [docLines, version, doc_project_id, ranges])-> timer.done() return callback(error) if error? try - docLines = JSON.parse result[0] + docLines = JSON.parse docLines + ranges = RedisManager._deserializeRanges(ranges) catch e return callback(e) - version = parseInt(result[1] or 0, 10) - doc_project_id = result[2] + + version = parseInt(version or 0, 10) # check doc is in requested project if doc_project_id? and doc_project_id isnt project_id logger.error project_id: project_id, doc_id: doc_id, doc_project_id: doc_project_id, "doc not in project" return callback(new Errors.NotFoundError("document not found")) - callback null, docLines, version, project_id + callback null, docLines, version, ranges getDocVersion: (doc_id, callback = (error, version) ->) -> rclient.get keys.docVersion(doc_id: doc_id), (error, version) -> @@ -104,7 +112,7 @@ module.exports = RedisManager = DOC_OPS_TTL: 60 * minutes DOC_OPS_MAX_LENGTH: 100 - updateDocument : (doc_id, docLines, newVersion, appliedOps = [], callback = (error) ->)-> + updateDocument : (doc_id, docLines, newVersion, appliedOps = [], ranges, callback = (error) ->)-> RedisManager.getDocVersion doc_id, (error, currentVersion) -> return callback(error) if error? if currentVersion + appliedOps.length != newVersion @@ -119,10 +127,27 @@ module.exports = RedisManager = multi.rpush keys.docOps(doc_id: doc_id), jsonOps... multi.expire keys.docOps(doc_id: doc_id), RedisManager.DOC_OPS_TTL multi.ltrim keys.docOps(doc_id: doc_id), -RedisManager.DOC_OPS_MAX_LENGTH, -1 + ranges = RedisManager._serializeRanges(ranges) + if ranges? + multi.set keys.ranges(doc_id:doc_id), ranges + else + multi.del keys.ranges(doc_id:doc_id) multi.exec (error, replys) -> return callback(error) if error? return callback() getDocIdsInProject: (project_id, callback = (error, doc_ids) ->) -> rclient.smembers keys.docsInProject(project_id: project_id), callback - + + _serializeRanges: (ranges) -> + jsonRanges = JSON.stringify(ranges) + if jsonRanges == '{}' + # Most doc will have empty ranges so don't fill redis with lots of '{}' keys + jsonRanges = null + return jsonRanges + + _deserializeRanges: (ranges) -> + if !ranges? or ranges == "" + return {} + else + return JSON.parse(ranges) \ No newline at end of file diff --git a/services/document-updater/app/coffee/ShareJsDB.coffee b/services/document-updater/app/coffee/ShareJsDB.coffee index 3d80c680cb..3e5dfe303f 100644 --- a/services/document-updater/app/coffee/ShareJsDB.coffee +++ b/services/document-updater/app/coffee/ShareJsDB.coffee @@ -1,12 +1,9 @@ Keys = require('./UpdateKeys') -Settings = require('settings-sharelatex') -DocumentManager = require "./DocumentManager" RedisManager = require "./RedisManager" Errors = require "./Errors" -logger = require "logger-sharelatex" module.exports = class ShareJsDB - constructor: () -> + constructor: (@project_id, @doc_id, @lines, @version) -> @appliedOps = {} # ShareJS calls this detacted from the instance, so we need # bind it to keep our context that can access @appliedOps @@ -31,22 +28,14 @@ module.exports = class ShareJsDB callback() getSnapshot: (doc_key, callback) -> - [project_id, doc_id] = Keys.splitProjectIdAndDocId(doc_key) - DocumentManager.getDoc project_id, doc_id, (error, lines, version) -> - return callback(error) if error? - if !lines? or !version? - return callback(new Errors.NotFoundError("document not found: #{doc_id}")) - - if lines.length > 0 and lines[0].text? - type = "json" - snapshot = lines: lines - else - type = "text" - snapshot = lines.join("\n") - callback null, - snapshot: snapshot - v: parseInt(version, 10) - type: type + if doc_key != Keys.combineProjectIdAndDocId(@project_id, @doc_id) + return callback(new Errors.NotFoundError("unexpected doc_key #{doc_key}, expected #{Keys.combineProjectIdAndDocId(@project_id, @doc_id)}")) + else + return callback null, { + snapshot: @lines.join("\n") + v: parseInt(@version, 10) + type: "text" + } # To be able to remove a doc from the ShareJS memory # we need to called Model::delete, which calls this diff --git a/services/document-updater/app/coffee/ShareJsUpdateManager.coffee b/services/document-updater/app/coffee/ShareJsUpdateManager.coffee index 985d03094a..1d36776b9f 100644 --- a/services/document-updater/app/coffee/ShareJsUpdateManager.coffee +++ b/services/document-updater/app/coffee/ShareJsUpdateManager.coffee @@ -6,22 +6,19 @@ Settings = require('settings-sharelatex') Keys = require "./UpdateKeys" {EventEmitter} = require "events" util = require "util" - -redis = require("redis-sharelatex") -rclient = redis.createClient(Settings.redis.web) - +WebRedisManager = require "./WebRedisManager" ShareJsModel:: = {} util.inherits ShareJsModel, EventEmitter module.exports = ShareJsUpdateManager = - getNewShareJsModel: () -> - db = new ShareJsDB() + getNewShareJsModel: (project_id, doc_id, lines, version) -> + db = new ShareJsDB(project_id, doc_id, lines, version) model = new ShareJsModel(db, maxDocLength: Settings.max_doc_length) model.db = db return model - applyUpdate: (project_id, doc_id, update, callback = (error, updatedDocLines) ->) -> + applyUpdate: (project_id, doc_id, update, lines, version, callback = (error, updatedDocLines) ->) -> logger.log project_id: project_id, doc_id: doc_id, update: update, "applying sharejs updates" jobs = [] @@ -30,7 +27,7 @@ module.exports = ShareJsUpdateManager = # getting stuck due to queued callbacks (line 260 of sharejs/server/model.coffee) # This adds a small but hopefully acceptable overhead (~12ms per 1000 updates on # my 2009 MBP). - model = @getNewShareJsModel() + model = @getNewShareJsModel(project_id, doc_id, lines, version) @_listenForOps(model) doc_key = Keys.combineProjectIdAndDocId(project_id, doc_id) model.applyOp doc_key, update, (error) -> @@ -40,13 +37,10 @@ module.exports = ShareJsUpdateManager = update.dup = true ShareJsUpdateManager._sendOp(project_id, doc_id, update) else - ShareJsUpdateManager._sendError(project_id, doc_id, error) return callback(error) logger.log project_id: project_id, doc_id: doc_id, error: error, "applied update" model.getSnapshot doc_key, (error, data) => - if error? - ShareJsUpdateManager._sendError(project_id, doc_id, error) - return callback(error) + return callback(error) if error? docLines = data.snapshot.split(/\r\n|\n|\r/) callback(null, docLines, data.v, model.db.appliedOps[doc_key] or []) @@ -55,18 +49,6 @@ module.exports = ShareJsUpdateManager = [project_id, doc_id] = Keys.splitProjectIdAndDocId(doc_key) ShareJsUpdateManager._sendOp(project_id, doc_id, opData) - _sendOp: (project_id, doc_id, opData) -> - data = - project_id: project_id - doc_id: doc_id - op: opData - data = JSON.stringify data - rclient.publish "applied-ops", data + _sendOp: (project_id, doc_id, op) -> + WebRedisManager.sendData {project_id, doc_id, op} - _sendError: (project_id, doc_id, error) -> - data = JSON.stringify - project_id: project_id - doc_id: doc_id - error: error.message || error - rclient.publish "applied-ops", data - diff --git a/services/document-updater/app/coffee/UpdateManager.coffee b/services/document-updater/app/coffee/UpdateManager.coffee index f35bc1a9b7..89f58bfd1f 100644 --- a/services/document-updater/app/coffee/UpdateManager.coffee +++ b/services/document-updater/app/coffee/UpdateManager.coffee @@ -2,11 +2,14 @@ LockManager = require "./LockManager" RedisManager = require "./RedisManager" WebRedisManager = require "./WebRedisManager" ShareJsUpdateManager = require "./ShareJsUpdateManager" -TrackChangesManager = require "./TrackChangesManager" +HistoryManager = require "./HistoryManager" Settings = require('settings-sharelatex') async = require("async") logger = require('logger-sharelatex') Metrics = require "./Metrics" +Errors = require "./Errors" +DocumentManager = require "./DocumentManager" +RangesManager = require "./RangesManager" module.exports = UpdateManager = processOutstandingUpdates: (project_id, doc_id, callback = (error) ->) -> @@ -43,14 +46,25 @@ module.exports = UpdateManager = (update, cb) -> UpdateManager.applyUpdate project_id, doc_id, update, cb callback - applyUpdate: (project_id, doc_id, update, callback = (error) ->) -> + applyUpdate: (project_id, doc_id, update, _callback = (error) ->) -> + callback = (error) -> + if error? + WebRedisManager.sendData {project_id, doc_id, error: error.message || error} + _callback(error) + UpdateManager._sanitizeUpdate update - ShareJsUpdateManager.applyUpdate project_id, doc_id, update, (error, updatedDocLines, version, appliedOps) -> + DocumentManager.getDoc project_id, doc_id, (error, lines, version, ranges) -> return callback(error) if error? - logger.log doc_id: doc_id, version: version, "updating doc in redis" - RedisManager.updateDocument doc_id, updatedDocLines, version, appliedOps, (error) -> + if !lines? or !version? + return callback(new Errors.NotFoundError("document not found: #{doc_id}")) + ShareJsUpdateManager.applyUpdate project_id, doc_id, update, lines, version, (error, updatedDocLines, version, appliedOps) -> return callback(error) if error? - TrackChangesManager.pushUncompressedHistoryOps project_id, doc_id, appliedOps, callback + RangesManager.applyUpdate project_id, doc_id, ranges, appliedOps, (error, new_ranges) -> + return callback(error) if error? + logger.log doc_id: doc_id, version: version, "updating doc in redis" + RedisManager.updateDocument doc_id, updatedDocLines, version, appliedOps, new_ranges, (error) -> + return callback(error) if error? + HistoryManager.pushUncompressedHistoryOps project_id, doc_id, appliedOps, callback lockUpdatesAndDo: (method, project_id, doc_id, args..., callback) -> LockManager.getLock doc_id, (error, lockValue) -> diff --git a/services/document-updater/app/coffee/WebRedisManager.coffee b/services/document-updater/app/coffee/WebRedisManager.coffee index 73c099f9da..eb3b6a583c 100644 --- a/services/document-updater/app/coffee/WebRedisManager.coffee +++ b/services/document-updater/app/coffee/WebRedisManager.coffee @@ -32,4 +32,7 @@ module.exports = WebRedisManager = ], (error, results) -> return callback(error) if error? [length, _] = results - callback(error, length) \ No newline at end of file + callback(error, length) + + sendData: (data) -> + rclient.publish "applied-ops", JSON.stringify(data) \ No newline at end of file diff --git a/services/document-updater/app/coffee/sharejs/types/text.coffee b/services/document-updater/app/coffee/sharejs/types/text.coffee index c64b4dfa68..2a3b79997d 100644 --- a/services/document-updater/app/coffee/sharejs/types/text.coffee +++ b/services/document-updater/app/coffee/sharejs/types/text.coffee @@ -31,7 +31,8 @@ checkValidComponent = (c) -> i_type = typeof c.i d_type = typeof c.d - throw new Error 'component needs an i or d field' unless (i_type == 'string') ^ (d_type == 'string') + c_type = typeof c.c + throw new Error 'component needs an i, d or c field' unless (i_type == 'string') ^ (d_type == 'string') ^ (c_type == 'string') throw new Error 'position cannot be negative' unless c.p >= 0 @@ -44,11 +45,15 @@ text.apply = (snapshot, op) -> for component in op if component.i? snapshot = strInject snapshot, component.p, component.i - else + else if component.d? deleted = snapshot[component.p...(component.p + component.d.length)] throw new Error "Delete component '#{component.d}' does not match deleted text '#{deleted}'" unless component.d == deleted snapshot = snapshot[...component.p] + snapshot[(component.p + component.d.length)..] - + else if component.c? + comment = snapshot[component.p...(component.p + component.c.length)] + throw new Error "Comment component '#{component.c}' does not match commented text '#{comment}'" unless component.c == comment + else + throw new Error "Unknown op type" snapshot @@ -112,7 +117,7 @@ transformPosition = (pos, c, insertAfter) -> pos + c.i.length else pos - else + else if c.d? # I think this could also be written as: Math.min(c.p, Math.min(c.p - otherC.p, otherC.d.length)) # but I think its harder to read that way, and it compiles using ternary operators anyway # so its no slower written like this. @@ -122,6 +127,10 @@ transformPosition = (pos, c, insertAfter) -> c.p else pos - c.d.length + else if c.c? + pos + else + throw new Error("unknown op type") # Helper method to transform a cursor position as a result of an op. # @@ -143,7 +152,7 @@ text._tc = transformComponent = (dest, c, otherC, side) -> if c.i? append dest, {i:c.i, p:transformPosition(c.p, otherC, side == 'right')} - else # Delete + else if c.d? # Delete if otherC.i? # delete vs insert s = c.d if c.p < otherC.p @@ -152,7 +161,7 @@ text._tc = transformComponent = (dest, c, otherC, side) -> if s != '' append dest, {d:s, p:c.p + otherC.i.length} - else # Delete vs delete + else if otherC.d? # Delete vs delete if c.p >= otherC.p + otherC.d.length append dest, {d:c.d, p:c.p - otherC.d.length} else if c.p + c.d.length <= otherC.p @@ -177,6 +186,51 @@ text._tc = transformComponent = (dest, c, otherC, side) -> # This could be rewritten similarly to insert v delete, above. newC.p = transformPosition newC.p, otherC append dest, newC + + else if otherC.c? + append dest, c + + else + throw new Error("unknown op type") + + else if c.c? # Comment + if otherC.i? + if c.p < otherC.p < c.p + c.c.length + offset = otherC.p - c.p + new_c = (c.c[0..(offset-1)] + otherC.i + c.c[offset...]) + append dest, {c:new_c, p:c.p, t: c.t} + else + append dest, {c:c.c, p:transformPosition(c.p, otherC, true), t: c.t} + + else if otherC.d? + if c.p >= otherC.p + otherC.d.length + append dest, {c:c.c, p:c.p - otherC.d.length, t: c.t} + else if c.p + c.c.length <= otherC.p + append dest, c + else # Delete overlaps comment + # They overlap somewhere. + newC = {c:'', p:c.p, t: c.t} + if c.p < otherC.p + newC.c = c.c[...(otherC.p - c.p)] + if c.p + c.c.length > otherC.p + otherC.d.length + newC.c += c.c[(otherC.p + otherC.d.length - c.p)..] + + # This is entirely optional - just for a check that the deleted + # text in the two ops matches + intersectStart = Math.max c.p, otherC.p + intersectEnd = Math.min c.p + c.c.length, otherC.p + otherC.d.length + cIntersect = c.c[intersectStart - c.p...intersectEnd - c.p] + otherIntersect = otherC.d[intersectStart - otherC.p...intersectEnd - otherC.p] + throw new Error 'Delete ops delete different text in the same region of the document' unless cIntersect == otherIntersect + + newC.p = transformPosition newC.p, otherC + append dest, newC + + else if otherC.c? + append dest, c + + else + throw new Error("unknown op type") dest diff --git a/services/document-updater/config/settings.defaults.coffee b/services/document-updater/config/settings.defaults.coffee index 15456db932..42daf505d9 100755 --- a/services/document-updater/config/settings.defaults.coffee +++ b/services/document-updater/config/settings.defaults.coffee @@ -32,6 +32,7 @@ module.exports = docVersion: ({doc_id}) -> "DocVersion:#{doc_id}" projectKey: ({doc_id}) -> "ProjectId:#{doc_id}" docsInProject: ({project_id}) -> "DocsIn:#{project_id}" + ranges: ({doc_id}) -> "Ranges:#{doc_id}" # }, { # cluster: [{ # port: "7000" @@ -44,6 +45,7 @@ module.exports = # docVersion: ({doc_id}) -> "DocVersion:{#{doc_id}}" # projectKey: ({doc_id}) -> "ProjectId:{#{doc_id}}" # docsInProject: ({project_id}) -> "DocsIn:{#{project_id}}" + # ranges: ({doc_id}) -> "Ranges:{#{doc_id}}" }] max_doc_length: 2 * 1024 * 1024 # 2mb diff --git a/services/document-updater/test/acceptance/coffee/ApplyingUpdatesToADocTests.coffee b/services/document-updater/test/acceptance/coffee/ApplyingUpdatesToADocTests.coffee index 4166f8499e..bdfe89b990 100644 --- a/services/document-updater/test/acceptance/coffee/ApplyingUpdatesToADocTests.coffee +++ b/services/document-updater/test/acceptance/coffee/ApplyingUpdatesToADocTests.coffee @@ -176,8 +176,12 @@ describe "Applying updates to a doc", -> describe "with a broken update", -> before (done) -> [@project_id, @doc_id] = [DocUpdaterClient.randomId(), DocUpdaterClient.randomId()] + @broken_update = { doc_id: @doc_id, v: @version, op: [d: "not the correct content", p: 0 ] } MockWebApi.insertDoc @project_id, @doc_id, {lines: @lines, version: @version} - DocUpdaterClient.sendUpdate @project_id, @doc_id, @undefined, (error) -> + + DocUpdaterClient.subscribeToAppliedOps @messageCallback = sinon.stub() + + DocUpdaterClient.sendUpdate @project_id, @doc_id, @broken_update, (error) -> throw error if error? setTimeout done, 200 @@ -185,6 +189,16 @@ describe "Applying updates to a doc", -> DocUpdaterClient.getDoc @project_id, @doc_id, (error, res, doc) => doc.lines.should.deep.equal @lines done() + + it "should send a message with an error", -> + @messageCallback.called.should.equal true + [channel, message] = @messageCallback.args[0] + channel.should.equal "applied-ops" + JSON.parse(message).should.deep.equal { + project_id: @project_id, + doc_id: @doc_id, + error:'Delete component \'not the correct content\' does not match deleted text \'one\ntwo\nthree\'' + } describe "with enough updates to flush to the track changes api", -> before (done) -> diff --git a/services/document-updater/test/acceptance/coffee/RangesTests.coffee b/services/document-updater/test/acceptance/coffee/RangesTests.coffee new file mode 100644 index 0000000000..0849d4551f --- /dev/null +++ b/services/document-updater/test/acceptance/coffee/RangesTests.coffee @@ -0,0 +1,226 @@ +sinon = require "sinon" +chai = require("chai") +chai.should() +expect = chai.expect +async = require "async" + +MockWebApi = require "./helpers/MockWebApi" +DocUpdaterClient = require "./helpers/DocUpdaterClient" + +describe "Ranges", -> + describe "tracking changes from ops", -> + before (done) -> + @project_id = DocUpdaterClient.randomId() + @user_id = DocUpdaterClient.randomId() + @id_seed = "587357bd35e64f6157" + @doc = { + id: DocUpdaterClient.randomId() + lines: ["aaa"] + } + @updates = [{ + doc: @doc.id + op: [{ i: "123", p: 1 }] + v: 0 + meta: { user_id: @user_id } + }, { + doc: @doc.id + op: [{ i: "456", p: 5 }] + v: 1 + meta: { user_id: @user_id, tc: @id_seed } + }, { + doc: @doc.id + op: [{ d: "12", p: 1 }] + v: 2 + meta: { user_id: @user_id } + }] + MockWebApi.insertDoc @project_id, @doc.id, { + lines: @doc.lines + version: 0 + } + jobs = [] + for update in @updates + do (update) => + jobs.push (callback) => DocUpdaterClient.sendUpdate @project_id, @doc.id, update, callback + DocUpdaterClient.preloadDoc @project_id, @doc.id, (error) => + throw error if error? + async.series jobs, (error) -> + throw error if error? + setTimeout done, 200 + + it "should update the ranges", (done) -> + DocUpdaterClient.getDoc @project_id, @doc.id, (error, res, data) => + throw error if error? + ranges = data.ranges + change = ranges.changes[0] + change.op.should.deep.equal { i: "456", p: 3 } + change.id.should.equal @id_seed + "000001" + change.metadata.user_id.should.equal @user_id + done() + + describe "Adding comments", -> + describe "standalone", -> + before (done) -> + @project_id = DocUpdaterClient.randomId() + @user_id = DocUpdaterClient.randomId() + @doc = { + id: DocUpdaterClient.randomId() + lines: ["foo bar baz"] + } + @updates = [{ + doc: @doc.id + op: [{ c: "bar", p: 4, t: @tid = DocUpdaterClient.randomId() }] + v: 0 + }] + MockWebApi.insertDoc @project_id, @doc.id, { + lines: @doc.lines + version: 0 + } + jobs = [] + for update in @updates + do (update) => + jobs.push (callback) => DocUpdaterClient.sendUpdate @project_id, @doc.id, update, callback + DocUpdaterClient.preloadDoc @project_id, @doc.id, (error) => + throw error if error? + async.series jobs, (error) -> + throw error if error? + setTimeout done, 200 + + it "should update the ranges", (done) -> + DocUpdaterClient.getDoc @project_id, @doc.id, (error, res, data) => + throw error if error? + ranges = data.ranges + comment = ranges.comments[0] + comment.op.should.deep.equal { c: "bar", p: 4, t: @tid } + done() + + describe "with conflicting ops needing OT", -> + before (done) -> + @project_id = DocUpdaterClient.randomId() + @user_id = DocUpdaterClient.randomId() + @doc = { + id: DocUpdaterClient.randomId() + lines: ["foo bar baz"] + } + @updates = [{ + doc: @doc.id + op: [{ i: "ABC", p: 3 }] + v: 0 + meta: { user_id: @user_id } + }, { + doc: @doc.id + op: [{ c: "bar", p: 4, t: @tid = DocUpdaterClient.randomId() }] + v: 0 + }] + MockWebApi.insertDoc @project_id, @doc.id, { + lines: @doc.lines + version: 0 + } + jobs = [] + for update in @updates + do (update) => + jobs.push (callback) => DocUpdaterClient.sendUpdate @project_id, @doc.id, update, callback + DocUpdaterClient.preloadDoc @project_id, @doc.id, (error) => + throw error if error? + async.series jobs, (error) -> + throw error if error? + setTimeout done, 200 + + it "should update the comments with the OT shifted comment", (done) -> + DocUpdaterClient.getDoc @project_id, @doc.id, (error, res, data) => + throw error if error? + ranges = data.ranges + comment = ranges.comments[0] + comment.op.should.deep.equal { c: "bar", p: 7, t: @tid } + done() + + describe "Loading ranges from persistence layer", -> + before (done) -> + @project_id = DocUpdaterClient.randomId() + @user_id = DocUpdaterClient.randomId() + @id_seed = "587357bd35e64f6157" + @doc = { + id: DocUpdaterClient.randomId() + lines: ["a123aa"] + } + @update = { + doc: @doc.id + op: [{ i: "456", p: 5 }] + v: 0 + meta: { user_id: @user_id, tc: @id_seed } + } + MockWebApi.insertDoc @project_id, @doc.id, { + lines: @doc.lines + version: 0 + ranges: { + changes: [{ + op: { i: "123", p: 1 } + metadata: + user_id: @user_id + ts: new Date() + }] + } + } + DocUpdaterClient.preloadDoc @project_id, @doc.id, (error) => + throw error if error? + DocUpdaterClient.sendUpdate @project_id, @doc.id, @update, (error) -> + throw error if error? + setTimeout done, 200 + + it "should have preloaded the existing ranges", (done) -> + DocUpdaterClient.getDoc @project_id, @doc.id, (error, res, data) => + throw error if error? + {changes} = data.ranges + changes[0].op.should.deep.equal { i: "123", p: 1 } + changes[1].op.should.deep.equal { i: "456", p: 5 } + done() + + it "should flush the ranges to the persistence layer again", (done) -> + DocUpdaterClient.flushDoc @project_id, @doc.id, (error) => + throw error if error? + MockWebApi.getDocument @project_id, @doc.id, (error, doc) => + {changes} = doc.ranges + changes[0].op.should.deep.equal { i: "123", p: 1 } + changes[1].op.should.deep.equal { i: "456", p: 5 } + done() + + describe "accepting a change", -> + before (done) -> + @project_id = DocUpdaterClient.randomId() + @user_id = DocUpdaterClient.randomId() + @id_seed = "587357bd35e64f6157" + @doc = { + id: DocUpdaterClient.randomId() + lines: ["aaa"] + } + @update = { + doc: @doc.id + op: [{ i: "456", p: 1 }] + v: 0 + meta: { user_id: @user_id, tc: @id_seed } + } + MockWebApi.insertDoc @project_id, @doc.id, { + lines: @doc.lines + version: 0 + } + DocUpdaterClient.preloadDoc @project_id, @doc.id, (error) => + throw error if error? + DocUpdaterClient.sendUpdate @project_id, @doc.id, @update, (error) => + throw error if error? + setTimeout () => + DocUpdaterClient.getDoc @project_id, @doc.id, (error, res, data) => + throw error if error? + ranges = data.ranges + change = ranges.changes[0] + change.op.should.deep.equal { i: "456", p: 1 } + change.id.should.equal @id_seed + "000001" + change.metadata.user_id.should.equal @user_id + done() + , 200 + + it "should remove the change after accepting", (done) -> + DocUpdaterClient.acceptChange @project_id, @doc.id, @id_seed + "000001", (error) => + throw error if error? + DocUpdaterClient.getDoc @project_id, @doc.id, (error, res, data) => + throw error if error? + expect(data.ranges.changes).to.be.undefined + done() \ No newline at end of file diff --git a/services/document-updater/test/acceptance/coffee/helpers/DocUpdaterClient.coffee b/services/document-updater/test/acceptance/coffee/helpers/DocUpdaterClient.coffee index a14f6f9364..afcbfd4b45 100644 --- a/services/document-updater/test/acceptance/coffee/helpers/DocUpdaterClient.coffee +++ b/services/document-updater/test/acceptance/coffee/helpers/DocUpdaterClient.coffee @@ -72,7 +72,6 @@ module.exports = DocUpdaterClient = deleteProject: (project_id, callback = () ->) -> request.del "http://localhost:3003/project/#{project_id}", callback - - - - + + acceptChange: (project_id, doc_id, change_id, callback = () ->) -> + request.post "http://localhost:3003/project/#{project_id}/doc/#{doc_id}/change/#{change_id}/accept", callback \ No newline at end of file diff --git a/services/document-updater/test/acceptance/coffee/helpers/MockWebApi.coffee b/services/document-updater/test/acceptance/coffee/helpers/MockWebApi.coffee index e77a18c0ea..f2b8bce318 100644 --- a/services/document-updater/test/acceptance/coffee/helpers/MockWebApi.coffee +++ b/services/document-updater/test/acceptance/coffee/helpers/MockWebApi.coffee @@ -11,10 +11,11 @@ module.exports = MockWebApi = doc.lines ?= [] @docs["#{project_id}:#{doc_id}"] = doc - setDocument: (project_id, doc_id, lines, version, callback = (error) ->) -> + setDocument: (project_id, doc_id, lines, version, ranges, callback = (error) ->) -> doc = @docs["#{project_id}:#{doc_id}"] ||= {} doc.lines = lines doc.version = version + doc.ranges = ranges callback null getDocument: (project_id, doc_id, callback = (error, doc) ->) -> @@ -31,7 +32,7 @@ module.exports = MockWebApi = res.send 404 app.post "/project/:project_id/doc/:doc_id", express.bodyParser(), (req, res, next) => - MockWebApi.setDocument req.params.project_id, req.params.doc_id, req.body.lines, req.body.version, (error) -> + MockWebApi.setDocument req.params.project_id, req.params.doc_id, req.body.lines, req.body.version, req.body.ranges, (error) -> if error? res.send 500 else diff --git a/services/document-updater/test/unit/coffee/DocumentManager/DocumentManagerTests.coffee b/services/document-updater/test/unit/coffee/DocumentManager/DocumentManagerTests.coffee new file mode 100644 index 0000000000..3f0279b5c7 --- /dev/null +++ b/services/document-updater/test/unit/coffee/DocumentManager/DocumentManagerTests.coffee @@ -0,0 +1,310 @@ +sinon = require('sinon') +chai = require('chai') +should = chai.should() +modulePath = "../../../../app/js/DocumentManager.js" +SandboxedModule = require('sandboxed-module') +Errors = require "../../../../app/js/Errors" + +describe "DocumentManager", -> + beforeEach -> + @DocumentManager = SandboxedModule.require modulePath, requires: + "./RedisManager": @RedisManager = {} + "./PersistenceManager": @PersistenceManager = {} + "./HistoryManager": @HistoryManager = {} + "logger-sharelatex": @logger = {log: sinon.stub()} + "./DocOpsManager": @DocOpsManager = {} + "./Metrics": @Metrics = + Timer: class Timer + done: sinon.stub() + "./WebRedisManager": @WebRedisManager = {} + "./DiffCodec": @DiffCodec = {} + "./UpdateManager": @UpdateManager = {} + "./RangesManager": @RangesManager = {} + @project_id = "project-id-123" + @doc_id = "doc-id-123" + @callback = sinon.stub() + @lines = ["one", "two", "three"] + @version = 42 + @ranges = { comments: "mock", entries: "mock" } + + describe "flushAndDeleteDoc", -> + describe "successfully", -> + beforeEach -> + @RedisManager.removeDocFromMemory = sinon.stub().callsArg(2) + @DocumentManager.flushDocIfLoaded = sinon.stub().callsArgWith(2) + @HistoryManager.flushDocChanges = sinon.stub().callsArg(2) + @DocumentManager.flushAndDeleteDoc @project_id, @doc_id, @callback + + it "should flush the doc", -> + @DocumentManager.flushDocIfLoaded + .calledWith(@project_id, @doc_id) + .should.equal true + + it "should remove the doc from redis", -> + @RedisManager.removeDocFromMemory + .calledWith(@project_id, @doc_id) + .should.equal true + + it "should call the callback without error", -> + @callback.calledWith(null).should.equal true + + it "should time the execution", -> + @Metrics.Timer::done.called.should.equal true + + it "should flush to the history api", -> + @HistoryManager.flushDocChanges + .calledWith(@project_id, @doc_id) + .should.equal true + + describe "flushDocIfLoaded", -> + describe "when the doc is in Redis", -> + beforeEach -> + @RedisManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version, @ranges) + @PersistenceManager.setDoc = sinon.stub().yields() + @DocumentManager.flushDocIfLoaded @project_id, @doc_id, @callback + + it "should get the doc from redis", -> + @RedisManager.getDoc + .calledWith(@project_id, @doc_id) + .should.equal true + + it "should write the doc lines to the persistence layer", -> + @PersistenceManager.setDoc + .calledWith(@project_id, @doc_id, @lines, @version, @ranges) + .should.equal true + + it "should call the callback without error", -> + @callback.calledWith(null).should.equal true + + it "should time the execution", -> + @Metrics.Timer::done.called.should.equal true + + describe "when the document is not in Redis", -> + beforeEach -> + @RedisManager.getDoc = sinon.stub().callsArgWith(2, null, null, null, null) + @PersistenceManager.setDoc = sinon.stub().yields() + @DocOpsManager.flushDocOpsToMongo = sinon.stub().callsArgWith(2) + @DocumentManager.flushDocIfLoaded @project_id, @doc_id, @callback + + it "should get the doc from redis", -> + @RedisManager.getDoc + .calledWith(@project_id, @doc_id) + .should.equal true + + it "should not write anything to the persistence layer", -> + @PersistenceManager.setDoc.called.should.equal false + @DocOpsManager.flushDocOpsToMongo.called.should.equal false + + it "should call the callback without error", -> + @callback.calledWith(null).should.equal true + + it "should time the execution", -> + @Metrics.Timer::done.called.should.equal true + + describe "getDocAndRecentOps", -> + describe "with a previous version specified", -> + beforeEach -> + @DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version, @ranges) + @RedisManager.getPreviousDocOps = sinon.stub().callsArgWith(3, null, @ops) + @DocumentManager.getDocAndRecentOps @project_id, @doc_id, @fromVersion, @callback + + it "should get the doc", -> + @DocumentManager.getDoc + .calledWith(@project_id, @doc_id) + .should.equal true + + it "should get the doc ops", -> + @RedisManager.getPreviousDocOps + .calledWith(@doc_id, @fromVersion, @version) + .should.equal true + + it "should call the callback with the doc info", -> + @callback.calledWith(null, @lines, @version, @ops, @ranges).should.equal true + + it "should time the execution", -> + @Metrics.Timer::done.called.should.equal true + + describe "with no previous version specified", -> + beforeEach -> + @DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version, @ranges) + @RedisManager.getPreviousDocOps = sinon.stub().callsArgWith(3, null, @ops) + @DocumentManager.getDocAndRecentOps @project_id, @doc_id, -1, @callback + + it "should get the doc", -> + @DocumentManager.getDoc + .calledWith(@project_id, @doc_id) + .should.equal true + + it "should not need to get the doc ops", -> + @RedisManager.getPreviousDocOps.called.should.equal false + + it "should call the callback with the doc info", -> + @callback.calledWith(null, @lines, @version, [], @ranges).should.equal true + + it "should time the execution", -> + @Metrics.Timer::done.called.should.equal true + + describe "getDoc", -> + describe "when the doc exists in Redis", -> + beforeEach -> + @RedisManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version, @ranges) + @DocumentManager.getDoc @project_id, @doc_id, @callback + + it "should get the doc from Redis", -> + @RedisManager.getDoc + .calledWith(@project_id, @doc_id) + .should.equal true + + it "should call the callback with the doc info", -> + @callback.calledWith(null, @lines, @version, @ranges, true).should.equal true + + it "should time the execution", -> + @Metrics.Timer::done.called.should.equal true + + describe "when the doc does not exist in Redis", -> + beforeEach -> + @RedisManager.getDoc = sinon.stub().callsArgWith(2, null, null, null, null, null) + @PersistenceManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version, @ranges) + @RedisManager.putDocInMemory = sinon.stub().yields() + @DocumentManager.getDoc @project_id, @doc_id, @callback + + it "should try to get the doc from Redis", -> + @RedisManager.getDoc + .calledWith(@project_id, @doc_id) + .should.equal true + + it "should get the doc from the PersistenceManager", -> + @PersistenceManager.getDoc + .calledWith(@project_id, @doc_id) + .should.equal true + + it "should set the doc in Redis", -> + @RedisManager.putDocInMemory + .calledWith(@project_id, @doc_id, @lines, @version, @ranges) + .should.equal true + + it "should call the callback with the doc info", -> + @callback.calledWith(null, @lines, @version, @ranges, false).should.equal true + + it "should time the execution", -> + @Metrics.Timer::done.called.should.equal true + + describe "setDoc", -> + describe "with plain tex lines", -> + beforeEach -> + @beforeLines = ["before", "lines"] + @afterLines = ["after", "lines"] + @DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @beforeLines, @version, @ranges, true) + @DiffCodec.diffAsShareJsOp = sinon.stub().callsArgWith(2, null, @ops) + @UpdateManager.applyUpdate = sinon.stub().callsArgWith(3, null) + @DocumentManager.flushDocIfLoaded = sinon.stub().callsArg(2) + @DocumentManager.flushAndDeleteDoc = sinon.stub().callsArg(2) + + describe "when already loaded", -> + beforeEach -> + @DocumentManager.setDoc @project_id, @doc_id, @afterLines, @source, @user_id, @callback + + it "should get the current doc lines", -> + @DocumentManager.getDoc + .calledWith(@project_id, @doc_id) + .should.equal true + + it "should return a diff of the old and new lines", -> + @DiffCodec.diffAsShareJsOp + .calledWith(@beforeLines, @afterLines) + .should.equal true + + it "should apply the diff as a ShareJS op", -> + @UpdateManager.applyUpdate + .calledWith( + @project_id, + @doc_id, + { + doc: @doc_id, + v: @version, + op: @ops, + meta: { + type: "external" + source: @source + user_id: @user_id + } + } + ) + .should.equal true + + it "should flush the doc to Mongo", -> + @DocumentManager.flushDocIfLoaded + .calledWith(@project_id, @doc_id) + .should.equal true + + it "should call the callback", -> + @callback.calledWith(null).should.equal true + + it "should time the execution", -> + @Metrics.Timer::done.called.should.equal true + + describe "when not already loaded", -> + beforeEach -> + @DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @beforeLines, @version, false) + @DocumentManager.setDoc @project_id, @doc_id, @afterLines, @source, @user_id, @callback + + it "should flush and delete the doc from the doc updater", -> + @DocumentManager.flushAndDeleteDoc + .calledWith(@project_id, @doc_id) + .should.equal true + + describe "without new lines", -> + beforeEach -> + @DocumentManager.setDoc @project_id, @doc_id, null, @source, @user_id, @callback + + it "should return the callback with an error", -> + @callback.calledWith(new Error("No lines were passed to setDoc")) + + it "should not try to get the doc lines", -> + @DocumentManager.getDoc.called.should.equal false + + describe "acceptChanges", -> + beforeEach -> + @change_id = "mock-change-id" + @version = 34 + @lines = ["original", "lines"] + @ranges = { entries: "mock", comments: "mock" } + @updated_ranges = { entries: "updated", comments: "updated" } + @DocumentManager.getDoc = sinon.stub().yields(null, @lines, @version, @ranges) + @RangesManager.acceptChange = sinon.stub().yields(null, @updated_ranges) + @RedisManager.updateDocument = sinon.stub().yields() + + describe "successfully", -> + beforeEach -> + @DocumentManager.acceptChange @project_id, @doc_id, @change_id, @callback + + it "should get the document's current ranges", -> + @DocumentManager.getDoc + .calledWith(@project_id, @doc_id) + .should.equal true + + it "should apply the accept change to the ranges", -> + @RangesManager.acceptChange + .calledWith(@change_id, @ranges) + .should.equal true + + it "should save the updated ranges", -> + @RedisManager.updateDocument + .calledWith(@doc_id, @lines, @version, [], @updated_ranges) + .should.equal true + + it "should call the callback", -> + @callback.called.should.equal true + + describe "when the doc is not found", -> + beforeEach -> + @DocumentManager.getDoc = sinon.stub().yields(null, null, null, null) + @DocumentManager.acceptChange @project_id, @doc_id, @change_id, @callback + + it "should not save anything", -> + @RedisManager.updateDocument.called.should.equal false + + it "should call the callback with a not found error", -> + error = new Errors.NotFoundError("document not found: #{@doc_id}") + @callback.calledWith(error).should.equal true + \ No newline at end of file diff --git a/services/document-updater/test/unit/coffee/DocumentManager/flushAndDeleteDocTests.coffee b/services/document-updater/test/unit/coffee/DocumentManager/flushAndDeleteDocTests.coffee deleted file mode 100644 index 911efce1ba..0000000000 --- a/services/document-updater/test/unit/coffee/DocumentManager/flushAndDeleteDocTests.coffee +++ /dev/null @@ -1,48 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/DocumentManager.js" -SandboxedModule = require('sandboxed-module') - -describe "DocumentUpdater.flushAndDeleteDoc", -> - beforeEach -> - @DocumentManager = SandboxedModule.require modulePath, requires: - "./RedisManager": @RedisManager = {} - "./PersistenceManager": @PersistenceManager = {} - "./TrackChangesManager": @TrackChangesManager = {} - "logger-sharelatex": @logger = {log: sinon.stub()} - "./DocOpsManager" :{} - "./Metrics": @Metrics = - Timer: class Timer - done: sinon.stub() - @project_id = "project-id-123" - @doc_id = "doc-id-123" - @callback = sinon.stub() - - describe "successfully", -> - beforeEach -> - @RedisManager.removeDocFromMemory = sinon.stub().callsArg(2) - @DocumentManager.flushDocIfLoaded = sinon.stub().callsArgWith(2) - @TrackChangesManager.flushDocChanges = sinon.stub().callsArg(2) - @DocumentManager.flushAndDeleteDoc @project_id, @doc_id, @callback - - it "should flush the doc", -> - @DocumentManager.flushDocIfLoaded - .calledWith(@project_id, @doc_id) - .should.equal true - - it "should remove the doc from redis", -> - @RedisManager.removeDocFromMemory - .calledWith(@project_id, @doc_id) - .should.equal true - - it "should call the callback without error", -> - @callback.calledWith(null).should.equal true - - it "should time the execution", -> - @Metrics.Timer::done.called.should.equal true - - it "should flush to track changes", -> - @TrackChangesManager.flushDocChanges - .calledWith(@project_id, @doc_id) - .should.equal true diff --git a/services/document-updater/test/unit/coffee/DocumentManager/flushDocIfLoadedTests.coffee b/services/document-updater/test/unit/coffee/DocumentManager/flushDocIfLoadedTests.coffee deleted file mode 100644 index 4a17e2b84c..0000000000 --- a/services/document-updater/test/unit/coffee/DocumentManager/flushDocIfLoadedTests.coffee +++ /dev/null @@ -1,68 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/DocumentManager.js" -SandboxedModule = require('sandboxed-module') - -describe "DocumentManager.flushDocIfLoaded", -> - beforeEach -> - @DocumentManager = SandboxedModule.require modulePath, requires: - "./RedisManager": @RedisManager = {} - "./PersistenceManager": @PersistenceManager = {} - "./DocOpsManager": @DocOpsManager = {} - "logger-sharelatex": @logger = {log: sinon.stub()} - "./Metrics": @Metrics = - Timer: class Timer - done: sinon.stub() - "./TrackChangesManager": {} - @project_id = "project-id-123" - @doc_id = "doc-id-123" - @lines = ["one", "two", "three"] - @version = 42 - @callback = sinon.stub() - - describe "when the doc is in Redis", -> - beforeEach -> - @RedisManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version) - @PersistenceManager.setDoc = sinon.stub().callsArgWith(4) - @DocumentManager.flushDocIfLoaded @project_id, @doc_id, @callback - - it "should get the doc from redis", -> - @RedisManager.getDoc - .calledWith(@project_id, @doc_id) - .should.equal true - - it "should write the doc lines to the persistence layer", -> - @PersistenceManager.setDoc - .calledWith(@project_id, @doc_id, @lines, @version) - .should.equal true - - it "should call the callback without error", -> - @callback.calledWith(null).should.equal true - - it "should time the execution", -> - @Metrics.Timer::done.called.should.equal true - - describe "when the document is not in Redis", -> - beforeEach -> - @RedisManager.getDoc = sinon.stub().callsArgWith(2, null, null, null) - @PersistenceManager.setDoc = sinon.stub().callsArgWith(4) - @DocOpsManager.flushDocOpsToMongo = sinon.stub().callsArgWith(2) - @DocumentManager.flushDocIfLoaded @project_id, @doc_id, @callback - - it "should get the doc from redis", -> - @RedisManager.getDoc - .calledWith(@project_id, @doc_id) - .should.equal true - - it "should not write anything to the persistence layer", -> - @PersistenceManager.setDoc.called.should.equal false - @DocOpsManager.flushDocOpsToMongo.called.should.equal false - - it "should call the callback without error", -> - @callback.calledWith(null).should.equal true - - it "should time the execution", -> - @Metrics.Timer::done.called.should.equal true - - diff --git a/services/document-updater/test/unit/coffee/DocumentManager/getDocAndRecentOpsTests.coffee b/services/document-updater/test/unit/coffee/DocumentManager/getDocAndRecentOpsTests.coffee deleted file mode 100644 index c77af9a77c..0000000000 --- a/services/document-updater/test/unit/coffee/DocumentManager/getDocAndRecentOpsTests.coffee +++ /dev/null @@ -1,67 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/DocumentManager.js" -SandboxedModule = require('sandboxed-module') - -describe "DocumentManager.getDocAndRecentOps", -> - beforeEach -> - @DocumentManager = SandboxedModule.require modulePath, requires: - "./RedisManager": @RedisManager = {} - "./PersistenceManager": @PersistenceManager = {} - "logger-sharelatex": @logger = {log: sinon.stub()} - "./Metrics": @Metrics = - Timer: class Timer - done: sinon.stub() - "./TrackChangesManager": {} - - @project_id = "project-id-123" - @doc_id = "doc-id-123" - @lines = ["one", "two", "three"] - @version = 42 - @fromVersion = 40 - @ops = ["mock-op-1", "mock-op-2"] - @callback = sinon.stub() - - describe "with a previous version specified", -> - beforeEach -> - @DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version) - @RedisManager.getPreviousDocOps = sinon.stub().callsArgWith(3, null, @ops) - @DocumentManager.getDocAndRecentOps @project_id, @doc_id, @fromVersion, @callback - - it "should get the doc", -> - @DocumentManager.getDoc - .calledWith(@project_id, @doc_id) - .should.equal true - - it "should get the doc ops", -> - @RedisManager.getPreviousDocOps - .calledWith(@doc_id, @fromVersion, @version) - .should.equal true - - it "should call the callback with the doc info", -> - @callback.calledWith(null, @lines, @version, @ops).should.equal true - - it "should time the execution", -> - @Metrics.Timer::done.called.should.equal true - - describe "with no previous version specified", -> - beforeEach -> - @DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version) - @RedisManager.getPreviousDocOps = sinon.stub().callsArgWith(3, null, @ops) - @DocumentManager.getDocAndRecentOps @project_id, @doc_id, -1, @callback - - it "should get the doc", -> - @DocumentManager.getDoc - .calledWith(@project_id, @doc_id) - .should.equal true - - it "should not need to get the doc ops", -> - @RedisManager.getPreviousDocOps.called.should.equal false - - it "should call the callback with the doc info", -> - @callback.calledWith(null, @lines, @version, []).should.equal true - - it "should time the execution", -> - @Metrics.Timer::done.called.should.equal true - diff --git a/services/document-updater/test/unit/coffee/DocumentManager/getDocTests.coffee b/services/document-updater/test/unit/coffee/DocumentManager/getDocTests.coffee deleted file mode 100644 index 3edf4cb67d..0000000000 --- a/services/document-updater/test/unit/coffee/DocumentManager/getDocTests.coffee +++ /dev/null @@ -1,70 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/DocumentManager.js" -SandboxedModule = require('sandboxed-module') - -describe "DocumentUpdater.getDoc", -> - beforeEach -> - @DocumentManager = SandboxedModule.require modulePath, requires: - "./RedisManager": @RedisManager = {} - "./PersistenceManager": @PersistenceManager = {} - "./DocOpsManager": @DocOpsManager = {} - "logger-sharelatex": @logger = {log: sinon.stub()} - "./Metrics": @Metrics = - Timer: class Timer - done: sinon.stub() - "./TrackChangesManager": {} - - @project_id = "project-id-123" - @doc_id = "doc-id-123" - @lines = ["one", "two", "three"] - @version = 42 - @callback = sinon.stub() - - describe "when the doc exists in Redis", -> - beforeEach -> - @RedisManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version) - @DocumentManager.getDoc @project_id, @doc_id, @callback - - it "should get the doc from Redis", -> - @RedisManager.getDoc - .calledWith(@project_id, @doc_id) - .should.equal true - - it "should call the callback with the doc info", -> - @callback.calledWith(null, @lines, @version, true).should.equal true - - it "should time the execution", -> - @Metrics.Timer::done.called.should.equal true - - describe "when the doc does not exist in Redis", -> - beforeEach -> - @RedisManager.getDoc = sinon.stub().callsArgWith(2, null, null, null) - @PersistenceManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version) - @RedisManager.putDocInMemory = sinon.stub().callsArg(4) - @DocumentManager.getDoc @project_id, @doc_id, @callback - - it "should try to get the doc from Redis", -> - @RedisManager.getDoc - .calledWith(@project_id, @doc_id) - .should.equal true - - it "should get the doc from the PersistenceManager", -> - @PersistenceManager.getDoc - .calledWith(@project_id, @doc_id) - .should.equal true - - it "should set the doc in Redis", -> - @RedisManager.putDocInMemory - .calledWith(@project_id, @doc_id, @lines, @version) - .should.equal true - - it "should call the callback with the doc info", -> - @callback.calledWith(null, @lines, @version, false).should.equal true - - it "should time the execution", -> - @Metrics.Timer::done.called.should.equal true - - - diff --git a/services/document-updater/test/unit/coffee/DocumentManager/setDocTests.coffee b/services/document-updater/test/unit/coffee/DocumentManager/setDocTests.coffee deleted file mode 100644 index 360d939b9f..0000000000 --- a/services/document-updater/test/unit/coffee/DocumentManager/setDocTests.coffee +++ /dev/null @@ -1,107 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/DocumentManager.js" -SandboxedModule = require('sandboxed-module') - -describe "DocumentManager.setDoc", -> - beforeEach -> - @DocumentManager = SandboxedModule.require modulePath, requires: - "./RedisManager": @RedisManager = {} - "./PersistenceManager": @PersistenceManager = {} - "./DiffCodec": @DiffCodec = {} - "./DocOpsManager":{} - "./UpdateManager": @UpdateManager = {} - "logger-sharelatex": @logger = {log: sinon.stub()} - "./Metrics": @Metrics = - Timer: class Timer - done: sinon.stub() - "./TrackChangesManager": {} - - @project_id = "project-id-123" - @doc_id = "doc-id-123" - @version = 42 - @ops = ["mock-ops"] - @callback = sinon.stub() - @source = "dropbox" - @user_id = "mock-user-id" - - describe "with plain tex lines", -> - beforeEach -> - @beforeLines = ["before", "lines"] - @afterLines = ["after", "lines"] - @DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @beforeLines, @version, true) - @DiffCodec.diffAsShareJsOp = sinon.stub().callsArgWith(2, null, @ops) - @UpdateManager.applyUpdate = sinon.stub().callsArgWith(3, null) - @DocumentManager.flushDocIfLoaded = sinon.stub().callsArg(2) - @DocumentManager.flushAndDeleteDoc = sinon.stub().callsArg(2) - - describe "when already loaded", -> - beforeEach -> - @DocumentManager.setDoc @project_id, @doc_id, @afterLines, @source, @user_id, @callback - - it "should get the current doc lines", -> - @DocumentManager.getDoc - .calledWith(@project_id, @doc_id) - .should.equal true - - it "should return a diff of the old and new lines", -> - @DiffCodec.diffAsShareJsOp - .calledWith(@beforeLines, @afterLines) - .should.equal true - - it "should apply the diff as a ShareJS op", -> - @UpdateManager.applyUpdate - .calledWith( - @project_id, - @doc_id, - { - doc: @doc_id, - v: @version, - op: @ops, - meta: { - type: "external" - source: @source - user_id: @user_id - } - } - ) - .should.equal true - - it "should flush the doc to Mongo", -> - @DocumentManager.flushDocIfLoaded - .calledWith(@project_id, @doc_id) - .should.equal true - - it "should call the callback", -> - @callback.calledWith(null).should.equal true - - it "should time the execution", -> - @Metrics.Timer::done.called.should.equal true - - describe "when not already loaded", -> - beforeEach -> - @DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @beforeLines, @version, false) - @DocumentManager.setDoc @project_id, @doc_id, @afterLines, @source, @user_id, @callback - - it "should flush and delete the doc from the doc updater", -> - @DocumentManager.flushAndDeleteDoc - .calledWith(@project_id, @doc_id) - .should.equal true - - describe "without new lines", -> - beforeEach -> - @DocumentManager.setDoc @project_id, @doc_id, null, @source, @user_id, @callback - - it "should return the callback with an error", -> - @callback.calledWith(new Error("No lines were passed to setDoc")) - - it "should not try to get the doc lines", -> - @DocumentManager.getDoc.called.should.equal false - - - - - - - diff --git a/services/document-updater/test/unit/coffee/TrackChangesManager/TrackChangesManagerTests.coffee b/services/document-updater/test/unit/coffee/HistoryManager/HistoryManagerTests.coffee similarity index 68% rename from services/document-updater/test/unit/coffee/TrackChangesManager/TrackChangesManagerTests.coffee rename to services/document-updater/test/unit/coffee/HistoryManager/HistoryManagerTests.coffee index 03106e2c2e..c33a18d4e6 100644 --- a/services/document-updater/test/unit/coffee/TrackChangesManager/TrackChangesManagerTests.coffee +++ b/services/document-updater/test/unit/coffee/HistoryManager/HistoryManagerTests.coffee @@ -1,11 +1,11 @@ SandboxedModule = require('sandboxed-module') sinon = require('sinon') require('chai').should() -modulePath = require('path').join __dirname, '../../../../app/js/TrackChangesManager' +modulePath = require('path').join __dirname, '../../../../app/js/HistoryManager' -describe "TrackChangesManager", -> +describe "HistoryManager", -> beforeEach -> - @TrackChangesManager = SandboxedModule.require modulePath, requires: + @HistoryManager = SandboxedModule.require modulePath, requires: "request": @request = {} "settings-sharelatex": @Settings = {} "logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() } @@ -22,7 +22,7 @@ describe "TrackChangesManager", -> describe "successfully", -> beforeEach -> @request.post = sinon.stub().callsArgWith(1, null, statusCode: 204) - @TrackChangesManager.flushDocChanges @project_id, @doc_id, @callback + @HistoryManager.flushDocChanges @project_id, @doc_id, @callback it "should send a request to the track changes api", -> @request.post @@ -35,7 +35,7 @@ describe "TrackChangesManager", -> describe "when the track changes api returns an error", -> beforeEach -> @request.post = sinon.stub().callsArgWith(1, null, statusCode: 500) - @TrackChangesManager.flushDocChanges @project_id, @doc_id, @callback + @HistoryManager.flushDocChanges @project_id, @doc_id, @callback it "should return the callback with an error", -> @callback.calledWith(new Error("track changes api return non-success code: 500")).should.equal true @@ -43,12 +43,12 @@ describe "TrackChangesManager", -> describe "pushUncompressedHistoryOps", -> beforeEach -> @ops = ["mock-ops"] - @TrackChangesManager.flushDocChanges = sinon.stub().callsArg(2) + @HistoryManager.flushDocChanges = sinon.stub().callsArg(2) describe "pushing the op", -> beforeEach -> @WebRedisManager.pushUncompressedHistoryOps = sinon.stub().callsArgWith(3, null, 1) - @TrackChangesManager.pushUncompressedHistoryOps @project_id, @doc_id, @ops, @callback + @HistoryManager.pushUncompressedHistoryOps @project_id, @doc_id, @ops, @callback it "should push the ops into redis", -> @WebRedisManager.pushUncompressedHistoryOps @@ -59,16 +59,16 @@ describe "TrackChangesManager", -> @callback.called.should.equal true it "should not try to flush the op", -> - @TrackChangesManager.flushDocChanges.called.should.equal false + @HistoryManager.flushDocChanges.called.should.equal false describe "when we hit a multiple of FLUSH_EVERY_N_OPS ops", -> beforeEach -> @WebRedisManager.pushUncompressedHistoryOps = - sinon.stub().callsArgWith(3, null, 2 * @TrackChangesManager.FLUSH_EVERY_N_OPS) - @TrackChangesManager.pushUncompressedHistoryOps @project_id, @doc_id, @ops, @callback + sinon.stub().callsArgWith(3, null, 2 * @HistoryManager.FLUSH_EVERY_N_OPS) + @HistoryManager.pushUncompressedHistoryOps @project_id, @doc_id, @ops, @callback it "should tell the track changes api to flush", -> - @TrackChangesManager.flushDocChanges + @HistoryManager.flushDocChanges .calledWith(@project_id, @doc_id) .should.equal true @@ -76,20 +76,20 @@ describe "TrackChangesManager", -> beforeEach -> @ops = ["op1", "op2", "op3"] @WebRedisManager.pushUncompressedHistoryOps = - sinon.stub().callsArgWith(3, null, 2 * @TrackChangesManager.FLUSH_EVERY_N_OPS + 1) - @TrackChangesManager.pushUncompressedHistoryOps @project_id, @doc_id, @ops, @callback + sinon.stub().callsArgWith(3, null, 2 * @HistoryManager.FLUSH_EVERY_N_OPS + 1) + @HistoryManager.pushUncompressedHistoryOps @project_id, @doc_id, @ops, @callback it "should tell the track changes api to flush", -> - @TrackChangesManager.flushDocChanges + @HistoryManager.flushDocChanges .calledWith(@project_id, @doc_id) .should.equal true - describe "when TrackChangesManager errors", -> + describe "when HistoryManager errors", -> beforeEach -> @WebRedisManager.pushUncompressedHistoryOps = - sinon.stub().callsArgWith(3, null, 2 * @TrackChangesManager.FLUSH_EVERY_N_OPS) - @TrackChangesManager.flushDocChanges = sinon.stub().callsArgWith(2, @error = new Error("oops")) - @TrackChangesManager.pushUncompressedHistoryOps @project_id, @doc_id, @ops, @callback + sinon.stub().callsArgWith(3, null, 2 * @HistoryManager.FLUSH_EVERY_N_OPS) + @HistoryManager.flushDocChanges = sinon.stub().callsArgWith(2, @error = new Error("oops")) + @HistoryManager.pushUncompressedHistoryOps @project_id, @doc_id, @ops, @callback it "should log out the error", -> @logger.error @@ -104,7 +104,7 @@ describe "TrackChangesManager", -> describe "with no ops", -> beforeEach -> @WebRedisManager.pushUncompressedHistoryOps = sinon.stub().callsArgWith(3, null, 1) - @TrackChangesManager.pushUncompressedHistoryOps @project_id, @doc_id, [], @callback + @HistoryManager.pushUncompressedHistoryOps @project_id, @doc_id, [], @callback it "should not call WebRedisManager.pushUncompressedHistoryOps", -> @WebRedisManager.pushUncompressedHistoryOps.called.should.equal false diff --git a/services/document-updater/test/unit/coffee/HttpController/HttpControllerTests.coffee b/services/document-updater/test/unit/coffee/HttpController/HttpControllerTests.coffee new file mode 100644 index 0000000000..4000b402aa --- /dev/null +++ b/services/document-updater/test/unit/coffee/HttpController/HttpControllerTests.coffee @@ -0,0 +1,376 @@ +sinon = require('sinon') +chai = require('chai') +should = chai.should() +modulePath = "../../../../app/js/HttpController.js" +SandboxedModule = require('sandboxed-module') +Errors = require "../../../../app/js/Errors.js" + +describe "HttpController", -> + beforeEach -> + @HttpController = SandboxedModule.require modulePath, requires: + "./DocumentManager": @DocumentManager = {} + "./ProjectManager": @ProjectManager = {} + "logger-sharelatex" : @logger = { log: sinon.stub() } + "./Metrics": @Metrics = {} + + @Metrics.Timer = class Timer + done: sinon.stub() + @project_id = "project-id-123" + @doc_id = "doc-id-123" + @next = sinon.stub() + @res = + send: sinon.stub() + + describe "getDoc", -> + beforeEach -> + @lines = ["one", "two", "three"] + @ops = ["mock-op-1", "mock-op-2"] + @version = 42 + @fromVersion = 42 + @ranges = { changes: "mock", comments: "mock" } + @req = + params: + project_id: @project_id + doc_id: @doc_id + + describe "when the document exists and no recent ops are requested", -> + beforeEach -> + @DocumentManager.getDocAndRecentOpsWithLock = sinon.stub().callsArgWith(3, null, @lines, @version, [], @ranges) + @HttpController.getDoc(@req, @res, @next) + + it "should get the doc", -> + @DocumentManager.getDocAndRecentOpsWithLock + .calledWith(@project_id, @doc_id, -1) + .should.equal true + + it "should return the doc as JSON", -> + @res.send + .calledWith(JSON.stringify({ + id: @doc_id + lines: @lines + version: @version + ops: [] + ranges: @ranges + })) + .should.equal true + + it "should log the request", -> + @logger.log + .calledWith(doc_id: @doc_id, project_id: @project_id, "getting doc via http") + .should.equal true + + it "should time the request", -> + @Metrics.Timer::done.called.should.equal true + + describe "when recent ops are requested", -> + beforeEach -> + @DocumentManager.getDocAndRecentOpsWithLock = sinon.stub().callsArgWith(3, null, @lines, @version, @ops) + @req.query = fromVersion: "#{@fromVersion}" + @HttpController.getDoc(@req, @res, @next) + + it "should get the doc", -> + @DocumentManager.getDocAndRecentOpsWithLock + .calledWith(@project_id, @doc_id, @fromVersion) + .should.equal true + + it "should return the doc as JSON", -> + @res.send + .calledWith(JSON.stringify({ + id: @doc_id + lines: @lines + version: @version + ops: @ops + })) + .should.equal true + + it "should log the request", -> + @logger.log + .calledWith(doc_id: @doc_id, project_id: @project_id, "getting doc via http") + .should.equal true + + it "should time the request", -> + @Metrics.Timer::done.called.should.equal true + + describe "when the document does not exist", -> + beforeEach -> + @DocumentManager.getDocAndRecentOpsWithLock = sinon.stub().callsArgWith(3, null, null, null) + @HttpController.getDoc(@req, @res, @next) + + it "should call next with NotFoundError", -> + @next + .calledWith(new Errors.NotFoundError("not found")) + .should.equal true + + describe "when an errors occurs", -> + beforeEach -> + @DocumentManager.getDocAndRecentOpsWithLock = sinon.stub().callsArgWith(3, new Error("oops"), null, null) + @HttpController.getDoc(@req, @res, @next) + + it "should call next with the error", -> + @next + .calledWith(new Error("oops")) + .should.equal true + + describe "setDoc", -> + beforeEach -> + @lines = ["one", "two", "three"] + @source = "dropbox" + @user_id = "user-id-123" + @req = + headers: {} + params: + project_id: @project_id + doc_id: @doc_id + body: + lines: @lines + source: @source + user_id: @user_id + + describe "successfully", -> + beforeEach -> + @DocumentManager.setDocWithLock = sinon.stub().callsArgWith(5) + @HttpController.setDoc(@req, @res, @next) + + it "should set the doc", -> + @DocumentManager.setDocWithLock + .calledWith(@project_id, @doc_id, @lines, @source, @user_id) + .should.equal true + + it "should return a successful No Content response", -> + @res.send + .calledWith(204) + .should.equal true + + it "should log the request", -> + @logger.log + .calledWith(doc_id: @doc_id, project_id: @project_id, lines: @lines, source: @source, user_id: @user_id, "setting doc via http") + .should.equal true + + it "should time the request", -> + @Metrics.Timer::done.called.should.equal true + + describe "when an errors occurs", -> + beforeEach -> + @DocumentManager.setDocWithLock = sinon.stub().callsArgWith(5, new Error("oops")) + @HttpController.setDoc(@req, @res, @next) + + it "should call next with the error", -> + @next + .calledWith(new Error("oops")) + .should.equal true + + describe "when the payload is too large", -> + beforeEach -> + lines = [] + for _ in [0..200000] + lines.push "test test test" + @req.body.lines = lines + @DocumentManager.setDocWithLock = sinon.stub().callsArgWith(5) + @HttpController.setDoc(@req, @res, @next) + + it 'should send back a 406 response', -> + @res.send.calledWith(406).should.equal true + + it 'should not call setDocWithLock', -> + @DocumentManager.setDocWithLock.callCount.should.equal 0 + + describe "flushProject", -> + beforeEach -> + @req = + params: + project_id: @project_id + + describe "successfully", -> + beforeEach -> + @ProjectManager.flushProjectWithLocks = sinon.stub().callsArgWith(1) + @HttpController.flushProject(@req, @res, @next) + + it "should flush the project", -> + @ProjectManager.flushProjectWithLocks + .calledWith(@project_id) + .should.equal true + + it "should return a successful No Content response", -> + @res.send + .calledWith(204) + .should.equal true + + it "should log the request", -> + @logger.log + .calledWith(project_id: @project_id, "flushing project via http") + .should.equal true + + it "should time the request", -> + @Metrics.Timer::done.called.should.equal true + + describe "when an errors occurs", -> + beforeEach -> + @ProjectManager.flushProjectWithLocks = sinon.stub().callsArgWith(1, new Error("oops")) + @HttpController.flushProject(@req, @res, @next) + + it "should call next with the error", -> + @next + .calledWith(new Error("oops")) + .should.equal true + + describe "flushDocIfLoaded", -> + beforeEach -> + @lines = ["one", "two", "three"] + @version = 42 + @req = + params: + project_id: @project_id + doc_id: @doc_id + + describe "successfully", -> + beforeEach -> + @DocumentManager.flushDocIfLoadedWithLock = sinon.stub().callsArgWith(2) + @HttpController.flushDocIfLoaded(@req, @res, @next) + + it "should flush the doc", -> + @DocumentManager.flushDocIfLoadedWithLock + .calledWith(@project_id, @doc_id) + .should.equal true + + it "should return a successful No Content response", -> + @res.send + .calledWith(204) + .should.equal true + + it "should log the request", -> + @logger.log + .calledWith(doc_id: @doc_id, project_id: @project_id, "flushing doc via http") + .should.equal true + + it "should time the request", -> + @Metrics.Timer::done.called.should.equal true + + describe "when an errors occurs", -> + beforeEach -> + @DocumentManager.flushDocIfLoadedWithLock = sinon.stub().callsArgWith(2, new Error("oops")) + @HttpController.flushDocIfLoaded(@req, @res, @next) + + it "should call next with the error", -> + @next + .calledWith(new Error("oops")) + .should.equal true + + describe "flushAndDeleteDoc", -> + beforeEach -> + @req = + params: + project_id: @project_id + doc_id: @doc_id + + describe "successfully", -> + beforeEach -> + @DocumentManager.flushAndDeleteDocWithLock = sinon.stub().callsArgWith(2) + @HttpController.flushAndDeleteDoc(@req, @res, @next) + + it "should flush and delete the doc", -> + @DocumentManager.flushAndDeleteDocWithLock + .calledWith(@project_id, @doc_id) + .should.equal true + + it "should return a successful No Content response", -> + @res.send + .calledWith(204) + .should.equal true + + it "should log the request", -> + @logger.log + .calledWith(doc_id: @doc_id, project_id: @project_id, "deleting doc via http") + .should.equal true + + it "should time the request", -> + @Metrics.Timer::done.called.should.equal true + + describe "when an errors occurs", -> + beforeEach -> + @DocumentManager.flushAndDeleteDocWithLock = sinon.stub().callsArgWith(2, new Error("oops")) + @HttpController.flushAndDeleteDoc(@req, @res, @next) + + it "should call next with the error", -> + @next + .calledWith(new Error("oops")) + .should.equal true + + describe "deleteProject", -> + beforeEach -> + @req = + params: + project_id: @project_id + + describe "successfully", -> + beforeEach -> + @ProjectManager.flushAndDeleteProjectWithLocks = sinon.stub().callsArgWith(1) + @HttpController.deleteProject(@req, @res, @next) + + it "should delete the project", -> + @ProjectManager.flushAndDeleteProjectWithLocks + .calledWith(@project_id) + .should.equal true + + it "should return a successful No Content response", -> + @res.send + .calledWith(204) + .should.equal true + + it "should log the request", -> + @logger.log + .calledWith(project_id: @project_id, "deleting project via http") + .should.equal true + + it "should time the request", -> + @Metrics.Timer::done.called.should.equal true + + describe "when an errors occurs", -> + beforeEach -> + @ProjectManager.flushAndDeleteProjectWithLocks = sinon.stub().callsArgWith(1, new Error("oops")) + @HttpController.deleteProject(@req, @res, @next) + + it "should call next with the error", -> + @next + .calledWith(new Error("oops")) + .should.equal true + + describe "acceptChange", -> + beforeEach -> + @req = + params: + project_id: @project_id + doc_id: @doc_id + change_id: @change_id = "mock-change-od-1" + + describe "successfully", -> + beforeEach -> + @DocumentManager.acceptChangeWithLock = sinon.stub().callsArgWith(3) + @HttpController.acceptChange(@req, @res, @next) + + it "should accept the change", -> + @DocumentManager.acceptChangeWithLock + .calledWith(@project_id, @doc_id, @change_id) + .should.equal true + + it "should return a successful No Content response", -> + @res.send + .calledWith(204) + .should.equal true + + it "should log the request", -> + @logger.log + .calledWith({@project_id, @doc_id, @change_id}, "accepting change via http") + .should.equal true + + it "should time the request", -> + @Metrics.Timer::done.called.should.equal true + + describe "when an errors occurs", -> + beforeEach -> + @DocumentManager.acceptChangeWithLock = sinon.stub().callsArgWith(3, new Error("oops")) + @HttpController.acceptChange(@req, @res, @next) + + it "should call next with the error", -> + @next + .calledWith(new Error("oops")) + .should.equal true diff --git a/services/document-updater/test/unit/coffee/HttpController/deleteProjectTests.coffee b/services/document-updater/test/unit/coffee/HttpController/deleteProjectTests.coffee deleted file mode 100644 index 796df52e80..0000000000 --- a/services/document-updater/test/unit/coffee/HttpController/deleteProjectTests.coffee +++ /dev/null @@ -1,63 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/HttpController.js" -SandboxedModule = require('sandboxed-module') -Errors = require "../../../../app/js/Errors.js" - -describe "HttpController.deleteProject", -> - beforeEach -> - @HttpController = SandboxedModule.require modulePath, requires: - "./DocumentManager": @DocumentManager = {} - "./ProjectManager": @ProjectManager = {} - "logger-sharelatex" : @logger = { log: sinon.stub() } - "./Metrics": @Metrics = {} - - @Metrics.Timer = class Timer - done: sinon.stub() - - @project_id = "project-id-123" - @res = - send: sinon.stub() - @req = - params: - project_id: @project_id - @next = sinon.stub() - - describe "successfully", -> - beforeEach -> - @ProjectManager.flushAndDeleteProjectWithLocks = sinon.stub().callsArgWith(1) - @HttpController.deleteProject(@req, @res, @next) - - it "should delete the project", -> - @ProjectManager.flushAndDeleteProjectWithLocks - .calledWith(@project_id) - .should.equal true - - it "should return a successful No Content response", -> - @res.send - .calledWith(204) - .should.equal true - - it "should log the request", -> - @logger.log - .calledWith(project_id: @project_id, "deleting project via http") - .should.equal true - - it "should time the request", -> - @Metrics.Timer::done.called.should.equal true - - describe "when an errors occurs", -> - beforeEach -> - @ProjectManager.flushAndDeleteProjectWithLocks = sinon.stub().callsArgWith(1, new Error("oops")) - @HttpController.deleteProject(@req, @res, @next) - - it "should call next with the error", -> - @next - .calledWith(new Error("oops")) - .should.equal true - - - - - diff --git a/services/document-updater/test/unit/coffee/HttpController/flushAndDeleteDocTests.coffee b/services/document-updater/test/unit/coffee/HttpController/flushAndDeleteDocTests.coffee deleted file mode 100644 index af09c2c1bd..0000000000 --- a/services/document-updater/test/unit/coffee/HttpController/flushAndDeleteDocTests.coffee +++ /dev/null @@ -1,64 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/HttpController.js" -SandboxedModule = require('sandboxed-module') -Errors = require "../../../../app/js/Errors.js" - -describe "HttpController.flushAndDeleteDoc", -> - beforeEach -> - @HttpController = SandboxedModule.require modulePath, requires: - "./DocumentManager": @DocumentManager = {} - "./ProjectManager":{} - "logger-sharelatex" : @logger = { log: sinon.stub() } - "./Metrics": @Metrics = {} - - @Metrics.Timer = class Timer - done: sinon.stub() - - @project_id = "project-id-123" - @doc_id = "doc-id-123" - @res = - send: sinon.stub() - @req = - params: - project_id: @project_id - doc_id: @doc_id - @next = sinon.stub() - - describe "successfully", -> - beforeEach -> - @DocumentManager.flushAndDeleteDocWithLock = sinon.stub().callsArgWith(2) - @HttpController.flushAndDeleteDoc(@req, @res, @next) - - it "should flush and delete the doc", -> - @DocumentManager.flushAndDeleteDocWithLock - .calledWith(@project_id, @doc_id) - .should.equal true - - it "should return a successful No Content response", -> - @res.send - .calledWith(204) - .should.equal true - - it "should log the request", -> - @logger.log - .calledWith(doc_id: @doc_id, project_id: @project_id, "deleting doc via http") - .should.equal true - - it "should time the request", -> - @Metrics.Timer::done.called.should.equal true - - describe "when an errors occurs", -> - beforeEach -> - @DocumentManager.flushAndDeleteDocWithLock = sinon.stub().callsArgWith(2, new Error("oops")) - @HttpController.flushAndDeleteDoc(@req, @res, @next) - - it "should call next with the error", -> - @next - .calledWith(new Error("oops")) - .should.equal true - - - - diff --git a/services/document-updater/test/unit/coffee/HttpController/flushDocIfLoadedTests.coffee b/services/document-updater/test/unit/coffee/HttpController/flushDocIfLoadedTests.coffee deleted file mode 100644 index 3321030624..0000000000 --- a/services/document-updater/test/unit/coffee/HttpController/flushDocIfLoadedTests.coffee +++ /dev/null @@ -1,65 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/HttpController.js" -SandboxedModule = require('sandboxed-module') -Errors = require "../../../../app/js/Errors.js" - -describe "HttpController.flushDocIfLoaded", -> - beforeEach -> - @HttpController = SandboxedModule.require modulePath, requires: - "./DocumentManager": @DocumentManager = {} - "./ProjectManager": {} - "logger-sharelatex" : @logger = { log: sinon.stub() } - "./Metrics": @Metrics = {} - - @Metrics.Timer = class Timer - done: sinon.stub() - - @project_id = "project-id-123" - @doc_id = "doc-id-123" - @lines = ["one", "two", "three"] - @version = 42 - @res = - send: sinon.stub() - @req = - params: - project_id: @project_id - doc_id: @doc_id - @next = sinon.stub() - - describe "successfully", -> - beforeEach -> - @DocumentManager.flushDocIfLoadedWithLock = sinon.stub().callsArgWith(2) - @HttpController.flushDocIfLoaded(@req, @res, @next) - - it "should flush the doc", -> - @DocumentManager.flushDocIfLoadedWithLock - .calledWith(@project_id, @doc_id) - .should.equal true - - it "should return a successful No Content response", -> - @res.send - .calledWith(204) - .should.equal true - - it "should log the request", -> - @logger.log - .calledWith(doc_id: @doc_id, project_id: @project_id, "flushing doc via http") - .should.equal true - - it "should time the request", -> - @Metrics.Timer::done.called.should.equal true - - describe "when an errors occurs", -> - beforeEach -> - @DocumentManager.flushDocIfLoadedWithLock = sinon.stub().callsArgWith(2, new Error("oops")) - @HttpController.flushDocIfLoaded(@req, @res, @next) - - it "should call next with the error", -> - @next - .calledWith(new Error("oops")) - .should.equal true - - - diff --git a/services/document-updater/test/unit/coffee/HttpController/flushProjectTests.coffee b/services/document-updater/test/unit/coffee/HttpController/flushProjectTests.coffee deleted file mode 100644 index e45269ce6d..0000000000 --- a/services/document-updater/test/unit/coffee/HttpController/flushProjectTests.coffee +++ /dev/null @@ -1,62 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/HttpController.js" -SandboxedModule = require('sandboxed-module') -Errors = require "../../../../app/js/Errors.js" - -describe "HttpController.flushProject", -> - beforeEach -> - @HttpController = SandboxedModule.require modulePath, requires: - "./DocumentManager": @DocumentManager = {} - "./ProjectManager": @ProjectManager = {} - "logger-sharelatex" : @logger = { log: sinon.stub() } - "./Metrics": @Metrics = {} - - @Metrics.Timer = class Timer - done: sinon.stub() - - @project_id = "project-id-123" - @res = - send: sinon.stub() - @req = - params: - project_id: @project_id - @next = sinon.stub() - - describe "successfully", -> - beforeEach -> - @ProjectManager.flushProjectWithLocks = sinon.stub().callsArgWith(1) - @HttpController.flushProject(@req, @res, @next) - - it "should flush the project", -> - @ProjectManager.flushProjectWithLocks - .calledWith(@project_id) - .should.equal true - - it "should return a successful No Content response", -> - @res.send - .calledWith(204) - .should.equal true - - it "should log the request", -> - @logger.log - .calledWith(project_id: @project_id, "flushing project via http") - .should.equal true - - it "should time the request", -> - @Metrics.Timer::done.called.should.equal true - - describe "when an errors occurs", -> - beforeEach -> - @ProjectManager.flushProjectWithLocks = sinon.stub().callsArgWith(1, new Error("oops")) - @HttpController.flushProject(@req, @res, @next) - - it "should call next with the error", -> - @next - .calledWith(new Error("oops")) - .should.equal true - - - - diff --git a/services/document-updater/test/unit/coffee/HttpController/getDocTests.coffee b/services/document-updater/test/unit/coffee/HttpController/getDocTests.coffee deleted file mode 100644 index 17e5ad8e08..0000000000 --- a/services/document-updater/test/unit/coffee/HttpController/getDocTests.coffee +++ /dev/null @@ -1,110 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/HttpController.js" -SandboxedModule = require('sandboxed-module') -Errors = require "../../../../app/js/Errors.js" - -describe "HttpController.getDoc", -> - beforeEach -> - @HttpController = SandboxedModule.require modulePath, requires: - "./DocumentManager": @DocumentManager = {} - "./ProjectManager": {} - "logger-sharelatex" : @logger = { log: sinon.stub() } - "./Metrics": @Metrics = {} - - @Metrics.Timer = class Timer - done: sinon.stub() - - @project_id = "project-id-123" - @doc_id = "doc-id-123" - @lines = ["one", "two", "three"] - @ops = ["mock-op-1", "mock-op-2"] - @version = 42 - @fromVersion = 42 - @res = - send: sinon.stub() - @req = - params: - project_id: @project_id - doc_id: @doc_id - @next = sinon.stub() - - describe "when the document exists and no recent ops are requested", -> - beforeEach -> - @DocumentManager.getDocAndRecentOpsWithLock = sinon.stub().callsArgWith(3, null, @lines, @version, []) - @HttpController.getDoc(@req, @res, @next) - - it "should get the doc", -> - @DocumentManager.getDocAndRecentOpsWithLock - .calledWith(@project_id, @doc_id, -1) - .should.equal true - - it "should return the doc as JSON", -> - @res.send - .calledWith(JSON.stringify({ - id: @doc_id - lines: @lines - version: @version - ops: [] - })) - .should.equal true - - it "should log the request", -> - @logger.log - .calledWith(doc_id: @doc_id, project_id: @project_id, "getting doc via http") - .should.equal true - - it "should time the request", -> - @Metrics.Timer::done.called.should.equal true - - describe "when recent ops are requested", -> - beforeEach -> - @DocumentManager.getDocAndRecentOpsWithLock = sinon.stub().callsArgWith(3, null, @lines, @version, @ops) - @req.query = fromVersion: "#{@fromVersion}" - @HttpController.getDoc(@req, @res, @next) - - it "should get the doc", -> - @DocumentManager.getDocAndRecentOpsWithLock - .calledWith(@project_id, @doc_id, @fromVersion) - .should.equal true - - it "should return the doc as JSON", -> - @res.send - .calledWith(JSON.stringify({ - id: @doc_id - lines: @lines - version: @version - ops: @ops - })) - .should.equal true - - it "should log the request", -> - @logger.log - .calledWith(doc_id: @doc_id, project_id: @project_id, "getting doc via http") - .should.equal true - - it "should time the request", -> - @Metrics.Timer::done.called.should.equal true - - describe "when the document does not exist", -> - beforeEach -> - @DocumentManager.getDocAndRecentOpsWithLock = sinon.stub().callsArgWith(3, null, null, null) - @HttpController.getDoc(@req, @res, @next) - - it "should call next with NotFoundError", -> - @next - .calledWith(new Errors.NotFoundError("not found")) - .should.equal true - - describe "when an errors occurs", -> - beforeEach -> - @DocumentManager.getDocAndRecentOpsWithLock = sinon.stub().callsArgWith(3, new Error("oops"), null, null) - @HttpController.getDoc(@req, @res, @next) - - it "should call next with the error", -> - @next - .calledWith(new Error("oops")) - .should.equal true - - diff --git a/services/document-updater/test/unit/coffee/HttpController/setDocTests.coffee b/services/document-updater/test/unit/coffee/HttpController/setDocTests.coffee deleted file mode 100644 index 385b8be044..0000000000 --- a/services/document-updater/test/unit/coffee/HttpController/setDocTests.coffee +++ /dev/null @@ -1,83 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/HttpController.js" -SandboxedModule = require('sandboxed-module') -Errors = require "../../../../app/js/Errors.js" - -describe "HttpController.setDoc", -> - beforeEach -> - @HttpController = SandboxedModule.require modulePath, requires: - "./DocumentManager": @DocumentManager = {} - "./ProjectManager": {} - "logger-sharelatex" : @logger = { log: sinon.stub() } - "./Metrics": @Metrics = {} - - @Metrics.Timer = class Timer - done: sinon.stub() - - @project_id = "project-id-123" - @doc_id = "doc-id-123" - @lines = ["one", "two", "three"] - @source = "dropbox" - @user_id = "user-id-123" - @res = - send: sinon.stub() - @req = - headers: {} - params: - project_id: @project_id - doc_id: @doc_id - body: - lines: @lines - source: @source - user_id: @user_id - @next = sinon.stub() - - describe "successfully", -> - beforeEach -> - @DocumentManager.setDocWithLock = sinon.stub().callsArgWith(5) - @HttpController.setDoc(@req, @res, @next) - - it "should set the doc", -> - @DocumentManager.setDocWithLock - .calledWith(@project_id, @doc_id, @lines, @source, @user_id) - .should.equal true - - it "should return a successful No Content response", -> - @res.send - .calledWith(204) - .should.equal true - - it "should log the request", -> - @logger.log - .calledWith(doc_id: @doc_id, project_id: @project_id, lines: @lines, source: @source, user_id: @user_id, "setting doc via http") - .should.equal true - - it "should time the request", -> - @Metrics.Timer::done.called.should.equal true - - describe "when an errors occurs", -> - beforeEach -> - @DocumentManager.setDocWithLock = sinon.stub().callsArgWith(5, new Error("oops")) - @HttpController.setDoc(@req, @res, @next) - - it "should call next with the error", -> - @next - .calledWith(new Error("oops")) - .should.equal true - - describe "when the payload is too large", -> - beforeEach -> - lines = [] - for _ in [0..200000] - lines.push "test test test" - @req.body.lines = lines - @DocumentManager.setDocWithLock = sinon.stub().callsArgWith(5) - @HttpController.setDoc(@req, @res, @next) - - it 'should send back a 406 response', -> - @res.send.calledWith(406).should.equal true - - it 'should not call setDocWithLock', -> - @DocumentManager.setDocWithLock.callCount.should.equal 0 diff --git a/services/document-updater/test/unit/coffee/PersistenceManager/PersistenceManagerTests.coffee b/services/document-updater/test/unit/coffee/PersistenceManager/PersistenceManagerTests.coffee new file mode 100644 index 0000000000..19a3d547a2 --- /dev/null +++ b/services/document-updater/test/unit/coffee/PersistenceManager/PersistenceManagerTests.coffee @@ -0,0 +1,173 @@ +sinon = require('sinon') +chai = require('chai') +should = chai.should() +modulePath = "../../../../app/js/PersistenceManager.js" +SandboxedModule = require('sandboxed-module') +Errors = require "../../../../app/js/Errors" + +describe "PersistenceManager", -> + beforeEach -> + @PersistenceManager = SandboxedModule.require modulePath, requires: + "request": @request = sinon.stub() + "settings-sharelatex": @Settings = {} + "./Metrics": @Metrics = + Timer: class Timer + done: sinon.stub() + "logger-sharelatex": @logger = {log: sinon.stub(), err: sinon.stub()} + @project_id = "project-id-123" + @doc_id = "doc-id-123" + @lines = ["one", "two", "three"] + @version = 42 + @callback = sinon.stub() + @ranges = { comments: "mock", entries: "mock" } + @Settings.apis = + web: + url: @url = "www.example.com" + user: @user = "sharelatex" + pass: @pass = "password" + + describe "getDoc", -> + + describe "with a successful response from the web api", -> + beforeEach -> + @request.callsArgWith(1, null, {statusCode: 200}, JSON.stringify({ + lines: @lines, + version: @version, + ranges: @ranges + })) + @PersistenceManager.getDoc(@project_id, @doc_id, @callback) + + it "should call the web api", -> + @request + .calledWith({ + url: "#{@url}/project/#{@project_id}/doc/#{@doc_id}" + method: "GET" + headers: + "accept": "application/json" + auth: + user: @user + pass: @pass + sendImmediately: true + jar: false + timeout: 5000 + }) + .should.equal true + + it "should call the callback with the doc lines, version and ranges", -> + @callback.calledWith(null, @lines, @version, @ranges).should.equal true + + it "should time the execution", -> + @Metrics.Timer::done.called.should.equal true + + describe "when request returns an error", -> + beforeEach -> + @request.callsArgWith(1, @error = new Error("oops"), null, null) + @PersistenceManager.getDoc(@project_id, @doc_id, @callback) + + it "should return the error", -> + @callback.calledWith(@error).should.equal true + + it "should time the execution", -> + @Metrics.Timer::done.called.should.equal true + + describe "when the request returns 404", -> + beforeEach -> + @request.callsArgWith(1, null, {statusCode: 404}, "") + @PersistenceManager.getDoc(@project_id, @doc_id, @callback) + + it "should return a NotFoundError", -> + @callback.calledWith(new Errors.NotFoundError("not found")).should.equal true + + it "should time the execution", -> + @Metrics.Timer::done.called.should.equal true + + describe "when the request returns an error status code", -> + beforeEach -> + @request.callsArgWith(1, null, {statusCode: 500}, "") + @PersistenceManager.getDoc(@project_id, @doc_id, @callback) + + it "should return an error", -> + @callback.calledWith(new Error("web api error")).should.equal true + + it "should time the execution", -> + @Metrics.Timer::done.called.should.equal true + + describe "when request returns an doc without lines", -> + beforeEach -> + @request.callsArgWith(1, null, {statusCode: 200}, JSON.stringify(version: @version)) + @PersistenceManager.getDoc(@project_id, @doc_id, @callback) + + it "should return and error", -> + @callback.calledWith(new Error("web API response had no doc lines")).should.equal true + + describe "when request returns an doc without a version", -> + beforeEach -> + @request.callsArgWith(1, null, {statusCode: 200}, JSON.stringify(lines: @lines)) + @PersistenceManager.getDoc(@project_id, @doc_id, @callback) + + it "should return and error", -> + @callback.calledWith(new Error("web API response had no valid doc version")).should.equal true + + describe "setDoc", -> + describe "with a successful response from the web api", -> + beforeEach -> + @request.callsArgWith(1, null, {statusCode: 200}) + @PersistenceManager.setDoc(@project_id, @doc_id, @lines, @version, @ranges, @callback) + + it "should call the web api", -> + @request + .calledWith({ + url: "#{@url}/project/#{@project_id}/doc/#{@doc_id}" + json: + lines: @lines + version: @version + ranges: @ranges + method: "POST" + auth: + user: @user + pass: @pass + sendImmediately: true + jar: false + timeout: 5000 + }) + .should.equal true + + it "should call the callback without error", -> + @callback.calledWith(null).should.equal true + + it "should time the execution", -> + @Metrics.Timer::done.called.should.equal true + + describe "when request returns an error", -> + beforeEach -> + @request.callsArgWith(1, @error = new Error("oops"), null, null) + @PersistenceManager.setDoc(@project_id, @doc_id, @lines, @version, @ranges, @callback) + + it "should return the error", -> + @callback.calledWith(@error).should.equal true + + it "should time the execution", -> + @Metrics.Timer::done.called.should.equal true + + describe "when the request returns 404", -> + beforeEach -> + @request.callsArgWith(1, null, {statusCode: 404}, "") + @PersistenceManager.setDoc(@project_id, @doc_id, @lines, @version, @ranges, @callback) + + it "should return a NotFoundError", -> + @callback.calledWith(new Errors.NotFoundError("not found")).should.equal true + + it "should time the execution", -> + @Metrics.Timer::done.called.should.equal true + + describe "when the request returns an error status code", -> + beforeEach -> + @request.callsArgWith(1, null, {statusCode: 500}, "") + @PersistenceManager.setDoc(@project_id, @doc_id, @lines, @version, @ranges, @callback) + + it "should return an error", -> + @callback.calledWith(new Error("web api error")).should.equal true + + it "should time the execution", -> + @Metrics.Timer::done.called.should.equal true + diff --git a/services/document-updater/test/unit/coffee/PersistenceManager/getDocTests.coffee b/services/document-updater/test/unit/coffee/PersistenceManager/getDocTests.coffee deleted file mode 100644 index d4f44afa46..0000000000 --- a/services/document-updater/test/unit/coffee/PersistenceManager/getDocTests.coffee +++ /dev/null @@ -1,104 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/PersistenceManager.js" -SandboxedModule = require('sandboxed-module') -Errors = require "../../../../app/js/Errors" - -describe "PersistenceManager.getDoc", -> - beforeEach -> - @PersistenceManager = SandboxedModule.require modulePath, requires: - "request": @request = sinon.stub() - "settings-sharelatex": @Settings = {} - "./Metrics": @Metrics = - Timer: class Timer - done: sinon.stub() - "logger-sharelatex": @logger = {log: sinon.stub(), err: sinon.stub()} - @project_id = "project-id-123" - @doc_id = "doc-id-123" - @lines = ["one", "two", "three"] - @version = 42 - @callback = sinon.stub() - @Settings.apis = - web: - url: @url = "www.example.com" - user: @user = "sharelatex" - pass: @pass = "password" - - describe "with a successful response from the web api", -> - beforeEach -> - @request.callsArgWith(1, null, {statusCode: 200}, JSON.stringify(lines: @lines, version: @version)) - @PersistenceManager.getDoc(@project_id, @doc_id, @callback) - - it "should call the web api", -> - @request - .calledWith({ - url: "#{@url}/project/#{@project_id}/doc/#{@doc_id}" - method: "GET" - headers: - "accept": "application/json" - auth: - user: @user - pass: @pass - sendImmediately: true - jar: false - timeout: 5000 - }) - .should.equal true - - it "should call the callback with the doc lines and version", -> - @callback.calledWith(null, @lines, @version).should.equal true - - it "should time the execution", -> - @Metrics.Timer::done.called.should.equal true - - describe "when request returns an error", -> - beforeEach -> - @request.callsArgWith(1, @error = new Error("oops"), null, null) - @PersistenceManager.getDoc(@project_id, @doc_id, @callback) - - it "should return the error", -> - @callback.calledWith(@error).should.equal true - - it "should time the execution", -> - @Metrics.Timer::done.called.should.equal true - - describe "when the request returns 404", -> - beforeEach -> - @request.callsArgWith(1, null, {statusCode: 404}, "") - @PersistenceManager.getDoc(@project_id, @doc_id, @callback) - - it "should return a NotFoundError", -> - @callback.calledWith(new Errors.NotFoundError("not found")).should.equal true - - it "should time the execution", -> - @Metrics.Timer::done.called.should.equal true - - describe "when the request returns an error status code", -> - beforeEach -> - @request.callsArgWith(1, null, {statusCode: 500}, "") - @PersistenceManager.getDoc(@project_id, @doc_id, @callback) - - it "should return an error", -> - @callback.calledWith(new Error("web api error")).should.equal true - - it "should time the execution", -> - @Metrics.Timer::done.called.should.equal true - - describe "when request returns an doc without lines", -> - beforeEach -> - @request.callsArgWith(1, null, {statusCode: 200}, JSON.stringify(version: @version)) - @PersistenceManager.getDoc(@project_id, @doc_id, @callback) - - it "should return and error", -> - @callback.calledWith(new Error("web API response had no doc lines")).should.equal true - - describe "when request returns an doc without a version", -> - beforeEach -> - @request.callsArgWith(1, null, {statusCode: 200}, JSON.stringify(lines: @lines)) - @PersistenceManager.getDoc(@project_id, @doc_id, @callback) - - it "should return and error", -> - @callback.calledWith(new Error("web API response had no valid doc version")).should.equal true - - diff --git a/services/document-updater/test/unit/coffee/PersistenceManager/setDocTests.coffee b/services/document-updater/test/unit/coffee/PersistenceManager/setDocTests.coffee deleted file mode 100644 index 98f252a35d..0000000000 --- a/services/document-updater/test/unit/coffee/PersistenceManager/setDocTests.coffee +++ /dev/null @@ -1,90 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/PersistenceManager.js" -SandboxedModule = require('sandboxed-module') -Errors = require "../../../../app/js/Errors" - -describe "PersistenceManager.setDoc", -> - beforeEach -> - @PersistenceManager = SandboxedModule.require modulePath, requires: - "request": @request = sinon.stub() - "settings-sharelatex": @Settings = {} - "./Metrics": @Metrics = - Timer: class Timer - done: sinon.stub() - "logger-sharelatex": @logger = {log: sinon.stub(), err: sinon.stub()} - @project_id = "project-id-123" - @doc_id = "doc-id-123" - @lines = ["one", "two", "three"] - @version = 42 - @callback = sinon.stub() - @Settings.apis = - web: - url: @url = "www.example.com" - user: @user = "sharelatex" - pass: @pass = "password" - - describe "with a successful response from the web api", -> - beforeEach -> - @request.callsArgWith(1, null, {statusCode: 200}, JSON.stringify(lines: @lines, version: @version)) - @PersistenceManager.setDoc(@project_id, @doc_id, @lines, @version, @callback) - - it "should call the web api", -> - @request - .calledWith({ - url: "#{@url}/project/#{@project_id}/doc/#{@doc_id}" - body: JSON.stringify - lines: @lines - version: @version - method: "POST" - headers: - "content-type": "application/json" - auth: - user: @user - pass: @pass - sendImmediately: true - jar: false - timeout: 5000 - }) - .should.equal true - - it "should call the callback without error", -> - @callback.calledWith(null).should.equal true - - it "should time the execution", -> - @Metrics.Timer::done.called.should.equal true - - describe "when request returns an error", -> - beforeEach -> - @request.callsArgWith(1, @error = new Error("oops"), null, null) - @PersistenceManager.setDoc(@project_id, @doc_id, @lines, @version, @callback) - - it "should return the error", -> - @callback.calledWith(@error).should.equal true - - it "should time the execution", -> - @Metrics.Timer::done.called.should.equal true - - describe "when the request returns 404", -> - beforeEach -> - @request.callsArgWith(1, null, {statusCode: 404}, "") - @PersistenceManager.setDoc(@project_id, @doc_id, @lines, @version, @callback) - - it "should return a NotFoundError", -> - @callback.calledWith(new Errors.NotFoundError("not found")).should.equal true - - it "should time the execution", -> - @Metrics.Timer::done.called.should.equal true - - describe "when the request returns an error status code", -> - beforeEach -> - @request.callsArgWith(1, null, {statusCode: 500}, "") - @PersistenceManager.setDoc(@project_id, @doc_id, @lines, @version, @callback) - - it "should return an error", -> - @callback.calledWith(new Error("web api error")).should.equal true - - it "should time the execution", -> - @Metrics.Timer::done.called.should.equal true - diff --git a/services/document-updater/test/unit/coffee/RedisManager/RedisManagerTests.coffee b/services/document-updater/test/unit/coffee/RedisManager/RedisManagerTests.coffee index 205692d634..420d2039b4 100644 --- a/services/document-updater/test/unit/coffee/RedisManager/RedisManagerTests.coffee +++ b/services/document-updater/test/unit/coffee/RedisManager/RedisManagerTests.coffee @@ -22,6 +22,7 @@ describe "RedisManager", -> projectKey: ({doc_id}) -> "ProjectId:#{doc_id}" pendingUpdates: ({doc_id}) -> "PendingUpdates:#{doc_id}" docsInProject: ({project_id}) -> "DocsIn:#{project_id}" + ranges: ({doc_id}) -> "Ranges:#{doc_id}" "logger-sharelatex": @logger = { error: sinon.stub(), log: sinon.stub(), warn: sinon.stub() } "./Metrics": @metrics = inc: sinon.stub() @@ -37,39 +38,45 @@ describe "RedisManager", -> @lines = ["one", "two", "three"] @jsonlines = JSON.stringify @lines @version = 42 + @ranges = { comments: "mock", entries: "mock" } + @json_ranges = JSON.stringify @ranges @rclient.get = sinon.stub() - @rclient.exec = sinon.stub().callsArgWith(0, null, [@jsonlines, @version, @project_id]) - @RedisManager.getDoc @project_id, @doc_id, @callback - - it "should get the lines from redis", -> - @rclient.get - .calledWith("doclines:#{@doc_id}") - .should.equal true - - it "should get the version from", -> - @rclient.get - .calledWith("DocVersion:#{@doc_id}") - .should.equal true + @rclient.exec = sinon.stub().callsArgWith(0, null, [@jsonlines, @version, @project_id, @json_ranges]) - it 'should return the document', -> - @callback - .calledWith(null, @lines, @version) - .should.equal true + describe "successfully", -> + beforeEach -> + @RedisManager.getDoc @project_id, @doc_id, @callback - describe "getDoc with an invalid project id", -> - beforeEach -> - @lines = ["one", "two", "three"] - @jsonlines = JSON.stringify @lines - @version = 42 - @another_project_id = "project-id-456" - @rclient.get = sinon.stub() - @rclient.exec = sinon.stub().callsArgWith(0, null, [@jsonlines, @version, @another_project_id]) - @RedisManager.getDoc @project_id, @doc_id, @callback + it "should get the lines from redis", -> + @rclient.get + .calledWith("doclines:#{@doc_id}") + .should.equal true + + it "should get the version from", -> + @rclient.get + .calledWith("DocVersion:#{@doc_id}") + .should.equal true + + it "should get the ranges", -> + @rclient.get + .calledWith("Ranges:#{@doc_id}") + .should.equal true - it 'should return an error', -> - @callback - .calledWith(new Errors.NotFoundError("not found")) - .should.equal true + it 'should return the document', -> + @callback + .calledWith(null, @lines, @version, @ranges) + .should.equal true + + describe "getDoc with an invalid project id", -> + beforeEach -> + @another_project_id = "project-id-456" + @rclient.exec = sinon.stub().callsArgWith(0, null, [@jsonlines, @version, @another_project_id, @json_ranges]) + @RedisManager.getDoc @project_id, @doc_id, @callback + + it 'should return an error', -> + @callback + .calledWith(new Errors.NotFoundError("not found")) + .should.equal true describe "getPreviousDocOpsTests", -> describe "with a start and an end value", -> @@ -161,18 +168,20 @@ describe "RedisManager", -> @rclient.rpush = sinon.stub() @rclient.expire = sinon.stub() @rclient.ltrim = sinon.stub() + @rclient.del = sinon.stub() @RedisManager.getDocVersion = sinon.stub() @lines = ["one", "two", "three"] @ops = [{ op: [{ i: "foo", p: 4 }] },{ op: [{ i: "bar", p: 8 }] }] @version = 42 + @ranges = { comments: "mock", entries: "mock" } @rclient.exec = sinon.stub().callsArg(0) describe "with a consistent version", -> beforeEach -> @RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version - @ops.length) - @RedisManager.updateDocument @doc_id, @lines, @version, @ops, @callback + @RedisManager.updateDocument @doc_id, @lines, @version, @ops, @ranges, @callback it "should get the current doc version to check for consistency", -> @RedisManager.getDocVersion @@ -188,6 +197,11 @@ describe "RedisManager", -> @rclient.set .calledWith("DocVersion:#{@doc_id}", @version) .should.equal true + + it "should set the ranges", -> + @rclient.set + .calledWith("Ranges:#{@doc_id}", JSON.stringify(@ranges)) + .should.equal true it "should push the doc op into the doc ops list", -> @rclient.rpush @@ -210,7 +224,7 @@ describe "RedisManager", -> describe "with an inconsistent version", -> beforeEach -> @RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version - @ops.length - 1) - @RedisManager.updateDocument @doc_id, @lines, @version, @ops, @callback + @RedisManager.updateDocument @doc_id, @lines, @version, @ops, @ranges, @callback it "should not call multi.exec", -> @rclient.exec.called.should.equal false @@ -223,7 +237,7 @@ describe "RedisManager", -> describe "with no updates", -> beforeEach -> @RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version) - @RedisManager.updateDocument @doc_id, @lines, @version, [], @callback + @RedisManager.updateDocument @doc_id, @lines, @version, [], @ranges, @callback it "should not do an rpush", -> @rclient.rpush @@ -234,36 +248,75 @@ describe "RedisManager", -> @rclient.set .calledWith("doclines:#{@doc_id}", JSON.stringify(@lines)) .should.equal true + + describe "with empty ranges", -> + beforeEach -> + @RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version - @ops.length) + @RedisManager.updateDocument @doc_id, @lines, @version, @ops, {}, @callback + + it "should not set the ranges", -> + @rclient.set + .calledWith("Ranges:#{@doc_id}", JSON.stringify(@ranges)) + .should.equal false + + it "should delete the ranges key", -> + @rclient.del + .calledWith("Ranges:#{@doc_id}") + .should.equal true describe "putDocInMemory", -> - beforeEach (done) -> + beforeEach -> @rclient.set = sinon.stub() @rclient.sadd = sinon.stub().yields() + @rclient.del = sinon.stub() @rclient.exec.yields() @lines = ["one", "two", "three"] @version = 42 - @RedisManager.putDocInMemory @project_id, @doc_id, @lines, @version, done - - it "should set the lines", -> - @rclient.set - .calledWith("doclines:#{@doc_id}", JSON.stringify @lines) - .should.equal true - - it "should set the version", -> - @rclient.set - .calledWith("DocVersion:#{@doc_id}", @version) - .should.equal true - - it "should set the project_id for the doc", -> - @rclient.set - .calledWith("ProjectId:#{@doc_id}", @project_id) - .should.equal true - - it "should add the doc_id to the project set", -> - @rclient.sadd - .calledWith("DocsIn:#{@project_id}", @doc_id) - .should.equal true + @ranges = { comments: "mock", entries: "mock" } + describe "with non-empty ranges", -> + beforeEach (done) -> + @RedisManager.putDocInMemory @project_id, @doc_id, @lines, @version, @ranges, done + + it "should set the lines", -> + @rclient.set + .calledWith("doclines:#{@doc_id}", JSON.stringify @lines) + .should.equal true + + it "should set the version", -> + @rclient.set + .calledWith("DocVersion:#{@doc_id}", @version) + .should.equal true + + it "should set the ranges", -> + @rclient.set + .calledWith("Ranges:#{@doc_id}", JSON.stringify(@ranges)) + .should.equal true + + it "should set the project_id for the doc", -> + @rclient.set + .calledWith("ProjectId:#{@doc_id}", @project_id) + .should.equal true + + it "should add the doc_id to the project set", -> + @rclient.sadd + .calledWith("DocsIn:#{@project_id}", @doc_id) + .should.equal true + + describe "with empty ranges", -> + beforeEach (done) -> + @RedisManager.putDocInMemory @project_id, @doc_id, @lines, @version, {}, done + + it "should delete the ranges key", -> + @rclient.del + .calledWith("Ranges:#{@doc_id}") + .should.equal true + + it "should not set the ranges", -> + @rclient.set + .calledWith("Ranges:#{@doc_id}", JSON.stringify(@ranges)) + .should.equal false + describe "removeDocFromMemory", -> beforeEach (done) -> @rclient.del = sinon.stub() diff --git a/services/document-updater/test/unit/coffee/ShareJS/TextTransformTests.coffee b/services/document-updater/test/unit/coffee/ShareJS/TextTransformTests.coffee new file mode 100644 index 0000000000..81440bfe5b --- /dev/null +++ b/services/document-updater/test/unit/coffee/ShareJS/TextTransformTests.coffee @@ -0,0 +1,283 @@ +text = require "../../../../app/js/sharejs/types/text" +require("chai").should() +RangesTracker = require "../../../../app/js/RangesTracker" + +describe "ShareJS text type", -> + beforeEach -> + @t = "mock-thread-id" + + describe "transform", -> + describe "insert / insert", -> + it "with an insert before", -> + dest = [] + text._tc(dest, { i: "foo", p: 9 }, { i: "bar", p: 3 }) + dest.should.deep.equal [{ i: "foo", p: 12 }] + + it "with an insert after", -> + dest = [] + text._tc(dest, { i: "foo", p: 3 }, { i: "bar", p: 9 }) + dest.should.deep.equal [{ i: "foo", p: 3 }] + + it "with an insert at the same place with side == 'right'", -> + dest = [] + text._tc(dest, { i: "foo", p: 3 }, { i: "bar", p: 3 }, 'right') + dest.should.deep.equal [{ i: "foo", p: 6 }] + + it "with an insert at the same place with side == 'left'", -> + dest = [] + text._tc(dest, { i: "foo", p: 3 }, { i: "bar", p: 3 }, 'left') + dest.should.deep.equal [{ i: "foo", p: 3 }] + + describe "insert / delete", -> + it "with a delete before", -> + dest = [] + text._tc(dest, { i: "foo", p: 9 }, { d: "bar", p: 3 }) + dest.should.deep.equal [{ i: "foo", p: 6 }] + + it "with a delete after", -> + dest = [] + text._tc(dest, { i: "foo", p: 3 }, { d: "bar", p: 9 }) + dest.should.deep.equal [{ i: "foo", p: 3 }] + + it "with a delete at the same place with side == 'right'", -> + dest = [] + text._tc(dest, { i: "foo", p: 3 }, { d: "bar", p: 3 }, 'right') + dest.should.deep.equal [{ i: "foo", p: 3 }] + + it "with a delete at the same place with side == 'left'", -> + dest = [] + + text._tc(dest, { i: "foo", p: 3 }, { d: "bar", p: 3 }, 'left') + dest.should.deep.equal [{ i: "foo", p: 3 }] + + describe "delete / insert", -> + it "with an insert before", -> + dest = [] + text._tc(dest, { d: "foo", p: 9 }, { i: "bar", p: 3 }) + dest.should.deep.equal [{ d: "foo", p: 12 }] + + it "with an insert after", -> + dest = [] + text._tc(dest, { d: "foo", p: 3 }, { i: "bar", p: 9 }) + dest.should.deep.equal [{ d: "foo", p: 3 }] + + it "with an insert at the same place with side == 'right'", -> + dest = [] + text._tc(dest, { d: "foo", p: 3 }, { i: "bar", p: 3 }, 'right') + dest.should.deep.equal [{ d: "foo", p: 6 }] + + it "with an insert at the same place with side == 'left'", -> + dest = [] + text._tc(dest, { d: "foo", p: 3 }, { i: "bar", p: 3 }, 'left') + dest.should.deep.equal [{ d: "foo", p: 6 }] + + it "with a delete that overlaps the insert location", -> + dest = [] + text._tc(dest, { d: "foo", p: 3 }, { i: "bar", p: 4 }) + dest.should.deep.equal [{ d: "f", p: 3 }, { d: "oo", p: 6 }] + + + describe "delete / delete", -> + it "with a delete before", -> + dest = [] + text._tc(dest, { d: "foo", p: 9 }, { d: "bar", p: 3 }) + dest.should.deep.equal [{ d: "foo", p: 6 }] + + it "with a delete after", -> + dest = [] + text._tc(dest, { d: "foo", p: 3 }, { d: "bar", p: 9 }) + dest.should.deep.equal [{ d: "foo", p: 3 }] + + it "with deleting the same content", -> + dest = [] + text._tc(dest, { d: "foo", p: 3 }, { d: "foo", p: 3 }, 'right') + dest.should.deep.equal [] + + it "with the delete overlapping before", -> + dest = [] + text._tc(dest, { d: "foobar", p: 3 }, { d: "abcfoo", p: 0 }, 'right') + dest.should.deep.equal [{ d: "bar", p: 0 }] + + it "with the delete overlapping after", -> + dest = [] + text._tc(dest, { d: "abcfoo", p: 3 }, { d: "foobar", p: 6 }) + dest.should.deep.equal [{ d: "abc", p: 3 }] + + it "with the delete overlapping the whole delete", -> + dest = [] + text._tc(dest, { d: "abcfoo123", p: 3 }, { d: "foo", p: 6 }) + dest.should.deep.equal [{ d: "abc123", p: 3 }] + + it "with the delete inside the whole delete", -> + dest = [] + text._tc(dest, { d: "foo", p: 6 }, { d: "abcfoo123", p: 3 }) + dest.should.deep.equal [] + + describe "comment / insert", -> + it "with an insert before", -> + dest = [] + text._tc(dest, { c: "foo", p: 9, @t }, { i: "bar", p: 3 }) + dest.should.deep.equal [{ c: "foo", p: 12, @t }] + + it "with an insert after", -> + dest = [] + text._tc(dest, { c: "foo", p: 3, @t }, { i: "bar", p: 9 }) + dest.should.deep.equal [{ c: "foo", p: 3, @t }] + + it "with an insert at the left edge", -> + dest = [] + text._tc(dest, { c: "foo", p: 3, @t }, { i: "bar", p: 3 }) + # RangesTracker doesn't inject inserts into comments on edges, so neither should we + dest.should.deep.equal [{ c: "foo", p: 6, @t }] + + it "with an insert at the right edge", -> + dest = [] + text._tc(dest, { c: "foo", p: 3, @t }, { i: "bar", p: 6 }) + # RangesTracker doesn't inject inserts into comments on edges, so neither should we + dest.should.deep.equal [{ c: "foo", p: 3, @t }] + + it "with an insert in the middle", -> + dest = [] + text._tc(dest, { c: "foo", p: 3, @t }, { i: "bar", p: 5 }) + dest.should.deep.equal [{ c: "fobaro", p: 3, @t }] + + describe "comment / delete", -> + it "with a delete before", -> + dest = [] + text._tc(dest, { c: "foo", p: 9, @t }, { d: "bar", p: 3 }) + dest.should.deep.equal [{ c: "foo", p: 6, @t }] + + it "with a delete after", -> + dest = [] + text._tc(dest, { c: "foo", p: 3, @t }, { i: "bar", p: 9 }) + dest.should.deep.equal [{ c: "foo", p: 3, @t }] + + it "with a delete overlapping the comment content before", -> + dest = [] + text._tc(dest, { c: "foobar", p: 6, @t }, { d: "123foo", p: 3 }) + dest.should.deep.equal [{ c: "bar", p: 3, @t }] + + it "with a delete overlapping the comment content after", -> + dest = [] + text._tc(dest, { c: "foobar", p: 6, @t }, { d: "bar123", p: 9 }) + dest.should.deep.equal [{ c: "foo", p: 6, @t }] + + it "with a delete overlapping the comment content in the middle", -> + dest = [] + text._tc(dest, { c: "foo123bar", p: 6, @t }, { d: "123", p: 9 }) + dest.should.deep.equal [{ c: "foobar", p: 6, @t }] + + it "with a delete overlapping the whole comment", -> + dest = [] + text._tc(dest, { c: "foo", p: 6, @t }, { d: "123foo456", p: 3 }) + dest.should.deep.equal [{ c: "", p: 3, @t }] + + describe "comment / insert", -> + it "should not do anything", -> + dest = [] + text._tc(dest, { i: "foo", p: 6 }, { c: "bar", p: 3 }) + dest.should.deep.equal [{ i: "foo", p: 6 }] + + describe "comment / delete", -> + it "should not do anything", -> + dest = [] + text._tc(dest, { d: "foo", p: 6 }, { c: "bar", p: 3 }) + dest.should.deep.equal [{ d: "foo", p: 6 }] + + describe "comment / comment", -> + it "should not do anything", -> + dest = [] + text._tc(dest, { c: "foo", p: 6 }, { c: "bar", p: 3 }) + dest.should.deep.equal [{ c: "foo", p: 6 }] + + describe "apply", -> + it "should apply an insert", -> + text.apply("foo", [{ i: "bar", p: 2 }]).should.equal "fobaro" + + it "should apply a delete", -> + text.apply("foo123bar", [{ d: "123", p: 3 }]).should.equal "foobar" + + it "should do nothing with a comment", -> + text.apply("foo123bar", [{ c: "123", p: 3 }]).should.equal "foo123bar" + + it "should throw an error when deleted content does not match", -> + (() -> + text.apply("foo123bar", [{ d: "456", p: 3 }]) + ).should.throw(Error) + + it "should throw an error when comment content does not match", -> + (() -> + text.apply("foo123bar", [{ c: "456", p: 3 }]) + ).should.throw(Error) + + describe "applying ops and comments in different orders", -> + it "should not matter which op or comment is applied first", -> + transform = (op1, op2, side) -> + d = [] + text._tc(d, op1, op2, side) + return d + + applySnapshot = (snapshot, op) -> + return text.apply(snapshot, op) + + applyRanges = (rangesTracker, ops) -> + for op in ops + rangesTracker.applyOp(op, {}) + return rangesTracker + + commentsEqual = (comments1, comments2) -> + return false if comments1.length != comments2.length + comments1.sort (a,b) -> + if a.offset - b.offset == 0 + return a.length - b.length + else + return a.offset - b.offset + comments2.sort (a,b) -> + if a.offset - b.offset == 0 + return a.length - b.length + else + return a.offset - b.offset + for comment1, i in comments1 + comment2 = comments2[i] + if comment1.offset != comment2.offset or comment1.length != comment2.length + return false + return true + + SNAPSHOT = "123" + + OPS = [] + # Insert ops + for p in [0..SNAPSHOT.length] + OPS.push {i: "a", p: p} + OPS.push {i: "bc", p: p} + for p in [0..(SNAPSHOT.length-1)] + for length in [1..(SNAPSHOT.length - p)] + OPS.push {d: SNAPSHOT.slice(p, p+length), p} + for p in [0..(SNAPSHOT.length-1)] + for length in [1..(SNAPSHOT.length - p)] + OPS.push {c: SNAPSHOT.slice(p, p+length), p, @t} + + for op1 in OPS + for op2 in OPS + op1_t = transform(op1, op2, "left") + op2_t = transform(op2, op1, "right") + + rt12 = new RangesTracker() + snapshot12 = applySnapshot(applySnapshot(SNAPSHOT, [op1]), op2_t) + applyRanges(rt12, [op1]) + applyRanges(rt12, op2_t) + + rt21 = new RangesTracker() + snapshot21 = applySnapshot(applySnapshot(SNAPSHOT, [op2]), op1_t) + applyRanges(rt21, [op2]) + applyRanges(rt21, op1_t) + + if snapshot12 != snapshot21 + console.error {op1, op2, op1_t, op2_t, snapshot12, snapshot21}, "Ops are not consistent" + throw new Error("OT is inconsistent") + + if !commentsEqual(rt12.comments, rt21.comments) + console.log rt12.comments + console.log rt21.comments + console.error {op1, op2, op1_t, op2_t, rt12_comments: rt12.comments, rt21_comments: rt21.comments}, "Comments are not consistent" + throw new Error("OT is inconsistent") diff --git a/services/document-updater/test/unit/coffee/ShareJsDB/GetOpsTests.coffee b/services/document-updater/test/unit/coffee/ShareJsDB/GetOpsTests.coffee deleted file mode 100644 index 5621f39a85..0000000000 --- a/services/document-updater/test/unit/coffee/ShareJsDB/GetOpsTests.coffee +++ /dev/null @@ -1,55 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/ShareJsDB.js" -SandboxedModule = require('sandboxed-module') - -describe "ShareJsDB.getOps", -> - beforeEach -> - @doc_id = "document-id" - @project_id = "project-id" - @doc_key = "#{@project_id}:#{@doc_id}" - @callback = sinon.stub() - @ops = [{p: 20, t: "foo"}] - @redis_ops = (JSON.stringify(op) for op in @ops) - @ShareJsDB = SandboxedModule.require modulePath, requires: - "./RedisManager": @RedisManager = {} - "./DocumentManager":{} - "logger-sharelatex": {} - @db = new @ShareJsDB() - - describe "with start == end", -> - beforeEach -> - @start = @end = 42 - @db.getOps @doc_key, @start, @end, @callback - - it "should return an empty array", -> - @callback.calledWith(null, []).should.equal true - - describe "with a non empty range", -> - beforeEach -> - @start = 35 - @end = 42 - @RedisManager.getPreviousDocOps = sinon.stub().callsArgWith(3, null, @ops) - @db.getOps @doc_key, @start, @end, @callback - - it "should get the range from redis", -> - @RedisManager.getPreviousDocOps - .calledWith(@doc_id, @start, @end-1) - .should.equal true - - it "should return the ops", -> - @callback.calledWith(null, @ops).should.equal true - - describe "with no specified end", -> - beforeEach -> - @start = 35 - @end = null - @RedisManager.getPreviousDocOps = sinon.stub().callsArgWith(3, null, @ops) - @db.getOps @doc_key, @start, @end, @callback - - it "should get until the end of the list", -> - @RedisManager.getPreviousDocOps - .calledWith(@doc_id, @start, -1) - .should.equal true - diff --git a/services/document-updater/test/unit/coffee/ShareJsDB/GetSnapshotTests.coffee b/services/document-updater/test/unit/coffee/ShareJsDB/GetSnapshotTests.coffee deleted file mode 100644 index f2527b01a2..0000000000 --- a/services/document-updater/test/unit/coffee/ShareJsDB/GetSnapshotTests.coffee +++ /dev/null @@ -1,87 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -expect = chai.expect -modulePath = "../../../../app/js/ShareJsDB.js" -SandboxedModule = require('sandboxed-module') -Errors = require "../../../../app/js/Errors" - -describe "ShareJsDB.getSnapshot", -> - beforeEach -> - @doc_id = "document-id" - @project_id = "project-id" - @doc_key = "#{@project_id}:#{@doc_id}" - @callback = sinon.stub() - @ShareJsDB = SandboxedModule.require modulePath, requires: - "./DocumentManager": @DocumentManager = {} - "./RedisManager": {} - "./DocOpsManager": {} - "logger-sharelatex": {} - @db = new @ShareJsDB() - - @version = 42 - - describe "with a text document", -> - beforeEach -> - @lines = ["one", "two", "three"] - - describe "successfully", -> - beforeEach -> - @DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version) - @db.getSnapshot @doc_key, @callback - - it "should get the doc", -> - @DocumentManager.getDoc - .calledWith(@project_id, @doc_id) - .should.equal true - - it "should return the doc lines", -> - @callback.args[0][1].snapshot.should.equal @lines.join("\n") - - it "should return the doc version", -> - @callback.args[0][1].v.should.equal @version - - it "should return the type as text", -> - @callback.args[0][1].type.should.equal "text" - - describe "when the doclines do not exist", -> - beforeEach -> - @DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, null, null) - @db.getSnapshot @doc_key, @callback - - it "should return the callback with a NotFoundError", -> - @callback.calledWith(new Errors.NotFoundError("not found")).should.equal true - - describe "when getDoc returns an error", -> - beforeEach -> - @DocumentManager.getDoc = sinon.stub().callsArgWith(2, @error = new Error("oops"), null, null) - @db.getSnapshot @doc_key, @callback - - it "should return the callback with an error", -> - @callback.calledWith(@error).should.equal true - - describe "with a JSON document", -> - beforeEach -> - @lines = [{text: "one"}, {text:"two"}, {text:"three"}] - - describe "successfully", -> - beforeEach -> - @DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version) - @db.getSnapshot @doc_key, @callback - - it "should get the doc", -> - @DocumentManager.getDoc - .calledWith(@project_id, @doc_id) - .should.equal true - - it "should return the doc lines", -> - expect(@callback.args[0][1].snapshot).to.deep.equal lines: @lines - - it "should return the doc version", -> - @callback.args[0][1].v.should.equal @version - - it "should return the type as text", -> - @callback.args[0][1].type.should.equal "json" - - - diff --git a/services/document-updater/test/unit/coffee/ShareJsDB/ShareJsDBTests.coffee b/services/document-updater/test/unit/coffee/ShareJsDB/ShareJsDBTests.coffee new file mode 100644 index 0000000000..aa03d9fb1e --- /dev/null +++ b/services/document-updater/test/unit/coffee/ShareJsDB/ShareJsDBTests.coffee @@ -0,0 +1,93 @@ +sinon = require('sinon') +chai = require('chai') +should = chai.should() +expect = chai.expect +modulePath = "../../../../app/js/ShareJsDB.js" +SandboxedModule = require('sandboxed-module') +Errors = require "../../../../app/js/Errors" + +describe "ShareJsDB", -> + beforeEach -> + @doc_id = "document-id" + @project_id = "project-id" + @doc_key = "#{@project_id}:#{@doc_id}" + @callback = sinon.stub() + @ShareJsDB = SandboxedModule.require modulePath, requires: + "./RedisManager": @RedisManager = {} + + @version = 42 + @lines = ["one", "two", "three"] + @db = new @ShareJsDB(@project_id, @doc_id, @lines, @version) + + describe "getSnapshot", -> + describe "successfully", -> + beforeEach -> + @db.getSnapshot @doc_key, @callback + + it "should return the doc lines", -> + @callback.args[0][1].snapshot.should.equal @lines.join("\n") + + it "should return the doc version", -> + @callback.args[0][1].v.should.equal @version + + it "should return the type as text", -> + @callback.args[0][1].type.should.equal "text" + + describe "when the key does not match", -> + beforeEach -> + @db.getSnapshot "bad:key", @callback + + it "should return the callback with a NotFoundError", -> + @callback.calledWith(new Errors.NotFoundError("not found")).should.equal true + + describe "getOps", -> + describe "with start == end", -> + beforeEach -> + @start = @end = 42 + @db.getOps @doc_key, @start, @end, @callback + + it "should return an empty array", -> + @callback.calledWith(null, []).should.equal true + + describe "with a non empty range", -> + beforeEach -> + @start = 35 + @end = 42 + @RedisManager.getPreviousDocOps = sinon.stub().callsArgWith(3, null, @ops) + @db.getOps @doc_key, @start, @end, @callback + + it "should get the range from redis", -> + @RedisManager.getPreviousDocOps + .calledWith(@doc_id, @start, @end-1) + .should.equal true + + it "should return the ops", -> + @callback.calledWith(null, @ops).should.equal true + + describe "with no specified end", -> + beforeEach -> + @start = 35 + @end = null + @RedisManager.getPreviousDocOps = sinon.stub().callsArgWith(3, null, @ops) + @db.getOps @doc_key, @start, @end, @callback + + it "should get until the end of the list", -> + @RedisManager.getPreviousDocOps + .calledWith(@doc_id, @start, -1) + .should.equal true + + describe "writeOps", -> + describe "writing an op", -> + beforeEach -> + @opData = + op: {p: 20, t: "foo"} + meta: {source: "bar"} + v: @version + @db.writeOp @doc_key, @opData, @callback + + it "should write into appliedOps", -> + expect(@db.appliedOps[@doc_key]).to.deep.equal [@opData] + + it "should call the callback without an error", -> + @callback.called.should.equal true + (@callback.args[0][0]?).should.equal false diff --git a/services/document-updater/test/unit/coffee/ShareJsDB/WriteOpsTests.coffee b/services/document-updater/test/unit/coffee/ShareJsDB/WriteOpsTests.coffee deleted file mode 100644 index 838f63034e..0000000000 --- a/services/document-updater/test/unit/coffee/ShareJsDB/WriteOpsTests.coffee +++ /dev/null @@ -1,39 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -expect = chai.expect -should = chai.should() -modulePath = "../../../../app/js/ShareJsDB.js" -SandboxedModule = require('sandboxed-module') - -describe "ShareJsDB.writeOps", -> - beforeEach -> - @project_id = "project-id" - @doc_id = "document-id" - @doc_key = "#{@project_id}:#{@doc_id}" - @callback = sinon.stub() - @opData = - op: {p: 20, t: "foo"} - meta: {source: "bar"} - @ShareJsDB = SandboxedModule.require modulePath, requires: - "./RedisManager": @RedisManager = {} - "./DocOpsManager": @DocOpsManager = {} - "./DocumentManager": {} - "logger-sharelatex": @logger = {error: sinon.stub()} - @db = new @ShareJsDB() - - describe "writing an op", -> - beforeEach -> - @version = 42 - @opData.v = @version - @db.writeOp @doc_key, @opData, @callback - - it "should write into appliedOps", -> - expect(@db.appliedOps[@doc_key]).to.deep.equal [@opData] - - it "should call the callback without an error", -> - @callback.called.should.equal true - (@callback.args[0][0]?).should.equal false - - - - diff --git a/services/document-updater/test/unit/coffee/ShareJsUpdateManager/ShareJsUpdateManagerTests.coffee b/services/document-updater/test/unit/coffee/ShareJsUpdateManager/ShareJsUpdateManagerTests.coffee index 94806a1a9d..42ba3f331b 100644 --- a/services/document-updater/test/unit/coffee/ShareJsUpdateManager/ShareJsUpdateManagerTests.coffee +++ b/services/document-updater/test/unit/coffee/ShareJsUpdateManager/ShareJsUpdateManagerTests.coffee @@ -17,12 +17,16 @@ describe "ShareJsUpdateManager", -> "./ShareJsDB" : @ShareJsDB = { mockDB: true } "redis-sharelatex" : createClient: () => @rclient = auth:-> "logger-sharelatex": @logger = { log: sinon.stub() } + "./WebRedisManager": @WebRedisManager = {} globals: clearTimeout: @clearTimeout = sinon.stub() describe "applyUpdate", -> beforeEach -> + @lines = ["one", "two"] @version = 34 + @update = {p: 4, t: "foo"} + @updatedDocLines = ["onefoo", "two"] @model = applyOp: sinon.stub().callsArg(2) getSnapshot: sinon.stub() @@ -31,20 +35,19 @@ describe "ShareJsUpdateManager", -> @ShareJsUpdateManager.getNewShareJsModel = sinon.stub().returns(@model) @ShareJsUpdateManager._listenForOps = sinon.stub() @ShareJsUpdateManager.removeDocFromCache = sinon.stub().callsArg(1) - @update = {p: 4, t: "foo"} - @updatedDocLines = ["one", "two"] describe "successfully", -> beforeEach (done) -> @model.getSnapshot.callsArgWith(1, null, {snapshot: @updatedDocLines.join("\n"), v: @version}) @model.db.appliedOps["#{@project_id}:#{@doc_id}"] = @appliedOps = ["mock-ops"] - @ShareJsUpdateManager.applyUpdate @project_id, @doc_id, @update, (err, docLines, version, appliedOps) => + @ShareJsUpdateManager.applyUpdate @project_id, @doc_id, @update, @lines, @version, (err, docLines, version, appliedOps) => @callback(err, docLines, version, appliedOps) done() it "should create a new ShareJs model", -> @ShareJsUpdateManager.getNewShareJsModel - .called.should.equal true + .calledWith(@project_id, @doc_id, @lines, @version) + .should.equal true it "should listen for ops on the model", -> @ShareJsUpdateManager._listenForOps @@ -67,34 +70,22 @@ describe "ShareJsUpdateManager", -> describe "when applyOp fails", -> beforeEach (done) -> @error = new Error("Something went wrong") - @ShareJsUpdateManager._sendError = sinon.stub() @model.applyOp = sinon.stub().callsArgWith(2, @error) - @ShareJsUpdateManager.applyUpdate @project_id, @doc_id, @update, (err, docLines, version) => + @ShareJsUpdateManager.applyUpdate @project_id, @doc_id, @update, @lines, @version, (err, docLines, version) => @callback(err, docLines, version) done() - it "should call sendError with the error", -> - @ShareJsUpdateManager._sendError - .calledWith(@project_id, @doc_id, @error) - .should.equal true - it "should call the callback with the error", -> @callback.calledWith(@error).should.equal true describe "when getSnapshot fails", -> beforeEach (done) -> @error = new Error("Something went wrong") - @ShareJsUpdateManager._sendError = sinon.stub() @model.getSnapshot.callsArgWith(1, @error) - @ShareJsUpdateManager.applyUpdate @project_id, @doc_id, @update, (err, docLines, version) => + @ShareJsUpdateManager.applyUpdate @project_id, @doc_id, @update, @lines, @version, (err, docLines, version) => @callback(err, docLines, version) done() - it "should call sendError with the error", -> - @ShareJsUpdateManager._sendError - .calledWith(@project_id, @doc_id, @error) - .should.equal true - it "should call the callback with the error", -> @callback.calledWith(@error).should.equal true @@ -114,22 +105,11 @@ describe "ShareJsUpdateManager", -> @opData = op: {t: "foo", p: 1} meta: source: "bar" - @rclient.publish = sinon.stub() + @WebRedisManager.sendData = sinon.stub() @callback("#{@project_id}:#{@doc_id}", @opData) it "should publish the op to redis", -> - @rclient.publish - .calledWith("applied-ops", JSON.stringify(project_id: @project_id, doc_id: @doc_id, op: @opData)) + @WebRedisManager.sendData + .calledWith({project_id: @project_id, doc_id: @doc_id, op: @opData}) .should.equal true - describe "_sendError", -> - beforeEach -> - @error_text = "Something went wrong" - @rclient.publish = sinon.stub() - @ShareJsUpdateManager._sendError(@project_id, @doc_id, new Error(@error_text)) - - it "should publish the error to the redis stream", -> - @rclient.publish - .calledWith("applied-ops", JSON.stringify(project_id: @project_id, doc_id: @doc_id, error: @error_text)) - .should.equal true - diff --git a/services/document-updater/test/unit/coffee/UpdateManager/ApplyingUpdates.coffee b/services/document-updater/test/unit/coffee/UpdateManager/UpdateManagerTests.coffee similarity index 63% rename from services/document-updater/test/unit/coffee/UpdateManager/ApplyingUpdates.coffee rename to services/document-updater/test/unit/coffee/UpdateManager/UpdateManagerTests.coffee index 43786f4b98..33578cb6f0 100644 --- a/services/document-updater/test/unit/coffee/UpdateManager/ApplyingUpdates.coffee +++ b/services/document-updater/test/unit/coffee/UpdateManager/UpdateManagerTests.coffee @@ -14,12 +14,14 @@ describe "UpdateManager", -> "./RedisManager" : @RedisManager = {} "./WebRedisManager" : @WebRedisManager = {} "./ShareJsUpdateManager" : @ShareJsUpdateManager = {} - "./TrackChangesManager" : @TrackChangesManager = {} + "./HistoryManager" : @HistoryManager = {} "logger-sharelatex": @logger = { log: sinon.stub() } "./Metrics": @Metrics = Timer: class Timer done: sinon.stub() "settings-sharelatex": Settings = {} + "./DocumentManager": @DocumentManager = {} + "./RangesManager": @RangesManager = {} describe "processOutstandingUpdates", -> beforeEach -> @@ -155,10 +157,16 @@ describe "UpdateManager", -> @update = {op: [{p: 42, i: "foo"}]} @updatedDocLines = ["updated", "lines"] @version = 34 + @lines = ["original", "lines"] + @ranges = { entries: "mock", comments: "mock" } + @updated_ranges = { entries: "updated", comments: "updated" } @appliedOps = ["mock-applied-ops"] - @ShareJsUpdateManager.applyUpdate = sinon.stub().callsArgWith(3, null, @updatedDocLines, @version, @appliedOps) - @RedisManager.updateDocument = sinon.stub().callsArg(4) - @TrackChangesManager.pushUncompressedHistoryOps = sinon.stub().callsArg(3) + @DocumentManager.getDoc = sinon.stub().yields(null, @lines, @version, @ranges) + @RangesManager.applyUpdate = sinon.stub().yields(null, @updated_ranges) + @ShareJsUpdateManager.applyUpdate = sinon.stub().yields(null, @updatedDocLines, @version, @appliedOps) + @RedisManager.updateDocument = sinon.stub().yields() + @WebRedisManager.sendData = sinon.stub() + @HistoryManager.pushUncompressedHistoryOps = sinon.stub().callsArg(3) describe "normally", -> beforeEach -> @@ -166,16 +174,21 @@ describe "UpdateManager", -> it "should apply the updates via ShareJS", -> @ShareJsUpdateManager.applyUpdate - .calledWith(@project_id, @doc_id, @update) + .calledWith(@project_id, @doc_id, @update, @lines, @version) + .should.equal true + + it "should update the ranges", -> + @RangesManager.applyUpdate + .calledWith(@project_id, @doc_id, @ranges, @appliedOps) .should.equal true it "should save the document", -> @RedisManager.updateDocument - .calledWith(@doc_id, @updatedDocLines, @version, @appliedOps) + .calledWith(@doc_id, @updatedDocLines, @version, @appliedOps, @updated_ranges) .should.equal true - it "should push the applied ops into the track changes queue", -> - @TrackChangesManager.pushUncompressedHistoryOps + it "should push the applied ops into the history queue", -> + @HistoryManager.pushUncompressedHistoryOps .calledWith(@project_id, @doc_id, @appliedOps) .should.equal true @@ -194,4 +207,94 @@ describe "UpdateManager", -> # \uFFFD is 'replacement character' @update.op[0].i.should.equal "\uFFFD\uFFFD" + + describe "with an error", -> + beforeEach -> + @error = new Error("something went wrong") + @ShareJsUpdateManager.applyUpdate = sinon.stub().yields(@error) + @UpdateManager.applyUpdate @project_id, @doc_id, @update, @callback + + it "should call WebRedisManager.sendData with the error", -> + @WebRedisManager.sendData + .calledWith({ + project_id: @project_id, + doc_id: @doc_id, + error: @error.message + }) + .should.equal true + + it "should call the callback with the error", -> + @callback.calledWith(@error).should.equal true + + + describe "lockUpdatesAndDo", -> + beforeEach -> + @method = sinon.stub().callsArgWith(3, null, @response_arg1) + @callback = sinon.stub() + @arg1 = "argument 1" + @response_arg1 = "response argument 1" + @lockValue = "mock-lock-value" + @LockManager.getLock = sinon.stub().callsArgWith(1, null, @lockValue) + @LockManager.releaseLock = sinon.stub().callsArg(2) + + describe "successfully", -> + beforeEach -> + @UpdateManager.continueProcessingUpdatesWithLock = sinon.stub() + @UpdateManager.processOutstandingUpdates = sinon.stub().callsArg(2) + @UpdateManager.lockUpdatesAndDo @method, @project_id, @doc_id, @arg1, @callback + + it "should lock the doc", -> + @LockManager.getLock + .calledWith(@doc_id) + .should.equal true + + it "should process any outstanding updates", -> + @UpdateManager.processOutstandingUpdates + .calledWith(@project_id, @doc_id) + .should.equal true + + it "should call the method", -> + @method + .calledWith(@project_id, @doc_id, @arg1) + .should.equal true + + it "should return the method response to the callback", -> + @callback + .calledWith(null, @response_arg1) + .should.equal true + + it "should release the lock", -> + @LockManager.releaseLock + .calledWith(@doc_id, @lockValue) + .should.equal true + + it "should continue processing updates", -> + @UpdateManager.continueProcessingUpdatesWithLock + .calledWith(@project_id, @doc_id) + .should.equal true + + describe "when processOutstandingUpdates returns an error", -> + beforeEach -> + @UpdateManager.processOutstandingUpdates = sinon.stub().callsArgWith(2, @error = new Error("Something went wrong")) + @UpdateManager.lockUpdatesAndDo @method, @project_id, @doc_id, @arg1, @callback + + it "should free the lock", -> + @LockManager.releaseLock.calledWith(@doc_id, @lockValue).should.equal true + + it "should return the error in the callback", -> + @callback.calledWith(@error).should.equal true + + describe "when the method returns an error", -> + beforeEach -> + @UpdateManager.processOutstandingUpdates = sinon.stub().callsArg(2) + @method = sinon.stub().callsArgWith(3, @error = new Error("something went wrong"), @response_arg1) + @UpdateManager.lockUpdatesAndDo @method, @project_id, @doc_id, @arg1, @callback + + it "should free the lock", -> + @LockManager.releaseLock.calledWith(@doc_id, @lockValue).should.equal true + + it "should return the error in the callback", -> + @callback.calledWith(@error).should.equal true + + diff --git a/services/document-updater/test/unit/coffee/UpdateManager/lockUpdatesAndDoTests.coffee b/services/document-updater/test/unit/coffee/UpdateManager/lockUpdatesAndDoTests.coffee deleted file mode 100644 index a4b455d219..0000000000 --- a/services/document-updater/test/unit/coffee/UpdateManager/lockUpdatesAndDoTests.coffee +++ /dev/null @@ -1,90 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/UpdateManager.js" -SandboxedModule = require('sandboxed-module') - -describe 'UpdateManager - lockUpdatesAndDo', -> - beforeEach -> - @UpdateManager = SandboxedModule.require modulePath, requires: - "./LockManager" : @LockManager = {} - "./RedisManager" : @RedisManager = {} - "./WebRedisManager" : @WebRedisManager = {} - "./ShareJsUpdateManager" : @ShareJsUpdateManager = {} - "./TrackChangesManager" : @TrackChangesManager = {} - "logger-sharelatex": @logger = { log: sinon.stub() } - "./Metrics": @Metrics = - Timer: class Timer - done: sinon.stub() - "settings-sharelatex": Settings = {} - @project_id = "project-id-123" - @doc_id = "doc-id-123" - @method = sinon.stub().callsArgWith(3, null, @response_arg1) - @callback = sinon.stub() - @arg1 = "argument 1" - @response_arg1 = "response argument 1" - @lockValue = "mock-lock-value" - @LockManager.getLock = sinon.stub().callsArgWith(1, null, @lockValue) - @LockManager.releaseLock = sinon.stub().callsArg(2) - - describe "successfully", -> - beforeEach -> - @UpdateManager.continueProcessingUpdatesWithLock = sinon.stub() - @UpdateManager.processOutstandingUpdates = sinon.stub().callsArg(2) - @UpdateManager.lockUpdatesAndDo @method, @project_id, @doc_id, @arg1, @callback - - it "should lock the doc", -> - @LockManager.getLock - .calledWith(@doc_id) - .should.equal true - - it "should process any outstanding updates", -> - @UpdateManager.processOutstandingUpdates - .calledWith(@project_id, @doc_id) - .should.equal true - - it "should call the method", -> - @method - .calledWith(@project_id, @doc_id, @arg1) - .should.equal true - - it "should return the method response to the callback", -> - @callback - .calledWith(null, @response_arg1) - .should.equal true - - it "should release the lock", -> - @LockManager.releaseLock - .calledWith(@doc_id, @lockValue) - .should.equal true - - it "should continue processing updates", -> - @UpdateManager.continueProcessingUpdatesWithLock - .calledWith(@project_id, @doc_id) - .should.equal true - - describe "when processOutstandingUpdates returns an error", -> - beforeEach -> - @UpdateManager.processOutstandingUpdates = sinon.stub().callsArgWith(2, @error = new Error("Something went wrong")) - @UpdateManager.lockUpdatesAndDo @method, @project_id, @doc_id, @arg1, @callback - - it "should free the lock", -> - @LockManager.releaseLock.calledWith(@doc_id, @lockValue).should.equal true - - it "should return the error in the callback", -> - @callback.calledWith(@error).should.equal true - - describe "when the method returns an error", -> - beforeEach -> - @UpdateManager.processOutstandingUpdates = sinon.stub().callsArg(2) - @method = sinon.stub().callsArgWith(3, @error = new Error("something went wrong"), @response_arg1) - @UpdateManager.lockUpdatesAndDo @method, @project_id, @doc_id, @arg1, @callback - - it "should free the lock", -> - @LockManager.releaseLock.calledWith(@doc_id, @lockValue).should.equal true - - it "should return the error in the callback", -> - @callback.calledWith(@error).should.equal true - - -