Просмотр исходного кода

Merge branch 'master' into sk-linked-files-from-project

Shane Kilkelly 8 лет назад
Родитель
Сommit
691eec48ba
48 измененных файлов с 1734 добавлено и 650 удалено
  1. 1 1
      services/web/Jenkinsfile
  2. 1 1
      services/web/Makefile
  3. 8 18
      services/web/README.md
  4. 18 0
      services/web/app/coffee/Features/Exports/ExportsController.coffee
  5. 93 0
      services/web/app/coffee/Features/Exports/ExportsHandler.coffee
  6. 2 46
      services/web/app/coffee/Features/Referal/ReferalAllocator.coffee
  7. 44 0
      services/web/app/coffee/Features/Referal/ReferalFeatures.coffee
  8. 83 0
      services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee
  9. 7 0
      services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
  10. 2 2
      services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee
  11. 2 0
      services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee
  12. 8 49
      services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
  13. 4 7
      services/web/app/coffee/Features/Subscription/UserFeaturesUpdater.coffee
  14. 50 0
      services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee
  15. 3 6
      services/web/app/coffee/Features/Uploads/FileTypeManager.coffee
  16. 2 0
      services/web/app/coffee/infrastructure/Features.coffee
  17. 2 0
      services/web/app/coffee/router.coffee
  18. 1 1
      services/web/app/views/contact-us-modal.pug
  19. 34 34
      services/web/app/views/project/editor/pdf.pug
  20. 1 1
      services/web/app/views/project/list/notifications.pug
  21. 5 1
      services/web/config/settings.defaults.coffee
  22. 1 0
      services/web/docker-compose.yml
  23. 1 0
      services/web/public/coffee/ide/editor/EditorManager.coffee
  24. 34 0
      services/web/public/coffee/ide/editor/components/spellMenu.coffee
  25. 31 18
      services/web/public/coffee/ide/editor/directives/aceEditor.coffee
  26. 66 117
      services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/HighlightedWordManager.coffee
  27. 56 0
      services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter.coffee
  28. 85 117
      services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee
  29. 7 0
      services/web/public/stylesheets/app/editor/rich-text.less
  30. 56 0
      services/web/test/acceptance/coffee/ExportsTests.coffee
  31. 4 3
      services/web/test/acceptance/coffee/ProjectInviteTests.coffee
  32. 151 0
      services/web/test/acceptance/coffee/SubscriptionTests.coffee
  33. 12 0
      services/web/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee
  34. 45 0
      services/web/test/acceptance/coffee/helpers/MockV1Api.coffee
  35. 6 0
      services/web/test/acceptance/coffee/helpers/User.coffee
  36. 95 0
      services/web/test/acceptance/config/settings.test.coffee
  37. 39 0
      services/web/test/unit/coffee/Exports/ExportsControllerTests.coffee
  38. 202 0
      services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee
  39. 7 118
      services/web/test/unit/coffee/Referal/ReferalAllocatorTests.coffee
  40. 65 0
      services/web/test/unit/coffee/Referal/ReferalFeaturesTests.coffee
  41. 173 0
      services/web/test/unit/coffee/Subscription/FeaturesUpdaterTests.coffee
  42. 1 0
      services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee
  43. 22 90
      services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee
  44. 3 14
      services/web/test/unit/coffee/Subscription/UserFeaturesUpdaterTests.coffee
  45. 128 0
      services/web/test/unit/coffee/Subscription/V1SusbcriptionManagerTests.coffee
  46. 14 4
      services/web/test/unit/coffee/Uploads/FileTypeManagerTests.coffee
  47. 46 0
      services/web/test/unit_frontend/coffee/ide/editor/aceEditor/spell-check/SpellCheckManagerTests.coffee
  48. 13 2
      services/web/webpack.config.js

+ 1 - 1
services/web/Jenkinsfile

@@ -101,7 +101,7 @@ pipeline {
         }
       }
       steps {
-        sh 'make minify'
+        sh 'WEBPACK_ENV=production make minify'
       }
     }
     

+ 1 - 1
services/web/Makefile

@@ -9,7 +9,7 @@ COFFEE := node_modules/.bin/coffee $(COFFEE_OPTIONS)
 GRUNT := node_modules/.bin/grunt
 APP_COFFEE_FILES := $(shell find app/coffee -name '*.coffee')
 FRONT_END_COFFEE_FILES := $(shell find public/coffee -name '*.coffee')
-TEST_COFFEE_FILES := $(shell find test -name '*.coffee')
+TEST_COFFEE_FILES := $(shell find test/*/coffee -name '*.coffee')
 MODULE_MAIN_COFFEE_FILES := $(shell find modules -type f -wholename '*main/index.coffee')
 MODULE_IDE_COFFEE_FILES := $(shell find modules -type f -wholename '*ide/index.coffee')
 COFFEE_FILES := app.coffee $(APP_COFFEE_FILES) $(FRONT_END_COFFEE_FILES) $(TEST_COFFEE_FILES)

+ 8 - 18
services/web/README.md

@@ -3,11 +3,11 @@ web-sharelatex
 
 web-sharelatex is the front-end web service of the open-source web-based collaborative LaTeX editor,
 [ShareLaTeX](https://www.sharelatex.com).
-It serves all the HTML pages, CSS and javascript to the client. web-sharelatex also contains 
+It serves all the HTML pages, CSS and javascript to the client. web-sharelatex also contains
 a lot of logic around creating and editing projects, and account management.
 
 
-The rest of the ShareLaTeX stack, along with information about contributing can be found in the 
+The rest of the ShareLaTeX stack, along with information about contributing can be found in the
 [sharelatex/sharelatex](https://github.com/sharelatex/sharelatex) repository.
 
 Build process
@@ -20,7 +20,7 @@ Image processing tasks are commented out in the gruntfile and the needed package
 New Docker-based build process
 ------------------------------
 
-Note that the Grunt workflow from above should still work, but we are transitioning to a 
+Note that the Grunt workflow from above should still work, but we are transitioning to a
 Docker based testing workflow, which is documented below:
 
 ### Running the app
@@ -59,19 +59,18 @@ Acceptance tests are run against a live service, which runs in the `acceptance_t
 To run the tests out-of-the-box, the makefile defines:
 
 ```
-make install # Only needs running once, or when npm packages are updated
-make acceptance_test
+make test_acceptance
 ```
 
 However, during development it is often useful to leave the service running for rapid iteration on the acceptance tests. This can be done with:
 
 ```
-make acceptance_test_start_service
-make acceptance_test_run # Run as many times as needed during development
-make acceptance_test_stop_service
+make test_acceptance_app_start_service
+make test_acceptance_app_run # Run as many times as needed during development
+make test_acceptance_app_stop_service
 ```
 
-`make acceptance_test` just runs these three commands in sequence.
+`make test_acceptance` just runs these three commands in sequence and then runs `make test_acceptance_modules` which performs the tests for each module in the `modules` directory. (Note that there is not currently an equivalent to the `-start` / `-run` x _n_ / `-stop` series for modules.)
 
 During development it is often useful to only run a subset of tests, which can be configured with arguments to the mocha CLI:
 
@@ -111,12 +110,3 @@ We gratefully acknowledge [IconShock](http://www.iconshock.com) for use of the i
 in the `public/img/iconshock` directory found via
 [findicons.com](http://findicons.com/icon/498089/height?id=526085#)
 
-
-## Acceptance Tests
-
-To run the Acceptance tests:
-
-- set `allowPublicAccess` to true, either in the configuration file,
-  or by setting the environment variable `SHARELATEX_ALLOW_PUBLIC_ACCESS` to `true`
-- start the server (`grunt`)
-- in a separate terminal, run `grunt test:acceptance`

+ 18 - 0
services/web/app/coffee/Features/Exports/ExportsController.coffee

@@ -0,0 +1,18 @@
+ExportsHandler = require("./ExportsHandler")
+AuthenticationController = require("../Authentication/AuthenticationController")
+logger = require("logger-sharelatex")
+
+module.exports =
+
+	exportProject: (req, res) ->
+		{project_id, brand_variation_id} = req.params
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		ExportsHandler.exportProject project_id, user_id, brand_variation_id, (err, export_data) ->
+			logger.log 
+				user_id:user_id
+				project_id: project_id
+				brand_variation_id:brand_variation_id
+				export_v1_id:export_data.v1_id
+				"exported project"
+			res.send export_v1_id: export_data.v1_id
+

+ 93 - 0
services/web/app/coffee/Features/Exports/ExportsHandler.coffee

@@ -0,0 +1,93 @@
+ProjectGetter = require('../Project/ProjectGetter')
+ProjectLocator = require('../Project/ProjectLocator')
+UserGetter = require('../User/UserGetter')
+logger = require('logger-sharelatex')
+settings = require 'settings-sharelatex'
+async = require 'async'
+request = require 'request'
+request = request.defaults()
+settings = require 'settings-sharelatex'
+
+module.exports = ExportsHandler = self =
+
+	exportProject: (project_id, user_id, brand_variation_id, callback=(error, export_data) ->) ->
+		self._buildExport project_id, user_id, brand_variation_id, (err, export_data) ->
+			return callback(err) if err?
+			self._requestExport export_data, (err, export_v1_id) ->
+				return callback(err) if err?
+				export_data.v1_id = export_v1_id
+				# TODO: possibly store the export data in Mongo
+				callback null, export_data
+
+	_buildExport: (project_id, user_id, brand_variation_id, callback=(err, export_data) ->) ->
+		jobs =
+			project: (cb) ->
+				ProjectGetter.getProject project_id, cb
+			# TODO: when we update async, signature will change from (cb, results) to (results, cb)
+			rootDoc: [ 'project', (cb, results) ->
+				ProjectLocator.findRootDoc {project: results.project, project_id: project_id}, cb
+			]
+			user: (cb) ->
+				UserGetter.getUser user_id, {first_name: 1, last_name: 1, email: 1}, cb
+			historyVersion: (cb) ->
+				self._requestVersion project_id, cb
+
+		async.auto jobs, (err, results) ->
+			if err?
+				logger.err err:err, project_id:project_id, user_id:user_id, brand_variation_id:brand_variation_id, "error building project export"
+				return callback(err)
+
+			{project, rootDoc, user, historyVersion} = results
+			if !rootDoc[1]?
+				err = new Error("cannot export project without root doc")
+				logger.err err:err, project_id: project_id
+				return callback(err)
+
+			export_data =
+				project:
+					id: project_id
+					rootDocPath: rootDoc[1]?.fileSystem
+					historyId: project.overleaf?.history?.id
+					historyVersion: historyVersion
+				user:
+					id: user_id
+					firstName: user.first_name
+					lastName: user.last_name
+					email: user.email
+					orcidId: null # until v2 gets ORCID
+				destination:
+					brandVariationId: brand_variation_id
+				options:
+					callbackUrl: null # for now, until we want v1 to call us back
+			callback null, export_data
+
+	_requestExport: (export_data, callback=(err, export_v1_id) ->) ->
+		request.post {
+			url: "#{settings.apis.v1.url}/api/v1/sharelatex/exports"
+			auth: {user: settings.apis.v1.user, pass: settings.apis.v1.pass }
+			json: export_data
+		}, (err, res, body) ->
+			if err?
+				logger.err err:err, export:export_data, "error making request to v1 export"
+				callback err
+			else if 200 <= res.statusCode < 300
+				callback null, body.exportId
+			else
+				err = new Error("v1 export returned a failure status code: #{res.statusCode}")
+				logger.err err:err, export:export_data, "v1 export returned failure status code: #{res.statusCode}"
+				callback err
+
+	_requestVersion: (project_id, callback=(err, export_v1_id) ->) ->
+		request.get {
+			url: "#{settings.apis.project_history.url}/project/#{project_id}/version"
+			json: true
+		}, (err, res, body) ->
+			if err?
+				logger.err err:err, project_id:project_id, "error making request to project history"
+				callback err
+			else if res.statusCode >= 200 and res.statusCode < 300
+				callback null, body.version
+			else
+				err = new Error("project history version returned a failure status code: #{res.statusCode}")
+				logger.err err:err, project_id:project_id, "project history version returned failure status code: #{res.statusCode}"
+				callback err

+ 2 - 46
services/web/app/coffee/Features/Referal/ReferalAllocator.coffee

@@ -1,8 +1,8 @@
 _ = require("underscore")
 logger = require('logger-sharelatex')
 User = require('../../models/User').User
-SubscriptionLocator = require "../Subscription/SubscriptionLocator"
 Settings = require "settings-sharelatex"
+FeaturesUpdater = require "../Subscription/FeaturesUpdater"
 
 module.exports = ReferalAllocator =
 	allocate: (referal_id, new_user_id, referal_source, referal_medium, callback = ->)->
@@ -25,50 +25,6 @@ module.exports = ReferalAllocator =
 					if err?
 						logger.err err:err, referal_id:referal_id, new_user_id:new_user_id, "something went wrong allocating referal"
 						return callback(err)
-					ReferalAllocator.assignBonus user._id, callback
+					FeaturesUpdater.refreshFeatures user._id, callback
 			else
 				callback()
-
-
-
-	assignBonus: (user_id, callback = (error) ->) ->
-		query = _id: user_id
-		User.findOne query, (error, user) ->
-			return callback(error) if error
-			return callback(new Error("user not found #{user_id} for assignBonus")) if !user?
-			logger.log user_id: user_id, refered_user_count: user.refered_user_count, "assigning bonus"
-			if user.refered_user_count? and user.refered_user_count > 0
-				newFeatures = ReferalAllocator._calculateFeatures(user)
-				if _.isEqual newFeatures, user.features
-					return callback()
-				User.update query, { $set: features: newFeatures }, callback
-			else
-				callback()
-
-	_calculateFeatures : (user)->
-		bonusLevel = ReferalAllocator._getBonusLevel(user)
-		currentFeatures = _.clone(user.features) #need to clone because we exend with underscore later
-		betterBonusFeatures = {}
-		_.each Settings.bonus_features["#{bonusLevel}"], (bonusLevel, key)->
-			currentLevel = user?.features?[key]
-			if _.isBoolean(currentLevel) and currentLevel == false
-				betterBonusFeatures[key] = bonusLevel
-
-			if _.isNumber(currentLevel)
-				if currentLevel == -1
-					return
-				bonusIsGreaterThanCurrent = currentLevel < bonusLevel
-				if bonusIsGreaterThanCurrent or bonusLevel == -1
-					betterBonusFeatures[key] = bonusLevel
-		newFeatures = _.extend(currentFeatures, betterBonusFeatures)
-		return newFeatures
-
-
-	_getBonusLevel: (user)->
-		highestBonusLevel = 0
-		_.each _.keys(Settings.bonus_features), (level)->
-			levelIsLessThanUser = level <= user.refered_user_count
-			levelIsMoreThanCurrentHighest = level >= highestBonusLevel
-			if levelIsLessThanUser and levelIsMoreThanCurrentHighest
-				highestBonusLevel = level
-		return highestBonusLevel

+ 44 - 0
services/web/app/coffee/Features/Referal/ReferalFeatures.coffee

@@ -0,0 +1,44 @@
+_ = require("underscore")
+logger = require('logger-sharelatex')
+User = require('../../models/User').User
+Settings = require "settings-sharelatex"
+
+module.exports = ReferalFeatures =
+	getBonusFeatures: (user_id, callback = (error) ->) ->
+		query = _id: user_id
+		User.findOne query, (error, user) ->
+			return callback(error) if error
+			return callback(new Error("user not found #{user_id} for assignBonus")) if !user?
+			logger.log user_id: user_id, refered_user_count: user.refered_user_count, "assigning bonus"
+			if user.refered_user_count? and user.refered_user_count > 0
+				newFeatures = ReferalFeatures._calculateFeatures(user)
+				callback null, newFeatures
+			else
+				callback null, {}
+
+	_calculateFeatures : (user)->
+		bonusLevel = ReferalFeatures._getBonusLevel(user)
+		currentFeatures = _.clone(user.features) #need to clone because we exend with underscore later
+		betterBonusFeatures = {}
+		_.each Settings.bonus_features["#{bonusLevel}"], (bonusLevel, key)->
+			currentLevel = user?.features?[key]
+			if _.isBoolean(currentLevel) and currentLevel == false
+				betterBonusFeatures[key] = bonusLevel
+
+			if _.isNumber(currentLevel)
+				if currentLevel == -1
+					return
+				bonusIsGreaterThanCurrent = currentLevel < bonusLevel
+				if bonusIsGreaterThanCurrent or bonusLevel == -1
+					betterBonusFeatures[key] = bonusLevel
+		newFeatures = _.extend(currentFeatures, betterBonusFeatures)
+		return newFeatures
+
+	_getBonusLevel: (user)->
+		highestBonusLevel = 0
+		_.each _.keys(Settings.bonus_features), (level)->
+			levelIsLessThanUser = level <= user.refered_user_count
+			levelIsMoreThanCurrentHighest = level >= highestBonusLevel
+			if levelIsLessThanUser and levelIsMoreThanCurrentHighest
+				highestBonusLevel = level
+		return highestBonusLevel

+ 83 - 0
services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee

@@ -0,0 +1,83 @@
+async = require("async")
+PlansLocator = require("./PlansLocator")
+_ = require("underscore")
+SubscriptionLocator = require("./SubscriptionLocator")
+UserFeaturesUpdater = require("./UserFeaturesUpdater")
+Settings = require("settings-sharelatex")
+logger = require("logger-sharelatex")
+ReferalFeatures = require("../Referal/ReferalFeatures")
+V1SubscriptionManager = require("./V1SubscriptionManager")
+
+oneMonthInSeconds = 60 * 60 * 24 * 30
+
+module.exports = FeaturesUpdater =
+	refreshFeatures: (user_id, callback)->
+		jobs =
+			individualFeatures: (cb) -> FeaturesUpdater._getIndividualFeatures user_id, cb
+			groupFeatureSets:   (cb) -> FeaturesUpdater._getGroupFeatureSets user_id, cb
+			v1Features:         (cb) -> FeaturesUpdater._getV1Features user_id, cb
+			bonusFeatures:      (cb) -> ReferalFeatures.getBonusFeatures user_id, cb
+		async.series jobs, (err, results)->
+			if err?
+				logger.err err:err, user_id:user_id,
+					"error getting subscription or group for refreshFeatures"
+				return callback(err)
+
+			{individualFeatures, groupFeatureSets, v1Features, bonusFeatures} = results
+			logger.log {user_id, individualFeatures, groupFeatureSets, v1Features, bonusFeatures}, 'merging user features'
+			featureSets = groupFeatureSets.concat [individualFeatures, v1Features, bonusFeatures]
+			features = _.reduce(featureSets, FeaturesUpdater._mergeFeatures, Settings.defaultFeatures)
+
+			logger.log {user_id, features}, 'updating user features'
+			UserFeaturesUpdater.updateFeatures user_id, features, callback
+
+	_getIndividualFeatures: (user_id, callback = (error, features = {}) ->) ->
+		SubscriptionLocator.getUsersSubscription user_id, (err, sub)->
+			callback err, FeaturesUpdater._subscriptionToFeatures(sub)
+
+	_getGroupFeatureSets: (user_id, callback = (error, featureSets = []) ->) ->
+		SubscriptionLocator.getGroupSubscriptionsMemberOf user_id, (err, subs) ->
+			callback err, (subs or []).map FeaturesUpdater._subscriptionToFeatures
+
+	_getV1Features: (user_id, callback = (error, features = {}) ->) ->
+		V1SubscriptionManager.getPlanCodeFromV1 user_id, (err, planCode) ->
+			callback err, FeaturesUpdater._planCodeToFeatures(planCode)
+
+	_mergeFeatures: (featuresA, featuresB) ->
+		features = Object.assign({}, featuresA)
+		for key, value of featuresB
+			# Special merging logic for non-boolean features
+			if key == 'compileGroup'
+				if features['compileGroup'] == 'priority' or featuresB['compileGroup'] == 'priority'
+					features['compileGroup'] = 'priority'
+				else
+					features['compileGroup'] = 'standard'
+			else if key == 'collaborators'
+				if features['collaborators'] == -1 or featuresB['collaborators'] == -1
+					features['collaborators'] = -1
+				else
+					features['collaborators'] = Math.max(
+						features['collaborators'] or 0,
+						featuresB['collaborators'] or 0
+					)
+			else if key == 'compileTimeout'
+				features['compileTimeout'] = Math.max(
+					features['compileTimeout'] or 0,
+					featuresB['compileTimeout'] or 0
+				)
+			else
+				# Boolean keys, true is better
+				features[key] = features[key] or featuresB[key]
+		return features
+
+	_subscriptionToFeatures: (subscription) ->
+		FeaturesUpdater._planCodeToFeatures(subscription?.planCode)
+
+	_planCodeToFeatures: (planCode) ->
+		if !planCode?
+			return {}
+		plan = PlansLocator.findLocalPlanInSettings planCode
+		if !plan?
+			return {}
+		else
+			return plan.features

+ 7 - 0
services/web/app/coffee/Features/Subscription/SubscriptionController.coffee

@@ -9,6 +9,7 @@ logger     = require('logger-sharelatex')
 GeoIpLookup = require("../../infrastructure/GeoIpLookup")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
 UserGetter = require "../User/UserGetter"
+FeaturesUpdater = require './FeaturesUpdater'
 
 module.exports = SubscriptionController =
 
@@ -237,3 +238,9 @@ module.exports = SubscriptionController =
 				return next(error) if error?
 				req.body = body
 				next()
+
+	refreshUserFeatures: (req, res, next) ->
+		{user_id} = req.params
+		FeaturesUpdater.refreshFeatures user_id, (error) ->
+			return next(error) if error?
+			res.sendStatus 200

+ 2 - 2
services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee

@@ -28,8 +28,8 @@ module.exports =
 	getSubscriptionByMemberIdAndId: (user_id, subscription_id, callback)->
 		Subscription.findOne {member_ids: user_id, _id:subscription_id}, {_id:1}, callback
 
-	getGroupSubscriptionMemberOf: (user_id, callback)->
-		Subscription.findOne {member_ids: user_id}, {_id:1, planCode:1}, callback
+	getGroupSubscriptionsMemberOf: (user_id, callback)->
+		Subscription.find {member_ids: user_id}, {_id:1, planCode:1}, callback
 
 	getGroupsWithEmailInvite: (email, callback) ->
 		Subscription.find { invited_emails: email }, callback

+ 2 - 0
services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee

@@ -46,4 +46,6 @@ module.exports =
 		webRouter.get "/user/subscription/upgrade-annual",  AuthenticationController.requireLogin(), SubscriptionController.renderUpgradeToAnnualPlanPage
 		webRouter.post "/user/subscription/upgrade-annual",  AuthenticationController.requireLogin(), SubscriptionController.processUpgradeToAnnualPlan
 
+		# Currently used in acceptance tests only, as a way to trigger the syncing logic
+		publicApiRouter.post "/user/:user_id/features/sync", AuthenticationController.httpAuth, SubscriptionController.refreshUserFeatures
 

+ 8 - 49
services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee

@@ -2,26 +2,26 @@ async = require("async")
 _ = require("underscore")
 Subscription = require('../../models/Subscription').Subscription
 SubscriptionLocator = require("./SubscriptionLocator")
-UserFeaturesUpdater = require("./UserFeaturesUpdater")
 PlansLocator = require("./PlansLocator")
 Settings = require("settings-sharelatex")
 logger = require("logger-sharelatex")
-ObjectId = require('mongoose').Types.ObjectId	
-ReferalAllocator = require("../Referal/ReferalAllocator")
+ObjectId = require('mongoose').Types.ObjectId
+FeaturesUpdater = require('./FeaturesUpdater')
 
 oneMonthInSeconds = 60 * 60 * 24 * 30
 
 module.exports = SubscriptionUpdater =
-
 	syncSubscription: (recurlySubscription, adminUser_id, callback) ->
 		logger.log adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "syncSubscription, creating new if subscription does not exist"
 		SubscriptionLocator.getUsersSubscription adminUser_id, (err, subscription)->
+			return callback(err) if err?
 			if subscription?
 				logger.log  adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "subscription does exist"
 				SubscriptionUpdater._updateSubscriptionFromRecurly recurlySubscription, subscription, callback
 			else
 				logger.log  adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "subscription does not exist, creating a new one"
 				SubscriptionUpdater._createNewSubscription adminUser_id, (err, subscription)->
+					return callback(err) if err?
 					SubscriptionUpdater._updateSubscriptionFromRecurly recurlySubscription, subscription, callback
 
 	addUserToGroup: (adminUser_id, user_id, callback)->
@@ -34,7 +34,7 @@ module.exports = SubscriptionUpdater =
 			if err?
 				logger.err err:err, searchOps:searchOps, insertOperation:insertOperation, "error findy and modify add user to group"
 				return callback(err)
-			UserFeaturesUpdater.updateFeatures user_id, subscription.planCode, callback
+			FeaturesUpdater.refreshFeatures user_id, callback
 	
 	addEmailInviteToGroup: (adminUser_id, email, callback) ->
 		logger.log {adminUser_id, email}, "adding email into mongo subscription"
@@ -53,7 +53,7 @@ module.exports = SubscriptionUpdater =
 			if err?
 				logger.err err:err, searchOps:searchOps, removeOperation:removeOperation, "error removing user from group"
 				return callback(err)
-			SubscriptionUpdater._setUsersMinimumFeatures user_id, callback
+			FeaturesUpdater.refreshFeatures user_id, callback
 
 	removeEmailInviteFromGroup: (adminUser_id, email, callback)->
 		Subscription.update {
@@ -62,9 +62,6 @@ module.exports = SubscriptionUpdater =
 			invited_emails: email
 		}, callback
 
-	refreshSubscription: (user_id, callback=(err)->) ->
-		SubscriptionUpdater._setUsersMinimumFeatures user_id, callback
-
 	deleteSubscription: (subscription_id, callback = (error) ->) ->
 		SubscriptionLocator.getSubscription subscription_id, (err, subscription) ->
 			return callback(err) if err?
@@ -72,7 +69,7 @@ module.exports = SubscriptionUpdater =
 			logger.log {subscription_id, affected_user_ids}, "deleting subscription and downgrading users"
 			Subscription.remove {_id: ObjectId(subscription_id)}, (err) ->
 				return callback(err) if err?
-				async.mapSeries affected_user_ids, SubscriptionUpdater._setUsersMinimumFeatures, callback
+				async.mapSeries affected_user_ids, FeaturesUpdater.refreshFeatures, callback
 
 	_createNewSubscription: (adminUser_id, callback)->
 		logger.log adminUser_id:adminUser_id, "creating new subscription"
@@ -100,43 +97,5 @@ module.exports = SubscriptionUpdater =
 			allIds = _.union subscription.member_ids, [subscription.admin_id]
 			jobs = allIds.map (user_id)->
 				return (cb)->
-					SubscriptionUpdater._setUsersMinimumFeatures user_id, cb
+					FeaturesUpdater.refreshFeatures user_id, cb
 			async.series jobs, callback
-
-	_setUsersMinimumFeatures: (user_id, callback)->
-		jobs =
-			subscription: (cb)->
-				SubscriptionLocator.getUsersSubscription user_id, cb
-			groupSubscription: (cb)->
-				SubscriptionLocator.getGroupSubscriptionMemberOf user_id, cb
-			v1PlanCode: (cb) ->
-				Modules = require '../../infrastructure/Modules'
-				Modules.hooks.fire 'getV1PlanCode', user_id, (err, results) ->
-					cb(err, results?[0] || null)
-		async.series jobs, (err, results)->
-			if err?
-				logger.err err:err, user_id:user_id,
-					"error getting subscription or group for _setUsersMinimumFeatures"
-				return callback(err)
-			{subscription, groupSubscription, v1PlanCode} = results
-			# Group Subscription
-			if groupSubscription? and groupSubscription.planCode?
-				logger.log user_id:user_id, "using group which user is memor of for features"
-				UserFeaturesUpdater.updateFeatures user_id, groupSubscription.planCode, callback
-			# Personal Subscription
-			else if subscription? and subscription.planCode? and subscription.planCode != Settings.defaultPlanCode
-				logger.log user_id:user_id, "using users subscription plan code for features"
-				UserFeaturesUpdater.updateFeatures user_id, subscription.planCode, callback
-			# V1 Subscription
-			else if v1PlanCode?
-				logger.log user_id: user_id, "using the V1 plan for features"
-				UserFeaturesUpdater.updateFeatures user_id, v1PlanCode, callback
-			# Default
-			else
-				logger.log user_id:user_id, "using default features for user with no subscription or group"
-				UserFeaturesUpdater.updateFeatures user_id, Settings.defaultPlanCode, (err)->
-					if err?
-						logger.err err:err, user_id:user_id, "Error setting minimum user feature"
-						return callback(err)
-					ReferalAllocator.assignBonus user_id, callback
-

+ 4 - 7
services/web/app/coffee/Features/Subscription/UserFeaturesUpdater.coffee

@@ -1,15 +1,12 @@
 logger = require("logger-sharelatex")
 User = require('../../models/User').User
-PlansLocator = require("./PlansLocator")
 
 module.exports =
-	
-	updateFeatures: (user_id, plan_code, callback = (err, features)->)->
+	updateFeatures: (user_id, features, callback = (err, features)->)->
 		conditions = _id:user_id
 		update = {}
-		plan = PlansLocator.findLocalPlanInSettings(plan_code)
-		logger.log user_id:user_id, features:plan.features, plan_code:plan_code, "updating users features"
-		update["features.#{key}"] = value for key, value of plan.features
+		logger.log user_id:user_id, features:features, "updating users features"
+		update["features.#{key}"] = value for key, value of features
 		User.update conditions, update, (err)->
-			callback err, plan.features
+			callback err, features
 

+ 50 - 0
services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee

@@ -0,0 +1,50 @@
+UserGetter = require "../User/UserGetter"
+request = require "request"
+settings = require "settings-sharelatex"
+logger = require "logger-sharelatex"
+
+module.exports = V1SubscriptionManager =
+	# Returned planCode = 'v1_pro' | 'v1_pro_plus' | 'v1_student' | 'v1_free' | null
+	# For this to work, we need plans in settings with plan-codes:
+	#   - 'v1_pro'
+	#   - 'v1_pro_plus'
+	#   - 'v1_student'
+	#   - 'v1_free'
+	getPlanCodeFromV1: (userId, callback=(err, planCode)->) ->
+		logger.log {userId}, "[V1SubscriptionManager] fetching v1 plan for user"
+		UserGetter.getUser userId, {'overleaf.id': 1}, (err, user) ->
+			return callback(err) if err?
+			v1Id = user?.overleaf?.id
+			if !v1Id?
+				logger.log {userId}, "[V1SubscriptionManager] no v1 id found for user"
+				return callback(null, null)
+			V1SubscriptionManager._v1PlanRequest v1Id, (err, body) ->
+				return callback(err) if err?
+				planName = body?.plan_name
+				logger.log {userId, planName, body}, "[V1SubscriptionManager] fetched v1 plan for user"
+				if planName in ['pro', 'pro_plus', 'student', 'free']
+					planName = "v1_#{planName}"
+				else
+					# Throw away 'anonymous', etc as being equivalent to null
+					planName = null
+				return callback(null, planName)
+
+	_v1PlanRequest: (v1Id, callback=(err, body)->) ->
+		if !settings?.apis?.v1
+			return callback null, null
+		request {
+			method: 'GET',
+			url: settings.apis.v1.url +
+				"/api/v1/sharelatex/users/#{v1Id}/plan_code"
+			auth:
+				user: settings.apis.v1.user
+				pass: settings.apis.v1.pass
+				sendImmediately: true
+			json: true,
+			timeout: 5 * 1000
+		}, (error, response, body) ->
+			return callback(error) if error?
+			if 200 <= response.statusCode < 300
+				return callback null, body
+			else
+				return callback new Error("non-success code from v1: #{response.statusCode}")

+ 3 - 6
services/web/app/coffee/Features/Uploads/FileTypeManager.coffee

@@ -3,7 +3,7 @@ Path = require("path")
 
 module.exports = FileTypeManager =
 	TEXT_EXTENSIONS : [
-		"tex", "latex", "sty", "cls", "bst", "bib", "bibtex", "txt", "tikz", "rtex", "md", "asy"
+		"tex", "latex", "sty", "cls", "bst", "bib", "bibtex", "txt", "tikz", "rtex", "md", "asy", "latexmkrc"
 	]
 
 	IGNORE_EXTENSIONS : [
@@ -34,7 +34,7 @@ module.exports = FileTypeManager =
 		extension = parts.slice(-1)[0]
 		if extension?
 			extension = extension.toLowerCase()
-		binaryFile = (@TEXT_EXTENSIONS.indexOf(extension) == -1 or parts.length <= 1)
+		binaryFile = (@TEXT_EXTENSIONS.indexOf(extension) == -1 or parts.length <= 1) and parts[0] != 'latexmkrc'
 		
 		if binaryFile
 			return callback null, true
@@ -52,13 +52,10 @@ module.exports = FileTypeManager =
 		if extension?
 			extension = extension.toLowerCase()
 		ignore = false
-		if name[0] == "."
+		if name[0] == "." and extension != 'latexmkrc'
 			ignore = true
 		if @IGNORE_EXTENSIONS.indexOf(extension) != -1
 			ignore = true
 		if @IGNORE_FILENAMES.indexOf(name) != -1
 			ignore = true
 		callback null, ignore
-
-
-

+ 2 - 0
services/web/app/coffee/infrastructure/Features.coffee

@@ -14,6 +14,8 @@ module.exports = Features =
 				return Settings.enableGithubSync
 			when 'v1-return-message'
 				return Settings.accountMerge? and Settings.overleaf?
+			when 'v2-banner'
+				return Settings.showV2Banner
 			when 'custom-togglers'
 				return Settings.overleaf?
 			when 'templates'

+ 2 - 0
services/web/app/coffee/router.coffee

@@ -26,6 +26,7 @@ HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 FileStoreController = require("./Features/FileStore/FileStoreController")
 HistoryController = require("./Features/History/HistoryController")
+ExportsController = require("./Features/Exports/ExportsController")
 PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
 StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
 ChatController = require("./Features/Chat/ChatController")
@@ -209,6 +210,7 @@ module.exports = class Router
 		webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2
 		privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory
 
+		webRouter.post '/project/:project_id/export/:brand_variation_id', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportProject
 
 		webRouter.get  '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects

+ 1 - 1
services/web/app/views/contact-us-modal.pug

@@ -22,7 +22,7 @@ script(type='text/ng-template', id='supportModalTemplate')
 						tabindex='1',
 						onkeyup='')
 				.contact-suggestions(ng-show="suggestions.length")
-					p.contact-suggestion-label !{translate("kb_suggestions_enquiry", { kbLink: "<a href='learn/kb' target='_blank'>__kb__</a>", kb: translate("knowledge_base") })} 
+					p.contact-suggestion-label !{translate("kb_suggestions_enquiry", { kbLink: "<a href='learn/kb' target='_blank'>" + translate("knowledge_base") + "</a>" })} 
 					ul.contact-suggestion-list
 						li(ng-repeat="suggestion in suggestions")
 							a.contact-suggestion-list-item(ng-href="{{ suggestion.url }}", ng-click="clickSuggestionLink(suggestion.url);" target="_blank")

+ 34 - 34
services/web/app/views/project/editor/pdf.pug

@@ -371,40 +371,40 @@ div.full-size.pdf(ng-controller="PdfController")
 					a.text-info(href="https://www.sharelatex.com/learn/Debugging_Compilation_timeout_errors", target="_blank")
 						| #{translate("learn_how_to_make_documents_compile_quickly")}
 
-			.alert.alert-success(ng-show="pdf.timedout && !hasPremiumCompile")
-				p(ng-if="project.owner._id == user.id")
-					strong #{translate("upgrade_for_faster_compiles")}
-				p(ng-if="project.owner._id != user.id")
-					strong #{translate("ask_proj_owner_to_upgrade_for_faster_compiles")}
-				p #{translate("free_accounts_have_timeout_upgrade_to_increase")}
-				p Plus:
-				div
-					ul.list-unstyled
-						li
-							i.fa.fa-check &nbsp;
-							| #{translate("unlimited_projects")}
-						li
-							i.fa.fa-check &nbsp;
-							| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
-						li
-							i.fa.fa-check &nbsp;
-							| #{translate("full_doc_history")}
-						li
-							i.fa.fa-check &nbsp;
-							| #{translate("sync_to_dropbox")}
-						li
-							i.fa.fa-check &nbsp;
-							| #{translate("sync_to_github")}
-						li
-							i.fa.fa-check &nbsp;
-							|#{translate("compile_larger_projects")}
-				p(ng-controller="FreeTrialModalController", ng-if="project.owner._id == user.id")
-					a.btn.btn-success.row-spaced-small(
-						href
-						ng-class="buttonClass"
-						ng-click="startFreeTrial('compile-timeout')"
-					) #{translate("start_free_trial")}
-
+			if settings.enableSubscriptions
+				.alert.alert-success(ng-show="pdf.timedout && !hasPremiumCompile")
+					p(ng-if="project.owner._id == user.id")
+						strong #{translate("upgrade_for_faster_compiles")}
+					p(ng-if="project.owner._id != user.id")
+						strong #{translate("ask_proj_owner_to_upgrade_for_faster_compiles")}
+					p #{translate("free_accounts_have_timeout_upgrade_to_increase")}
+					p Plus:
+					div
+						ul.list-unstyled
+							li
+								i.fa.fa-check &nbsp;
+								| #{translate("unlimited_projects")}
+							li
+								i.fa.fa-check &nbsp;
+								| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
+							li
+								i.fa.fa-check &nbsp;
+								| #{translate("full_doc_history")}
+							li
+								i.fa.fa-check &nbsp;
+								| #{translate("sync_to_dropbox")}
+							li
+								i.fa.fa-check &nbsp;
+								| #{translate("sync_to_github")}
+							li
+								i.fa.fa-check &nbsp;
+								|#{translate("compile_larger_projects")}
+					p(ng-controller="FreeTrialModalController", ng-if="project.owner._id == user.id")
+						a.btn.btn-success.row-spaced-small(
+							href
+							ng-class="buttonClass"
+							ng-click="startFreeTrial('compile-timeout')"
+						) #{translate("start_free_trial")}
 
 			.alert.alert-danger(ng-show="pdf.autoCompileDisabled")
 				p

+ 1 - 1
services/web/app/views/project/list/notifications.pug

@@ -1,4 +1,4 @@
-if (user.awareOfV2 && !settings.overleaf)
+if hasFeature('v2-banner')
 	.userNotifications
 		ul.list-unstyled.notifications-list(ng-controller="OverleafV2NotificationController", ng-show="visible")
 			li.notification_entry

+ 5 - 1
services/web/config/settings.defaults.coffee

@@ -156,6 +156,10 @@ module.exports = settings =
 			url: process.env['LINKED_URL_PROXY']
 		thirdpartyreferences:
 			url: "http://#{process.env['THIRD_PARTY_REFERENCES_HOST'] or 'localhost'}:3046"
+		v1:
+			url: "http://#{process.env['V1_HOST'] or 'localhost'}:5000"
+			user: 'overleaf'
+			pass: 'password'
 
 	templates:
 		user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2"
@@ -363,7 +367,7 @@ module.exports = settings =
 
 	appName: "ShareLaTeX (Community Edition)"
 	adminEmail: "placeholder@example.com"
-	
+
 	brandPrefix: "" # Set to 'ol-' for overleaf styles
 
 	nav:

+ 1 - 0
services/web/docker-compose.yml

@@ -18,6 +18,7 @@ services:
       ENABLED_LINKED_FILE_TYPES: 'url'
       LINKED_URL_PROXY: 'http://localhost:6543'
       ENABLED_LINKED_FILE_TYPES: 'url,project_file'
+      SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee
     depends_on:
       - redis
       - mongo

+ 1 - 0
services/web/public/coffee/ide/editor/EditorManager.coffee

@@ -1,5 +1,6 @@
 define [
 	"ide/editor/Document"
+	"ide/editor/components/spellMenu"
 	"ide/editor/directives/aceEditor"
 	"ide/editor/directives/toggleSwitch"
 	"ide/editor/controllers/SavingNotificationController"

+ 34 - 0
services/web/public/coffee/ide/editor/components/spellMenu.coffee

@@ -0,0 +1,34 @@
+define ["base"], (App) ->
+  App.component "spellMenu", {
+    bindings: {
+      open: "<"
+      top: "<"
+      left: "<"
+      highlight: "<"
+      replaceWord: "&"
+      learnWord: "&"
+    }
+    template: """
+  <div
+    class="dropdown context-menu spell-check-menu"
+    ng-show="$ctrl.open"
+    ng-style="{top: $ctrl.top, left: $ctrl.left}"
+    ng-class="{open: $ctrl.open}"
+  >
+    <ul class="dropdown-menu">
+      <li ng-repeat="suggestion in $ctrl.highlight.suggestions | limitTo:8">
+        <a
+          href
+          ng-click="$ctrl.replaceWord({ highlight: $ctrl.highlight, suggestion: suggestion })"
+        >
+          {{ suggestion }}
+        </a>
+      </li>
+      <li class="divider"></li>
+      <li>
+        <a href ng-click="$ctrl.learnWord({ highlight: $ctrl.highlight })">Add to Dictionary</a>
+      </li>
+    </ul>
+  </div>
+    """
+  }

+ 31 - 18
services/web/public/coffee/ide/editor/directives/aceEditor.coffee

@@ -7,6 +7,7 @@ define [
 	"ide/editor/directives/aceEditor/undo/UndoManager"
 	"ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager"
 	"ide/editor/directives/aceEditor/spell-check/SpellCheckManager"
+	"ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter"
 	"ide/editor/directives/aceEditor/highlights/HighlightsManager"
 	"ide/editor/directives/aceEditor/cursor-position/CursorPositionManager"
 	"ide/editor/directives/aceEditor/track-changes/TrackChangesManager"
@@ -15,7 +16,7 @@ define [
 	"ide/graphics/services/graphics"
 	"ide/preamble/services/preamble"
     "ide/files/services/files"
-], (App, Ace, SearchBox, Vim, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager, TrackChangesManager, MetadataManager) ->
+], (App, Ace, SearchBox, Vim, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, SpellCheckAdapter, HighlightsManager, CursorPositionManager, TrackChangesManager, MetadataManager) ->
 	EditSession = ace.require('ace/edit_session').EditSession
 	ModeList = ace.require('ace/ext/modelist')
 	Vim = ace.require('ace/keyboard/vim').Vim
@@ -103,7 +104,8 @@ define [
 
 				if scope.spellCheck # only enable spellcheck when explicitly required
 					spellCheckCache = $cacheFactory.get("spellCheck-#{scope.name}") || $cacheFactory("spellCheck-#{scope.name}", {capacity: 1000})
-					spellCheckManager = new SpellCheckManager(scope, editor, element, spellCheckCache, $http, $q)
+					spellCheckManager = new SpellCheckManager(scope, spellCheckCache, $http, $q, new SpellCheckAdapter(editor))
+
 				undoManager           = new UndoManager(scope, editor, element)
 				highlightsManager     = new HighlightsManager(scope, editor, element)
 				cursorPositionManager = new CursorPositionManager(scope, editor, element, localStorage)
@@ -361,6 +363,23 @@ define [
 						session.setScrollTop(session.getScrollTop() + 1)
 						session.setScrollTop(session.getScrollTop() - 1)
 
+				onSessionChangeForSpellCheck = (e) ->
+					spellCheckManager.onSessionChange()
+					e.oldSession?.getDocument().off "change", spellCheckManager.onChange
+					e.session.getDocument().on "change", spellCheckManager.onChange
+					e.oldSession?.off "changeScrollTop", spellCheckManager.onScroll
+					e.session.on "changeScrollTop", spellCheckManager.onScroll
+
+				initSpellCheck = () ->
+					spellCheckManager.init()
+					editor.on 'changeSession', onSessionChangeForSpellCheck
+					onSessionChangeForSpellCheck({ session: editor.getSession() }) # Force initial setup
+					editor.on 'nativecontextmenu', spellCheckManager.onContextMenu
+
+				tearDownSpellCheck = () ->
+					editor.off 'changeSession', onSessionChangeForSpellCheck
+					editor.off 'nativecontextmenu', spellCheckManager.onContextMenu
+
 				attachToAce = (sharejs_doc) ->
 					lines = sharejs_doc.getSnapshot().split("\n")
 					session = editor.getSession()
@@ -406,6 +425,7 @@ define [
 					editor.initing = false
 					# now ready to edit document
 					editor.setReadOnly(scope.readOnly) # respect the readOnly setting, normally false
+					initSpellCheck()
 
 					resetScrollMargins()
 
@@ -467,6 +487,7 @@ define [
 
 				scope.$on '$destroy', () ->
 					if scope.sharejsDoc?
+						tearDownSpellCheck()
 						detachFromAce(scope.sharejsDoc)
 						session = editor.getSession()
 						session?.destroy()
@@ -488,22 +509,14 @@ define [
 						>Dismiss</a>
 					</div>
 					<div class="ace-editor-body"></div>
-					<div
-						class="dropdown context-menu spell-check-menu"
-						ng-show="spellingMenu.open"
-						ng-style="{top: spellingMenu.top, left: spellingMenu.left}"
-						ng-class="{open: spellingMenu.open}"
-					>
-						<ul class="dropdown-menu">
-							<li ng-repeat="suggestion in spellingMenu.highlight.suggestions | limitTo:8">
-								<a href ng-click="replaceWord(spellingMenu.highlight, suggestion)">{{ suggestion }}</a>
-							</li>
-							<li class="divider"></li>
-							<li>
-								<a href ng-click="learnWord(spellingMenu.highlight)">Add to Dictionary</a>
-							</li>
-						</ul>
-					</div>
+					<spell-menu
+						open="spellMenu.open"
+						top="spellMenu.top"
+						left="spellMenu.left"
+						highlight="spellMenu.highlight"
+						replace-word="replaceWord(highlight, suggestion)"
+						learn-word="learnWord(highlight)"
+					></spell-menu>
 					<div
 						class="annotation-label"
 						ng-show="annotationLabel.show"

+ 66 - 117
services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/HighlightedWordManager.coffee

@@ -4,144 +4,93 @@ define [
 	Range = ace.require("ace/range").Range
 
 	class Highlight
-		constructor: (options) ->
-			@row = options.row
-			@column = options.column
+		constructor: (@markerId, @range, options) ->
 			@word = options.word
 			@suggestions = options.suggestions
 
 	class HighlightedWordManager
 		constructor: (@editor) ->
 			@reset()
-		
+
 		reset: () ->
-			@highlights = rows: []
-
-		addHighlight: (highlight) ->
-			unless highlight instanceof Highlight
-				highlight = new Highlight(highlight)
-			range = new Range(
-				highlight.row, highlight.column,
-				highlight.row, highlight.column + highlight.word.length
-			)
-			highlight.markerId = @editor.getSession().addMarker range, "spelling-highlight", 'text', false
-			@highlights.rows[highlight.row] ||= []
-			@highlights.rows[highlight.row].push highlight
+			@highlights?.forEach (highlight) =>
+				@editor.getSession().removeMarker(highlight.markerId)
+			@highlights = []
+
+		addHighlight: (options) ->
+			session = @editor.getSession()
+			doc = session.getDocument()
+			# Set up Range that will automatically update it's positions when the
+			# document changes
+			range = new Range()
+			range.start = doc.createAnchor({
+				row: options.row,
+				column: options.column
+			})
+			range.end = doc.createAnchor({
+				row: options.row,
+				column: options.column + options.word.length
+			})
+			# Prevent range from adding newly typed characters to the end of the word.
+			# This makes it appear as if the spelling error continues to the next word
+			# even after a space
+			range.end.$insertRight = true
+
+			markerId = session.addMarker range, "spelling-highlight", 'text', false
+
+			@highlights.push new Highlight(markerId, range, options)
 
 		removeHighlight: (highlight) ->
 			@editor.getSession().removeMarker(highlight.markerId)
-			for h, i in @highlights.rows[highlight.row]
-				if h == highlight
-					@highlights.rows[highlight.row].splice(i, 1)
+			@highlights = @highlights.filter (hl) ->
+				hl != highlight
 
 		removeWord: (word) ->
-			toRemove = []
-			for row in @highlights.rows
-				for highlight in (row || [])
-					if highlight.word == word
-						toRemove.push(highlight)
-			for highlight in toRemove
-				@removeHighlight highlight
+			@highlights.filter (highlight) ->
+				highlight.word == word
+			.forEach (highlight) =>
+				@removeHighlight(highlight)
 
-		moveHighlight: (highlight, position) ->
-			@removeHighlight highlight
-			highlight.row = position.row
-			highlight.column = position.column
-			@addHighlight highlight
-
-		clearRows: (from, to) ->
-			from ||= 0
-			to ||= @highlights.rows.length - 1
-			for row in @highlights.rows.slice(from, to + 1)
-				for highlight in (row || []).slice(0)
-					@removeHighlight highlight
-
-		insertRows: (offset, number) ->
-			# rows are inserted after offset. i.e. offset row is not modified
-			affectedHighlights = []
-			for row in @highlights.rows.slice(offset)
-				affectedHighlights.push(highlight) for highlight in (row || [])
-			for highlight in affectedHighlights
-				@moveHighlight highlight,
-					row: highlight.row + number
-					column: highlight.column
-
-		removeRows: (offset, number) ->
-			# offset is the first row to delete
-			affectedHighlights = []
-			for row in @highlights.rows.slice(offset)
-				affectedHighlights.push(highlight) for highlight in (row || [])
-			for highlight in affectedHighlights
-				if highlight.row >= offset + number
-					@moveHighlight highlight,
-						row: highlight.row - number
-						column: highlight.column
-				else
-					@removeHighlight highlight
+		clearRow: (row) ->
+			@highlights.filter (highlight) ->
+				highlight.range.start.row == row
+			.forEach (highlight) =>
+				@removeHighlight(highlight)
 
 		findHighlightWithinRange: (range) ->
-			rows = @highlights.rows.slice(range.start.row, range.end.row + 1)
-			for row in rows
-				for highlight in (row || [])
-					if @_doesHighlightOverlapRange(highlight, range.start, range.end)
-						return highlight
-			return null
-
-		applyChange: (change) ->
-			start = change.start
-			end = change.end
-			if change.action == "insert"
-				if start.row != end.row
-					rowsAdded = end.row - start.row
-					@insertRows start.row + 1, rowsAdded
-				# make a copy since we're going to modify in place
-				oldHighlights = (@highlights.rows[start.row] || []).slice(0)
-				for highlight in oldHighlights
-					if highlight.column > start.column
-						# insertion was fully before this highlight
-						@moveHighlight highlight,
-							row: end.row
-							column: highlight.column + (end.column - start.column)
-					else if highlight.column + highlight.word.length >= start.column
-						# insertion was inside this highlight
-						@removeHighlight highlight
-
-			else if change.action == "remove"
-				if start.row == end.row
-					oldHighlights = (@highlights.rows[start.row] || []).slice(0)
-				else
-					rowsRemoved = end.row - start.row
-					oldHighlights =
-						(@highlights.rows[start.row] || []).concat(
-							(@highlights.rows[end.row] || [])
-						)
-					@removeRows start.row + 1, rowsRemoved
-
-				for highlight in oldHighlights
-					if @_doesHighlightOverlapRange highlight, start, end
-						@removeHighlight highlight
-					else if @_isHighlightAfterRange highlight, start, end
-						@moveHighlight highlight,
-							row: start.row
-							column: highlight.column - (end.column - start.column)
+			_.find @highlights, (highlight) =>
+				@_doesHighlightOverlapRange highlight, range.start, range.end
 
 		_doesHighlightOverlapRange: (highlight, start, end) ->
+			highlightRow = highlight.range.start.row
+			highlightStartColumn = highlight.range.start.column
+			highlightEndColumn = highlight.range.end.column
+
 			highlightIsAllBeforeRange =
-				highlight.row < start.row or
-				(highlight.row == start.row and highlight.column + highlight.word.length <= start.column)
+				highlightRow < start.row or
+				(highlightRow == start.row and highlightEndColumn <= start.column)
 			highlightIsAllAfterRange =
-				highlight.row > end.row or
-				(highlight.row == end.row and highlight.column >= end.column)
+				highlightRow > end.row or
+				(highlightRow == end.row and highlightStartColumn >= end.column)
 			!(highlightIsAllBeforeRange or highlightIsAllAfterRange)
 
-		_isHighlightAfterRange: (highlight, start, end) ->
-			return true if highlight.row > end.row
-			return false if highlight.row < end.row
-			highlight.column >= end.column
-			
-
+		clearHighlightTouchingRange: (range) ->
+			highlight = _.find @highlights, (hl) =>
+				@_doesHighlightTouchRange hl, range.start, range.end
+			if highlight
+				@removeHighlight highlight
 
-			
+		_doesHighlightTouchRange: (highlight, start, end) ->
+			highlightRow = highlight.range.start.row
+			highlightStartColumn = highlight.range.start.column
+			highlightEndColumn = highlight.range.end.column
 
-	
+			rangeStartIsWithinHighlight =
+				highlightStartColumn <= start.column and
+				highlightEndColumn >= start.column
+			rangeEndIsWithinHighlight =
+				highlightStartColumn <= end.column and
+				highlightEndColumn >= end.column
 
+			highlightRow == start.row and
+				(rangeStartIsWithinHighlight or rangeEndIsWithinHighlight)

+ 56 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter.coffee

@@ -0,0 +1,56 @@
+define [
+	"ace/ace"
+	"ide/editor/directives/aceEditor/spell-check/HighlightedWordManager"
+], (Ace, HighlightedWordManager) ->
+	Range = ace.require('ace/range').Range
+
+	class SpellCheckAdapter
+		constructor: (@editor) ->
+			@highlightedWordManager = new HighlightedWordManager(@editor)
+
+		getLines: () ->
+			@editor.getValue().split('\n')
+
+		normalizeChangeEvent: (e) -> e
+
+		getCoordsFromContextMenuEvent: (e) ->
+			e.domEvent.stopPropagation()
+			return {
+				x: e.domEvent.clientX,
+				y: e.domEvent.clientY
+			}
+
+		preventContextMenuEventDefault: (e) ->
+			e.domEvent.preventDefault()
+
+		getHighlightFromCoords: (coords) ->
+			position = @editor.renderer.screenToTextCoordinates(coords.x, coords.y)
+			@highlightedWordManager.findHighlightWithinRange({
+				start: position
+				end: position
+			})
+
+		selectHighlightedWord: (highlight) ->
+			row = highlight.range.start.row
+			startColumn = highlight.range.start.column
+			endColumn = highlight.range.end.column
+
+			@editor.getSession().getSelection().setSelectionRange(
+				new Range(
+					row, startColumn,
+					row, endColumn
+				)
+			)
+
+		replaceWord: (highlight, newWord) =>
+			row = highlight.range.start.row
+			startColumn = highlight.range.start.column
+			endColumn = highlight.range.end.column
+
+			@editor.getSession().replace(new Range(
+				row, startColumn,
+				row, endColumn
+			), newWord)
+
+			# Bring editor back into focus after clicking on suggestion
+			@editor.focus()

+ 85 - 117
services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee

@@ -1,129 +1,88 @@
-define [
-	"ide/editor/directives/aceEditor/spell-check/HighlightedWordManager"
-	"ace/ace"
-], (HighlightedWordManager) ->
-	Range = ace.require("ace/range").Range
-
+define [], () ->
 	class SpellCheckManager
-		constructor: (@$scope, @editor, @element, @cache, @$http, @$q) ->
-			$(document.body).append @element.find(".spell-check-menu")
+		constructor: (@$scope, @cache, @$http, @$q, @adapter) ->
+			@$scope.spellMenu = {
+				open: false
+				top: '0px'
+				left: '0px'
+				suggestions: []
+			}
 			@inProgressRequest = null
 			@updatedLines = []
-			@highlightedWordManager = new HighlightedWordManager(@editor)
 
-			@$scope.$watch "spellCheckLanguage", (language, oldLanguage) =>
+			@$scope.$watch 'spellCheckLanguage', (language, oldLanguage) =>
 				if language != oldLanguage and oldLanguage?
 					@runFullCheck()
 
-			onChange = (e) =>
-				@runCheckOnChange(e)
-				
-			onScroll = () =>
-				@closeContextMenu()
+			@$scope.replaceWord = @adapter.replaceWord
+			@$scope.learnWord = @learnWord
 
-			@editor.on "changeSession", (e) =>
-				@highlightedWordManager.reset()
-				if @inProgressRequest?
-					@inProgressRequest.abort()
+			$(document).on 'click', (e) =>
+				@closeContextMenu() if e.which != 3 # Ignore if right click
+				return true
 
-				if @$scope.spellCheckEnabled and @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
-					@runSpellCheckSoon(200)
+		init: () ->
+			@updatedLines = Array(@adapter.getLines().length).fill(true)
+			@runSpellCheckSoon(200) if @isSpellCheckEnabled()
 
-				e.oldSession?.getDocument().off "change", onChange
-				e.session.getDocument().on "change", onChange
-				
-				e.oldSession?.off "changeScrollTop", onScroll
-				e.session.on "changeScrollTop", onScroll
+		isSpellCheckEnabled: () ->
+			return !!(
+				@$scope.spellCheck and
+				@$scope.spellCheckLanguage and
+				@$scope.spellCheckLanguage != ''
+			)
 
-			@$scope.spellingMenu = {left: '0px', top: '0px'}
+		onChange: (e) =>
+			if @isSpellCheckEnabled()
+				@markLinesAsUpdated(@adapter.normalizeChangeEvent(e))
 
-			@editor.on "nativecontextmenu", (e) =>
-				e.domEvent.stopPropagation();
-				@closeContextMenu(e.domEvent)
-				@openContextMenu(e.domEvent)
+				@adapter.highlightedWordManager.clearHighlightTouchingRange(e)
 
-			$(document).on "click", (e) =>
-				if e.which != 3 # Ignore if this was a right click
-					@closeContextMenu(e)
-				return true
+				@runSpellCheckSoon()
 
-			@$scope.replaceWord = (highlight, suggestion) =>
-				@replaceWord(highlight, suggestion)
+		onSessionChange: () =>
+			@adapter.highlightedWordManager.reset()
+			@inProgressRequest.abort() if @inProgressRequest?
 
-			@$scope.learnWord = (highlight) =>
-				@learnWord(highlight)
+			@runSpellCheckSoon(200) if @isSpellCheckEnabled()
 
-		runFullCheck: () ->
-			@highlightedWordManager.clearRows()
-			if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
-				@runSpellCheck()
+		onContextMenu: (e) =>
+			@closeContextMenu()
+			@openContextMenu(e)
 
-		runCheckOnChange: (e) ->
-			if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
-				@highlightedWordManager.applyChange(e)
-				@markLinesAsUpdated(e)
-				@runSpellCheckSoon()
+		onScroll: () => @closeContextMenu()
 
 		openContextMenu: (e) ->
-			position = @editor.renderer.screenToTextCoordinates(e.clientX, e.clientY)
-			highlight = @highlightedWordManager.findHighlightWithinRange
-				start: position
-				end:   position
-
-			@$scope.$apply () =>
-				@$scope.spellingMenu.highlight = highlight
-
+			coords = @adapter.getCoordsFromContextMenuEvent(e)
+			highlight = @adapter.getHighlightFromCoords(coords)
 			if highlight
-				e.stopPropagation()
-				e.preventDefault()
-
-				@editor.getSession().getSelection().setSelectionRange(
-					new Range(
-						highlight.row, highlight.column
-						highlight.row, highlight.column + highlight.word.length
-					)
-				)
-
+				@adapter.preventContextMenuEventDefault(e)
+				@adapter.selectHighlightedWord(highlight)
 				@$scope.$apply () =>
-					@$scope.spellingMenu.open = true
-					@$scope.spellingMenu.left = e.clientX + 'px'
-					@$scope.spellingMenu.top = e.clientY + 'px'
+					@$scope.spellMenu = {
+						open: true
+						top: coords.y + 'px'
+						left: coords.x + 'px'
+						highlight: highlight
+					}
 				return false
 
-		closeContextMenu: (e) ->
-			# this is triggered on scroll, so for performance only apply
-			# setting when it changes
-			if @$scope?.spellingMenu?.open != false
+		closeContextMenu: () ->
+			# This is triggered on scroll, so for performance only apply setting when
+			# it changes
+			if @$scope?.spellMenu and @$scope.spellMenu.open != false
 				@$scope.$apply () =>
-					@$scope.spellingMenu.open = false
+					@$scope.spellMenu.open = false
 
-		replaceWord: (highlight, text) ->
-			@editor.getSession().replace(new Range(
-				highlight.row, highlight.column,
-				highlight.row, highlight.column + highlight.word.length
-			), text)
-
-		learnWord: (highlight) ->
+		learnWord: (highlight) =>
 			@apiRequest "/learn", word: highlight.word
-			@highlightedWordManager.removeWord highlight.word
+			@adapter.highlightedWordManager.removeWord highlight.word
 			language = @$scope.spellCheckLanguage
 			@cache?.put("#{language}:#{highlight.word}", true)
 
-		getHighlightedWordAtCursor: () ->
-			cursor = @editor.getCursorPosition()
-			highlight = @highlightedWordManager.findHighlightWithinRange
-				start: cursor
-				end: cursor
-			return highlight
-
-		runSpellCheckSoon: (delay = 1000) ->
-			run = () =>
-				delete @timeoutId
-				@runSpellCheck(@updatedLines)
-				@updatedLines = []
-			if @timeoutId?
-				clearTimeout @timeoutId
-			@timeoutId = setTimeout run, delay
+		runFullCheck: () ->
+			@adapter.highlightedWordManager.reset()
+			@runSpellCheck() if @isSpellCheckEnabled()
 
 		markLinesAsUpdated: (change) ->
 			start = change.start
@@ -146,6 +105,15 @@ define [
 				@updatedLines[start.row] = true
 				removeLines()
 
+		runSpellCheckSoon: (delay = 1000) ->
+			run = () =>
+				delete @timeoutId
+				@runSpellCheck(@updatedLines)
+				@updatedLines = []
+			if @timeoutId?
+				clearTimeout @timeoutId
+			@timeoutId = setTimeout run, delay
+
 		runSpellCheck: (linesToProcess) ->
 			{words, positions} = @getWords(linesToProcess)
 			language = @$scope.spellCheckLanguage
@@ -178,11 +146,11 @@ define [
 			displayResult = (highlights) =>
 				if linesToProcess?
 					for shouldProcess, row in linesToProcess
-						@highlightedWordManager.clearRows(row, row) if shouldProcess
+						@adapter.highlightedWordManager.clearRow(row) if shouldProcess
 				else
-					@highlightedWordManager.clearRows()
+					@adapter.highlightedWordManager.reset()
 				for highlight in highlights
-					@highlightedWordManager.addHighlight highlight
+					@adapter.highlightedWordManager.addHighlight highlight
 
 			if not words.length
 				displayResult highlights
@@ -212,8 +180,24 @@ define [
 							seen[key] = true
 					displayResult highlights
 
+		apiRequest: (endpoint, data, callback = (error, result) ->)->
+			data.token = window.user.id
+			data._csrf = window.csrfToken
+			# use angular timeout option to cancel request if doc is changed
+			requestHandler = @$q.defer()
+			options = {timeout: requestHandler.promise}
+			httpRequest = @$http.post("/spelling" + endpoint, data, options)
+			.then (response) =>
+				callback(null, response.data)
+			.catch (response) =>
+				callback(new Error('api failure'))
+			# provide a method to cancel the request
+			abortRequest = () ->
+				requestHandler.resolve()
+			return { abort: abortRequest }
+
 		getWords: (linesToProcess) ->
-			lines = @editor.getValue().split("\n")
+			lines = @adapter.getLines()
 			words = []
 			positions = []
 			for line, row in lines
@@ -232,22 +216,6 @@ define [
 						words.push(word)
 			return words: words, positions: positions
 
-		apiRequest: (endpoint, data, callback = (error, result) ->)->
-			data.token = window.user.id
-			data._csrf = window.csrfToken
-			# use angular timeout option to cancel request if doc is changed
-			requestHandler = @$q.defer()
-			options = {timeout: requestHandler.promise}
-			httpRequest = @$http.post("/spelling" + endpoint, data, options)
-			.then (response) =>
-				callback(null, response.data)
-			.catch (response) =>
-				callback(new Error('api failure'))
-			# provide a method to cancel the request
-			abortRequest = () ->
-				requestHandler.resolve()
-			return { abort: abortRequest }
-
 		blacklistedCommandRegex: ///
 			\\                    # initial backslash
 			(label                # any of these commands

+ 7 - 0
services/web/public/stylesheets/app/editor/rich-text.less

@@ -219,4 +219,11 @@
     font-style: italic;
     color: #999;
   }
+
+  .spelling-error {
+    background-image: url(/img/spellcheck-underline.png);
+    background-repeat: repeat-x;
+    background-position: bottom;
+  }
 }
+

+ 56 - 0
services/web/test/acceptance/coffee/ExportsTests.coffee

@@ -0,0 +1,56 @@
+expect = require('chai').expect
+request = require './helpers/request'
+_ = require 'underscore'
+
+
+User = require './helpers/User'
+ProjectGetter = require '../../../app/js/Features/Project/ProjectGetter.js'
+ExportsHandler = require '../../../app/js/Features/Exports/ExportsHandler.js'
+
+MockProjectHistoryApi = require './helpers/MockProjectHistoryApi'
+MockV1Api = require './helpers/MockV1Api'
+
+describe 'Exports', ->
+	before (done) ->
+		@brand_variation_id = '18'
+		@owner = new User()
+		@owner.login (error) =>
+			throw error if error?
+			@owner.createProject 'example-project', {template: 'example'}, (error, @project_id) =>
+				throw error if error?
+				done()
+
+	describe 'exporting a project', ->
+		beforeEach (done) ->
+			@version = Math.floor(Math.random() * 10000)
+			MockProjectHistoryApi.setProjectVersion(@project_id, @version)
+			@export_id = Math.floor(Math.random() * 10000)
+			MockV1Api.setExportId(@export_id)
+			MockV1Api.clearExportParams()
+			@owner.request {
+				method: 'POST',
+				url: "/project/#{@project_id}/export/#{@brand_variation_id}",
+				json: {},
+			}, (error, response, body) =>
+				throw error if error?
+				expect(response.statusCode).to.equal 200
+				@exportResponseBody = body
+				done()
+
+		it 'should have sent correct data to v1', (done) ->
+			{project, user, destination, options} = MockV1Api.getLastExportParams()
+			# project details should match
+			expect(project.id).to.equal @project_id
+			expect(project.rootDocPath).to.equal '/main.tex'
+			# version should match what was retrieved from project-history
+			expect(project.historyVersion).to.equal @version
+			# user details should match
+			expect(user.id).to.equal @owner.id
+			expect(user.email).to.equal @owner.email
+			# brand-variation should match
+			expect(destination.brandVariationId).to.equal @brand_variation_id
+			done()
+
+		it 'should have returned the export ID provided by v1', (done) ->
+			expect(@exportResponseBody.export_v1_id).to.equal @export_id
+			done()

+ 4 - 3
services/web/test/acceptance/coffee/ProjectInviteTests.coffee

@@ -16,6 +16,7 @@ createInvite = (sendingUser, projectId, email, callback=(err, invite)->) ->
 				privileges: 'readAndWrite'
 		}, (err, response, body) ->
 			return callback(err) if err
+			expect(response.statusCode).to.equal 200
 			callback(null, body.invite)
 
 createProject = (owner, projectName, callback=(err, projectId, project)->) ->
@@ -207,9 +208,9 @@ describe "ProjectInviteTests", ->
 		@email = 'smoketestuser@example.com'
 		@projectName = 'sharing test'
 		Async.series [
-			(cb) => @user.login cb
-			(cb) => @user.logout cb
+			(cb) => @user.ensureUserExists cb
 			(cb) => @sendingUser.login cb
+			(cb) => @sendingUser.setFeatures { collaborators: 10 }, cb
 		], done
 
 	describe 'creating invites', ->
@@ -266,7 +267,7 @@ describe "ProjectInviteTests", ->
 					(cb) => expectInvitesInJoinProjectCount @sendingUser, @projectId, 0, cb
 				], done
 
-			it 'should allow the project owner to many invites at once', (done) ->
+			it 'should allow the project owner to create many invites at once', (done) ->
 				@inviteOne = null
 				@inviteTwo = null
 				Async.series [

+ 151 - 0
services/web/test/acceptance/coffee/SubscriptionTests.coffee

@@ -0,0 +1,151 @@
+expect = require("chai").expect
+async = require("async")
+UserClient = require "./helpers/User"
+request = require "./helpers/request"
+settings = require "settings-sharelatex"
+{ObjectId} = require("../../../app/js/infrastructure/mongojs")
+Subscription = require("../../../app/js/models/Subscription").Subscription
+User = require("../../../app/js/models/User").User
+
+MockV1Api = require "./helpers/MockV1Api"
+
+syncUserAndGetFeatures = (user, callback = (error, features) ->) ->
+	request {
+		method: 'POST',
+		url: "/user/#{user._id}/features/sync",
+		auth:
+			user: 'sharelatex'
+			pass: 'password'
+			sendImmediately: true
+	}, (error, response, body) ->
+		throw error if error?
+		expect(response.statusCode).to.equal 200
+		User.findById user._id, (error, user) ->
+			return callback(error) if error?
+			features = user.toObject().features
+			delete features.$init # mongoose internals
+			return callback null, features
+
+describe "Subscriptions", ->
+	beforeEach (done) ->
+		@user = new UserClient()
+		@user.ensureUserExists (error) ->
+			throw error if error?
+			done()
+
+	describe "when user has no subscriptions", ->
+		it "should set their features to the basic set", (done) ->
+			syncUserAndGetFeatures @user, (error, features) =>
+				throw error if error?
+				expect(features).to.deep.equal(settings.defaultFeatures)
+				done()
+
+	describe "when the user has an individual subscription", ->
+		beforeEach ->
+			Subscription.create {
+				admin_id: @user._id
+				planCode: 'collaborator'
+				customAccount: true
+			} # returns a promise
+
+		it "should set their features to the upgraded set", (done) ->
+			syncUserAndGetFeatures @user, (error, features) =>
+				throw error if error?
+				plan = settings.plans.find (plan) -> plan.planCode == 'collaborator'
+				expect(features).to.deep.equal(plan.features)
+				done()
+
+	describe "when the user is in a group subscription", ->
+		beforeEach ->
+			Subscription.create {
+				admin_id: ObjectId()
+				member_ids: [@user._id]
+				groupAccount: true
+				planCode: 'collaborator'
+				customAccount: true
+			} # returns a promise
+
+		it "should set their features to the upgraded set", (done) ->
+			syncUserAndGetFeatures @user, (error, features) =>
+				throw error if error?
+				plan = settings.plans.find (plan) -> plan.planCode == 'collaborator'
+				expect(features).to.deep.equal(plan.features)
+				done()
+
+	describe "when the user has bonus features", ->
+		beforeEach ->
+			User.update {
+				_id: @user._id
+			}, {
+				refered_user_count: 10
+			} # returns a promise
+
+		it "should set their features to the bonus set", (done) ->
+			syncUserAndGetFeatures @user, (error, features) =>
+				throw error if error?
+				expect(features).to.deep.equal(Object.assign(
+					{}, settings.defaultFeatures,	settings.bonus_features[9]
+				))
+				done()
+
+	describe "when the user has a v1 plan", ->
+		beforeEach ->
+			MockV1Api.setUser 42, plan_name: 'free'
+			User.update {
+				_id: @user._id
+			}, {
+				overleaf:
+					id: 42
+			} # returns a promise
+
+		it "should set their features to the v1 plan", (done) ->
+			syncUserAndGetFeatures @user, (error, features) =>
+				throw error if error?
+				plan = settings.plans.find (plan) -> plan.planCode == 'v1_free'
+				expect(features).to.deep.equal(plan.features)
+				done()
+
+	describe "when the user has a v1 plan and bonus features", ->
+		beforeEach ->
+			MockV1Api.setUser 42, plan_name: 'free'
+			User.update {
+				_id: @user._id
+			}, {
+				overleaf:
+					id: 42
+				refered_user_count: 10
+			} # returns a promise
+
+		it "should set their features to the best of the v1 plan and bonus features", (done) ->
+			syncUserAndGetFeatures @user, (error, features) =>
+				throw error if error?
+				v1plan = settings.plans.find (plan) -> plan.planCode == 'v1_free'
+				expectedFeatures = Object.assign(
+					{}, v1plan.features, settings.bonus_features[9]
+				)
+				expect(features).to.deep.equal(expectedFeatures)
+				done()
+
+	describe "when the user has a group and personal subscription", ->
+		beforeEach (done) ->
+			Subscription.create {
+				admin_id: @user._id
+				planCode: 'professional'
+				customAccount: true
+			}, (error) =>
+				throw error if error?
+				Subscription.create {
+					admin_id: ObjectId()
+					member_ids: [@user._id]
+					groupAccount: true
+					planCode: 'collaborator'
+					customAccount: true
+				}, done
+			return
+
+		it "should set their features to the best set", (done) ->
+			syncUserAndGetFeatures @user, (error, features) =>
+				throw error if error?
+				plan = settings.plans.find (plan) -> plan.planCode == 'professional'
+				expect(features).to.deep.equal(plan.features)
+				done()

+ 12 - 0
services/web/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee

@@ -6,9 +6,14 @@ module.exports = MockProjectHistoryApi =
 
 	oldFiles: {}
 
+	projectVersions: {}
+
 	addOldFile: (project_id, version, pathname, content) ->
 		@oldFiles["#{project_id}:#{version}:#{pathname}"] = content
 
+	setProjectVersion: (project_id, version) ->
+		@projectVersions[project_id] = version
+
 	run: () ->
 		app.post "/project", (req, res, next) =>
 			res.json project: id: 1
@@ -21,6 +26,13 @@ module.exports = MockProjectHistoryApi =
 			else
 				res.send 404
 
+		app.get "/project/:project_id/version", (req, res, next) =>
+			{project_id} = req.params
+			if @projectVersions[project_id]?
+				res.json version: @projectVersions[project_id]
+			else
+				res.send 404
+
 		app.listen 3054, (error) ->
 			throw error if error?
 		.on "error", (error) ->

+ 45 - 0
services/web/test/acceptance/coffee/helpers/MockV1Api.coffee

@@ -0,0 +1,45 @@
+express = require("express")
+app = express()
+bodyParser = require('body-parser')
+
+app.use(bodyParser.json())
+
+module.exports = MockV1Api =
+	users: { }
+
+	setUser: (id, user) ->
+		@users[id] = user
+
+	exportId: null
+
+	exportParams: null
+
+	setExportId: (id) ->
+		@exportId = id
+
+	getLastExportParams: () ->
+		@exportParams
+
+	clearExportParams: () ->
+		@exportParams = null
+
+	run: () ->
+		app.get "/api/v1/sharelatex/users/:ol_user_id/plan_code", (req, res, next) =>
+			user = @users[req.params.ol_user_id]
+			if user
+				res.json user
+			else
+				res.sendStatus 404
+
+		app.post "/api/v1/sharelatex/exports", (req, res, next) =>
+			#{project, version, pathname}
+			@exportParams = Object.assign({}, req.body)
+			res.json exportId: @exportId
+
+		app.listen 5000, (error) ->
+			throw error if error?
+		.on "error", (error) ->
+			console.error "error starting MockV1Api:", error.message
+			process.exit(1)
+
+MockV1Api.run()

+ 6 - 0
services/web/test/acceptance/coffee/helpers/User.coffee

@@ -40,6 +40,12 @@ class User
 				@referal_id = user?.referal_id
 				callback(null, @password)
 
+	setFeatures: (features, callback = (error) ->) ->
+		update = {}
+		for key, value of features
+			update["features.#{key}"] = value
+		UserModel.update { _id: @id }, update, callback
+
 	logout: (callback = (error) ->) ->
 		@getCsrfToken (error) =>
 			return callback(error) if error?

+ 95 - 0
services/web/test/acceptance/config/settings.test.coffee

@@ -0,0 +1,95 @@
+module.exports =
+	enableSubscriptions: true
+
+	features: features =
+		v1_free:
+			collaborators: 1
+			dropbox: false
+			versioning: false
+			github: true
+			templates: false
+			references: false
+			referencesSearch: false
+			mendeley: true
+			compileTimeout: 60
+			compileGroup: "standard"
+			trackChanges: false
+		personal:
+			collaborators: 1
+			dropbox: false
+			versioning: false
+			github: false
+			templates: false
+			references: false
+			referencesSearch: false
+			mendeley: false
+			compileTimeout: 60
+			compileGroup: "standard"
+			trackChanges: false
+		collaborator:
+			collaborators: 10
+			dropbox: true
+			versioning: true
+			github: true
+			templates: true
+			references: true
+			referencesSearch: true
+			mendeley: true
+			compileTimeout: 180
+			compileGroup: "priority"
+			trackChanges: true
+		professional:
+			collaborators: -1
+			dropbox: true
+			versioning: true
+			github: true
+			templates: true
+			references: true
+			referencesSearch: true
+			mendeley: true
+			compileTimeout: 180
+			compileGroup: "priority"
+			trackChanges: true
+
+	defaultFeatures: features.personal
+	defaultPlanCode: 'personal'
+
+	plans: plans = [{
+		planCode: "v1_free"
+		name: "V1 Free"
+		price: 0
+		features: features.v1_free
+	},{
+		planCode: "personal"
+		name: "Personal"
+		price: 0
+		features: features.personal
+	},{
+		planCode: "collaborator"
+		name: "Collaborator"
+		price: 1500
+		features: features.collaborator
+	},{
+		planCode: "professional"
+		name: "Professional"
+		price: 3000
+		features: features.professional
+	}]
+
+	bonus_features:
+		1:
+			collaborators: 2
+			dropbox: false
+			versioning: false
+		3:
+			collaborators: 4
+			dropbox: false
+			versioning: false
+		6:
+			collaborators: 4
+			dropbox: true
+			versioning: true
+		9:
+			collaborators: -1
+			dropbox: true
+			versioning: true

+ 39 - 0
services/web/test/unit/coffee/Exports/ExportsControllerTests.coffee

@@ -0,0 +1,39 @@
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+chai = require('chai')
+expect = chai.expect
+sinon = require('sinon')
+modulePath = require('path').join __dirname, '../../../../app/js/Features/Exports/ExportsController.js'
+
+
+describe 'ExportsController', ->
+	project_id = "123njdskj9jlk"
+	user_id = "123nd3ijdks"
+	brand_variation_id = 22
+
+	beforeEach ->
+		@handler =
+			getUserNotifications: sinon.stub().callsArgWith(1)
+		@req =
+			params:
+				project_id: project_id
+				brand_variation_id: brand_variation_id
+			session:
+				user:
+					_id:user_id
+			i18n:
+				translate:->
+		@AuthenticationController =
+			getLoggedInUserId: sinon.stub().returns(@req.session.user._id)
+		@controller = SandboxedModule.require modulePath, requires:
+			"./ExportsHandler":@handler
+			'logger-sharelatex':
+				log:->
+				err:->
+			'../Authentication/AuthenticationController': @AuthenticationController
+
+	it 'should ask the handler to perform the export', (done) ->
+		@handler.exportProject = sinon.stub().yields(null, {iAmAnExport: true, v1_id: 897})
+		@controller.exportProject @req, send:(body) =>
+			expect(body).to.deep.equal {export_v1_id: 897}
+			done()

+ 202 - 0
services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee

@@ -0,0 +1,202 @@
+sinon = require('sinon')
+chai = require('chai')
+should = chai.should()
+expect = chai.expect
+modulePath = '../../../../app/js/Features/Exports/ExportsHandler.js'
+SandboxedModule = require('sandboxed-module')
+
+describe 'ExportsHandler', ->
+
+	beforeEach ->
+		@ProjectGetter = {}
+		@ProjectLocator = {}
+		@UserGetter = {}
+		@settings = {}
+		@stubRequest = {}
+		@request = defaults: => return @stubRequest
+		@ExportsHandler = SandboxedModule.require modulePath, requires:
+			'logger-sharelatex':
+				log: ->
+				err: ->
+			'../Project/ProjectGetter': @ProjectGetter
+			'../Project/ProjectLocator': @ProjectLocator
+			'../User/UserGetter': @UserGetter
+			'settings-sharelatex': @settings
+			'request': @request
+		@project_id = "project-id-123"
+		@project_history_id = 987
+		@user_id = "user-id-456"
+		@brand_variation_id = 789
+		@callback = sinon.stub()
+
+	describe 'exportProject', ->
+		beforeEach (done) ->
+			@export_data = {iAmAnExport: true}
+			@response_body = {iAmAResponseBody: true}
+			@ExportsHandler._buildExport = sinon.stub().yields(null, @export_data)
+			@ExportsHandler._requestExport = sinon.stub().yields(null, @response_body)
+			@ExportsHandler.exportProject @project_id, @user_id, @brand_variation_id, (error, export_data) =>
+				@callback(error, export_data)
+				done()
+
+		it "should build the export", ->
+			@ExportsHandler._buildExport
+			.calledWith(@project_id, @user_id, @brand_variation_id)
+			.should.equal true
+
+		it "should request the export", ->
+			@ExportsHandler._requestExport
+			.calledWith(@export_data)
+			.should.equal true
+
+		it "should return the export", ->
+			@callback
+			.calledWith(null, @export_data)
+			.should.equal true
+
+	describe '_buildExport', ->
+		beforeEach (done) ->
+			@project =
+				id: @project_id
+				overleaf:
+					history:
+						id: @project_history_id
+			@user =
+				id: @user_id
+				first_name: 'Arthur'
+				last_name: 'Author'
+				email: 'arthur.author@arthurauthoring.org'
+			@rootDocPath = 'main.tex'
+			@historyVersion = 777
+			@ProjectGetter.getProject = sinon.stub().yields(null, @project)
+			@ProjectLocator.findRootDoc = sinon.stub().yields(null, [null, {fileSystem: 'main.tex'}])
+			@UserGetter.getUser = sinon.stub().yields(null, @user)
+			@ExportsHandler._requestVersion = sinon.stub().yields(null, @historyVersion)
+			done()
+
+		describe "when all goes well", ->
+			beforeEach (done) ->
+				@ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) =>
+					@callback(error, export_data)
+					done()
+
+			it "should request the project history version", ->
+				@ExportsHandler._requestVersion.called
+				.should.equal true
+
+			it "should return export data", ->
+				expected_export_data = 
+					project:
+						id: @project_id
+						rootDocPath: @rootDocPath
+						historyId: @project_history_id
+						historyVersion: @historyVersion
+					user:
+						id: @user_id
+						firstName: @user.first_name
+						lastName: @user.last_name
+						email: @user.email
+						orcidId: null
+					destination:
+						brandVariationId: @brand_variation_id
+					options:
+						callbackUrl: null
+				@callback.calledWith(null, expected_export_data)
+				.should.equal true
+
+		describe "when project is not found", ->
+			beforeEach (done) ->
+				@ProjectGetter.getProject = sinon.stub().yields(new Error("project not found"))
+				@ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) =>
+					@callback(error, export_data)
+					done()
+
+			it "should return an error", ->
+				(@callback.args[0][0] instanceof Error)
+				.should.equal true
+
+		describe "when project has no root doc", ->
+			beforeEach (done) ->
+				@ProjectLocator.findRootDoc = sinon.stub().yields(null, [null, null])
+				@ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) =>
+					@callback(error, export_data)
+					done()
+
+			it "should return an error", ->
+				(@callback.args[0][0] instanceof Error)
+				.should.equal true
+
+		describe "when user is not found", ->
+			beforeEach (done) ->
+				@UserGetter.getUser = sinon.stub().yields(new Error("user not found"))
+				@ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) =>
+					@callback(error, export_data)
+					done()
+
+			it "should return an error", ->
+				(@callback.args[0][0] instanceof Error)
+				.should.equal true
+
+		describe "when project history request fails", ->
+			beforeEach (done) ->
+				@ExportsHandler._requestVersion = sinon.stub().yields(new Error("project history call failed"))
+				@ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) =>
+					@callback(error, export_data)
+					done()
+
+			it "should return an error", ->
+				(@callback.args[0][0] instanceof Error)
+				.should.equal true
+
+	describe '_requestExport', ->
+		beforeEach (done) ->
+			@settings.apis =
+				v1:
+					url: 'http://localhost:5000'
+					user: 'overleaf'
+					pass: 'pass'
+			@export_data = {iAmAnExport: true}
+			@export_id = 4096
+			@stubPost = sinon.stub().yields(null, {statusCode: 200}, { exportId: @export_id })
+			done()
+
+		describe "when all goes well", ->
+			beforeEach (done) ->
+				@stubRequest.post = @stubPost
+				@ExportsHandler._requestExport @export_data, (error, export_v1_id) =>
+					@callback(error, export_v1_id)
+					done()
+
+			it 'should issue the request', ->
+				expect(@stubPost.getCall(0).args[0]).to.deep.equal
+					url: @settings.apis.v1.url + '/api/v1/sharelatex/exports'
+					auth:
+						user: @settings.apis.v1.user
+						pass: @settings.apis.v1.pass
+					json: @export_data
+
+			it 'should return the v1 export id', ->
+				@callback.calledWith(null, @export_id)
+				.should.equal true
+
+		describe "when the request fails", ->
+			beforeEach (done) ->
+				@stubRequest.post = sinon.stub().yields(new Error("export request failed"))
+				@ExportsHandler._requestExport @export_data, (error, export_v1_id) =>
+					@callback(error, export_v1_id)
+					done()
+
+			it "should return an error", ->
+				(@callback.args[0][0] instanceof Error)
+				.should.equal true
+
+		describe "when the request returns an error code", ->
+			beforeEach (done) ->
+				@stubRequest.post = sinon.stub().yields(null, {statusCode: 401}, { })
+				@ExportsHandler._requestExport @export_data, (error, export_v1_id) =>
+					@callback(error, export_v1_id)
+					done()
+
+			it "should return the error", ->
+				(@callback.args[0][0] instanceof Error)
+				.should.equal true

+ 7 - 118
services/web/test/unit/coffee/Referal/ReferalAllocatorTests.coffee

@@ -4,12 +4,12 @@ require('chai').should()
 sinon = require('sinon')
 modulePath = require('path').join __dirname, '../../../../app/js/Features/Referal/ReferalAllocator.js'
 
-describe 'Referalallocator', ->
+describe 'ReferalAllocator', ->
 
 	beforeEach ->
 		@ReferalAllocator = SandboxedModule.require modulePath, requires:
 			'../../models/User': User: @User = {}
-			"../Subscription/SubscriptionLocator": @SubscriptionLocator = {}
+			"../Subscription/FeaturesUpdater": @FeaturesUpdater = {}
 			"settings-sharelatex": @Settings = {}
 			'logger-sharelatex':
 				log:->
@@ -26,7 +26,7 @@ describe 'Referalallocator', ->
 				@referal_source = "bonus"
 				@User.update = sinon.stub().callsArgWith 3, null
 				@User.findOne = sinon.stub().callsArgWith 1, null, { _id: @user_id }
-				@ReferalAllocator.assignBonus = sinon.stub().callsArg 1
+				@FeaturesUpdater.refreshFeatures = sinon.stub().yields()
 				@ReferalAllocator.allocate @referal_id, @new_user_id, @referal_source, @referal_medium, @callback
 
 			it 'should update the referring user with the refered users id', ->
@@ -44,8 +44,8 @@ describe 'Referalallocator', ->
 					.calledWith( referal_id: @referal_id )
 					.should.equal true
 			
-			it "shoudl assign the user their bonus", ->
-				@ReferalAllocator.assignBonus
+			it "should refresh the user's subscription", ->
+				@FeaturesUpdater.refreshFeatures
 					.calledWith(@user_id)
 					.should.equal true
 
@@ -57,7 +57,7 @@ describe 'Referalallocator', ->
 				@referal_source = "public_share"
 				@User.update = sinon.stub().callsArgWith 3, null
 				@User.findOne = sinon.stub().callsArgWith 1, null, { _id: @user_id }
-				@ReferalAllocator.assignBonus = sinon.stub().callsArg 1
+				@FeaturesUpdater.refreshFeatures = sinon.stub().yields()
 				@ReferalAllocator.allocate @referal_id, @new_user_id, @referal_source, @referal_medium, @callback
 
 			it 'should not update the referring user with the refered users id', ->
@@ -69,118 +69,7 @@ describe 'Referalallocator', ->
 					.should.equal true
 			
 			it "should not assign the user a bonus", ->
-				@ReferalAllocator.assignBonus.called.should.equal false
+				@FeaturesUpdater.refreshFeatures.called.should.equal false
 
 			it "should call the callback", ->
 				@callback.called.should.equal true
-
-	describe "assignBonus", ->
-			beforeEach ->
-				@refered_user_count = 3
-				@Settings.bonus_features =
-					"3":
-						collaborators: 3
-						dropbox: false
-						versioning: false
-				stubbedUser = { 
-					refered_user_count: @refered_user_count, 
-					features:{collaborators:1, dropbox:false, versioning:false} 
-				}
-
-				@User.findOne = sinon.stub().callsArgWith 1, null, stubbedUser
-				@User.update = sinon.stub().callsArgWith 2, null
-				@ReferalAllocator.assignBonus @user_id, @callback
-
-			it "should get the users number of refered user", ->
-				@User.findOne
-					.calledWith(_id: @user_id)
-					.should.equal true
-
-			it "should update the user to bonus features", ->
-				@User.update
-					.calledWith({
-						_id: @user_id
-					}, {
-						$set:
-							features:
-								@Settings.bonus_features[@refered_user_count.toString()]
-					})
-					.should.equal true
-
-			it "should call the callback", ->
-				@callback.called.should.equal true
-	
-		describe "when there is nothing to assign", ->
-
-			beforeEach ->
-				@ReferalAllocator._calculateBonuses = sinon.stub().returns({})
-				@stubbedUser =
-					refered_user_count:4
-					features:{collaborators:3, versioning:true, dropbox:false}
-				@Settings.bonus_features =
-					"4":
-						collaborators:3
-						versioning:true
-						dropbox:false
-				@User.findOne = sinon.stub().callsArgWith 1, null, @stubbedUser
-				@User.update = sinon.stub().callsArgWith 2, null
-
-			it "should not call update if there are no bonuses to apply", (done)->
-				@ReferalAllocator.assignBonus @user_id, (err)=>
-					@User.update.called.should.equal false
-					done()
-
-		describe "when the user has better features already", ->
-			
-			beforeEach ->
-				@refered_user_count = 3
-				@stubbedUser =
-					refered_user_count:4
-					features:
-						collaborators:3
-						dropbox:false
-						versioning:false
-				@Settings.bonus_features =
-					"4":
-						collaborators: 10
-						dropbox: true
-						versioning: false
-
-				@User.findOne = sinon.stub().callsArgWith 1, null, @stubbedUser
-				@User.update = sinon.stub().callsArgWith 2, null
-
-			it "should not set in in mongo when the feature is better", (done)->
-				@ReferalAllocator.assignBonus @user_id, =>
-					@User.update.calledWith({_id: @user_id }, {$set: features:{dropbox:true, versioning:false, collaborators:10} }).should.equal true
-					done()
-
-			it "should not overright if the user has -1 users", (done)->
-				@stubbedUser.features.collaborators = -1
-				@ReferalAllocator.assignBonus @user_id, =>
-					@User.update.calledWith({_id: @user_id }, {$set: features:{dropbox:true, versioning:false, collaborators:-1} }).should.equal true
-					done()
-
-		describe "when the user is not at a bonus level", ->
-			beforeEach ->
-				@refered_user_count = 0
-				@Settings.bonus_features =
-					"1":
-						collaborators: 3
-						dropbox: false
-						versioning: false
-				@User.findOne = sinon.stub().callsArgWith 1, null, { refered_user_count: @refered_user_count }
-				@User.update = sinon.stub().callsArgWith 2, null
-				@ReferalAllocator.assignBonus @user_id, @callback
-
-			it "should get the users number of refered user", ->
-				@User.findOne
-					.calledWith(_id: @user_id)
-					.should.equal true
-
-			it "should not update the user to bonus features", ->
-				@User.update.called.should.equal false
-
-			it "should call the callback", ->
-				@callback.called.should.equal true
-
-

+ 65 - 0
services/web/test/unit/coffee/Referal/ReferalFeaturesTests.coffee

@@ -0,0 +1,65 @@
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+require('chai').should()
+sinon = require('sinon')
+modulePath = require('path').join __dirname, '../../../../app/js/Features/Referal/ReferalFeatures.js'
+
+describe 'ReferalFeatures', ->
+
+	beforeEach ->
+		@ReferalFeatures = SandboxedModule.require modulePath, requires:
+			'../../models/User': User: @User = {}
+			"settings-sharelatex": @Settings = {}
+			'logger-sharelatex':
+				log:->
+				err:->
+		@callback = sinon.stub()
+		@referal_id = "referal-id-123"
+		@referal_medium = "twitter"
+		@user_id = "user-id-123"
+		@new_user_id = "new-user-id-123"
+
+	describe "getBonusFeatures", ->
+			beforeEach ->
+				@refered_user_count = 3
+				@Settings.bonus_features =
+					"3":
+						collaborators: 3
+						dropbox: false
+						versioning: false
+				stubbedUser = { 
+					refered_user_count: @refered_user_count, 
+					features:{collaborators:1, dropbox:false, versioning:false} 
+				}
+
+				@User.findOne = sinon.stub().callsArgWith 1, null, stubbedUser
+				@ReferalFeatures.getBonusFeatures @user_id, @callback
+
+			it "should get the users number of refered user", ->
+				@User.findOne
+					.calledWith(_id: @user_id)
+					.should.equal true
+
+			it "should call the callback with the features", ->
+				@callback.calledWith(null, @Settings.bonus_features[3]).should.equal true
+
+		describe "when the user is not at a bonus level", ->
+			beforeEach ->
+				@refered_user_count = 0
+				@Settings.bonus_features =
+					"1":
+						collaborators: 3
+						dropbox: false
+						versioning: false
+				@User.findOne = sinon.stub().callsArgWith 1, null, { refered_user_count: @refered_user_count }
+				@ReferalFeatures.getBonusFeatures @user_id, @callback
+
+			it "should get the users number of refered user", ->
+				@User.findOne
+					.calledWith(_id: @user_id)
+					.should.equal true
+
+			it "should call the callback with no features", ->
+				@callback.calledWith(null, {}).should.equal true
+
+

+ 173 - 0
services/web/test/unit/coffee/Subscription/FeaturesUpdaterTests.coffee

@@ -0,0 +1,173 @@
+SandboxedModule = require('sandboxed-module')
+should = require('chai').should()
+expect = require('chai').expect
+sinon = require 'sinon'
+modulePath = "../../../../app/js/Features/Subscription/FeaturesUpdater"
+assert = require("chai").assert
+ObjectId = require('mongoose').Types.ObjectId	
+
+describe "FeaturesUpdater", ->
+
+	beforeEach ->
+		@user_id = ObjectId().toString()
+
+		@FeaturesUpdater = SandboxedModule.require modulePath, requires:
+			'./UserFeaturesUpdater': @UserFeaturesUpdater = {}
+			'./SubscriptionLocator': @SubscriptionLocator = {} 
+			'./PlansLocator': @PlansLocator = {}
+			"logger-sharelatex": log:->
+			'settings-sharelatex': @Settings = {}
+			"../Referal/ReferalFeatures" : @ReferalFeatures = {}
+			"./V1SubscriptionManager": @V1SubscriptionManager = {}
+
+	describe "refreshFeatures", ->
+		beforeEach ->
+			@UserFeaturesUpdater.updateFeatures = sinon.stub().yields()
+			@FeaturesUpdater._getIndividualFeatures = sinon.stub().yields(null, { 'individual': 'features' })
+			@FeaturesUpdater._getGroupFeatureSets = sinon.stub().yields(null, [{ 'group': 'features' }, { 'group': 'features2' }])
+			@FeaturesUpdater._getV1Features = sinon.stub().yields(null, { 'v1': 'features' })
+			@ReferalFeatures.getBonusFeatures = sinon.stub().yields(null, { 'bonus': 'features' })
+			@FeaturesUpdater._mergeFeatures = sinon.stub().returns({'merged': 'features'})
+			@callback = sinon.stub()
+			@FeaturesUpdater.refreshFeatures @user_id, @callback
+
+		it "should get the individual features", ->
+			@FeaturesUpdater._getIndividualFeatures
+				.calledWith(@user_id)
+				.should.equal true
+
+		it "should get the group features", ->
+			@FeaturesUpdater._getGroupFeatureSets
+				.calledWith(@user_id)
+				.should.equal true
+
+		it "should get the v1 features", ->
+			@FeaturesUpdater._getV1Features
+				.calledWith(@user_id)
+				.should.equal true
+
+		it "should get the bonus features", ->
+			@ReferalFeatures.getBonusFeatures
+				.calledWith(@user_id)
+				.should.equal true
+
+		it "should merge from the default features", ->
+			@FeaturesUpdater._mergeFeatures.calledWith(@Settings.defaultFeatures).should.equal true
+
+		it "should merge the individual features", ->
+			@FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'individual': 'features' }).should.equal true
+
+		it "should merge the group features", ->
+			@FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'group': 'features' }).should.equal true
+			@FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'group': 'features2' }).should.equal true
+
+		it "should merge the v1 features", ->
+			@FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'v1': 'features' }).should.equal true
+
+		it "should merge the bonus features", ->
+			@FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'bonus': 'features' }).should.equal true
+
+		it "should update the user with the merged features", ->
+			@UserFeaturesUpdater.updateFeatures
+				.calledWith(@user_id, {'merged': 'features'})
+				.should.equal true
+
+	describe "_mergeFeatures", ->
+		it "should prefer priority over standard for compileGroup", ->
+			expect(@FeaturesUpdater._mergeFeatures({
+				compileGroup: 'priority'
+			}, {
+				compileGroup: 'standard'
+			})).to.deep.equal({
+				compileGroup: 'priority'
+			})
+			expect(@FeaturesUpdater._mergeFeatures({
+				compileGroup: 'standard'
+			}, {
+				compileGroup: 'priority'
+			})).to.deep.equal({
+				compileGroup: 'priority'
+			})
+			expect(@FeaturesUpdater._mergeFeatures({
+				compileGroup: 'priority'
+			}, {
+				compileGroup: 'priority'
+			})).to.deep.equal({
+				compileGroup: 'priority'
+			})
+			expect(@FeaturesUpdater._mergeFeatures({
+				compileGroup: 'standard'
+			}, {
+				compileGroup: 'standard'
+			})).to.deep.equal({
+				compileGroup: 'standard'
+			})
+
+		it "should prefer -1 over any other for collaborators", ->
+			expect(@FeaturesUpdater._mergeFeatures({
+				collaborators: -1
+			}, {
+				collaborators: 10
+			})).to.deep.equal({
+				collaborators: -1
+			})
+			expect(@FeaturesUpdater._mergeFeatures({
+				collaborators: 10
+			}, {
+				collaborators: -1
+			})).to.deep.equal({
+				collaborators: -1
+			})
+			expect(@FeaturesUpdater._mergeFeatures({
+				collaborators: 4
+			}, {
+				collaborators: 10
+			})).to.deep.equal({
+				collaborators: 10
+			})
+
+		it "should prefer the higher of compileTimeout", ->
+			expect(@FeaturesUpdater._mergeFeatures({
+				compileTimeout: 20
+			}, {
+				compileTimeout: 10
+			})).to.deep.equal({
+				compileTimeout: 20
+			})
+			expect(@FeaturesUpdater._mergeFeatures({
+				compileTimeout: 10
+			}, {
+				compileTimeout: 20
+			})).to.deep.equal({
+				compileTimeout: 20
+			})
+
+		it "should prefer the true over false for other keys", ->
+			expect(@FeaturesUpdater._mergeFeatures({
+				github: true
+			}, {
+				github: false
+			})).to.deep.equal({
+				github: true
+			})
+			expect(@FeaturesUpdater._mergeFeatures({
+				github: false
+			}, {
+				github: true
+			})).to.deep.equal({
+				github: true
+			})
+			expect(@FeaturesUpdater._mergeFeatures({
+				github: true
+			}, {
+				github: true
+			})).to.deep.equal({
+				github: true
+			})
+			expect(@FeaturesUpdater._mergeFeatures({
+				github: false
+			}, {
+				github: false
+			})).to.deep.equal({
+				github: false
+			})

+ 1 - 0
services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee

@@ -75,6 +75,7 @@ describe "SubscriptionController", ->
 			"./SubscriptionDomainHandler":@SubscriptionDomainHandler
 			"../User/UserGetter": @UserGetter
 			"./RecurlyWrapper": @RecurlyWrapper = {}
+			"./FeaturesUpdater": @FeaturesUpdater = {}
 
 
 		@res = new MockResponse()

+ 22 - 90
services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee

@@ -1,5 +1,6 @@
 SandboxedModule = require('sandboxed-module')
 should = require('chai').should()
+expect = require('chai').expect
 sinon = require 'sinon'
 modulePath = "../../../../app/js/Features/Subscription/SubscriptionUpdater"
 assert = require("chai").assert
@@ -22,6 +23,7 @@ describe "SubscriptionUpdater", ->
 			save: sinon.stub().callsArgWith(0)
 			freeTrial:{}
 			planCode:"student_or_something"
+		@user_id = @adminuser_id
 
 		@groupSubscription =
 			admin_id: @adminUser._id
@@ -48,15 +50,15 @@ describe "SubscriptionUpdater", ->
 		@Settings = 
 			freeTrialPlanCode: "collaborator"
 			defaultPlanCode: "personal"
+			defaultFeatures: { "default": "features" }
 
 		@UserFeaturesUpdater =
-			updateFeatures : sinon.stub().callsArgWith(2)
+			updateFeatures : sinon.stub().yields()
 
 		@PlansLocator =
 			findLocalPlanInSettings: sinon.stub().returns({})
 
-		@ReferalAllocator = assignBonus:sinon.stub().callsArgWith(1)
-		@ReferalAllocator.cock = true
+		@ReferalFeatures = getBonusFeatures: sinon.stub().callsArgWith(1)
 		@Modules = {hooks: {fire: sinon.stub().callsArgWith(2, null, null)}}
 		@SubscriptionUpdater = SandboxedModule.require modulePath, requires:
 			'../../models/Subscription': Subscription:@SubscriptionModel
@@ -65,8 +67,7 @@ describe "SubscriptionUpdater", ->
 			'./PlansLocator': @PlansLocator
 			"logger-sharelatex": log:->
 			'settings-sharelatex': @Settings
-			"../Referal/ReferalAllocator" : @ReferalAllocator
-			'../../infrastructure/Modules': @Modules
+			"./FeaturesUpdater": @FeaturesUpdater = {}
 
 
 	describe "syncSubscription", ->
@@ -97,7 +98,7 @@ describe "SubscriptionUpdater", ->
 
 	describe "_updateSubscriptionFromRecurly", ->
 		beforeEach ->
-			@SubscriptionUpdater._setUsersMinimumFeatures = sinon.stub().callsArgWith(1)
+			@FeaturesUpdater.refreshFeatures = sinon.stub().callsArgWith(1)
 			
 		it "should update the subscription with token etc when not expired", (done)->
 			@SubscriptionUpdater._updateSubscriptionFromRecurly @recurlySubscription, @subscription, (err)=>
@@ -108,7 +109,7 @@ describe "SubscriptionUpdater", ->
 				assert.equal(@subscription.freeTrial.expiresAt, undefined)
 				assert.equal(@subscription.freeTrial.planCode, undefined)
 				@subscription.save.called.should.equal true
-				@SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@adminUser._id).should.equal true
+				@FeaturesUpdater.refreshFeatures.calledWith(@adminUser._id).should.equal true
 				done()
 
 		it "should remove the recurlySubscription_id when expired", (done)->
@@ -117,15 +118,15 @@ describe "SubscriptionUpdater", ->
 			@SubscriptionUpdater._updateSubscriptionFromRecurly @recurlySubscription, @subscription, (err)=>
 				assert.equal(@subscription.recurlySubscription_id, undefined)
 				@subscription.save.called.should.equal true
-				@SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@adminUser._id).should.equal true
+				@FeaturesUpdater.refreshFeatures.calledWith(@adminUser._id).should.equal true
 				done()
 
 		it "should update all the users features", (done)->
 			@SubscriptionUpdater._updateSubscriptionFromRecurly @recurlySubscription, @subscription, (err)=>
-				@SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@adminUser._id).should.equal true
-				@SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@allUserIds[0]).should.equal true
-				@SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@allUserIds[1]).should.equal true
-				@SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@allUserIds[2]).should.equal true
+				@FeaturesUpdater.refreshFeatures.calledWith(@adminUser._id).should.equal true
+				@FeaturesUpdater.refreshFeatures.calledWith(@allUserIds[0]).should.equal true
+				@FeaturesUpdater.refreshFeatures.calledWith(@allUserIds[1]).should.equal true
+				@FeaturesUpdater.refreshFeatures.calledWith(@allUserIds[2]).should.equal true
 				done()
 
 		it "should set group to true and save how many members can be added to group", (done)->
@@ -152,6 +153,9 @@ describe "SubscriptionUpdater", ->
 				done()
 
 	describe "addUserToGroup", ->
+		beforeEach ->
+			@FeaturesUpdater.refreshFeatures = sinon.stub().callsArgWith(1)
+
 		it "should add the users id to the group as a set", (done)->
 			@SubscriptionUpdater.addUserToGroup @adminUser._id, @otherUserId, =>
 				searchOps = 
@@ -163,12 +167,12 @@ describe "SubscriptionUpdater", ->
 
 		it "should update the users features", (done)->
 			@SubscriptionUpdater.addUserToGroup @adminUser._id, @otherUserId, =>
-				@UserFeaturesUpdater.updateFeatures.calledWith(@otherUserId, @subscription.planCode).should.equal true
+				@FeaturesUpdater.refreshFeatures.calledWith(@otherUserId).should.equal true
 				done()
 
 	describe "removeUserFromGroup", ->
 		beforeEach ->
-			@SubscriptionUpdater._setUsersMinimumFeatures = sinon.stub().callsArgWith(1)
+			@FeaturesUpdater.refreshFeatures = sinon.stub().callsArgWith(1)
 
 		it "should pull the users id from the group", (done)->
 			@SubscriptionUpdater.removeUserFromGroup @adminUser._id, @otherUserId, =>
@@ -181,69 +185,7 @@ describe "SubscriptionUpdater", ->
 
 		it "should update the users features", (done)->
 			@SubscriptionUpdater.removeUserFromGroup @adminUser._id, @otherUserId, =>
-				@SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@otherUserId).should.equal true
-				done()
-
-	describe "_setUsersMinimumFeatures", ->
-
-		it "should call updateFeatures with the subscription if set", (done)->
-			@SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription)
-			@SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null)
-
-			@SubscriptionUpdater._setUsersMinimumFeatures @adminUser._id, (err)=>
-				args = @UserFeaturesUpdater.updateFeatures.args[0]
-				assert.equal args[0], @adminUser._id
-				assert.equal args[1], @subscription.planCode
-				done()
-
-		it "should call updateFeatures with the  group subscription if set", (done)->
-			@SubscriptionLocator.getUsersSubscription.callsArgWith(1, null)
-			@SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null, @groupSubscription)
-
-			@SubscriptionUpdater._setUsersMinimumFeatures @adminUser._id, (err)=>
-				args = @UserFeaturesUpdater.updateFeatures.args[0]
-				assert.equal args[0], @adminUser._id
-				assert.equal args[1], @groupSubscription.planCode
-				done()
-
-		it "should call updateFeatures with the overleaf subscription if set", (done)->
-			@SubscriptionLocator.getUsersSubscription.callsArgWith(1, null)
-			@SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null, null)
-			@Modules.hooks.fire = sinon.stub().callsArgWith(2, null, ['ol_pro'])
-
-			@SubscriptionUpdater._setUsersMinimumFeatures @adminUser._id, (err)=>
-				args = @UserFeaturesUpdater.updateFeatures.args[0]
-				assert.equal args[0], @adminUser._id
-				assert.equal args[1], 'ol_pro'
-				done()
-
-		it "should call not call updateFeatures  with users subscription if the subscription plan code is the default one (downgraded)", (done)->
-			@subscription.planCode = @Settings.defaultPlanCode
-			@SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription)
-			@SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null, @groupSubscription)
-			@Modules.hooks.fire = sinon.stub().callsArgWith(2, null, null)
-			@SubscriptionUpdater._setUsersMinimumFeatures @adminuser_id, (err)=>
-				args = @UserFeaturesUpdater.updateFeatures.args[0]
-				assert.equal args[0], @adminUser._id
-				assert.equal args[1], @groupSubscription.planCode
-				done()
-
-
-		it "should call updateFeatures with default if there are no subscriptions for user", (done)->
-			@SubscriptionLocator.getUsersSubscription.callsArgWith(1, null)
-			@SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null)
-			@Modules.hooks.fire = sinon.stub().callsArgWith(2, null, null)
-			@SubscriptionUpdater._setUsersMinimumFeatures @adminuser_id, (err)=>
-				args = @UserFeaturesUpdater.updateFeatures.args[0]
-				assert.equal args[0], @adminUser._id
-				assert.equal args[1], @Settings.defaultPlanCode
-				done()
-
-		it "should call assignBonus", (done)->
-			@SubscriptionLocator.getUsersSubscription.callsArgWith(1, null)
-			@SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null)
-			@SubscriptionUpdater._setUsersMinimumFeatures @adminuser_id, (err)=>
-				@ReferalAllocator.assignBonus.calledWith(@adminuser_id).should.equal true
+				@FeaturesUpdater.refreshFeatures.calledWith(@otherUserId).should.equal true
 				done()
 
 	describe "deleteSubscription", ->
@@ -255,7 +197,7 @@ describe "SubscriptionUpdater", ->
 				member_ids: [ ObjectId(), ObjectId(), ObjectId() ]
 			}
 			@SubscriptionLocator.getSubscription = sinon.stub().yields(null, @subscription)
-			@SubscriptionUpdater._setUsersMinimumFeatures = sinon.stub().yields()
+			@FeaturesUpdater.refreshFeatures = sinon.stub().yields()
 			@SubscriptionUpdater.deleteSubscription @subscription_id, done
 			
 		it "should look up the subscription", ->
@@ -269,22 +211,12 @@ describe "SubscriptionUpdater", ->
 				.should.equal true
 		
 		it "should downgrade the admin_id", ->
-			@SubscriptionUpdater._setUsersMinimumFeatures
+			@FeaturesUpdater.refreshFeatures
 				.calledWith(@subscription.admin_id)
 				.should.equal true
 		
 		it "should downgrade all of the members", ->
 			for user_id in @subscription.member_ids
-				@SubscriptionUpdater._setUsersMinimumFeatures
+				@FeaturesUpdater.refreshFeatures
 					.calledWith(user_id)
 					.should.equal true
-
-	describe 'refreshSubscription', ->
-		beforeEach ->
-			@SubscriptionUpdater._setUsersMinimumFeatures = sinon.stub()
-				.callsArgWith(1, null)
-
-		it 'should call to _setUsersMinimumFeatures', ->
-			@SubscriptionUpdater.refreshSubscription(@adminUser._id, ()->)
-			@SubscriptionUpdater._setUsersMinimumFeatures.callCount.should.equal 1
-			@SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@adminUser._id).should.equal true

+ 3 - 14
services/web/test/unit/coffee/Subscription/UserFeaturesUpdaterTests.coffee

@@ -4,31 +4,20 @@ sinon = require 'sinon'
 modulePath = "../../../../app/js/Features/Subscription/UserFeaturesUpdater"
 assert = require("chai").assert
 
-
 describe "UserFeaturesUpdater", ->
-
 	beforeEach ->
-
 		@User =
 			update: sinon.stub().callsArgWith(2)
-
-		@PlansLocator = 
-			findLocalPlanInSettings : sinon.stub()
-
 		@UserFeaturesUpdater = SandboxedModule.require modulePath, requires:
 			'../../models/User': User:@User
 			"logger-sharelatex": log:->
-			'./PlansLocator': @PlansLocator
 
 	describe "updateFeatures", ->
-
 		it "should send the users features", (done)->
 			user_id = "5208dd34438842e2db000005"
-			plan_code = "student"
-			@features = features:{versioning:true, collaborators:10}
-			@PlansLocator.findLocalPlanInSettings = sinon.stub().returns(@features)
-			@UserFeaturesUpdater.updateFeatures user_id, plan_code, (err, features)=>
+			@features = {versioning:true, collaborators:10}
+			@UserFeaturesUpdater.updateFeatures user_id, @features, (err, features)=>
 				update = {"features.versioning":true, "features.collaborators":10}
 				@User.update.calledWith({"_id":user_id}, update).should.equal true
-				features.should.deep.equal @features.features
+				features.should.deep.equal @features
 				done()

+ 128 - 0
services/web/test/unit/coffee/Subscription/V1SusbcriptionManagerTests.coffee

@@ -0,0 +1,128 @@
+should = require('chai').should()
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+path = require('path')
+modulePath = path.join __dirname, '../../../../app/js/Features/Subscription/V1SubscriptionManager'
+sinon = require("sinon")
+expect = require("chai").expect
+
+
+describe 'V1SubscriptionManager', ->
+	beforeEach ->
+		@V1SubscriptionManager = SandboxedModule.require modulePath, requires:
+			"../User/UserGetter": @UserGetter = {}
+			"logger-sharelatex":
+				log: sinon.stub()
+				err: sinon.stub()
+				warn: sinon.stub()
+			"settings-sharelatex":
+				overleaf:
+					host: @host = "http://overleaf.example.com"
+			"request": @request = sinon.stub()
+		@V1SubscriptionManager._v1PlanRequest = sinon.stub()
+		@userId = 'abcd'
+		@v1UserId = 42
+		@user =
+			_id: @userId
+			email: 'user@example.com'
+			overleaf:
+				id: @v1UserId
+
+	describe 'getPlanCodeFromV1', ->
+		beforeEach ->
+			@responseBody =
+				id: 32,
+				plan_name: 'pro'
+			@UserGetter.getUser = sinon.stub()
+				.yields(null, @user)
+			@V1SubscriptionManager._v1PlanRequest = sinon.stub()
+				.yields(null, @responseBody)
+			@call = (cb) =>
+				@V1SubscriptionManager.getPlanCodeFromV1 @userId, cb
+
+		describe 'when all goes well', ->
+
+			it 'should call getUser', (done) ->
+				@call (err, planCode) =>
+					expect(
+						@UserGetter.getUser.callCount
+					).to.equal 1
+					expect(
+						@UserGetter.getUser.calledWith(@userId)
+					).to.equal true
+					done()
+
+			it 'should call _v1PlanRequest', (done) ->
+				@call (err, planCode) =>
+					expect(
+						@V1SubscriptionManager._v1PlanRequest.callCount
+					).to.equal 1
+					expect(
+						@V1SubscriptionManager._v1PlanRequest.calledWith(
+							@v1UserId
+						)
+					).to.equal true
+					done()
+
+			it 'should produce a plan-code without error', (done) ->
+				@call (err, planCode) =>
+					expect(err).to.not.exist
+					expect(planCode).to.equal 'v1_pro'
+					done()
+
+			describe 'when the plan_name from v1 is null', ->
+				beforeEach ->
+					@responseBody.plan_name = null
+
+				it 'should produce a null plan-code without error', (done) ->
+					@call (err, planCode) =>
+						expect(err).to.not.exist
+						expect(planCode).to.equal null
+						done()
+
+		describe 'when getUser produces an error', ->
+			beforeEach ->
+				@UserGetter.getUser = sinon.stub()
+					.yields(new Error('woops'))
+
+			it 'should not call _v1PlanRequest', (done) ->
+				@call (err, planCode) =>
+					expect(
+						@V1SubscriptionManager._v1PlanRequest.callCount
+					).to.equal 0
+					done()
+
+			it 'should produce an error', (done) ->
+				@call (err, planCode) =>
+					expect(err).to.exist
+					expect(planCode).to.not.exist
+					done()
+
+		describe 'when getUser does not find a user', ->
+			beforeEach ->
+				@UserGetter.getUser = sinon.stub()
+					.yields(null, null)
+
+			it 'should not call _v1PlanRequest', (done) ->
+				@call (err, planCode) =>
+					expect(
+						@V1SubscriptionManager._v1PlanRequest.callCount
+					).to.equal 0
+					done()
+
+			it 'should produce a null plan-code, without error', (done) ->
+				@call (err, planCode) =>
+					expect(err).to.not.exist
+					expect(planCode).to.not.exist
+					done()
+
+		describe 'when the request to v1 fails', ->
+			beforeEach ->
+				@V1SubscriptionManager._v1PlanRequest = sinon.stub()
+					.yields(new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err, planCode) =>
+					expect(err).to.exist
+					expect(planCode).to.not.exist
+					done()

+ 14 - 4
services/web/test/unit/coffee/Uploads/FileTypeManagerTests.coffee

@@ -39,7 +39,7 @@ describe "FileTypeManager", ->
 		beforeEach ->
 			@stat = { size: 100 }
 			@fs.stat = sinon.stub().callsArgWith(1, null, @stat)
-			
+
 		it "should return .tex files as not binary", ->
 			@FileTypeManager.isBinary "file.tex", "/path/on/disk", (error, binary) ->
 				binary.should.equal false
@@ -80,10 +80,18 @@ describe "FileTypeManager", ->
 			@FileTypeManager.isBinary "tex", "/path/on/disk", (error, binary) ->
 				binary.should.equal true
 
+		it "should return .latexmkrc file as not binary", ->
+			@FileTypeManager.isBinary ".latexmkrc", "/path/on/disk", (error, binary) ->
+				binary.should.equal false
+
+		it "should return latexmkrc file as not binary", ->
+			@FileTypeManager.isBinary "latexmkrc", "/path/on/disk", (error, binary) ->
+				binary.should.equal false
+
 		it "should ignore the case of an extension", ->
 			@FileTypeManager.isBinary "file.TEX", "/path/on/disk", (error, binary) ->
 				binary.should.equal false
-		
+
 		it "should return large text files as binary", ->
 			@stat.size = 2 * 1024 * 1024 # 2Mb
 			@FileTypeManager.isBinary "file.tex", "/path/on/disk", (error, binary) ->
@@ -98,6 +106,10 @@ describe "FileTypeManager", ->
 			@FileTypeManager.shouldIgnore "path/.git", (error, ignore) ->
 				ignore.should.equal true
 
+		it "should not ignore .latexmkrc dotfile", ->
+			@FileTypeManager.shouldIgnore "path/.latexmkrc", (error, ignore) ->
+				ignore.should.equal false
+
 		it "should ignore __MACOSX", ->
 			@FileTypeManager.shouldIgnore "path/__MACOSX", (error, ignore) ->
 				ignore.should.equal true
@@ -109,5 +121,3 @@ describe "FileTypeManager", ->
 		it "should ignore the case of the extension", ->
 			@FileTypeManager.shouldIgnore "file.AUX", (error, ignore) ->
 				ignore.should.equal true
-			
-

+ 46 - 0
services/web/test/unit_frontend/coffee/ide/editor/aceEditor/spell-check/SpellCheckManagerTests.coffee

@@ -0,0 +1,46 @@
+define [
+  'ide/editor/directives/aceEditor/spell-check/SpellCheckManager'
+], (SpellCheckManager) ->
+  describe 'SpellCheckManager', ->
+    beforeEach (done) ->
+      @timelord = sinon.useFakeTimers()
+
+      window.user = { id: 1 }
+      window.csrfToken = 'token'
+      @scope = {
+        $watch: sinon.stub()
+        spellCheck: true
+        spellCheckLanguage: 'en'
+      }
+      @highlightedWordManager = {
+        reset: sinon.stub()
+        clearRow: sinon.stub()
+        addHighlight: sinon.stub()
+      }
+      @adapter = {
+        getLines: sinon.stub()
+        highlightedWordManager: @highlightedWordManager
+      }
+      inject ($q, $http, $httpBackend, $cacheFactory) =>
+        @$http = $http
+        @$q = $q
+        @$httpBackend = $httpBackend
+        cache = $cacheFactory('spellCheckTest', {capacity: 1000})
+        @spellCheckManager = new SpellCheckManager(@scope, cache, $http, $q, @adapter)
+        done()
+
+    afterEach ->
+      @timelord.restore()
+
+    it 'runs a full check soon after init', () ->
+      @$httpBackend.when('POST', '/spelling/check').respond({
+        misspellings: [{
+          index: 0
+          suggestions: ['opposition']
+        }]
+      })
+      @adapter.getLines.returns(['oppozition'])
+      @spellCheckManager.init()
+      @timelord.tick(200)
+      @$httpBackend.flush()
+      expect(@highlightedWordManager.addHighlight).to.have.been.called

+ 13 - 2
services/web/webpack.config.js

@@ -1,7 +1,9 @@
 const fs = require('fs')
 const path = require('path')
+const webpack = require('webpack')
 
 const MODULES_PATH = path.join(__dirname, '/modules')
+const webpackENV = process.env.WEBPACK_ENV || 'development'
 
 // Generate a hash of entry points, including modules
 const entryPoints = {}
@@ -63,7 +65,16 @@ module.exports = {
 		}]
 	},
 
-	// TODO
-	// plugins: {}
+	plugins: [
+		new webpack.DefinePlugin({
+			// Swaps out checks for NODE_ENV with the env. This is used by various
+			// libs to enable dev-only features. These checks then become something
+			// like `if ('production' == 'production')`. Minification will then strip
+			// the dev-only code from the bundle
+			'process.env': {
+				NODE_ENV: JSON.stringify(webpackENV)
+			},
+		})
+	]
 }