diff --git a/services/document-updater/app.coffee b/services/document-updater/app.coffee index 41cab59680..b4188292da 100644 --- a/services/document-updater/app.coffee +++ b/services/document-updater/app.coffee @@ -47,6 +47,7 @@ app.post '/project/:project_id/doc/:doc_id', HttpCont app.post '/project/:project_id/doc/:doc_id/flush', HttpController.flushDocIfLoaded app.delete '/project/:project_id/doc/:doc_id', HttpController.flushAndDeleteDoc app.delete '/project/:project_id', HttpController.deleteProject +app.post '/project/:project_id', HttpController.updateProject app.post '/project/:project_id/flush', HttpController.flushProject app.post '/project/:project_id/doc/:doc_id/change/:change_id/accept', HttpController.acceptChanges app.post '/project/:project_id/doc/:doc_id/change/accept', HttpController.acceptChanges diff --git a/services/document-updater/app/coffee/DocumentManager.coffee b/services/document-updater/app/coffee/DocumentManager.coffee index 5ddca2e6a8..8e69989d09 100644 --- a/services/document-updater/app/coffee/DocumentManager.coffee +++ b/services/document-updater/app/coffee/DocumentManager.coffee @@ -7,6 +7,7 @@ HistoryManager = require "./HistoryManager" RealTimeRedisManager = require "./RealTimeRedisManager" Errors = require "./Errors" RangesManager = require "./RangesManager" +async = require "async" MAX_UNFLUSHED_AGE = 300 * 1000 # 5 mins, document should be flushed to mongo this time after a change @@ -30,20 +31,20 @@ module.exports = DocumentManager = else callback null, lines, version, ranges, pathname, unflushedTime, true - getDocAndRecentOps: (project_id, doc_id, fromVersion, _callback = (error, lines, version, recentOps, ranges) ->) -> + getDocAndRecentOps: (project_id, doc_id, fromVersion, _callback = (error, lines, version, ops, ranges, pathname) ->) -> timer = new Metrics.Timer("docManager.getDocAndRecentOps") callback = (args...) -> timer.done() _callback(args...) - DocumentManager.getDoc project_id, doc_id, (error, lines, version, ranges) -> + DocumentManager.getDoc project_id, doc_id, (error, lines, version, ranges, pathname) -> return callback(error) if error? if fromVersion == -1 - callback null, lines, version, [], ranges + callback null, lines, version, [], ranges, pathname else RedisManager.getPreviousDocOps doc_id, fromVersion, version, (error, ops) -> return callback(error) if error? - callback null, lines, version, ops, ranges + callback null, lines, version, ops, ranges, pathname setDoc: (project_id, doc_id, newLines, source, user_id, undoing, _callback = (error) ->) -> timer = new Metrics.Timer("docManager.setDoc") @@ -155,6 +156,14 @@ module.exports = DocumentManager = return callback(error) if error? callback() + renameDoc: (project_id, doc_id, user_id, update, _callback = (error) ->) -> + timer = new Metrics.Timer("docManager.updateProject") + callback = (args...) -> + timer.done() + _callback(args...) + + RedisManager.renameDoc project_id, doc_id, user_id, update, callback + getDocAndFlushIfOld: (project_id, doc_id, callback = (error, doc) ->) -> DocumentManager.getDoc project_id, doc_id, (error, lines, version, ranges, pathname, unflushedTime, alreadyLoaded) -> return callback(error) if error? @@ -170,7 +179,7 @@ module.exports = DocumentManager = UpdateManager = require "./UpdateManager" UpdateManager.lockUpdatesAndDo DocumentManager.getDoc, project_id, doc_id, callback - getDocAndRecentOpsWithLock: (project_id, doc_id, fromVersion, callback = (error, lines, version) ->) -> + getDocAndRecentOpsWithLock: (project_id, doc_id, fromVersion, callback = (error, lines, version, ops, ranges, pathname) ->) -> UpdateManager = require "./UpdateManager" UpdateManager.lockUpdatesAndDo DocumentManager.getDocAndRecentOps, project_id, doc_id, fromVersion, callback @@ -197,3 +206,7 @@ module.exports = DocumentManager = deleteCommentWithLock: (project_id, doc_id, thread_id, callback = (error) ->) -> UpdateManager = require "./UpdateManager" UpdateManager.lockUpdatesAndDo DocumentManager.deleteComment, project_id, doc_id, thread_id, callback + + renameDocWithLock: (project_id, doc_id, user_id, update, callback = (error) ->) -> + UpdateManager = require "./UpdateManager" + UpdateManager.lockUpdatesAndDo DocumentManager.renameDoc, project_id, doc_id, user_id, update, callback diff --git a/services/document-updater/app/coffee/HttpController.coffee b/services/document-updater/app/coffee/HttpController.coffee index 0c03a4f7bd..ef9f860552 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, ranges) -> + DocumentManager.getDocAndRecentOpsWithLock project_id, doc_id, fromVersion, (error, lines, version, ops, ranges, pathname) -> timer.done() return next(error) if error? logger.log project_id: project_id, doc_id: doc_id, "got doc via http" @@ -30,6 +30,7 @@ module.exports = HttpController = version: version ops: ops ranges: ranges + pathname: pathname _getTotalSizeOfLines: (lines) -> size = 0 @@ -141,7 +142,7 @@ module.exports = HttpController = return next(error) if error? logger.log {project_id, doc_id}, "accepted #{ change_ids.length } changes via http" res.send 204 # No Content - + deleteComment: (req, res, next = (error) ->) -> {project_id, doc_id, comment_id} = req.params logger.log {project_id, doc_id, comment_id}, "deleting comment via http" @@ -151,5 +152,15 @@ module.exports = HttpController = return next(error) if error? logger.log {project_id, doc_id, comment_id}, "deleted comment via http" res.send 204 # No Content - + updateProject: (req, res, next = (error) ->) -> + timer = new Metrics.Timer("http.updateProject") + project_id = req.params.project_id + {userId, docUpdates, fileUpdates} = req.body + logger.log {project_id, docUpdates, fileUpdates}, "updating project via http" + + ProjectManager.updateProjectWithLocks project_id, userId, docUpdates, fileUpdates, (error) -> + timer.done() + return next(error) if error? + logger.log project_id: project_id, "updated project via http" + res.send 204 # No Content diff --git a/services/document-updater/app/coffee/ProjectManager.coffee b/services/document-updater/app/coffee/ProjectManager.coffee index 26b6e79b0d..2f85173b4c 100644 --- a/services/document-updater/app/coffee/ProjectManager.coffee +++ b/services/document-updater/app/coffee/ProjectManager.coffee @@ -93,3 +93,21 @@ module.exports = ProjectManager = clearProjectState: (project_id, callback = (error) ->) -> RedisManager.clearProjectState project_id, callback + + updateProjectWithLocks: (project_id, user_id, docUpdates, fileUpdates, _callback = (error) ->) -> + timer = new Metrics.Timer("projectManager.updateProject") + callback = (args...) -> + timer.done() + _callback(args...) + + handleDocUpdate = (update, cb) -> + doc_id = update.id + DocumentManager.renameDocWithLock project_id, doc_id, user_id, update, cb + + handleFileUpdate = (update, cb) -> + file_id = update.id + RedisManager.renameFile project_id, file_id, user_id, update, cb + + async.each docUpdates, handleDocUpdate, (error) -> + return callback(error) if error? + async.each fileUpdates, handleFileUpdate, callback diff --git a/services/document-updater/app/coffee/RedisManager.coffee b/services/document-updater/app/coffee/RedisManager.coffee index d2ab35a3ee..56ce0fb9f9 100644 --- a/services/document-updater/app/coffee/RedisManager.coffee +++ b/services/document-updater/app/coffee/RedisManager.coffee @@ -272,6 +272,33 @@ module.exports = RedisManager = else callback null, docUpdateCount + renameDoc: (project_id, doc_id, user_id, update, callback = (error) ->) -> + RedisManager.getDoc project_id, doc_id, (error, lines, version) -> + return callback(error) if error? + + if lines? and version? + rclient.set keys.pathname(doc_id:doc_id), update.newPathname, (error) -> + return callback(error) if error? + RedisManager._renameEntity project_id, 'doc', doc_id, user_id, update, callback + else + RedisManager._renameEntity project_id, 'doc', doc_id, user_id, update, callback + + renameFile: (project_id, file_id, user_id, update, callback = (error) ->) -> + RedisManager._renameEntity project_id, 'file', file_id, user_id, update, callback + + _renameEntity: (project_id, entity_type, entity_id, user_id, update, callback = (error) ->) -> + update = + pathname: update.pathname + new_pathname: update.newPathname + meta: + user_id: user_id + ts: new Date() + update[entity_type] = entity_id + + logger.log {project_id, update}, "queue rename operation to project-history" + jsonUpdate = JSON.stringify(update) + + rclient.rpush projectHistoryKeys.projectHistoryOps({project_id}), jsonUpdate, callback clearUnflushedTime: (doc_id, callback = (error) ->) -> rclient.del keys.unflushedTime(doc_id:doc_id), callback diff --git a/services/document-updater/test/acceptance/coffee/ApplyingUpdatesToProjectStructureTests.coffee b/services/document-updater/test/acceptance/coffee/ApplyingUpdatesToProjectStructureTests.coffee new file mode 100644 index 0000000000..21657793a8 --- /dev/null +++ b/services/document-updater/test/acceptance/coffee/ApplyingUpdatesToProjectStructureTests.coffee @@ -0,0 +1,98 @@ +sinon = require "sinon" +chai = require("chai") +chai.should() +Settings = require('settings-sharelatex') +rclient_history = require("redis-sharelatex").createClient(Settings.redis.history) +ProjectHistoryKeys = Settings.redis.project_history.key_schema + +MockWebApi = require "./helpers/MockWebApi" +DocUpdaterClient = require "./helpers/DocUpdaterClient" + +describe "Applying updates to a project's structure", -> + before -> + @user_id = 'user-id-123' + + describe "renaming a file", -> + before (done) -> + @project_id = DocUpdaterClient.randomId() + @fileUpdate = + id: DocUpdaterClient.randomId() + pathname: '/file-path' + newPathname: '/new-file-path' + @fileUpdates = [ @fileUpdate ] + DocUpdaterClient.sendProjectUpdate @project_id, @user_id, [], @fileUpdates, (error) -> + throw error if error? + setTimeout done, 200 + + it "should push the applied file renames to the project history changes api", (done) -> + rclient_history.lrange ProjectHistoryKeys.projectHistoryOps({@project_id}), 0, -1, (error, updates) => + throw error if error? + + update = JSON.parse(updates[0]) + update.file.should.equal @fileUpdate.id + update.pathname.should.equal '/file-path' + update.new_pathname.should.equal '/new-file-path' + update.meta.user_id.should.equal @user_id + update.meta.ts.should.be.a('string') + + done() + + describe "renaming a document", -> + before -> + @docUpdate = + id: DocUpdaterClient.randomId() + pathname: '/doc-path' + newPathname: '/new-doc-path' + @docUpdates = [ @docUpdate ] + + describe "when the document is not loaded", -> + before (done) -> + @project_id = DocUpdaterClient.randomId() + DocUpdaterClient.sendProjectUpdate @project_id, @user_id, @docUpdates, [], (error) -> + throw error if error? + setTimeout done, 200 + + it "should push the applied doc renames to the project history changes api", (done) -> + rclient_history.lrange ProjectHistoryKeys.projectHistoryOps({@project_id}), 0, -1, (error, updates) => + throw error if error? + + update = JSON.parse(updates[0]) + update.doc.should.equal @docUpdate.id + update.pathname.should.equal '/doc-path' + update.new_pathname.should.equal '/new-doc-path' + update.meta.user_id.should.equal @user_id + update.meta.ts.should.be.a('string') + + done() + + describe "when the document is loaded", -> + before (done) -> + @project_id = DocUpdaterClient.randomId() + MockWebApi.insertDoc @project_id, @docUpdate.id, {} + DocUpdaterClient.preloadDoc @project_id, @docUpdate.id, (error) => + throw error if error? + sinon.spy MockWebApi, "getDocument" + DocUpdaterClient.sendProjectUpdate @project_id, @user_id, @docUpdates, [], (error) -> + throw error if error? + setTimeout done, 200 + + after -> + MockWebApi.getDocument.restore() + + it "should update the doc", (done) -> + DocUpdaterClient.getDoc @project_id, @docUpdate.id, (error, res, doc) => + doc.pathname.should.equal @docUpdate.newPathname + done() + + it "should push the applied doc renames to the project history changes api", (done) -> + rclient_history.lrange ProjectHistoryKeys.projectHistoryOps({@project_id}), 0, -1, (error, updates) => + throw error if error? + + update = JSON.parse(updates[0]) + update.doc.should.equal @docUpdate.id + update.pathname.should.equal '/doc-path' + update.new_pathname.should.equal '/new-doc-path' + update.meta.user_id.should.equal @user_id + update.meta.ts.should.be.a('string') + + done() diff --git a/services/document-updater/test/acceptance/coffee/helpers/DocUpdaterClient.coffee b/services/document-updater/test/acceptance/coffee/helpers/DocUpdaterClient.coffee index 6b2a5ac2fb..f70271021b 100644 --- a/services/document-updater/test/acceptance/coffee/helpers/DocUpdaterClient.coffee +++ b/services/document-updater/test/acceptance/coffee/helpers/DocUpdaterClient.coffee @@ -86,3 +86,10 @@ module.exports = DocUpdaterClient = if body? and res.statusCode >= 200 and res.statusCode < 300 body = JSON.parse(body) callback error, res, body + + sendProjectUpdate: (project_id, userId, docUpdates, fileUpdates, callback = (error) ->) -> + request.post { + url: "http://localhost:3003/project/#{project_id}" + json: { userId, docUpdates, fileUpdates } + }, (error, res, body) -> + callback error, res, body diff --git a/services/document-updater/test/unit/coffee/DocumentManager/DocumentManagerTests.coffee b/services/document-updater/test/unit/coffee/DocumentManager/DocumentManagerTests.coffee index ac0601b34b..702617f7ae 100644 --- a/services/document-updater/test/unit/coffee/DocumentManager/DocumentManagerTests.coffee +++ b/services/document-updater/test/unit/coffee/DocumentManager/DocumentManagerTests.coffee @@ -23,6 +23,7 @@ describe "DocumentManager", -> "./RangesManager": @RangesManager = {} @project_id = "project-id-123" @doc_id = "doc-id-123" + @user_id = 1234 @callback = sinon.stub() @lines = ["one", "two", "three"] @version = 42 @@ -108,7 +109,7 @@ describe "DocumentManager", -> describe "getDocAndRecentOps", -> describe "with a previous version specified", -> beforeEach -> - @DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version, @ranges) + @DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version, @ranges, @pathname) @RedisManager.getPreviousDocOps = sinon.stub().callsArgWith(3, null, @ops) @DocumentManager.getDocAndRecentOps @project_id, @doc_id, @fromVersion, @callback @@ -123,14 +124,14 @@ describe "DocumentManager", -> .should.equal true it "should call the callback with the doc info", -> - @callback.calledWith(null, @lines, @version, @ops, @ranges).should.equal true + @callback.calledWith(null, @lines, @version, @ops, @ranges, @pathname).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) + @DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version, @ranges, @pathname) @RedisManager.getPreviousDocOps = sinon.stub().callsArgWith(3, null, @ops) @DocumentManager.getDocAndRecentOps @project_id, @doc_id, -1, @callback @@ -143,7 +144,7 @@ describe "DocumentManager", -> @RedisManager.getPreviousDocOps.called.should.equal false it "should call the callback with the doc info", -> - @callback.calledWith(null, @lines, @version, [], @ranges).should.equal true + @callback.calledWith(null, @lines, @version, [], @ranges, @pathname).should.equal true it "should time the execution", -> @Metrics.Timer::done.called.should.equal true @@ -439,3 +440,20 @@ describe "DocumentManager", -> it "should call the callback with the lines and versions", -> @callback.calledWith(null, @lines, @version).should.equal true + + describe "renameDoc", -> + beforeEach -> + @update = 'some-update' + @RedisManager.renameDoc = sinon.stub().yields() + + describe "successfully", -> + beforeEach -> + @DocumentManager.renameDoc @project_id, @doc_id, @user_id, @update, @callback + + it "should rename the document", -> + @RedisManager.renameDoc + .calledWith(@project_id, @doc_id, @user_id, @update) + .should.equal true + + it "should call the callback", -> + @callback.called.should.equal true diff --git a/services/document-updater/test/unit/coffee/HttpController/HttpControllerTests.coffee b/services/document-updater/test/unit/coffee/HttpController/HttpControllerTests.coffee index 17b5d10304..d52956635d 100644 --- a/services/document-updater/test/unit/coffee/HttpController/HttpControllerTests.coffee +++ b/services/document-updater/test/unit/coffee/HttpController/HttpControllerTests.coffee @@ -28,6 +28,7 @@ describe "HttpController", -> @version = 42 @fromVersion = 42 @ranges = { changes: "mock", comments: "mock" } + @pathname = '/a/b/c' @req = params: project_id: @project_id @@ -35,7 +36,7 @@ describe "HttpController", -> describe "when the document exists and no recent ops are requested", -> beforeEach -> - @DocumentManager.getDocAndRecentOpsWithLock = sinon.stub().callsArgWith(3, null, @lines, @version, [], @ranges) + @DocumentManager.getDocAndRecentOpsWithLock = sinon.stub().callsArgWith(3, null, @lines, @version, [], @ranges, @pathname) @HttpController.getDoc(@req, @res, @next) it "should get the doc", -> @@ -51,6 +52,7 @@ describe "HttpController", -> version: @version ops: [] ranges: @ranges + pathname: @pathname })) .should.equal true @@ -492,3 +494,41 @@ describe "HttpController", -> @next .calledWith(new Error("oops")) .should.equal true + + describe "updateProject", -> + beforeEach -> + @userId = "user-id-123" + @docUpdates = sinon.stub() + @fileUpdates = sinon.stub() + @req = + body: {@userId, @docUpdates, @fileUpdates} + params: + project_id: @project_id + + describe "successfully", -> + beforeEach -> + @ProjectManager.updateProjectWithLocks = sinon.stub().callsArgWith(4) + @HttpController.updateProject(@req, @res, @next) + + it "should accept the change", -> + @ProjectManager.updateProjectWithLocks + .calledWith(@project_id, @userId, @docUpdates, @fileUpdates) + .should.equal true + + it "should return a successful No Content response", -> + @res.send + .calledWith(204) + .should.equal true + + it "should time the request", -> + @Metrics.Timer::done.called.should.equal true + + describe "when an errors occurs", -> + beforeEach -> + @ProjectManager.updateProjectWithLocks = sinon.stub().callsArgWith(4, new Error("oops")) + @HttpController.updateProject(@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/ProjectManager/updateProjectTests.coffee b/services/document-updater/test/unit/coffee/ProjectManager/updateProjectTests.coffee new file mode 100644 index 0000000000..7009405842 --- /dev/null +++ b/services/document-updater/test/unit/coffee/ProjectManager/updateProjectTests.coffee @@ -0,0 +1,73 @@ +sinon = require('sinon') +chai = require('chai') +should = chai.should() +modulePath = "../../../../app/js/ProjectManager.js" +SandboxedModule = require('sandboxed-module') + +describe "ProjectManager", -> + beforeEach -> + @ProjectManager = SandboxedModule.require modulePath, requires: + "./RedisManager": @RedisManager = {} + "./DocumentManager": @DocumentManager = {} + "logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() } + "./Metrics": @Metrics = + Timer: class Timer + done: sinon.stub() + + @project_id = "project-id-123" + @user_id = "user-id-123" + @callback = sinon.stub() + + describe "updateProjectWithLocks", -> + beforeEach -> + @firstDocUpdate = + id: 1 + update: 'foo' + @secondDocUpdate = + id: 2 + update: 'bar' + @docUpdates = [ @firstDocUpdate, @secondDocUpdate ] + @firstFileUpdate = + id: 2 + update: 'bar' + @fileUpdates = [ @firstFileUpdate ] + @DocumentManager.renameDocWithLock = sinon.stub().yields() + @RedisManager.renameFile = sinon.stub().yields() + + describe "successfully", -> + beforeEach -> + @ProjectManager.updateProjectWithLocks @project_id, @user_id, @docUpdates, @fileUpdates, @callback + + it "should rename the docs in the updates", -> + @DocumentManager.renameDocWithLock + .calledWith(@project_id, @firstDocUpdate.id, @user_id, @firstDocUpdate) + .should.equal true + @DocumentManager.renameDocWithLock + .calledWith(@project_id, @secondDocUpdate.id, @user_id, @secondDocUpdate) + .should.equal true + + it "should rename the files in the updates", -> + @RedisManager.renameFile + .calledWith(@project_id, @firstFileUpdate.id, @user_id, @firstFileUpdate) + .should.equal true + + it "should call the callback", -> + @callback.called.should.equal true + + describe "when renaming a doc fails", -> + beforeEach -> + @error = new Error('error') + @DocumentManager.renameDocWithLock = sinon.stub().yields(@error) + @ProjectManager.updateProjectWithLocks @project_id, @user_id, @docUpdates, @fileUpdates, @callback + + it "should call the callback with the error", -> + @callback.calledWith(@error).should.equal true + + describe "when renaming a file fails", -> + beforeEach -> + @error = new Error('error') + @RedisManager.renameFile = sinon.stub().yields(@error) + @ProjectManager.updateProjectWithLocks @project_id, @user_id, @docUpdates, @fileUpdates, @callback + + it "should call the callback with the error", -> + @callback.calledWith(@error).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 f5bf3843fa..157c315b63 100644 --- a/services/document-updater/test/unit/coffee/RedisManager/RedisManagerTests.coffee +++ b/services/document-updater/test/unit/coffee/RedisManager/RedisManagerTests.coffee @@ -673,3 +673,69 @@ describe "RedisManager", -> @rclient.del .calledWith("ProjectState:#{@project_id}") .should.equal true + + describe "renameDoc", -> + beforeEach () -> + @rclient.rpush = sinon.stub().yields() + @rclient.set = sinon.stub().yields() + @update = + id: @doc_id + pathname: @pathname = 'pathname' + newPathname: @newPathname = 'new-pathname' + + describe "the document is cached in redis", -> + beforeEach -> + @RedisManager.getDoc = sinon.stub().callsArgWith(2, null, 'lines', 'version') + @RedisManager.renameDoc @project_id, @doc_id, @userId, @update, @callback + + it "update the cached pathname", -> + @rclient.set + .calledWith("Pathname:#{@doc_id}", @newPathname) + .should.equal true + + it "should queue an update", -> + update = + pathname: @pathname + new_pathname: @newPathname + meta: + user_id: @userId + ts: new Date() + doc: @doc_id + @rclient.rpush + .calledWith("ProjectHistory:Ops:#{@project_id}", JSON.stringify(update)) + .should.equal true + + it "should call the callback", -> + @callback.calledWith().should.equal true + + describe "the document is not cached in redis", -> + beforeEach -> + @RedisManager.getDoc = sinon.stub().callsArgWith(2, null, null, null) + @RedisManager.renameDoc @project_id, @doc_id, @userId, @update, @callback + + it "does not update the cached pathname", -> + @rclient.set.called.should.equal false + + describe "renameFile", -> + beforeEach () -> + @rclient.rpush = sinon.stub().yields() + @file_id = 1234 + + @update = + pathname: @pathname = '/old' + newPathname: @newPathname = '/new' + + @RedisManager.renameFile @project_id, @file_id, @userId, @update + + it "should queue an update", -> + update = + pathname: @pathname + new_pathname: @newPathname + meta: + user_id: @userId + ts: new Date() + file: @file_id + + @rclient.rpush + .calledWith("ProjectHistory:Ops:#{@project_id}", JSON.stringify(update)) + .should.equal true