diff --git a/services/web/Jenkinsfile b/services/web/Jenkinsfile
index 7953aa0dbc..7b44d70315 100644
--- a/services/web/Jenkinsfile
+++ b/services/web/Jenkinsfile
@@ -101,7 +101,7 @@ pipeline {
}
}
steps {
- sh 'make minify'
+ sh 'WEBPACK_ENV=production make minify'
}
}
diff --git a/services/web/Makefile b/services/web/Makefile
index a0f8c1c503..720df034e8 100644
--- a/services/web/Makefile
+++ b/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)
diff --git a/services/web/README.md b/services/web/README.md
index 811e7fbd33..7c8cbf0155 100644
--- a/services/web/README.md
+++ b/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`
diff --git a/services/web/app/coffee/Features/Exports/ExportsController.coffee b/services/web/app/coffee/Features/Exports/ExportsController.coffee
new file mode 100644
index 0000000000..cda2296249
--- /dev/null
+++ b/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
+
diff --git a/services/web/app/coffee/Features/Exports/ExportsHandler.coffee b/services/web/app/coffee/Features/Exports/ExportsHandler.coffee
new file mode 100644
index 0000000000..727b01a575
--- /dev/null
+++ b/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
diff --git a/services/web/app/coffee/Features/Referal/ReferalAllocator.coffee b/services/web/app/coffee/Features/Referal/ReferalAllocator.coffee
index b28979aaf0..53d3696807 100644
--- a/services/web/app/coffee/Features/Referal/ReferalAllocator.coffee
+++ b/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
diff --git a/services/web/app/coffee/Features/Referal/ReferalFeatures.coffee b/services/web/app/coffee/Features/Referal/ReferalFeatures.coffee
new file mode 100644
index 0000000000..34651ef1f5
--- /dev/null
+++ b/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
diff --git a/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee b/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee
new file mode 100644
index 0000000000..5c176c611f
--- /dev/null
+++ b/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
\ No newline at end of file
diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
index 9da6fc688d..32d2abf594 100644
--- a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
+++ b/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
\ No newline at end of file
diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee
index 1452f26d99..33376f504b 100644
--- a/services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee
+++ b/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
\ No newline at end of file
diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee
index d6d049f5bf..8c5f631e12 100644
--- a/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee
+++ b/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
diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
index 649551b5b2..cb5c39d122 100644
--- a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
+++ b/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
-
diff --git a/services/web/app/coffee/Features/Subscription/UserFeaturesUpdater.coffee b/services/web/app/coffee/Features/Subscription/UserFeaturesUpdater.coffee
index c0b691e677..28a49b2126 100644
--- a/services/web/app/coffee/Features/Subscription/UserFeaturesUpdater.coffee
+++ b/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
diff --git a/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee b/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee
new file mode 100644
index 0000000000..05dc140be2
--- /dev/null
+++ b/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}")
\ No newline at end of file
diff --git a/services/web/app/coffee/Features/Uploads/FileTypeManager.coffee b/services/web/app/coffee/Features/Uploads/FileTypeManager.coffee
index 918f66df7b..524c6915e0 100644
--- a/services/web/app/coffee/Features/Uploads/FileTypeManager.coffee
+++ b/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
-
-
-
diff --git a/services/web/app/coffee/infrastructure/Features.coffee b/services/web/app/coffee/infrastructure/Features.coffee
index 3878a97dbc..afb2eefb9f 100644
--- a/services/web/app/coffee/infrastructure/Features.coffee
+++ b/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'
diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee
index 7ec4dafbf4..e6b2692f7c 100644
--- a/services/web/app/coffee/router.coffee
+++ b/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")
@@ -205,6 +206,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
diff --git a/services/web/app/views/contact-us-modal.pug b/services/web/app/views/contact-us-modal.pug
index aad68a53d3..eacfd10533 100644
--- a/services/web/app/views/contact-us-modal.pug
+++ b/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: "__kb__", kb: translate("knowledge_base") })}
+ p.contact-suggestion-label !{translate("kb_suggestions_enquiry", { kbLink: "" + translate("knowledge_base") + "" })}
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")
diff --git a/services/web/app/views/project/editor/pdf.pug b/services/web/app/views/project/editor/pdf.pug
index a874a0b88b..26de2f35b1 100644
--- a/services/web/app/views/project/editor/pdf.pug
+++ b/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
- | #{translate("unlimited_projects")}
- li
- i.fa.fa-check
- | #{translate("collabs_per_proj", {collabcount:'Multiple'})}
- li
- i.fa.fa-check
- | #{translate("full_doc_history")}
- li
- i.fa.fa-check
- | #{translate("sync_to_dropbox")}
- li
- i.fa.fa-check
- | #{translate("sync_to_github")}
- li
- i.fa.fa-check
- |#{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
+ | #{translate("unlimited_projects")}
+ li
+ i.fa.fa-check
+ | #{translate("collabs_per_proj", {collabcount:'Multiple'})}
+ li
+ i.fa.fa-check
+ | #{translate("full_doc_history")}
+ li
+ i.fa.fa-check
+ | #{translate("sync_to_dropbox")}
+ li
+ i.fa.fa-check
+ | #{translate("sync_to_github")}
+ li
+ i.fa.fa-check
+ |#{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
diff --git a/services/web/app/views/project/list/notifications.pug b/services/web/app/views/project/list/notifications.pug
index f173555e1c..ee004039fc 100644
--- a/services/web/app/views/project/list/notifications.pug
+++ b/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
diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee
index d53b1a73ba..0892804778 100644
--- a/services/web/config/settings.defaults.coffee
+++ b/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:
diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml
index 5a668bc4a3..a062c0df4e 100644
--- a/services/web/docker-compose.yml
+++ b/services/web/docker-compose.yml
@@ -17,6 +17,7 @@ services:
PROJECT_HISTORY_ENABLED: 'true'
ENABLED_LINKED_FILE_TYPES: 'url'
LINKED_URL_PROXY: 'http://localhost:6543'
+ SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee
depends_on:
- redis
- mongo
diff --git a/services/web/public/coffee/ide/editor/EditorManager.coffee b/services/web/public/coffee/ide/editor/EditorManager.coffee
index 72bbe8509a..e3cabf8e98 100644
--- a/services/web/public/coffee/ide/editor/EditorManager.coffee
+++ b/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"
diff --git a/services/web/public/coffee/ide/editor/components/spellMenu.coffee b/services/web/public/coffee/ide/editor/components/spellMenu.coffee
new file mode 100644
index 0000000000..ff3462e03e
--- /dev/null
+++ b/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: """
+
+ """
+ }
\ No newline at end of file
diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee
index ef3c5b20f8..617b41b845 100644
--- a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee
+++ b/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
-
+
- @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
+ reset: () ->
+ @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)
diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter.coffee
new file mode 100644
index 0000000000..2afc2cf0ff
--- /dev/null
+++ b/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()
diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee
index acbd636531..cbe4fdbd64 100644
--- a/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee
+++ b/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()
-
- if @$scope.spellCheckEnabled and @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
- @runSpellCheckSoon(200)
-
- e.oldSession?.getDocument().off "change", onChange
- e.session.getDocument().on "change", onChange
-
- e.oldSession?.off "changeScrollTop", onScroll
- e.session.on "changeScrollTop", onScroll
-
- @$scope.spellingMenu = {left: '0px', top: '0px'}
-
- @editor.on "nativecontextmenu", (e) =>
- e.domEvent.stopPropagation();
- @closeContextMenu(e.domEvent)
- @openContextMenu(e.domEvent)
-
- $(document).on "click", (e) =>
- if e.which != 3 # Ignore if this was a right click
- @closeContextMenu(e)
+ $(document).on 'click', (e) =>
+ @closeContextMenu() if e.which != 3 # Ignore if right click
return true
- @$scope.replaceWord = (highlight, suggestion) =>
- @replaceWord(highlight, suggestion)
+ init: () ->
+ @updatedLines = Array(@adapter.getLines().length).fill(true)
+ @runSpellCheckSoon(200) if @isSpellCheckEnabled()
- @$scope.learnWord = (highlight) =>
- @learnWord(highlight)
+ isSpellCheckEnabled: () ->
+ return !!(
+ @$scope.spellCheck and
+ @$scope.spellCheckLanguage and
+ @$scope.spellCheckLanguage != ''
+ )
- runFullCheck: () ->
- @highlightedWordManager.clearRows()
- if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
- @runSpellCheck()
+ onChange: (e) =>
+ if @isSpellCheckEnabled()
+ @markLinesAsUpdated(@adapter.normalizeChangeEvent(e))
+
+ @adapter.highlightedWordManager.clearHighlightTouchingRange(e)
- runCheckOnChange: (e) ->
- if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
- @highlightedWordManager.applyChange(e)
- @markLinesAsUpdated(e)
@runSpellCheckSoon()
+ onSessionChange: () =>
+ @adapter.highlightedWordManager.reset()
+ @inProgressRequest.abort() if @inProgressRequest?
+
+ @runSpellCheckSoon(200) if @isSpellCheckEnabled()
+
+ onContextMenu: (e) =>
+ @closeContextMenu()
+ @openContextMenu(e)
+
+ 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
diff --git a/services/web/public/coffee/main/learn.coffee b/services/web/public/coffee/main/learn.coffee
index eeae4219b8..375c2f0365 100644
--- a/services/web/public/coffee/main/learn.coffee
+++ b/services/web/public/coffee/main/learn.coffee
@@ -55,32 +55,4 @@ define [
hits = _.map response.hits, buildHitViewModel
updateHits hits
- $scope.showMissingTemplateModal = () ->
- modalInstance = $modal.open(
- templateUrl: "missingWikiPageModal"
- controller: "MissingWikiPageController"
- )
-
- App.controller 'MissingWikiPageController', ($scope, $modalInstance) ->
- $scope.form = {}
- $scope.sent = false
- $scope.sending = false
- $scope.contactUs = ->
- if !$scope.form.message?
- console.log "message not set"
- return
- $scope.sending = true
- ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32)
- params =
- email: $scope.form.email or "support@sharelatex.com"
- message: $scope.form.message or ""
- subject: "new wiki page sujection - [#{ticketNumber}]"
- labels: "support wiki"
-
- Groove.createTicket params, (err, json)->
- $scope.sent = true
- $scope.$apply()
-
- $scope.close = () ->
- $modalInstance.close()
diff --git a/services/web/public/stylesheets/app/editor/rich-text.less b/services/web/public/stylesheets/app/editor/rich-text.less
index 493540e705..37a896269d 100644
--- a/services/web/public/stylesheets/app/editor/rich-text.less
+++ b/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;
+ }
}
+
diff --git a/services/web/test/acceptance/coffee/ExportsTests.coffee b/services/web/test/acceptance/coffee/ExportsTests.coffee
new file mode 100644
index 0000000000..7a6784008d
--- /dev/null
+++ b/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()
diff --git a/services/web/test/acceptance/coffee/ProjectInviteTests.coffee b/services/web/test/acceptance/coffee/ProjectInviteTests.coffee
index b599578738..02598b320f 100644
--- a/services/web/test/acceptance/coffee/ProjectInviteTests.coffee
+++ b/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 [
diff --git a/services/web/test/acceptance/coffee/SubscriptionTests.coffee b/services/web/test/acceptance/coffee/SubscriptionTests.coffee
new file mode 100644
index 0000000000..ce762ea0ee
--- /dev/null
+++ b/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()
\ No newline at end of file
diff --git a/services/web/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee b/services/web/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee
index 381d7ab272..b7df202d72 100644
--- a/services/web/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee
+++ b/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) ->
diff --git a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee
new file mode 100644
index 0000000000..5c2cf47ad9
--- /dev/null
+++ b/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()
diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee
index 7d8e9086d4..a02ab5c42c 100644
--- a/services/web/test/acceptance/coffee/helpers/User.coffee
+++ b/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?
diff --git a/services/web/test/acceptance/config/settings.test.coffee b/services/web/test/acceptance/config/settings.test.coffee
new file mode 100644
index 0000000000..3d665f8a54
--- /dev/null
+++ b/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
diff --git a/services/web/test/unit/coffee/Exports/ExportsControllerTests.coffee b/services/web/test/unit/coffee/Exports/ExportsControllerTests.coffee
new file mode 100644
index 0000000000..821eb5b4e0
--- /dev/null
+++ b/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()
diff --git a/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee b/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee
new file mode 100644
index 0000000000..6333db8270
--- /dev/null
+++ b/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
diff --git a/services/web/test/unit/coffee/Referal/ReferalAllocatorTests.coffee b/services/web/test/unit/coffee/Referal/ReferalAllocatorTests.coffee
index 3fe925f91c..308c2ce80e 100644
--- a/services/web/test/unit/coffee/Referal/ReferalAllocatorTests.coffee
+++ b/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
-
-
diff --git a/services/web/test/unit/coffee/Referal/ReferalFeaturesTests.coffee b/services/web/test/unit/coffee/Referal/ReferalFeaturesTests.coffee
new file mode 100644
index 0000000000..dfa70f8ebe
--- /dev/null
+++ b/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
+
+
diff --git a/services/web/test/unit/coffee/Subscription/FeaturesUpdaterTests.coffee b/services/web/test/unit/coffee/Subscription/FeaturesUpdaterTests.coffee
new file mode 100644
index 0000000000..c8f77bfd40
--- /dev/null
+++ b/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
+ })
diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee
index 22f571ffa8..04888e2dd7 100644
--- a/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee
+++ b/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()
diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee
index 93479a03a1..dd2afad56b 100644
--- a/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee
+++ b/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
diff --git a/services/web/test/unit/coffee/Subscription/UserFeaturesUpdaterTests.coffee b/services/web/test/unit/coffee/Subscription/UserFeaturesUpdaterTests.coffee
index d388d67c3b..1ba4e0b32f 100644
--- a/services/web/test/unit/coffee/Subscription/UserFeaturesUpdaterTests.coffee
+++ b/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()
\ No newline at end of file
diff --git a/services/web/test/unit/coffee/Subscription/V1SusbcriptionManagerTests.coffee b/services/web/test/unit/coffee/Subscription/V1SusbcriptionManagerTests.coffee
new file mode 100644
index 0000000000..ae6237e627
--- /dev/null
+++ b/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()
diff --git a/services/web/test/unit/coffee/Uploads/FileTypeManagerTests.coffee b/services/web/test/unit/coffee/Uploads/FileTypeManagerTests.coffee
index c6fdf64829..be456c74a5 100644
--- a/services/web/test/unit/coffee/Uploads/FileTypeManagerTests.coffee
+++ b/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
-
-
diff --git a/services/web/test/unit_frontend/coffee/ide/editor/aceEditor/spell-check/SpellCheckManagerTests.coffee b/services/web/test/unit_frontend/coffee/ide/editor/aceEditor/spell-check/SpellCheckManagerTests.coffee
new file mode 100644
index 0000000000..1b0dddced6
--- /dev/null
+++ b/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
diff --git a/services/web/webpack.config.js b/services/web/webpack.config.js
index c914bc1f21..34ea397d36 100644
--- a/services/web/webpack.config.js
+++ b/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 = {}
@@ -80,6 +82,15 @@ module.exports = {
jquery: path.join(__dirname, 'node_modules/jquery/dist/jquery'),
}
},
- // 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)
+ },
+ })
+ ]
}