Parcourir la source

Refactor. Handle republishing of notifications on resend.

Shane Kilkelly il y a 10 ans
Parent
commit
ece0491e3d

+ 4 - 27
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee

@@ -20,27 +20,6 @@ module.exports = CollaboratorsInviteController =
 				return next(err)
 			res.json({invites: invites})
 
-	_trySendInviteNotification: (projectId, sendingUser, invite, callback=(err)->) ->
-		email = invite.email
-		UserGetter.getUser {email: email}, {_id: 1}, (err, existingUser) ->
-			if err?
-				logger.err {projectId, email}, "error checking if user exists"
-				return callback(err)
-			if !existingUser?
-				logger.log {projectId, email}, "no existing user found, returning"
-				return callback(null)
-			ProjectGetter.getProject projectId, {_id: 1, name: 1}, (err, project) ->
-				if err?
-					logger.err {projectId, email}, "error getting project"
-					return callback(err)
-				if !project?
-					logger.log {projectId}, "no project found while sending notification, returning"
-					return callback(null)
-				NotificationsBuilder.projectInvite(invite, project, sendingUser, existingUser).create(callback)
-
-	_tryCancelInviteNotification: (inviteId, currentUser, callback=()->) ->
-			NotificationsBuilder.projectInvite({_id: inviteId}, null, null, currentUser).read(callback)
-
 	inviteToProject: (req, res, next) ->
 		projectId = req.params.Project_id
 		email = req.body.email
@@ -57,14 +36,12 @@ module.exports = CollaboratorsInviteController =
 			if !email? or email == ""
 				logger.log {projectId, email, sendingUserId}, "invalid email address"
 				return res.sendStatus(400)
-			CollaboratorsInviteHandler.inviteToProject projectId, sendingUserId, email, privileges, (err, invite) ->
+			CollaboratorsInviteHandler.inviteToProject projectId, sendingUser, email, privileges, (err, invite) ->
 				if err?
 					logger.err {projectId, email, sendingUserId}, "error creating project invite"
 					return next(err)
 				logger.log {projectId, email, sendingUserId}, "invite created"
-				EditorRealTimeController.emitToRoom projectId, 'project:membership:changed', {invites: true}
-				# async check if email is for an existing user, send a notification
-				CollaboratorsInviteController._trySendInviteNotification(projectId, sendingUser, invite, ()->)
+				EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {invites: true})
 				return res.json {invite: invite}
 
 	revokeInvite: (req, res, next) ->
@@ -81,8 +58,9 @@ module.exports = CollaboratorsInviteController =
 	resendInvite: (req, res, next) ->
 		projectId = req.params.Project_id
 		inviteId = req.params.invite_id
+		sendingUser = req.session.user
 		logger.log {projectId, inviteId}, "resending invite"
-		CollaboratorsInviteHandler.resendInvite projectId, inviteId, (err) ->
+		CollaboratorsInviteHandler.resendInvite projectId, sendingUser, inviteId, (err) ->
 			if err?
 				logger.err {projectId, inviteId}, "error resending invite"
 				return next(err)
@@ -142,5 +120,4 @@ module.exports = CollaboratorsInviteController =
 				logger.err {projectId, inviteId}, "error accepting invite by token"
 				return next(err)
 			EditorRealTimeController.emitToRoom projectId, 'project:membership:changed', {invites: true, members: true}
-			CollaboratorsInviteController._tryCancelInviteNotification inviteId, currentUser, () ->
 			res.redirect "/project/#{projectId}"

+ 49 - 10
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee

@@ -2,10 +2,13 @@ ProjectInvite = require("../../models/ProjectInvite").ProjectInvite
 logger = require('logger-sharelatex')
 CollaboratorsEmailHandler = require "./CollaboratorsEmailHandler"
 CollaboratorsHandler = require "./CollaboratorsHandler"
+UserGetter = require "../User/UserGetter"
+ProjectGetter = require "../Project/ProjectGetter"
 Async = require "async"
 PrivilegeLevels = require "../Authorization/PrivilegeLevels"
 Errors = require "../Errors/Errors"
 Crypto = require 'crypto'
+NotificationsBuilder = require("../Notifications/NotificationsBuilder")
 
 
 module.exports = CollaboratorsInviteHandler =
@@ -27,26 +30,57 @@ module.exports = CollaboratorsInviteHandler =
 				return callback(err)
 			callback(null, count)
 
-	inviteToProject: (projectId, sendingUserId, email, privileges, callback=(err,invite)->) ->
-		logger.log {projectId, sendingUserId, email, privileges}, "adding invite"
+	_trySendInviteNotification: (projectId, sendingUser, invite, callback=(err)->) ->
+		email = invite.email
+		UserGetter.getUser {email: email}, {_id: 1}, (err, existingUser) ->
+			if err?
+				logger.err {projectId, email}, "error checking if user exists"
+				return callback(err)
+			if !existingUser?
+				logger.log {projectId, email}, "no existing user found, returning"
+				return callback(null)
+			ProjectGetter.getProject projectId, {_id: 1, name: 1}, (err, project) ->
+				if err?
+					logger.err {projectId, email}, "error getting project"
+					return callback(err)
+				if !project?
+					logger.log {projectId}, "no project found while sending notification, returning"
+					return callback(null)
+				NotificationsBuilder.projectInvite(invite, project, sendingUser, existingUser).create(callback)
+
+	_tryCancelInviteNotification: (inviteId, callback=()->) ->
+			NotificationsBuilder.projectInvite({_id: inviteId}, null, null, null).read(callback)
+
+	_sendMessages: (projectId, sendingUser, invite, callback=(err)->) ->
+		logger.log {projectId, inviteId: invite._id}, "sending notification and email for invite"
+		CollaboratorsEmailHandler.notifyUserOfProjectInvite projectId, invite.email, invite, (err)->
+			return callback(err) if err?
+			CollaboratorsInviteHandler._trySendInviteNotification projectId, sendingUser, invite, (err)->
+				return callback(err) if err?
+				callback()
+
+	inviteToProject: (projectId, sendingUser, email, privileges, callback=(err,invite)->) ->
+		logger.log {projectId, sendingUserId: sendingUser._id, email, privileges}, "adding invite"
 		Crypto.randomBytes 24, (err, buffer) ->
 			if err?
-				logger.err {err, projectId, sendingUserId, email}, "error generating random token"
+				logger.err {err, projectId, sendingUserId: sendingUser._id, email}, "error generating random token"
 				return callback(err)
 			token = buffer.toString('hex')
 			invite = new ProjectInvite {
 				email: email
 				token: token
-				sendingUserId: sendingUserId
+				sendingUserId: sendingUser._id
 				projectId: projectId
 				privileges: privileges
 			}
 			invite.save (err, invite) ->
 				if err?
-					logger.err {err, projectId, sendingUserId, email}, "error saving token"
+					logger.err {err, projectId, sendingUserId: sendingUser._id, email}, "error saving token"
 					return callback(err)
-				CollaboratorsEmailHandler.notifyUserOfProjectInvite projectId, email, invite
-				callback(null, invite)
+				CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) ->
+					if err?
+						logger.err {projectId, email}, "error sending messages for invite"
+					callback(err, invite)
 
 	revokeInvite: (projectId, inviteId, callback=(err)->) ->
 		logger.log {projectId, inviteId}, "removing invite"
@@ -54,9 +88,10 @@ module.exports = CollaboratorsInviteHandler =
 			if err?
 				logger.err {err, projectId, inviteId}, "error removing invite"
 				return callback(err)
+			CollaboratorsInviteHandler._tryCancelInviteNotification(inviteId, ()->)
 			callback(null)
 
-	resendInvite: (projectId, inviteId, callback=(err)->) ->
+	resendInvite: (projectId, sendingUser, inviteId, callback=(err)->) ->
 		logger.log {projectId, inviteId}, "resending invite email"
 		ProjectInvite.findOne {_id: inviteId, projectId: projectId}, (err, invite) ->
 			if err?
@@ -65,8 +100,11 @@ module.exports = CollaboratorsInviteHandler =
 			if !invite?
 				logger.err {err, projectId, inviteId}, "no invite found, nothing to resend"
 				return callback(null)
-			CollaboratorsEmailHandler.notifyUserOfProjectInvite projectId, invite.email, invite
-			callback(null)
+			CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) ->
+				if err?
+					logger.err {projectid, inviteId}, "error resending invite messages"
+					return callback(err)
+				callback(null)
 
 	getInviteByToken: (projectId, tokenString, callback=(err,invite)->) ->
 		logger.log {projectId, tokenString}, "fetching invite by token"
@@ -100,4 +138,5 @@ module.exports = CollaboratorsInviteHandler =
 					if err?
 						logger.err {err, projectId, inviteId}, "error removing invite"
 						return callback(err)
+					CollaboratorsInviteHandler._tryCancelInviteNotification inviteId, ()->
 					callback()

+ 2 - 2
services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee

@@ -12,7 +12,7 @@ module.exports =
 				groupName: licence.name
 				subscription_id: licence.subscription_id
 			logger.log user_id:user._id, key:key, "creating notification key for user"
-			NotificationsHandler.createNotification user._id, @key, "notification_group_invite", messageOpts, null, callback
+			NotificationsHandler.createNotification user._id, @key, "notification_group_invite", messageOpts, null, false, callback
 
 		read: (callback = ->)->
 			NotificationsHandler.markAsReadWithKey user._id, @key, callback
@@ -26,6 +26,6 @@ module.exports =
 				projectId: project._id.toString()
 				token: invite.token
 			logger.log {user_id: user._id, project_id: project._id, invite_id: invite._id, key: @key}, "creating project invite notification for user"
-			NotificationsHandler.createNotification user._id, @key, "notification_project_invite", messageOpts, invite.expires, callback
+			NotificationsHandler.createNotification user._id, @key, "notification_project_invite", messageOpts, invite.expires, true, callback
 		read:  (callback=()->) ->
 			NotificationsHandler.markAsReadByKeyOnly @key, callback

+ 3 - 1
services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee

@@ -29,7 +29,7 @@ module.exports =
 					unreadNotifications = []
 				callback(null, unreadNotifications)
 
-	createNotification: (user_id, key, templateKey, messageOpts, expiryDateTime, callback)->
+	createNotification: (user_id, key, templateKey, messageOpts, expiryDateTime, forceCreate, callback)->
 		payload = {
 				key:key
 				messageOpts:messageOpts
@@ -37,6 +37,8 @@ module.exports =
 		}
 		if expiryDateTime?
 			payload.expires = expiryDateTime
+		if forceCreate
+			payload.forceCreate = true
 		opts =
 			uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
 			timeout: oneSecond

+ 10 - 175
services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee

@@ -26,166 +26,6 @@ describe "CollaboratorsInviteController", ->
 		@project_id = "project-id-123"
 		@callback = sinon.stub()
 
-	describe '_tryCancelInviteNotification', ->
-		beforeEach ->
-			@inviteId = ObjectId()
-			@currentUser = {_id: ObjectId()}
-			@notification = {read: sinon.stub().callsArgWith(0, null)}
-			@NotificationsBuilder.projectInvite = sinon.stub().returns(@notification)
-			@call = (callback) =>
-				@CollaboratorsInviteController._tryCancelInviteNotification @inviteId, @currentUser, callback
-
-		it 'should not produce an error', (done) ->
-			@call (err) =>
-				expect(err).to.be.oneOf [null, undefined]
-				done()
-
-		it 'should call notification.read', (done) ->
-			@call (err) =>
-				@notification.read.callCount.should.equal 1
-				done()
-
-		describe 'when notification.read produces an error', ->
-			beforeEach ->
-				@notification = {read: sinon.stub().callsArgWith(0, new Error('woops'))}
-				@NotificationsBuilder.projectInvite = sinon.stub().returns(@notification)
-
-			it 'should produce an error', (done) ->
-				@call (err) =>
-					expect(err).to.be.instanceof Error
-					done()
-
-	describe "_trySendInviteNotification", ->
-
-		beforeEach ->
-			@invite =
-				_id: ObjectId(),
-				token: "some_token",
-				sendingUserId: ObjectId(),
-				projectId: @project_id,
-				targetEmail: 'user@example.com'
-				createdAt: new Date(),
-			@sendingUser =
-				_id: ObjectId()
-				first_name: "jim"
-			@existingUser = {_id: ObjectId()}
-			@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @existingUser)
-			@fakeProject =
-				_id: @project_id
-				name: "some project"
-			@ProjectGetter.getProject = sinon.stub().callsArgWith(2, null, @fakeProject)
-			@notification = {create: sinon.stub().callsArgWith(0, null)}
-			@NotificationsBuilder.projectInvite = sinon.stub().returns(@notification)
-			@call = (callback) =>
-				@CollaboratorsInviteController._trySendInviteNotification @project_id, @sendingUser, @invite, callback
-
-		describe 'when the user exists', ->
-
-			beforeEach ->
-
-			it 'should not produce an error', (done) ->
-				@call (err) =>
-					expect(err).to.be.oneOf [null, undefined]
-					done()
-
-			it 'should call getUser', (done) ->
-				@call (err) =>
-					@UserGetter.getUser.callCount.should.equal 1
-					@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
-					done()
-
-			it 'should call getProject', (done) ->
-				@call (err) =>
-					@ProjectGetter.getProject.callCount.should.equal 1
-					@ProjectGetter.getProject.calledWith(@project_id).should.equal true
-					done()
-
-			it 'should call NotificationsBuilder.projectInvite.create', (done) ->
-				@call (err) =>
-					@NotificationsBuilder.projectInvite.callCount.should.equal 1
-					@notification.create.callCount.should.equal 1
-					done()
-
-			describe 'when getProject produces an error', ->
-
-				beforeEach ->
-					@ProjectGetter.getProject.callsArgWith(2, new Error('woops'))
-
-				it 'should produce an error', (done) ->
-					@call (err) =>
-						expect(err).to.be.instanceof Error
-						done()
-
-				it 'should not call NotificationsBuilder.projectInvite.create', (done) ->
-					@call (err) =>
-						@NotificationsBuilder.projectInvite.callCount.should.equal 0
-						@notification.create.callCount.should.equal 0
-						done()
-
-			describe 'when projectInvite.create produces an error', ->
-
-				beforeEach ->
-					@notification.create.callsArgWith(0, new Error('woops'))
-
-				it 'should produce an error', (done) ->
-					@call (err) =>
-						expect(err).to.be.instanceof Error
-						done()
-
-		describe 'when the user does not exist', ->
-
-			beforeEach ->
-				@UserGetter.getUser = sinon.stub().callsArgWith(2, null, null)
-
-			it 'should not produce an error', (done) ->
-				@call (err) =>
-					expect(err).to.be.oneOf [null, undefined]
-					done()
-
-			it 'should call getUser', (done) ->
-				@call (err) =>
-					@UserGetter.getUser.callCount.should.equal 1
-					@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
-					done()
-
-			it 'should not call getProject', (done) ->
-				@call (err) =>
-					@ProjectGetter.getProject.callCount.should.equal 0
-					done()
-
-			it 'should not call NotificationsBuilder.projectInvite.create', (done) ->
-				@call (err) =>
-					@NotificationsBuilder.projectInvite.callCount.should.equal 0
-					@notification.create.callCount.should.equal 0
-					done()
-
-		describe 'when the getUser produces an error', ->
-
-			beforeEach ->
-				@UserGetter.getUser = sinon.stub().callsArgWith(2, new Error('woops'))
-
-			it 'should produce an error', (done) ->
-				@call (err) =>
-					expect(err).to.be.instanceof Error
-					done()
-
-			it 'should call getUser', (done) ->
-				@call (err) =>
-					@UserGetter.getUser.callCount.should.equal 1
-					@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
-					done()
-
-			it 'should not call getProject', (done) ->
-				@call (err) =>
-					@ProjectGetter.getProject.callCount.should.equal 0
-					done()
-
-			it 'should not call NotificationsBuilder.projectInvite.create', (done) ->
-				@call (err) =>
-					@NotificationsBuilder.projectInvite.callCount.should.equal 0
-					@notification.create.callCount.should.equal 0
-					done()
-
 	describe 'getAllInvites', ->
 
 		beforeEach ->
@@ -231,8 +71,10 @@ describe "CollaboratorsInviteController", ->
 			@targetEmail = "user@example.com"
 			@req.params =
 				Project_id: @project_id
+			@current_user =
+				_id: @current_user_id = "current-user-id"
 			@req.session =
-				user: _id: @current_user_id = "current-user-id"
+				user: @current_user
 			@req.body =
 				email: @targetEmail
 				privileges: @privileges = "readAndWrite"
@@ -248,7 +90,6 @@ describe "CollaboratorsInviteController", ->
 			}
 			@LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, true)
 			@CollaboratorsInviteHandler.inviteToProject = sinon.stub().callsArgWith(4, null, @invite)
-			@CollaboratorsInviteController._trySendInviteNotification = sinon.stub()
 			@callback = sinon.stub()
 			@next = sinon.stub()
 
@@ -268,15 +109,12 @@ describe "CollaboratorsInviteController", ->
 
 			it 'should have called inviteToProject', ->
 				@CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 1
-				@CollaboratorsInviteHandler.inviteToProject.calledWith(@project_id,@current_user_id,@targetEmail,@privileges).should.equal true
+				@CollaboratorsInviteHandler.inviteToProject.calledWith(@project_id,@current_user,@targetEmail,@privileges).should.equal true
 
 			it 'should have called emitToRoom', ->
 				@EditorRealTimeController.emitToRoom.callCount.should.equal 1
 				@EditorRealTimeController.emitToRoom.calledWith(@project_id, 'project:membership:changed').should.equal true
 
-			it 'should call _trySendInviteNotification', ->
-				@CollaboratorsInviteController._trySendInviteNotification.callCount.should.equal 1
-
 		describe 'when the user is not allowed to add more collaborators', ->
 
 			beforeEach ->
@@ -321,7 +159,7 @@ describe "CollaboratorsInviteController", ->
 
 			it 'should have called inviteToProject', ->
 				@CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 1
-				@CollaboratorsInviteHandler.inviteToProject.calledWith(@project_id,@current_user_id,@targetEmail,@privileges).should.equal true
+				@CollaboratorsInviteHandler.inviteToProject.calledWith(@project_id,@current_user,@targetEmail,@privileges).should.equal true
 
 	describe "viewInvite", ->
 
@@ -604,7 +442,7 @@ describe "CollaboratorsInviteController", ->
 				user: _id: @current_user_id = "current-user-id"
 			@res.render = sinon.stub()
 			@res.sendStatus = sinon.stub()
-			@CollaboratorsInviteHandler.resendInvite = sinon.stub().callsArgWith(2, null)
+			@CollaboratorsInviteHandler.resendInvite = sinon.stub().callsArgWith(3, null)
 			@callback = sinon.stub()
 			@next = sinon.stub()
 
@@ -624,7 +462,7 @@ describe "CollaboratorsInviteController", ->
 
 			beforeEach ->
 				@err = new Error('woops')
-				@CollaboratorsInviteHandler.resendInvite = sinon.stub().callsArgWith(2, @err)
+				@CollaboratorsInviteHandler.resendInvite = sinon.stub().callsArgWith(3, @err)
 				@CollaboratorsInviteController.resendInvite @req, @res, @next
 
 			it 'should not produce a 201 response', ->
@@ -637,15 +475,16 @@ describe "CollaboratorsInviteController", ->
 			it 'should have called resendInvite', ->
 				@CollaboratorsInviteHandler.resendInvite.callCount.should.equal 1
 
-
 	describe "revokeInvite", ->
 
 		beforeEach ->
 			@req.params =
 				Project_id: @project_id
 				invite_id: @invite_id = "thuseoautoh"
+			@current_user =
+				_id: @current_user_id = "current-user-id"
 			@req.session =
-				user: _id: @current_user_id = "current-user-id"
+				user: @current_user
 			@res.render = sinon.stub()
 			@res.sendStatus = sinon.stub()
 			@CollaboratorsInviteHandler.revokeInvite = sinon.stub().callsArgWith(2, null)
@@ -698,7 +537,6 @@ describe "CollaboratorsInviteController", ->
 			@res.render = sinon.stub()
 			@res.redirect = sinon.stub()
 			@CollaboratorsInviteHandler.acceptInvite = sinon.stub().callsArgWith(4, null)
-			@CollaboratorsInviteController._tryCancelInviteNotification = sinon.stub()
 			@callback = sinon.stub()
 			@next = sinon.stub()
 
@@ -718,9 +556,6 @@ describe "CollaboratorsInviteController", ->
 				@EditorRealTimeController.emitToRoom.callCount.should.equal 1
 				@EditorRealTimeController.emitToRoom.calledWith(@project_id, 'project:membership:changed').should.equal true
 
-			it 'should call _tryCancelInviteNotification', ->
-				@CollaboratorsInviteController._tryCancelInviteNotification.callCount.should.equal 1
-
 		describe 'when revokeInvite produces an error', ->
 
 			beforeEach ->

+ 242 - 15
services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee

@@ -24,14 +24,20 @@ describe "CollaboratorsInviteHandler", ->
 		@Crypto = Crypto
 		@CollaboratorsInviteHandler = SandboxedModule.require modulePath, requires:
 			'settings-sharelatex': @settings = {}
+			'../../models/ProjectInvite': {ProjectInvite: @ProjectInvite}
 			'logger-sharelatex': @logger = {err: sinon.stub(), error: sinon.stub(), log: sinon.stub()}
 			'./CollaboratorsEmailHandler': @CollaboratorsEmailHandler = {}
 			"./CollaboratorsHandler": @CollaboratorsHandler = {addUserIdToProject: sinon.stub()}
-			'../../models/ProjectInvite': {ProjectInvite: @ProjectInvite}
+			'../User/UserGetter': @UserGetter = {getUser: sinon.stub()}
+			"../Project/ProjectGetter": @ProjectGetter = {}
+			"../Notifications/NotificationsBuilder": @NotificationsBuilder = {}
 			'crypto': @Crypto
 
 		@projectId = ObjectId()
 		@sendingUserId = ObjectId()
+		@sendingUser =
+			_id: @sendingUserId
+			name: "Bob"
 		@email = "user@example.com"
 		@userId = ObjectId()
 		@user =
@@ -125,9 +131,9 @@ describe "CollaboratorsInviteHandler", ->
 		beforeEach ->
 			@ProjectInvite::save = sinon.spy (cb) -> cb(null, this)
 			@randomBytesSpy = sinon.spy(@Crypto, 'randomBytes')
-			@CollaboratorsEmailHandler.notifyUserOfProjectInvite = sinon.stub()
+			@CollaboratorsInviteHandler._sendMessages = sinon.stub().callsArgWith(3, null)
 			@call = (callback) =>
-				@CollaboratorsInviteHandler.inviteToProject @projectId, @sendingUserId, @email, @privileges, callback
+				@CollaboratorsInviteHandler.inviteToProject @projectId, @sendingUser, @email, @privileges, callback
 
 		afterEach ->
 			@randomBytesSpy.restore()
@@ -160,10 +166,10 @@ describe "CollaboratorsInviteHandler", ->
 					@ProjectInvite::save.callCount.should.equal 1
 					done()
 
-			it 'should have called CollaboratorsEmailHandler.notifyUserOfProjectInvite', (done) ->
+			it 'should have called _sendMessages', (done) ->
 				@call (err, invite) =>
-					@CollaboratorsEmailHandler.notifyUserOfProjectInvite.callCount.should.equal 1
-					@CollaboratorsEmailHandler.notifyUserOfProjectInvite.calledWith(@projectId, @email).should.equal true
+					@CollaboratorsInviteHandler._sendMessages.callCount.should.equal 1
+					@CollaboratorsInviteHandler._sendMessages.calledWith(@projectId, @sendingUser).should.equal true
 					done()
 
 		describe 'when saving model produces an error', ->
@@ -176,10 +182,64 @@ describe "CollaboratorsInviteHandler", ->
 					expect(err).to.be.instanceof Error
 					done()
 
+	describe '_sendMessages', ->
+
+		beforeEach ->
+			@CollaboratorsEmailHandler.notifyUserOfProjectInvite = sinon.stub().callsArgWith(3, null)
+			@CollaboratorsInviteHandler._trySendInviteNotification = sinon.stub().callsArgWith(3, null)
+			@call = (callback) =>
+				@CollaboratorsInviteHandler._sendMessages @projectId, @sendingUser, @fakeInvite, callback
+
+		describe 'when all goes well', ->
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.be.oneOf [null, undefined]
+					done()
+
+			it 'should call CollaboratorsEmailHandler.notifyUserOfProjectInvite', (done) ->
+				@call (err) =>
+					@CollaboratorsEmailHandler.notifyUserOfProjectInvite.callCount.should.equal 1
+					@CollaboratorsEmailHandler.notifyUserOfProjectInvite.calledWith(@projectId, @fakeInvite.email, @fakeInvite).should.equal true
+					done()
+
+			it 'should call _trySendInviteNotification', (done) ->
+				@call (err) =>
+					@CollaboratorsInviteHandler._trySendInviteNotification.callCount.should.equal 1
+					@CollaboratorsInviteHandler._trySendInviteNotification.calledWith(@projectId, @sendingUser, @fakeInvite).should.equal true
+					done()
+
+		describe 'when CollaboratorsEmailHandler.notifyUserOfProjectInvite produces an error', ->
+
+			beforeEach ->
+				@CollaboratorsEmailHandler.notifyUserOfProjectInvite = sinon.stub().callsArgWith(3, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err, invite) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should not call _trySendInviteNotification', (done) ->
+				@call (err) =>
+					@CollaboratorsInviteHandler._trySendInviteNotification.callCount.should.equal 0
+					done()
+
+		describe 'when _trySendInviteNotification produces an error', ->
+
+			beforeEach ->
+				@CollaboratorsInviteHandler._trySendInviteNotification = sinon.stub().callsArgWith(3, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err, invite) =>
+					expect(err).to.be.instanceof Error
+					done()
+
 	describe 'revokeInvite', ->
 
 		beforeEach ->
 			@ProjectInvite.remove.callsArgWith(1, null)
+			@CollaboratorsInviteHandler._tryCancelInviteNotification = sinon.stub().callsArgWith(1, null)
 			@call = (callback) =>
 				@CollaboratorsInviteHandler.revokeInvite @projectId, @inviteId, callback
 
@@ -199,6 +259,12 @@ describe "CollaboratorsInviteHandler", ->
 					@ProjectInvite.remove.calledWith({projectId: @projectId, _id: @inviteId}).should.equal true
 					done()
 
+			it 'should call _tryCancelInviteNotification', (done) ->
+				@call (err) =>
+					@CollaboratorsInviteHandler._tryCancelInviteNotification.callCount.should.equal 1
+					@CollaboratorsInviteHandler._tryCancelInviteNotification.calledWith(@inviteId).should.equal true
+					done()
+
 		describe 'when remove produces an error', ->
 
 			beforeEach ->
@@ -213,9 +279,9 @@ describe "CollaboratorsInviteHandler", ->
 
 		beforeEach ->
 			@ProjectInvite.findOne.callsArgWith(1, null, @fakeInvite)
-			@CollaboratorsEmailHandler.notifyUserOfProjectInvite = sinon.stub()
+			@CollaboratorsInviteHandler._sendMessages = sinon.stub().callsArgWith(3, null)
 			@call = (callback) =>
-				@CollaboratorsInviteHandler.resendInvite @projectId, @inviteId, callback
+				@CollaboratorsInviteHandler.resendInvite @projectId, @sendingUser, @inviteId, callback
 
 		describe 'when all goes well', ->
 
@@ -233,10 +299,10 @@ describe "CollaboratorsInviteHandler", ->
 					@ProjectInvite.findOne.calledWith({_id: @inviteId, projectId: @projectId}).should.equal true
 					done()
 
-			it 'should have called CollaboratorsEmailHandler.notifyUserOfProjectInvite', (done) ->
+			it 'should have called _sendMessages', (done) ->
 				@call (err, invite) =>
-					@CollaboratorsEmailHandler.notifyUserOfProjectInvite.callCount.should.equal 1
-					@CollaboratorsEmailHandler.notifyUserOfProjectInvite.calledWith(@projectId, @email).should.equal true
+					@CollaboratorsInviteHandler._sendMessages.callCount.should.equal 1
+					@CollaboratorsInviteHandler._sendMessages.calledWith(@projectId, @sendingUser, @fakeInvite).should.equal true
 					done()
 
 		describe 'when findOne produces an error', ->
@@ -249,9 +315,9 @@ describe "CollaboratorsInviteHandler", ->
 					expect(err).to.be.instanceof Error
 					done()
 
-			it 'should not have called CollaboratorsEmailHandler.notifyUserOfProjectInvite', (done) ->
+			it 'should not have called _sendMessages', (done) ->
 				@call (err, invite) =>
-					@CollaboratorsEmailHandler.notifyUserOfProjectInvite.callCount.should.equal 0
+					@CollaboratorsInviteHandler._sendMessages.callCount.should.equal 0
 					done()
 
 		describe 'when findOne does not find an invite', ->
@@ -265,9 +331,9 @@ describe "CollaboratorsInviteHandler", ->
 					expect(err).to.be.oneOf [null, undefined]
 					done()
 
-			it 'should not have called CollaboratorsEmailHandler.notifyUserOfProjectInvite', (done) ->
+			it 'should not have called _sendMessages', (done) ->
 				@call (err, invite) =>
-					@CollaboratorsEmailHandler.notifyUserOfProjectInvite.callCount.should.equal 0
+					@CollaboratorsInviteHandler._sendMessages.callCount.should.equal 0
 					done()
 
 	describe 'getInviteByToken', ->
@@ -335,6 +401,7 @@ describe "CollaboratorsInviteHandler", ->
 			@CollaboratorsHandler.addUserIdToProject.callsArgWith(4, null)
 			@_getInviteByToken = sinon.stub(@CollaboratorsInviteHandler, 'getInviteByToken')
 			@_getInviteByToken.callsArgWith(2, null, @fakeInvite)
+			@CollaboratorsInviteHandler._tryCancelInviteNotification = sinon.stub().callsArgWith(1, null)
 			@ProjectInvite.remove.callsArgWith(1, null)
 			@call = (callback) =>
 				@CollaboratorsInviteHandler.acceptInvite @projectId, @inviteId, @token, @user, callback
@@ -494,3 +561,163 @@ describe "CollaboratorsInviteHandler", ->
 				@call (err) =>
 					@ProjectInvite.remove.callCount.should.equal 1
 					done()
+
+	describe '_tryCancelInviteNotification', ->
+		beforeEach ->
+			@inviteId = ObjectId()
+			@currentUser = {_id: ObjectId()}
+			@notification = {read: sinon.stub().callsArgWith(0, null)}
+			@NotificationsBuilder.projectInvite = sinon.stub().returns(@notification)
+			@call = (callback) =>
+				@CollaboratorsInviteHandler._tryCancelInviteNotification @inviteId, callback
+
+		it 'should not produce an error', (done) ->
+			@call (err) =>
+				expect(err).to.be.oneOf [null, undefined]
+				done()
+
+		it 'should call notification.read', (done) ->
+			@call (err) =>
+				@notification.read.callCount.should.equal 1
+				done()
+
+		describe 'when notification.read produces an error', ->
+			beforeEach ->
+				@notification = {read: sinon.stub().callsArgWith(0, new Error('woops'))}
+				@NotificationsBuilder.projectInvite = sinon.stub().returns(@notification)
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+	describe "_trySendInviteNotification", ->
+
+		beforeEach ->
+			@invite =
+				_id: ObjectId(),
+				token: "some_token",
+				sendingUserId: ObjectId(),
+				projectId: @project_id,
+				targetEmail: 'user@example.com'
+				createdAt: new Date(),
+			@sendingUser =
+				_id: ObjectId()
+				first_name: "jim"
+			@existingUser = {_id: ObjectId()}
+			@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @existingUser)
+			@fakeProject =
+				_id: @project_id
+				name: "some project"
+			@ProjectGetter.getProject = sinon.stub().callsArgWith(2, null, @fakeProject)
+			@notification = {create: sinon.stub().callsArgWith(0, null)}
+			@NotificationsBuilder.projectInvite = sinon.stub().returns(@notification)
+			@call = (callback) =>
+				@CollaboratorsInviteHandler._trySendInviteNotification @project_id, @sendingUser, @invite, callback
+
+		describe 'when the user exists', ->
+
+			beforeEach ->
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.oneOf [null, undefined]
+					done()
+
+			it 'should call getUser', (done) ->
+				@call (err) =>
+					@UserGetter.getUser.callCount.should.equal 1
+					@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
+					done()
+
+			it 'should call getProject', (done) ->
+				@call (err) =>
+					@ProjectGetter.getProject.callCount.should.equal 1
+					@ProjectGetter.getProject.calledWith(@project_id).should.equal true
+					done()
+
+			it 'should call NotificationsBuilder.projectInvite.create', (done) ->
+				@call (err) =>
+					@NotificationsBuilder.projectInvite.callCount.should.equal 1
+					@notification.create.callCount.should.equal 1
+					done()
+
+			describe 'when getProject produces an error', ->
+
+				beforeEach ->
+					@ProjectGetter.getProject.callsArgWith(2, new Error('woops'))
+
+				it 'should produce an error', (done) ->
+					@call (err) =>
+						expect(err).to.be.instanceof Error
+						done()
+
+				it 'should not call NotificationsBuilder.projectInvite.create', (done) ->
+					@call (err) =>
+						@NotificationsBuilder.projectInvite.callCount.should.equal 0
+						@notification.create.callCount.should.equal 0
+						done()
+
+			describe 'when projectInvite.create produces an error', ->
+
+				beforeEach ->
+					@notification.create.callsArgWith(0, new Error('woops'))
+
+				it 'should produce an error', (done) ->
+					@call (err) =>
+						expect(err).to.be.instanceof Error
+						done()
+
+		describe 'when the user does not exist', ->
+
+			beforeEach ->
+				@UserGetter.getUser = sinon.stub().callsArgWith(2, null, null)
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.oneOf [null, undefined]
+					done()
+
+			it 'should call getUser', (done) ->
+				@call (err) =>
+					@UserGetter.getUser.callCount.should.equal 1
+					@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
+					done()
+
+			it 'should not call getProject', (done) ->
+				@call (err) =>
+					@ProjectGetter.getProject.callCount.should.equal 0
+					done()
+
+			it 'should not call NotificationsBuilder.projectInvite.create', (done) ->
+				@call (err) =>
+					@NotificationsBuilder.projectInvite.callCount.should.equal 0
+					@notification.create.callCount.should.equal 0
+					done()
+
+		describe 'when the getUser produces an error', ->
+
+			beforeEach ->
+				@UserGetter.getUser = sinon.stub().callsArgWith(2, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should call getUser', (done) ->
+				@call (err) =>
+					@UserGetter.getUser.callCount.should.equal 1
+					@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
+					done()
+
+			it 'should not call getProject', (done) ->
+				@call (err) =>
+					@ProjectGetter.getProject.callCount.should.equal 0
+					done()
+
+			it 'should not call NotificationsBuilder.projectInvite.create', (done) ->
+				@call (err) =>
+					@NotificationsBuilder.projectInvite.callCount.should.equal 0
+					@notification.create.callCount.should.equal 0
+					done()

+ 21 - 2
services/web/test/UnitTests/coffee/Notifications/NotificationsHandlerTests.coffee

@@ -61,9 +61,10 @@ describe 'NotificationsHandler', ->
 			@messageOpts = {value:12344}
 			@templateKey = "renderThisHtml"
 			@expiry = null
+			@forceCreate = false
 
 		it "should post the message over", (done)->
-			@handler.createNotification user_id, @key, @templateKey, @messageOpts, @expiry, =>
+			@handler.createNotification user_id, @key, @templateKey, @messageOpts, @expiry, @forceCreate, =>
 				args = @request.args[0][0]
 				args.uri.should.equal "#{notificationUrl}/user/#{user_id}"
 				args.timeout.should.equal 1000
@@ -77,9 +78,10 @@ describe 'NotificationsHandler', ->
 				@messageOpts = {value:12344}
 				@templateKey = "renderThisHtml"
 				@expiry = new Date()
+				@forceCreate = false
 
 			it 'should post the message over with expiry field', (done) ->
-				@handler.createNotification user_id, @key, @templateKey, @messageOpts, @expiry, =>
+				@handler.createNotification user_id, @key, @templateKey, @messageOpts, @expiry, @forceCreate, =>
 					args = @request.args[0][0]
 					args.uri.should.equal "#{notificationUrl}/user/#{user_id}"
 					args.timeout.should.equal 1000
@@ -87,6 +89,23 @@ describe 'NotificationsHandler', ->
 					assert.deepEqual(args.json, expectedJson)
 					done()
 
+		describe 'when forceCreate is true', ->
+			beforeEach ->
+				@key = "some key here"
+				@messageOpts = {value:12344}
+				@templateKey = "renderThisHtml"
+				@expiry = null
+				@forceCreate = true
+
+			it 'should add a forceCreate=true flag to the payload', (done) ->
+				@handler.createNotification user_id, @key, @templateKey, @messageOpts, @expiry, @forceCreate, =>
+					args = @request.args[0][0]
+					args.uri.should.equal "#{notificationUrl}/user/#{user_id}"
+					args.timeout.should.equal 1000
+					expectedJson = {key:@key, templateKey:@templateKey, messageOpts:@messageOpts, forceCreate: @forceCreate}
+					assert.deepEqual(args.json, expectedJson)
+					done()
+
 	describe "markAsReadByKeyOnly", ->
 		beforeEach ->
 			@key = "some key here"