Merge pull request #5087 from overleaf/em-promisify-subscription-updater

Promisify FeaturesUpdater and SubscriptionHandler

GitOrigin-RevId: 1a9725afa119c0eaee3d975a11197b6f702f1307
This commit is contained in:
Eric Mc Sween
2021-09-20 08:20:14 -04:00
committed by Copybot
parent 8b1665a97c
commit 0f7bfe9173
16 changed files with 1324 additions and 1793 deletions
@@ -2,6 +2,7 @@ let InstitutionsFeatures
const UserGetter = require('../User/UserGetter')
const PlansLocator = require('../Subscription/PlansLocator')
const Settings = require('@overleaf/settings')
const { promisifyAll } = require('../../util/promises')
module.exports = InstitutionsFeatures = {
getInstitutionsFeatures(userId, callback) {
@@ -44,3 +45,5 @@ module.exports = InstitutionsFeatures = {
})
},
}
module.exports.promises = promisifyAll(module.exports)
@@ -7,6 +7,7 @@ const {
promises: InstitutionsAPIPromises,
} = require('./InstitutionsAPI')
const FeaturesUpdater = require('../Subscription/FeaturesUpdater')
const FeaturesHelper = require('../Subscription/FeaturesHelper')
const UserGetter = require('../User/UserGetter')
const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
@@ -55,7 +56,7 @@ async function _checkUsersFeatures(userIds) {
}
users.forEach(user => {
const hasProFeaturesOrBetter = FeaturesUpdater.isFeatureSetBetter(
const hasProFeaturesOrBetter = FeaturesHelper.isFeatureSetBetter(
user.features,
Settings.features.professional
)
@@ -1,4 +1,5 @@
const _ = require('underscore')
const { promisify } = require('util')
const { User } = require('../../models/User')
const Settings = require('@overleaf/settings')
@@ -47,3 +48,7 @@ module.exports = ReferalFeatures = {
return highestBonusLevel
},
}
module.exports.promises = {
getBonusFeatures: promisify(module.exports.getBonusFeatures),
}
@@ -0,0 +1,94 @@
const _ = require('lodash')
/**
* Merge feature sets coming from different sources
*/
function mergeFeatures(featuresA, featuresB) {
const features = Object.assign({}, featuresA)
for (const key in featuresB) {
// Special merging logic for non-boolean features
if (key === 'compileGroup') {
if (
features.compileGroup === 'priority' ||
featuresB.compileGroup === 'priority'
) {
features.compileGroup = 'priority'
} else {
features.compileGroup = 'standard'
}
} else if (key === 'collaborators') {
if (features.collaborators === -1 || featuresB.collaborators === -1) {
features.collaborators = -1
} else {
features.collaborators = Math.max(
features.collaborators || 0,
featuresB.collaborators || 0
)
}
} else if (key === 'compileTimeout') {
features.compileTimeout = Math.max(
features.compileTimeout || 0,
featuresB.compileTimeout || 0
)
} else {
// Boolean keys, true is better
features[key] = features[key] || featuresB[key]
}
}
return features
}
/**
* Returns whether `featuresA` is a better feature set than `featuresB`
*/
function isFeatureSetBetter(featuresA, featuresB) {
const mergedFeatures = mergeFeatures(featuresA, featuresB)
return _.isEqual(featuresA, mergedFeatures)
}
/**
* Return what's missing from `currentFeatures` to equal `expectedFeatures`
*/
function compareFeatures(currentFeatures, expectedFeatures) {
currentFeatures = _.clone(currentFeatures)
expectedFeatures = _.clone(expectedFeatures)
if (_.isEqual(currentFeatures, expectedFeatures)) {
return {}
}
const mismatchReasons = {}
const featureKeys = [
...new Set([
...Object.keys(currentFeatures),
...Object.keys(expectedFeatures),
]),
]
featureKeys.sort().forEach(key => {
if (expectedFeatures[key] !== currentFeatures[key]) {
mismatchReasons[key] = expectedFeatures[key]
}
})
if (mismatchReasons.compileTimeout) {
// store the compile timeout difference instead of the new compile timeout
mismatchReasons.compileTimeout =
expectedFeatures.compileTimeout - currentFeatures.compileTimeout
}
if (mismatchReasons.collaborators) {
// store the collaborators difference instead of the new number only
// replace -1 by 100 to make it clearer
if (expectedFeatures.collaborators === -1) {
expectedFeatures.collaborators = 100
}
if (currentFeatures.collaborators === -1) {
currentFeatures.collaborators = 100
}
mismatchReasons.collaborators =
expectedFeatures.collaborators - currentFeatures.collaborators
}
return mismatchReasons
}
module.exports = { mergeFeatures, isFeatureSetBetter, compareFeatures }
@@ -1,9 +1,10 @@
const async = require('async')
const OError = require('@overleaf/o-error')
const PlansLocator = require('./PlansLocator')
const _ = require('lodash')
const { callbackify } = require('util')
const { callbackifyMultiResult } = require('../../util/promises')
const PlansLocator = require('./PlansLocator')
const SubscriptionLocator = require('./SubscriptionLocator')
const UserFeaturesUpdater = require('./UserFeaturesUpdater')
const FeaturesHelper = require('./FeaturesHelper')
const Settings = require('@overleaf/settings')
const logger = require('logger-sharelatex')
const ReferalFeatures = require('../Referal/ReferalFeatures')
@@ -12,339 +13,187 @@ const InstitutionsFeatures = require('../Institutions/InstitutionsFeatures')
const UserGetter = require('../User/UserGetter')
const AnalyticsManager = require('../Analytics/AnalyticsManager')
const FeaturesUpdater = {
refreshFeatures(userId, reason, callback = () => {}) {
UserGetter.getUser(userId, { _id: 1, features: 1 }, (err, user) => {
if (err) {
return callback(err)
}
const oldFeatures = _.clone(user.features)
FeaturesUpdater._computeFeatures(userId, (error, features) => {
if (error) {
return callback(error)
}
logger.log({ userId, features }, 'updating user features')
async function refreshFeatures(userId, reason) {
const user = await UserGetter.promises.getUser(userId, {
_id: 1,
features: 1,
})
const oldFeatures = _.clone(user.features)
const features = await computeFeatures(userId)
logger.log({ userId, features }, 'updating user features')
const matchedFeatureSet = FeaturesUpdater._getMatchedFeatureSet(
features
)
AnalyticsManager.setUserPropertyForUser(
userId,
'feature-set',
matchedFeatureSet
)
const matchedFeatureSet = _getMatchedFeatureSet(features)
AnalyticsManager.setUserPropertyForUser(
userId,
'feature-set',
matchedFeatureSet
)
UserFeaturesUpdater.updateFeatures(
userId,
features,
(err, newFeatures, featuresChanged) => {
if (err) {
return callback(err)
}
if (oldFeatures.dropbox === true && features.dropbox === false) {
logger.log({ userId }, '[FeaturesUpdater] must unlink dropbox')
const Modules = require('../../infrastructure/Modules')
Modules.hooks.fire('removeDropbox', userId, reason, err => {
if (err) {
logger.error(err)
}
return callback(null, newFeatures, featuresChanged)
})
} else {
return callback(null, newFeatures, featuresChanged)
}
}
)
})
})
},
_computeFeatures(userId, callback) {
const jobs = {
individualFeatures(cb) {
FeaturesUpdater._getIndividualFeatures(userId, cb)
},
groupFeatureSets(cb) {
FeaturesUpdater._getGroupFeatureSets(userId, cb)
},
institutionFeatures(cb) {
InstitutionsFeatures.getInstitutionsFeatures(userId, cb)
},
v1Features(cb) {
FeaturesUpdater._getV1Features(userId, cb)
},
bonusFeatures(cb) {
ReferalFeatures.getBonusFeatures(userId, cb)
},
featuresOverrides(cb) {
FeaturesUpdater._getFeaturesOverrides(userId, cb)
},
const {
features: newFeatures,
featuresChanged,
} = await UserFeaturesUpdater.promises.updateFeatures(userId, features)
if (oldFeatures.dropbox === true && features.dropbox === false) {
logger.log({ userId }, '[FeaturesUpdater] must unlink dropbox')
const Modules = require('../../infrastructure/Modules')
try {
await Modules.promises.hooks.fire('removeDropbox', userId, reason)
} catch (err) {
logger.error(err)
}
async.series(jobs, function (err, results) {
if (err) {
OError.tag(
err,
'error getting subscription or group for refreshFeatures',
{
userId,
}
)
return callback(err)
}
}
return { features: newFeatures, featuresChanged }
}
const {
individualFeatures,
groupFeatureSets,
institutionFeatures,
v1Features,
bonusFeatures,
featuresOverrides,
} = results
logger.log(
{
userId,
individualFeatures,
groupFeatureSets,
institutionFeatures,
v1Features,
bonusFeatures,
featuresOverrides,
},
'merging user features'
)
const featureSets = groupFeatureSets.concat([
individualFeatures,
institutionFeatures,
v1Features,
bonusFeatures,
featuresOverrides,
])
const features = _.reduce(
featureSets,
FeaturesUpdater._mergeFeatures,
Settings.defaultFeatures
)
callback(null, features)
})
},
_getIndividualFeatures(userId, callback) {
SubscriptionLocator.getUserIndividualSubscription(userId, (err, sub) =>
callback(err, FeaturesUpdater._subscriptionToFeatures(sub))
)
},
_getGroupFeatureSets(userId, callback) {
SubscriptionLocator.getGroupSubscriptionsMemberOf(userId, (err, subs) =>
callback(err, (subs || []).map(FeaturesUpdater._subscriptionToFeatures))
)
},
_getFeaturesOverrides(userId, callback) {
UserGetter.getUser(userId, { featuresOverrides: 1 }, (error, user) => {
if (error) {
return callback(error)
}
if (
!user ||
!user.featuresOverrides ||
user.featuresOverrides.length === 0
) {
return callback(null, {})
}
const activeFeaturesOverrides = []
for (const featuresOverride of user.featuresOverrides) {
if (
!featuresOverride.expiresAt ||
featuresOverride.expiresAt > new Date()
) {
activeFeaturesOverrides.push(featuresOverride.features)
}
}
const features = _.reduce(
activeFeaturesOverrides,
FeaturesUpdater._mergeFeatures,
{}
)
callback(null, features)
})
},
_getV1Features(userId, callback) {
V1SubscriptionManager.getPlanCodeFromV1(
async function computeFeatures(userId) {
const individualFeatures = await _getIndividualFeatures(userId)
const groupFeatureSets = await _getGroupFeatureSets(userId)
const institutionFeatures = await InstitutionsFeatures.promises.getInstitutionsFeatures(
userId
)
const v1Features = await _getV1Features(userId)
const bonusFeatures = await ReferalFeatures.promises.getBonusFeatures(userId)
const featuresOverrides = await _getFeaturesOverrides(userId)
logger.log(
{
userId,
function (err, planCode, v1Id) {
if (err) {
if ((err ? err.name : undefined) === 'NotFoundError') {
return callback(null, [])
}
return callback(err)
}
individualFeatures,
groupFeatureSets,
institutionFeatures,
v1Features,
bonusFeatures,
featuresOverrides,
},
'merging user features'
)
const featureSets = groupFeatureSets.concat([
individualFeatures,
institutionFeatures,
v1Features,
bonusFeatures,
featuresOverrides,
])
const features = _.reduce(
featureSets,
FeaturesHelper.mergeFeatures,
Settings.defaultFeatures
)
return features
}
callback(
err,
FeaturesUpdater._mergeFeatures(
V1SubscriptionManager.getGrandfatheredFeaturesForV1User(v1Id) || {},
FeaturesUpdater.planCodeToFeatures(planCode)
)
)
}
)
},
async function _getIndividualFeatures(userId) {
const sub = await SubscriptionLocator.promises.getUserIndividualSubscription(
userId
)
return _subscriptionToFeatures(sub)
}
_mergeFeatures(featuresA, featuresB) {
const features = Object.assign({}, featuresA)
for (const key in featuresB) {
// Special merging logic for non-boolean features
if (key === 'compileGroup') {
if (
features.compileGroup === 'priority' ||
featuresB.compileGroup === 'priority'
) {
features.compileGroup = 'priority'
} else {
features.compileGroup = 'standard'
}
} else if (key === 'collaborators') {
if (features.collaborators === -1 || featuresB.collaborators === -1) {
features.collaborators = -1
} else {
features.collaborators = Math.max(
features.collaborators || 0,
featuresB.collaborators || 0
)
}
} else if (key === 'compileTimeout') {
features.compileTimeout = Math.max(
features.compileTimeout || 0,
featuresB.compileTimeout || 0
)
} else {
// Boolean keys, true is better
features[key] = features[key] || featuresB[key]
}
async function _getGroupFeatureSets(userId) {
const subs = await SubscriptionLocator.promises.getGroupSubscriptionsMemberOf(
userId
)
return (subs || []).map(_subscriptionToFeatures)
}
async function _getFeaturesOverrides(userId) {
const user = await UserGetter.promises.getUser(userId, {
featuresOverrides: 1,
})
if (!user || !user.featuresOverrides || user.featuresOverrides.length === 0) {
return {}
}
const activeFeaturesOverrides = []
for (const featuresOverride of user.featuresOverrides) {
if (
!featuresOverride.expiresAt ||
featuresOverride.expiresAt > new Date()
) {
activeFeaturesOverrides.push(featuresOverride.features)
}
return features
},
}
const features = _.reduce(
activeFeaturesOverrides,
FeaturesHelper.mergeFeatures,
{}
)
return features
}
isFeatureSetBetter(featuresA, featuresB) {
const mergedFeatures = FeaturesUpdater._mergeFeatures(featuresA, featuresB)
return _.isEqual(featuresA, mergedFeatures)
},
_subscriptionToFeatures(subscription) {
return FeaturesUpdater.planCodeToFeatures(
subscription ? subscription.planCode : undefined
)
},
planCodeToFeatures(planCode) {
if (!planCode) {
return {}
}
const plan = PlansLocator.findLocalPlanInSettings(planCode)
if (!plan) {
async function _getV1Features(userId) {
let planCode, v1Id
try {
;({
planCode,
v1Id,
} = await V1SubscriptionManager.promises.getPlanCodeFromV1(userId))
} catch (err) {
if (err.name === 'NotFoundError') {
return {}
} else {
return plan.features
throw err
}
},
compareFeatures(currentFeatures, expectedFeatures) {
currentFeatures = _.clone(currentFeatures)
expectedFeatures = _.clone(expectedFeatures)
if (_.isEqual(currentFeatures, expectedFeatures)) {
return {}
}
const mismatchReasons = {}
const featureKeys = [
...new Set([
...Object.keys(currentFeatures),
...Object.keys(expectedFeatures),
]),
]
featureKeys.sort().forEach(key => {
if (expectedFeatures[key] !== currentFeatures[key]) {
mismatchReasons[key] = expectedFeatures[key]
}
})
if (mismatchReasons.compileTimeout) {
// store the compile timeout difference instead of the new compile timeout
mismatchReasons.compileTimeout =
expectedFeatures.compileTimeout - currentFeatures.compileTimeout
}
if (mismatchReasons.collaborators) {
// store the collaborators difference instead of the new number only
// replace -1 by 100 to make it clearer
if (expectedFeatures.collaborators === -1) {
expectedFeatures.collaborators = 100
}
if (currentFeatures.collaborators === -1) {
currentFeatures.collaborators = 100
}
mismatchReasons.collaborators =
expectedFeatures.collaborators - currentFeatures.collaborators
}
return mismatchReasons
},
doSyncFromV1(v1UserId, callback) {
logger.log({ v1UserId }, '[AccountSync] starting account sync')
return UserGetter.getUser(
{ 'overleaf.id': v1UserId },
{ _id: 1 },
function (err, user) {
if (err != null) {
OError.tag(err, '[AccountSync] error getting user', {
v1UserId,
})
return callback(err)
}
if ((user != null ? user._id : undefined) == null) {
logger.warn({ v1UserId }, '[AccountSync] no user found for v1 id')
return callback(null)
}
logger.log(
{ v1UserId, userId: user._id },
'[AccountSync] updating user subscription and features'
)
return FeaturesUpdater.refreshFeatures(user._id, 'sync-v1', callback)
}
)
},
_getMatchedFeatureSet(features) {
for (const [name, featureSet] of Object.entries(Settings.features)) {
if (_.isEqual(features, featureSet)) {
return name
}
}
return 'mixed'
},
}
return FeaturesHelper.mergeFeatures(
V1SubscriptionManager.getGrandfatheredFeaturesForV1User(v1Id) || {},
_planCodeToFeatures(planCode)
)
}
const refreshFeaturesPromise = (userId, reason) =>
new Promise(function (resolve, reject) {
FeaturesUpdater.refreshFeatures(
userId,
reason,
(error, features, featuresChanged) => {
if (error) {
reject(error)
} else {
resolve({ features, featuresChanged })
}
}
)
})
FeaturesUpdater.promises = {
refreshFeatures: refreshFeaturesPromise,
function _subscriptionToFeatures(subscription) {
return _planCodeToFeatures(subscription && subscription.planCode)
}
module.exports = FeaturesUpdater
function _planCodeToFeatures(planCode) {
if (!planCode) {
return {}
}
const plan = PlansLocator.findLocalPlanInSettings(planCode)
if (!plan) {
return {}
} else {
return plan.features
}
}
async function doSyncFromV1(v1UserId) {
logger.log({ v1UserId }, '[AccountSync] starting account sync')
const user = await UserGetter.promises.getUser(
{ 'overleaf.id': v1UserId },
{ _id: 1 }
)
if (user == null) {
logger.warn({ v1UserId }, '[AccountSync] no user found for v1 id')
return
}
logger.log(
{ v1UserId, userId: user._id },
'[AccountSync] updating user subscription and features'
)
return refreshFeatures(user._id, 'sync-v1')
}
function _getMatchedFeatureSet(features) {
for (const [name, featureSet] of Object.entries(Settings.features)) {
if (_.isEqual(features, featureSet)) {
return name
}
}
return 'mixed'
}
module.exports = {
computeFeatures: callbackify(computeFeatures),
refreshFeatures: callbackifyMultiResult(refreshFeatures, [
'features',
'featuresChanged',
]),
doSyncFromV1: callbackifyMultiResult(doSyncFromV1, [
'features',
'featuresChanged',
]),
promises: {
computeFeatures,
refreshFeatures,
doSyncFromV1,
},
}
@@ -447,7 +447,7 @@ function recurlyNotificationParser(req, res, next) {
async function refreshUserFeatures(req, res) {
const { user_id: userId } = req.params
await FeaturesUpdater.promises.refreshFeatures(userId)
await FeaturesUpdater.promises.refreshFeatures(userId, 'acceptance-test')
res.sendStatus(200)
}
@@ -1,8 +1,8 @@
const async = require('async')
const { promisify } = require('util')
const RecurlyWrapper = require('./RecurlyWrapper')
const RecurlyClient = require('./RecurlyClient')
const { User } = require('../../models/User')
const { promisifyAll } = require('../../util/promises')
const logger = require('logger-sharelatex')
const SubscriptionUpdater = require('./SubscriptionUpdater')
const LimitationsManager = require('./LimitationsManager')
@@ -10,334 +10,352 @@ const EmailHandler = require('../Email/EmailHandler')
const PlansLocator = require('./PlansLocator')
const SubscriptionHelper = require('./SubscriptionHelper')
const SubscriptionHandler = {
validateNoSubscriptionInRecurly(userId, callback) {
if (callback == null) {
callback = function () {}
}
RecurlyWrapper.listAccountActiveSubscriptions(
userId,
function (error, subscriptions) {
if (subscriptions == null) {
subscriptions = []
}
if (error != null) {
return callback(error)
}
if (subscriptions.length > 0) {
SubscriptionUpdater.syncSubscription(
subscriptions[0],
userId,
function (error) {
if (error != null) {
return callback(error)
}
callback(null, false)
}
)
} else {
callback(null, true)
}
function validateNoSubscriptionInRecurly(userId, callback) {
if (callback == null) {
callback = function () {}
}
RecurlyWrapper.listAccountActiveSubscriptions(
userId,
function (error, subscriptions) {
if (subscriptions == null) {
subscriptions = []
}
)
},
createSubscription(user, subscriptionDetails, recurlyTokenIds, callback) {
SubscriptionHandler.validateNoSubscriptionInRecurly(
user._id,
function (error, valid) {
if (error != null) {
return callback(error)
}
if (!valid) {
return callback(new Error('user already has subscription in recurly'))
}
RecurlyWrapper.createSubscription(
user,
subscriptionDetails,
recurlyTokenIds,
function (error, recurlySubscription) {
if (error != null) {
return callback(error)
}
if (subscriptions.length > 0) {
SubscriptionUpdater.syncSubscription(
subscriptions[0],
userId,
function (error) {
if (error != null) {
return callback(error)
}
return SubscriptionUpdater.syncSubscription(
recurlySubscription,
user._id,
function (error) {
if (error != null) {
return callback(error)
}
return callback()
}
)
callback(null, false)
}
)
} else {
callback(null, true)
}
)
},
}
)
}
updateSubscription(user, planCode, couponCode, callback) {
LimitationsManager.userHasV2Subscription(
function createSubscription(
user,
subscriptionDetails,
recurlyTokenIds,
callback
) {
validateNoSubscriptionInRecurly(user._id, function (error, valid) {
if (error != null) {
return callback(error)
}
if (!valid) {
return callback(new Error('user already has subscription in recurly'))
}
RecurlyWrapper.createSubscription(
user,
function (err, hasSubscription, subscription) {
if (err) {
logger.warn(
{ err, user_id: user._id, hasSubscription },
'there was an error checking user v2 subscription'
)
}
if (!hasSubscription) {
return callback()
} else {
return async.series(
[
function (cb) {
if (couponCode == null) {
return cb()
}
RecurlyWrapper.getSubscription(
subscription.recurlySubscription_id,
{ includeAccount: true },
function (err, usersSubscription) {
if (err != null) {
return cb(err)
}
RecurlyWrapper.redeemCoupon(
usersSubscription.account.account_code,
couponCode,
cb
)
}
)
},
function (cb) {
let changeAtTermEnd
const currentPlan = PlansLocator.findLocalPlanInSettings(
subscription.planCode
)
const newPlan = PlansLocator.findLocalPlanInSettings(planCode)
if (currentPlan && newPlan) {
changeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
currentPlan,
newPlan
)
} else {
logger.error(
{ currentPlan: subscription.planCode, newPlan: planCode },
'unable to locate both plans in settings'
)
return cb(
new Error('unable to locate both plans in settings')
)
}
const timeframe = changeAtTermEnd ? 'term_end' : 'now'
RecurlyClient.changeSubscriptionByUuid(
subscription.recurlySubscription_id,
{ planCode: planCode, timeframe: timeframe },
function (error, subscriptionChange) {
if (error != null) {
return cb(error)
}
// v2 recurly API wants a UUID, but UUID isn't included in the subscription change response
// we got the UUID from the DB using userHasV2Subscription() - it is the only property
// we need to be able to build a 'recurlySubscription' object for syncSubscription()
SubscriptionHandler.syncSubscription(
{ uuid: subscription.recurlySubscription_id },
user._id,
cb
)
}
)
},
],
callback
)
}
}
)
},
cancelPendingSubscriptionChange(user, callback) {
LimitationsManager.userHasV2Subscription(
user,
function (err, hasSubscription, subscription) {
if (err) {
return callback(err)
}
if (hasSubscription) {
RecurlyClient.removeSubscriptionChangeByUuid(
subscription.recurlySubscription_id,
function (error) {
if (error != null) {
return callback(error)
}
callback()
}
)
} else {
callback()
}
}
)
},
cancelSubscription(user, callback) {
LimitationsManager.userHasV2Subscription(
user,
function (err, hasSubscription, subscription) {
if (err) {
logger.warn(
{ err, user_id: user._id, hasSubscription },
'there was an error checking user v2 subscription'
)
}
if (hasSubscription) {
RecurlyClient.cancelSubscriptionByUuid(
subscription.recurlySubscription_id,
function (error) {
if (error != null) {
return callback(error)
}
const emailOpts = {
to: user.email,
first_name: user.first_name,
}
const ONE_HOUR_IN_MS = 1000 * 60 * 60
setTimeout(
() =>
EmailHandler.sendEmail(
'canceledSubscription',
emailOpts,
err => {
if (err != null) {
logger.warn(
{ err },
'failed to send confirmation email for subscription cancellation'
)
}
}
),
ONE_HOUR_IN_MS
)
callback()
}
)
} else {
callback()
}
}
)
},
reactivateSubscription(user, callback) {
LimitationsManager.userHasV2Subscription(
user,
function (err, hasSubscription, subscription) {
if (err) {
logger.warn(
{ err, user_id: user._id, hasSubscription },
'there was an error checking user v2 subscription'
)
}
if (hasSubscription) {
RecurlyClient.reactivateSubscriptionByUuid(
subscription.recurlySubscription_id,
function (error) {
if (error != null) {
return callback(error)
}
EmailHandler.sendEmail(
'reactivatedSubscription',
{ to: user.email },
err => {
if (err != null) {
logger.warn(
{ err },
'failed to send reactivation confirmation email'
)
}
}
)
callback()
}
)
} else {
callback()
}
}
)
},
syncSubscription(recurlySubscription, requesterData, callback) {
RecurlyWrapper.getSubscription(
recurlySubscription.uuid,
{ includeAccount: true },
subscriptionDetails,
recurlyTokenIds,
function (error, recurlySubscription) {
if (error != null) {
return callback(error)
}
User.findById(
recurlySubscription.account.account_code,
{ _id: 1 },
function (error, user) {
return SubscriptionUpdater.syncSubscription(
recurlySubscription,
user._id,
function (error) {
if (error != null) {
return callback(error)
}
if (user == null) {
return callback(new Error('no user found'))
}
SubscriptionUpdater.syncSubscription(
recurlySubscription,
user != null ? user._id : undefined,
requesterData,
callback
)
return callback()
}
)
}
)
},
})
}
// attempt to collect past due invoice for customer. Only do that when a) the
// customer is using Paypal and b) there is only one past due invoice.
// This is used because Recurly doesn't always attempt collection of paast due
// invoices after Paypal billing info were updated.
attemptPaypalInvoiceCollection(recurlyAccountCode, callback) {
RecurlyWrapper.getBillingInfo(recurlyAccountCode, (error, billingInfo) => {
if (error) {
function updateSubscription(user, planCode, couponCode, callback) {
LimitationsManager.userHasV2Subscription(
user,
function (err, hasSubscription, subscription) {
if (err) {
logger.warn(
{ err, user_id: user._id, hasSubscription },
'there was an error checking user v2 subscription'
)
}
if (!hasSubscription) {
return callback()
} else {
return async.series(
[
function (cb) {
if (couponCode == null) {
return cb()
}
RecurlyWrapper.getSubscription(
subscription.recurlySubscription_id,
{ includeAccount: true },
function (err, usersSubscription) {
if (err != null) {
return cb(err)
}
RecurlyWrapper.redeemCoupon(
usersSubscription.account.account_code,
couponCode,
cb
)
}
)
},
function (cb) {
let changeAtTermEnd
const currentPlan = PlansLocator.findLocalPlanInSettings(
subscription.planCode
)
const newPlan = PlansLocator.findLocalPlanInSettings(planCode)
if (currentPlan && newPlan) {
changeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
currentPlan,
newPlan
)
} else {
logger.error(
{ currentPlan: subscription.planCode, newPlan: planCode },
'unable to locate both plans in settings'
)
return cb(new Error('unable to locate both plans in settings'))
}
const timeframe = changeAtTermEnd ? 'term_end' : 'now'
RecurlyClient.changeSubscriptionByUuid(
subscription.recurlySubscription_id,
{ planCode: planCode, timeframe: timeframe },
function (error, subscriptionChange) {
if (error != null) {
return cb(error)
}
// v2 recurly API wants a UUID, but UUID isn't included in the subscription change response
// we got the UUID from the DB using userHasV2Subscription() - it is the only property
// we need to be able to build a 'recurlySubscription' object for syncSubscription()
syncSubscription(
{ uuid: subscription.recurlySubscription_id },
user._id,
cb
)
}
)
},
],
callback
)
}
}
)
}
function cancelPendingSubscriptionChange(user, callback) {
LimitationsManager.userHasV2Subscription(
user,
function (err, hasSubscription, subscription) {
if (err) {
return callback(err)
}
if (hasSubscription) {
RecurlyClient.removeSubscriptionChangeByUuid(
subscription.recurlySubscription_id,
function (error) {
if (error != null) {
return callback(error)
}
callback()
}
)
} else {
callback()
}
}
)
}
function cancelSubscription(user, callback) {
LimitationsManager.userHasV2Subscription(
user,
function (err, hasSubscription, subscription) {
if (err) {
logger.warn(
{ err, user_id: user._id, hasSubscription },
'there was an error checking user v2 subscription'
)
}
if (hasSubscription) {
RecurlyClient.cancelSubscriptionByUuid(
subscription.recurlySubscription_id,
function (error) {
if (error != null) {
return callback(error)
}
const emailOpts = {
to: user.email,
first_name: user.first_name,
}
const ONE_HOUR_IN_MS = 1000 * 60 * 60
setTimeout(
() =>
EmailHandler.sendEmail(
'canceledSubscription',
emailOpts,
err => {
if (err != null) {
logger.warn(
{ err },
'failed to send confirmation email for subscription cancellation'
)
}
}
),
ONE_HOUR_IN_MS
)
callback()
}
)
} else {
callback()
}
}
)
}
function reactivateSubscription(user, callback) {
LimitationsManager.userHasV2Subscription(
user,
function (err, hasSubscription, subscription) {
if (err) {
logger.warn(
{ err, user_id: user._id, hasSubscription },
'there was an error checking user v2 subscription'
)
}
if (hasSubscription) {
RecurlyClient.reactivateSubscriptionByUuid(
subscription.recurlySubscription_id,
function (error) {
if (error != null) {
return callback(error)
}
EmailHandler.sendEmail(
'reactivatedSubscription',
{ to: user.email },
err => {
if (err != null) {
logger.warn(
{ err },
'failed to send reactivation confirmation email'
)
}
}
)
callback()
}
)
} else {
callback()
}
}
)
}
function syncSubscription(recurlySubscription, requesterData, callback) {
RecurlyWrapper.getSubscription(
recurlySubscription.uuid,
{ includeAccount: true },
function (error, recurlySubscription) {
if (error != null) {
return callback(error)
}
if (!billingInfo.paypal_billing_agreement_id) {
// this is not a Paypal user
return callback()
}
RecurlyWrapper.getAccountPastDueInvoices(
recurlyAccountCode,
(error, pastDueInvoices) => {
if (error) {
User.findById(
recurlySubscription.account.account_code,
{ _id: 1 },
function (error, user) {
if (error != null) {
return callback(error)
}
if (pastDueInvoices.length !== 1) {
// no past due invoices, or more than one. Ignore.
return callback()
if (user == null) {
return callback(new Error('no user found'))
}
RecurlyWrapper.attemptInvoiceCollection(
pastDueInvoices[0].invoice_number,
SubscriptionUpdater.syncSubscription(
recurlySubscription,
user != null ? user._id : undefined,
requesterData,
callback
)
}
)
})
},
extendTrial(subscription, daysToExend, callback) {
return RecurlyWrapper.extendTrial(
subscription.recurlySubscription_id,
daysToExend,
callback
)
},
}
)
}
SubscriptionHandler.promises = promisifyAll(SubscriptionHandler)
module.exports = SubscriptionHandler
// attempt to collect past due invoice for customer. Only do that when a) the
// customer is using Paypal and b) there is only one past due invoice.
// This is used because Recurly doesn't always attempt collection of paast due
// invoices after Paypal billing info were updated.
function attemptPaypalInvoiceCollection(recurlyAccountCode, callback) {
RecurlyWrapper.getBillingInfo(recurlyAccountCode, (error, billingInfo) => {
if (error) {
return callback(error)
}
if (!billingInfo.paypal_billing_agreement_id) {
// this is not a Paypal user
return callback()
}
RecurlyWrapper.getAccountPastDueInvoices(
recurlyAccountCode,
(error, pastDueInvoices) => {
if (error) {
return callback(error)
}
if (pastDueInvoices.length !== 1) {
// no past due invoices, or more than one. Ignore.
return callback()
}
RecurlyWrapper.attemptInvoiceCollection(
pastDueInvoices[0].invoice_number,
callback
)
}
)
})
}
function extendTrial(subscription, daysToExend, callback) {
return RecurlyWrapper.extendTrial(
subscription.recurlySubscription_id,
daysToExend,
callback
)
}
module.exports = {
validateNoSubscriptionInRecurly,
createSubscription,
updateSubscription,
cancelPendingSubscriptionChange,
cancelSubscription,
reactivateSubscription,
syncSubscription,
attemptPaypalInvoiceCollection,
extendTrial,
promises: {
validateNoSubscriptionInRecurly: promisify(validateNoSubscriptionInRecurly),
createSubscription: promisify(createSubscription),
updateSubscription: promisify(updateSubscription),
cancelPendingSubscriptionChange: promisify(cancelPendingSubscriptionChange),
cancelSubscription: promisify(cancelSubscription),
reactivateSubscription: promisify(reactivateSubscription),
syncSubscription: promisify(syncSubscription),
attemptPaypalInvoiceCollection: promisify(attemptPaypalInvoiceCollection),
extendTrial: promisify(extendTrial),
},
}
@@ -1,12 +1,10 @@
const { db, ObjectId } = require('../../infrastructure/mongodb')
const OError = require('@overleaf/o-error')
const async = require('async')
const { promisify, callbackify } = require('../../util/promises')
const { callbackify } = require('../../util/promises')
const { Subscription } = require('../../models/Subscription')
const SubscriptionLocator = require('./SubscriptionLocator')
const UserGetter = require('../User/UserGetter')
const PlansLocator = require('./PlansLocator')
const FeaturesUpdater = require('./FeaturesUpdater')
const FeaturesHelper = require('./FeaturesHelper')
const AnalyticsManager = require('../Analytics/AnalyticsManager')
const { DeletedSubscription } = require('../../models/DeletedSubscription')
const logger = require('logger-sharelatex')
@@ -25,7 +23,7 @@ const logger = require('logger-sharelatex')
*
* If the subscription is Recurly, we silently do nothing.
*/
function updateAdmin(subscription, adminId, callback) {
async function updateAdmin(subscription, adminId) {
const query = {
_id: ObjectId(subscription._id),
customAccount: true,
@@ -38,207 +36,115 @@ function updateAdmin(subscription, adminId, callback) {
} else {
update.$set.manager_ids = [ObjectId(adminId)]
}
Subscription.updateOne(query, update, callback)
await Subscription.updateOne(query, update).exec()
}
function syncSubscription(
async function syncSubscription(
recurlySubscription,
adminUserId,
requesterData,
callback
requesterData = {}
) {
if (!callback) {
callback = requesterData
requesterData = {}
let subscription = await SubscriptionLocator.promises.getUsersSubscription(
adminUserId
)
if (subscription == null) {
subscription = await _createNewSubscription(adminUserId)
}
SubscriptionLocator.getUsersSubscription(
adminUserId,
function (err, subscription) {
if (err != null) {
return callback(err)
}
if (subscription != null) {
updateSubscriptionFromRecurly(
recurlySubscription,
subscription,
requesterData,
callback
)
} else {
_createNewSubscription(adminUserId, function (err, subscription) {
if (err != null) {
return callback(err)
}
updateSubscriptionFromRecurly(
recurlySubscription,
subscription,
requesterData,
callback
)
})
}
}
await updateSubscriptionFromRecurly(
recurlySubscription,
subscription,
requesterData
)
}
function addUserToGroup(subscriptionId, userId, callback) {
Subscription.updateOne(
async function addUserToGroup(subscriptionId, userId) {
await Subscription.updateOne(
{ _id: subscriptionId },
{ $addToSet: { member_ids: userId } },
function (err) {
if (err != null) {
return callback(err)
}
FeaturesUpdater.refreshFeatures(userId, 'add-to-group', function () {
callbackify(_sendUserGroupPlanCodeUserProperty)(userId, callback)
})
}
)
{ $addToSet: { member_ids: userId } }
).exec()
await FeaturesUpdater.promises.refreshFeatures(userId, 'add-to-group')
await _sendUserGroupPlanCodeUserProperty(userId)
}
function removeUserFromGroup(subscriptionId, userId, callback) {
Subscription.updateOne(
async function removeUserFromGroup(subscriptionId, userId) {
await Subscription.updateOne(
{ _id: subscriptionId },
{ $pull: { member_ids: userId } },
function (error) {
if (error) {
OError.tag(error, 'error removing user from group', {
subscriptionId,
userId,
})
return callback(error)
}
UserGetter.getUser(userId, function (error, user) {
if (error) {
return callback(error)
}
FeaturesUpdater.refreshFeatures(
userId,
'remove-user-from-group',
function () {
callbackify(_sendUserGroupPlanCodeUserProperty)(userId, callback)
}
)
})
}
)
}
function removeUserFromAllGroups(userId, callback) {
SubscriptionLocator.getMemberSubscriptions(
{ $pull: { member_ids: userId } }
).exec()
await FeaturesUpdater.promises.refreshFeatures(
userId,
function (error, subscriptions) {
if (error) {
return callback(error)
}
if (!subscriptions) {
return callback()
}
const subscriptionIds = subscriptions.map(sub => sub._id)
const removeOperation = { $pull: { member_ids: userId } }
Subscription.updateMany(
{ _id: subscriptionIds },
removeOperation,
function (error) {
if (error) {
OError.tag(error, 'error removing user from groups', {
userId,
subscriptionIds,
})
return callback(error)
}
UserGetter.getUser(userId, function (error, user) {
if (error) {
return callback(error)
}
FeaturesUpdater.refreshFeatures(
userId,
'remove-user-from-groups',
function () {
callbackify(_sendUserGroupPlanCodeUserProperty)(
userId,
callback
)
}
)
})
}
)
}
'remove-user-from-group'
)
await _sendUserGroupPlanCodeUserProperty(userId)
}
function deleteWithV1Id(v1TeamId, callback) {
Subscription.deleteOne({ 'overleaf.id': v1TeamId }, callback)
}
function deleteSubscription(subscription, deleterData, callback) {
if (callback == null) {
callback = function () {}
async function removeUserFromAllGroups(userId) {
const subscriptions = await SubscriptionLocator.promises.getMemberSubscriptions(
userId
)
if (subscriptions.length === 0) {
return
}
async.series(
[
cb =>
// 1. create deletedSubscription
createDeletedSubscription(subscription, deleterData, cb),
cb =>
// 2. remove subscription
Subscription.deleteOne({ _id: subscription._id }, cb),
cb =>
// 3. refresh users features
refreshUsersFeatures(subscription, cb),
],
callback
const subscriptionIds = subscriptions.map(sub => sub._id)
const removeOperation = { $pull: { member_ids: userId } }
await Subscription.updateMany(
{ _id: subscriptionIds },
removeOperation
).exec()
await FeaturesUpdater.promises.refreshFeatures(
userId,
'remove-user-from-groups'
)
await _sendUserGroupPlanCodeUserProperty(userId)
}
function restoreSubscription(subscriptionId, callback) {
SubscriptionLocator.getDeletedSubscription(
subscriptionId,
function (err, deletedSubscription) {
if (err) {
return callback(err)
}
const subscription = deletedSubscription.subscription
async.series(
[
cb =>
// 1. upsert subscription
db.subscriptions.updateOne(
{ _id: subscription._id },
subscription,
{ upsert: true },
cb
),
cb =>
// 2. refresh users features. Do this before removing the
// subscription so the restore can be retried if this fails
refreshUsersFeatures(subscription, cb),
cb =>
// 3. remove deleted subscription
DeletedSubscription.deleteOne(
{ 'subscription._id': subscription._id },
callback
),
],
callback
)
}
)
async function deleteWithV1Id(v1TeamId) {
await Subscription.deleteOne({ 'overleaf.id': v1TeamId }).exec()
}
function refreshUsersFeatures(subscription, callback) {
async function deleteSubscription(subscription, deleterData) {
// 1. create deletedSubscription
await createDeletedSubscription(subscription, deleterData)
// 2. remove subscription
await Subscription.deleteOne({ _id: subscription._id }).exec()
// 3. refresh users features
await refreshUsersFeatures(subscription)
}
async function restoreSubscription(subscriptionId) {
const deletedSubscription = await SubscriptionLocator.promises.getDeletedSubscription(
subscriptionId
)
const subscription = deletedSubscription.subscription
// 1. upsert subscription
await db.subscriptions.updateOne({ _id: subscription._id }, subscription, {
upsert: true,
})
// 2. refresh users features. Do this before removing the
// subscription so the restore can be retried if this fails
await refreshUsersFeatures(subscription)
// 3. remove deleted subscription
await DeletedSubscription.deleteOne({
'subscription._id': subscription._id,
}).exec()
}
async function refreshUsersFeatures(subscription) {
const userIds = [subscription.admin_id].concat(subscription.member_ids || [])
async.mapSeries(
userIds,
function (userId, cb) {
FeaturesUpdater.refreshFeatures(userId, 'subscription-updater', cb)
},
callback
)
for (const userId of userIds) {
await FeaturesUpdater.promises.refreshFeatures(
userId,
'subscription-updater'
)
}
}
function createDeletedSubscription(subscription, deleterData, callback) {
async function createDeletedSubscription(subscription, deleterData) {
subscription.teamInvites = []
subscription.invited_emails = []
const filter = { 'subscription._id': subscription._id }
@@ -250,65 +156,56 @@ function createDeletedSubscription(subscription, deleterData, callback) {
subscription: subscription,
}
const options = { upsert: true, new: true, setDefaultsOnInsert: true }
DeletedSubscription.findOneAndUpdate(filter, data, options, callback)
await DeletedSubscription.findOneAndUpdate(filter, data, options).exec()
}
function _createNewSubscription(adminUserId, callback) {
async function _createNewSubscription(adminUserId) {
const subscription = new Subscription({
admin_id: adminUserId,
manager_ids: [adminUserId],
})
subscription.save(err => callback(err, subscription))
await subscription.save()
return subscription
}
function _deleteAndReplaceSubscriptionFromRecurly(
async function _deleteAndReplaceSubscriptionFromRecurly(
recurlySubscription,
subscription,
requesterData,
callback
requesterData
) {
const adminUserId = subscription.admin_id
deleteSubscription(subscription, requesterData, err => {
if (err) {
return callback(err)
}
_createNewSubscription(adminUserId, (err, newSubscription) => {
if (err) {
return callback(err)
}
updateSubscriptionFromRecurly(
recurlySubscription,
newSubscription,
requesterData,
callback
)
})
})
await deleteSubscription(subscription, requesterData)
const newSubscription = await _createNewSubscription(adminUserId)
await updateSubscriptionFromRecurly(
recurlySubscription,
newSubscription,
requesterData
)
}
function updateSubscriptionFromRecurly(
async function updateSubscriptionFromRecurly(
recurlySubscription,
subscription,
requesterData,
callback
requesterData
) {
if (recurlySubscription.state === 'expired') {
return deleteSubscription(subscription, requesterData, callback)
await deleteSubscription(subscription, requesterData)
return
}
const updatedPlanCode = recurlySubscription.plan.plan_code
const plan = PlansLocator.findLocalPlanInSettings(updatedPlanCode)
if (plan == null) {
return callback(new Error(`plan code not found: ${updatedPlanCode}`))
throw new Error(`plan code not found: ${updatedPlanCode}`)
}
if (!plan.groupPlan && subscription.groupPlan) {
// If downgrading from group to individual plan, delete group sub and create a new one
return _deleteAndReplaceSubscriptionFromRecurly(
await _deleteAndReplaceSubscriptionFromRecurly(
recurlySubscription,
subscription,
requesterData,
callback
requesterData
)
return
}
subscription.recurlySubscription_id = recurlySubscription.uuid
@@ -336,25 +233,22 @@ function updateSubscriptionFromRecurly(
})
}
}
subscription.save(function (error) {
if (error) {
return callback(error)
}
refreshUsersFeatures(subscription, callback)
})
await subscription.save()
await refreshUsersFeatures(subscription)
}
async function _sendUserGroupPlanCodeUserProperty(userId) {
try {
const subscriptions =
(await SubscriptionLocator.promises.getMemberSubscriptions(userId)) || []
const subscriptions = await SubscriptionLocator.promises.getMemberSubscriptions(
userId
)
let bestPlanCode = null
let bestFeatures = {}
for (const subscription of subscriptions) {
const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
if (
plan &&
FeaturesUpdater.isFeatureSetBetter(plan.features, bestFeatures)
FeaturesHelper.isFeatureSetBetter(plan.features, bestFeatures)
) {
bestPlanCode = plan.planCode
bestFeatures = plan.features
@@ -374,28 +268,28 @@ async function _sendUserGroupPlanCodeUserProperty(userId) {
}
module.exports = {
updateAdmin,
syncSubscription,
deleteSubscription,
createDeletedSubscription,
addUserToGroup,
refreshUsersFeatures,
removeUserFromGroup,
removeUserFromAllGroups,
deleteWithV1Id,
restoreSubscription,
updateSubscriptionFromRecurly,
updateAdmin: callbackify(updateAdmin),
syncSubscription: callbackify(syncSubscription),
deleteSubscription: callbackify(deleteSubscription),
createDeletedSubscription: callbackify(createDeletedSubscription),
addUserToGroup: callbackify(addUserToGroup),
refreshUsersFeatures: callbackify(refreshUsersFeatures),
removeUserFromGroup: callbackify(removeUserFromGroup),
removeUserFromAllGroups: callbackify(removeUserFromAllGroups),
deleteWithV1Id: callbackify(deleteWithV1Id),
restoreSubscription: callbackify(restoreSubscription),
updateSubscriptionFromRecurly: callbackify(updateSubscriptionFromRecurly),
promises: {
updateAdmin: promisify(updateAdmin),
syncSubscription: promisify(syncSubscription),
addUserToGroup: promisify(addUserToGroup),
refreshUsersFeatures: promisify(refreshUsersFeatures),
removeUserFromGroup: promisify(removeUserFromGroup),
removeUserFromAllGroups: promisify(removeUserFromAllGroups),
deleteSubscription: promisify(deleteSubscription),
createDeletedSubscription: promisify(createDeletedSubscription),
deleteWithV1Id: promisify(deleteWithV1Id),
restoreSubscription: promisify(restoreSubscription),
updateSubscriptionFromRecurly: promisify(updateSubscriptionFromRecurly),
updateAdmin,
syncSubscription,
addUserToGroup,
refreshUsersFeatures,
removeUserFromGroup,
removeUserFromAllGroups,
deleteSubscription,
createDeletedSubscription,
deleteWithV1Id,
restoreSubscription,
updateSubscriptionFromRecurly,
},
}
@@ -1,4 +1,5 @@
const { User } = require('../../models/User')
const { promisifyAll } = require('../../util/promises')
function _featuresChanged(newFeatures, featuresBefore) {
for (const feature in newFeatures) {
@@ -39,3 +40,9 @@ module.exports = {
})
},
}
module.exports.promises = promisifyAll(module.exports, {
multiResult: {
updateFeatures: ['features', 'featuresChanged'],
},
})
@@ -16,6 +16,7 @@ const UserGetter = require('../User/UserGetter')
const request = require('request')
const settings = require('@overleaf/settings')
const { V1ConnectionError, NotFoundError } = require('../Errors/Errors')
const { promisifyAll } = require('../../util/promises')
module.exports = V1SubscriptionManager = {
// Returned planCode = 'v1_pro' | 'v1_pro_plus' | 'v1_student' | 'v1_free' | null
@@ -214,3 +215,10 @@ function __guard__(value, transform) {
? transform(value)
: undefined
}
module.exports.promises = promisifyAll(module.exports, {
without: ['getGrandfatheredFeaturesForV1User'],
multiResult: {
getPlanCodeFromV1: ['planCode', 'v1Id'],
},
})
+3 -2
View File
@@ -3,6 +3,7 @@ const minimist = require('minimist')
const _ = require('lodash')
const async = require('async')
const FeaturesUpdater = require('../app/src/Features/Subscription/FeaturesUpdater')
const FeaturesHelper = require('../app/src/Features/Subscription/FeaturesHelper')
const UserFeaturesUpdater = require('../app/src/Features/Subscription/UserFeaturesUpdater')
const ScriptLogger = {
@@ -54,12 +55,12 @@ const ScriptLogger = {
}
const checkAndUpdateUser = (user, callback) =>
FeaturesUpdater._computeFeatures(user._id, (error, freshFeatures) => {
FeaturesUpdater.computeFeatures(user._id, (error, freshFeatures) => {
if (error) {
return callback(error)
}
const mismatchReasons = FeaturesUpdater.compareFeatures(
const mismatchReasons = FeaturesHelper.compareFeatures(
user.features,
freshFeatures
)
@@ -106,6 +106,8 @@ describe('InstitutionsManager', function () {
},
'../Subscription/FeaturesUpdater': {
refreshFeatures: this.refreshFeatures,
},
'../Subscription/FeaturesHelper': {
isFeatureSetBetter: (this.isFeatureSetBetter = sinon.stub()),
},
'../User/UserGetter': this.UserGetter,
@@ -0,0 +1,126 @@
const SandboxedModule = require('sandboxed-module')
const { expect } = require('chai')
const MODULE_PATH = '../../../../app/src/Features/Subscription/FeaturesHelper'
describe('FeaturesHelper', function () {
beforeEach(function () {
this.FeaturesHelper = SandboxedModule.require(MODULE_PATH)
})
describe('mergeFeatures', function () {
it('should prefer priority over standard for compileGroup', function () {
expect(
this.FeaturesHelper.mergeFeatures(
{ compileGroup: 'priority' },
{ compileGroup: 'standard' }
)
).to.deep.equal({ compileGroup: 'priority' })
expect(
this.FeaturesHelper.mergeFeatures(
{ compileGroup: 'standard' },
{ compileGroup: 'priority' }
)
).to.deep.equal({ compileGroup: 'priority' })
expect(
this.FeaturesHelper.mergeFeatures(
{ compileGroup: 'priority' },
{ compileGroup: 'priority' }
)
).to.deep.equal({ compileGroup: 'priority' })
expect(
this.FeaturesHelper.mergeFeatures(
{ compileGroup: 'standard' },
{ compileGroup: 'standard' }
)
).to.deep.equal({ compileGroup: 'standard' })
})
it('should prefer -1 over any other for collaborators', function () {
expect(
this.FeaturesHelper.mergeFeatures(
{ collaborators: -1 },
{ collaborators: 10 }
)
).to.deep.equal({ collaborators: -1 })
expect(
this.FeaturesHelper.mergeFeatures(
{ collaborators: 10 },
{ collaborators: -1 }
)
).to.deep.equal({ collaborators: -1 })
expect(
this.FeaturesHelper.mergeFeatures(
{ collaborators: 4 },
{ collaborators: 10 }
)
).to.deep.equal({ collaborators: 10 })
})
it('should prefer the higher of compileTimeout', function () {
expect(
this.FeaturesHelper.mergeFeatures(
{ compileTimeout: 20 },
{ compileTimeout: 10 }
)
).to.deep.equal({ compileTimeout: 20 })
expect(
this.FeaturesHelper.mergeFeatures(
{ compileTimeout: 10 },
{ compileTimeout: 20 }
)
).to.deep.equal({ compileTimeout: 20 })
})
it('should prefer the true over false for other keys', function () {
expect(
this.FeaturesHelper.mergeFeatures({ github: true }, { github: false })
).to.deep.equal({ github: true })
expect(
this.FeaturesHelper.mergeFeatures({ github: false }, { github: true })
).to.deep.equal({ github: true })
expect(
this.FeaturesHelper.mergeFeatures({ github: true }, { github: true })
).to.deep.equal({ github: true })
expect(
this.FeaturesHelper.mergeFeatures({ github: false }, { github: false })
).to.deep.equal({ github: false })
})
})
describe('isFeatureSetBetter', function () {
it('simple comparisons', function () {
const result1 = this.FeaturesHelper.isFeatureSetBetter(
{ dropbox: true },
{ dropbox: false }
)
expect(result1).to.be.true
const result2 = this.FeaturesHelper.isFeatureSetBetter(
{ dropbox: false },
{ dropbox: true }
)
expect(result2).to.be.false
})
it('compound comparisons with same features', function () {
const result1 = this.FeaturesHelper.isFeatureSetBetter(
{ collaborators: 9, dropbox: true },
{ collaborators: 10, dropbox: true }
)
expect(result1).to.be.false
const result2 = this.FeaturesHelper.isFeatureSetBetter(
{ collaborators: -1, dropbox: true },
{ collaborators: 10, dropbox: true }
)
expect(result2).to.be.true
const result3 = this.FeaturesHelper.isFeatureSetBetter(
{ collaborators: -1, compileTimeout: 60, dropbox: true },
{ collaborators: 10, compileTimeout: 60, dropbox: true }
)
expect(result3).to.be.true
})
})
})
@@ -1,556 +1,244 @@
const SandboxedModule = require('sandboxed-module')
const { assert, expect } = require('chai')
const { expect } = require('chai')
const sinon = require('sinon')
const modulePath = '../../../../app/src/Features/Subscription/FeaturesUpdater'
const { ObjectId } = require('mongodb')
const MODULE_PATH = '../../../../app/src/Features/Subscription/FeaturesUpdater'
describe('FeaturesUpdater', function () {
beforeEach(function () {
this.user_id = ObjectId().toString()
this.user = {
_id: new ObjectId(),
features: {},
}
this.v1UserId = 12345
this.subscriptions = {
individual: { planCode: 'individual-plan' },
group1: { planCode: 'group-plan-1' },
group2: { planCode: 'group-plan-2' },
noDropbox: { planCode: 'no-dropbox' },
}
this.FeaturesUpdater = SandboxedModule.require(modulePath, {
this.UserFeaturesUpdater = {
promises: {
updateFeatures: sinon
.stub()
.resolves({ features: { some: 'features' }, featuresChanged: true }),
},
}
this.SubscriptionLocator = {
promises: {
getUserIndividualSubscription: sinon.stub(),
getGroupSubscriptionsMemberOf: sinon.stub(),
},
}
this.SubscriptionLocator.promises.getUserIndividualSubscription
.withArgs(this.user._id)
.resolves(this.subscriptions.individual)
this.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
.withArgs(this.user._id)
.resolves([this.subscriptions.group1, this.subscriptions.group2])
this.Settings = {
defaultFeatures: { default: 'features' },
plans: [
{ planCode: 'individual-plan', features: { individual: 'features' } },
{ planCode: 'group-plan-1', features: { group1: 'features' } },
{ planCode: 'group-plan-2', features: { group2: 'features' } },
{ planCode: 'v1-plan', features: { v1: 'features' } },
{ planCode: 'no-dropbox', features: { dropbox: false } },
],
features: {
all: {
default: 'features',
individual: 'features',
group1: 'features',
group2: 'features',
institutions: 'features',
v1: 'features',
grandfathered: 'features',
bonus: 'features',
},
},
}
this.ReferalFeatures = {
promises: {
getBonusFeatures: sinon.stub().resolves({ bonus: 'features' }),
},
}
this.V1SubscriptionManager = {
getGrandfatheredFeaturesForV1User: sinon.stub(),
promises: {
getPlanCodeFromV1: sinon.stub(),
},
}
this.V1SubscriptionManager.promises.getPlanCodeFromV1
.withArgs(this.user._id)
.resolves({ planCode: 'v1-plan', v1Id: this.v1UserId })
this.V1SubscriptionManager.getGrandfatheredFeaturesForV1User
.withArgs(this.v1UserId)
.returns({ grandfathered: 'features' })
this.InstitutionsFeatures = {
promises: {
getInstitutionsFeatures: sinon
.stub()
.resolves({ institutions: 'features' }),
},
}
this.UserGetter = {
promises: {
getUser: sinon.stub().resolves(null),
},
}
this.UserGetter.promises.getUser.withArgs(this.user._id).resolves(this.user)
this.UserGetter.promises.getUser
.withArgs({ 'overleaf.id': this.v1UserId })
.resolves(this.user)
this.AnalyticsManager = {
setUserPropertyForUser: sinon.stub(),
}
this.Modules = {
promises: { hooks: { fire: sinon.stub().resolves() } },
}
this.FeaturesHelper = {
mergeFeatures: sinon.stub().callsFake((a, b) => ({ ...a, ...b })),
}
this.FeaturesUpdater = SandboxedModule.require(MODULE_PATH, {
requires: {
'./UserFeaturesUpdater': (this.UserFeaturesUpdater = {}),
'./SubscriptionLocator': (this.SubscriptionLocator = {}),
'./PlansLocator': (this.PlansLocator = {}),
'@overleaf/settings': (this.Settings = {
features: {
personal: {
collaborators: 1,
dropbox: false,
compileTimeout: 60,
compileGroup: 'standard',
},
collaborator: {
collaborators: 10,
dropbox: true,
compileTimeout: 240,
compileGroup: 'priority',
},
professional: {
collaborators: -1,
dropbox: true,
compileTimeout: 240,
compileGroup: 'priority',
},
},
}),
'../Referal/ReferalFeatures': (this.ReferalFeatures = {}),
'./V1SubscriptionManager': (this.V1SubscriptionManager = {}),
'../Institutions/InstitutionsFeatures': (this.InstitutionsFeatures = {}),
'../User/UserGetter': (this.UserGetter = {}),
'../Analytics/AnalyticsManager': (this.AnalyticsManager = {
setUserPropertyForUser: sinon.stub(),
}),
'../../infrastructure/Modules': (this.Modules = {
hooks: { fire: sinon.stub() },
}),
'./UserFeaturesUpdater': this.UserFeaturesUpdater,
'./SubscriptionLocator': this.SubscriptionLocator,
'./FeaturesHelper': this.FeaturesHelper,
'@overleaf/settings': this.Settings,
'../Referal/ReferalFeatures': this.ReferalFeatures,
'./V1SubscriptionManager': this.V1SubscriptionManager,
'../Institutions/InstitutionsFeatures': this.InstitutionsFeatures,
'../User/UserGetter': this.UserGetter,
'../Analytics/AnalyticsManager': this.AnalyticsManager,
'../../infrastructure/Modules': this.Modules,
},
})
})
describe('refreshFeatures', function () {
beforeEach(function () {
this.user = {
_id: this.user_id,
features: {},
}
this.UserFeaturesUpdater.updateFeatures = sinon
.stub()
.yields(null, { some: 'features' }, true)
this.FeaturesUpdater._getIndividualFeatures = sinon
.stub()
.yields(null, { individual: 'features' })
this.FeaturesUpdater._getGroupFeatureSets = sinon
.stub()
.yields(null, [{ group: 'features' }, { group: 'features2' }])
this.InstitutionsFeatures.getInstitutionsFeatures = sinon
.stub()
.yields(null, { institutions: 'features' })
this.FeaturesUpdater._getV1Features = sinon
.stub()
.yields(null, { v1: 'features' })
this.ReferalFeatures.getBonusFeatures = sinon
.stub()
.yields(null, { bonus: 'features' })
this.FeaturesUpdater._mergeFeatures = sinon
.stub()
.returns({ merged: 'features' })
this.UserGetter.getUser = sinon.stub().yields(null, this.user)
this.callback = sinon.stub()
})
it('should return features and featuresChanged', function () {
this.FeaturesUpdater.refreshFeatures(
this.user_id,
'test',
(err, features, featuresChanged) => {
expect(err).to.not.exist
expect(features).to.exist
expect(featuresChanged).to.exist
}
it('should return features and featuresChanged', async function () {
const {
features,
featuresChanged,
} = await this.FeaturesUpdater.promises.refreshFeatures(
this.user._id,
'test'
)
expect(features).to.exist
expect(featuresChanged).to.exist
})
describe('normally', function () {
beforeEach(function () {
this.FeaturesUpdater.refreshFeatures(
this.user_id,
'test',
this.callback
beforeEach(async function () {
await this.FeaturesUpdater.promises.refreshFeatures(
this.user._id,
'test'
)
})
it('should get the individual features', function () {
this.FeaturesUpdater._getIndividualFeatures
.calledWith(this.user_id)
.should.equal(true)
})
it('should get the group features', function () {
this.FeaturesUpdater._getGroupFeatureSets
.calledWith(this.user_id)
.should.equal(true)
})
it('should get the institution features', function () {
this.InstitutionsFeatures.getInstitutionsFeatures
.calledWith(this.user_id)
.should.equal(true)
})
it('should get the v1 features', function () {
this.FeaturesUpdater._getV1Features
.calledWith(this.user_id)
.should.equal(true)
})
it('should get the bonus features', function () {
this.ReferalFeatures.getBonusFeatures
.calledWith(this.user_id)
.should.equal(true)
})
it('should merge from the default features', function () {
this.FeaturesUpdater._mergeFeatures
.calledWith(this.Settings.defaultFeatures)
.should.equal(true)
})
it('should merge the individual features', function () {
this.FeaturesUpdater._mergeFeatures
.calledWith(sinon.match.any, { individual: 'features' })
.should.equal(true)
})
it('should merge the group features', function () {
this.FeaturesUpdater._mergeFeatures
.calledWith(sinon.match.any, { group: 'features' })
.should.equal(true)
this.FeaturesUpdater._mergeFeatures
.calledWith(sinon.match.any, { group: 'features2' })
.should.equal(true)
})
it('should merge the institutions features', function () {
this.FeaturesUpdater._mergeFeatures
.calledWith(sinon.match.any, { institutions: 'features' })
.should.equal(true)
})
it('should merge the v1 features', function () {
this.FeaturesUpdater._mergeFeatures
.calledWith(sinon.match.any, { v1: 'features' })
.should.equal(true)
})
it('should merge the bonus features', function () {
this.FeaturesUpdater._mergeFeatures
.calledWith(sinon.match.any, { bonus: 'features' })
.should.equal(true)
})
it('should update the user with the merged features', function () {
this.UserFeaturesUpdater.updateFeatures
.calledWith(this.user_id, { merged: 'features' })
.should.equal(true)
expect(
this.UserFeaturesUpdater.promises.updateFeatures
).to.have.been.calledWith(this.user._id, this.Settings.features.all)
})
it('should send the corresponding feature set user property', function () {
expect(
this.AnalyticsManager.setUserPropertyForUser
).to.have.been.calledWith(this.user._id, 'feature-set', 'all')
})
})
describe('analytics user properties', function () {
it('should send the corresponding feature set user property', function () {
this.FeaturesUpdater._mergeFeatures = sinon
.stub()
.returns(this.Settings.features.personal)
this.FeaturesUpdater.refreshFeatures(
this.user_id,
'test',
this.callback
)
sinon.assert.calledWith(
this.AnalyticsManager.setUserPropertyForUser,
this.user_id,
'feature-set',
'personal'
describe('with a non-standard feature set', async function () {
beforeEach(async function () {
this.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
.withArgs(this.user._id)
.resolves(null)
await this.FeaturesUpdater.promises.refreshFeatures(
this.user._id,
'test'
)
})
it('should send mixed feature set user property', function () {
this.FeaturesUpdater._mergeFeatures = sinon
.stub()
.returns({ dropbox: true, feature: 'some' })
this.FeaturesUpdater.refreshFeatures(
this.user_id,
'test',
this.callback
)
sinon.assert.calledWith(
this.AnalyticsManager.setUserPropertyForUser,
this.user_id,
this.user._id,
'feature-set',
'mixed'
)
})
})
describe('when losing dropbox feature', function () {
beforeEach(function () {
this.user = {
_id: this.user_id,
features: { dropbox: true },
}
this.UserGetter.getUser = sinon.stub().yields(null, this.user)
this.FeaturesUpdater._mergeFeatures = sinon
.stub()
.returns({ dropbox: false })
this.FeaturesUpdater.refreshFeatures(
this.user_id,
'test',
this.callback
describe('when losing dropbox feature', async function () {
beforeEach(async function () {
this.user.features = { dropbox: true }
this.SubscriptionLocator.promises.getUserIndividualSubscription
.withArgs(this.user._id)
.resolves(this.subscriptions.noDropbox)
await this.FeaturesUpdater.promises.refreshFeatures(
this.user._id,
'test'
)
})
it('should fire module hook to unlink dropbox', function () {
this.Modules.hooks.fire
.calledWith('removeDropbox', this.user._id, 'test')
.should.equal(true)
})
})
})
describe('_mergeFeatures', function () {
it('should prefer priority over standard for compileGroup', function () {
expect(
this.FeaturesUpdater._mergeFeatures(
{
compileGroup: 'priority',
},
{
compileGroup: 'standard',
}
expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
'removeDropbox',
this.user._id,
'test'
)
).to.deep.equal({
compileGroup: 'priority',
})
expect(
this.FeaturesUpdater._mergeFeatures(
{
compileGroup: 'standard',
},
{
compileGroup: 'priority',
}
)
).to.deep.equal({
compileGroup: 'priority',
})
expect(
this.FeaturesUpdater._mergeFeatures(
{
compileGroup: 'priority',
},
{
compileGroup: 'priority',
}
)
).to.deep.equal({
compileGroup: 'priority',
})
expect(
this.FeaturesUpdater._mergeFeatures(
{
compileGroup: 'standard',
},
{
compileGroup: 'standard',
}
)
).to.deep.equal({
compileGroup: 'standard',
})
})
it('should prefer -1 over any other for collaborators', function () {
expect(
this.FeaturesUpdater._mergeFeatures(
{
collaborators: -1,
},
{
collaborators: 10,
}
)
).to.deep.equal({
collaborators: -1,
})
expect(
this.FeaturesUpdater._mergeFeatures(
{
collaborators: 10,
},
{
collaborators: -1,
}
)
).to.deep.equal({
collaborators: -1,
})
expect(
this.FeaturesUpdater._mergeFeatures(
{
collaborators: 4,
},
{
collaborators: 10,
}
)
).to.deep.equal({
collaborators: 10,
})
})
it('should prefer the higher of compileTimeout', function () {
expect(
this.FeaturesUpdater._mergeFeatures(
{
compileTimeout: 20,
},
{
compileTimeout: 10,
}
)
).to.deep.equal({
compileTimeout: 20,
})
expect(
this.FeaturesUpdater._mergeFeatures(
{
compileTimeout: 10,
},
{
compileTimeout: 20,
}
)
).to.deep.equal({
compileTimeout: 20,
})
})
it('should prefer the true over false for other keys', function () {
expect(
this.FeaturesUpdater._mergeFeatures(
{
github: true,
},
{
github: false,
}
)
).to.deep.equal({
github: true,
})
expect(
this.FeaturesUpdater._mergeFeatures(
{
github: false,
},
{
github: true,
}
)
).to.deep.equal({
github: true,
})
expect(
this.FeaturesUpdater._mergeFeatures(
{
github: true,
},
{
github: true,
}
)
).to.deep.equal({
github: true,
})
expect(
this.FeaturesUpdater._mergeFeatures(
{
github: false,
},
{
github: false,
}
)
).to.deep.equal({
github: false,
})
})
})
describe('doSyncFromV1', function () {
beforeEach(function () {
this.v1UserId = 1
this.user = {
_id: this.user_id,
email: 'user@example.com',
overleaf: {
id: this.v1UserId,
},
}
this.UserGetter.getUser = sinon.stub().callsArgWith(2, null, this.user)
this.FeaturesUpdater.refreshFeatures = sinon.stub().yields(null)
this.call = cb => {
this.FeaturesUpdater.doSyncFromV1(this.v1UserId, cb)
}
})
describe('when all goes well', function () {
it('should call getUser', function (done) {
this.call(() => {
expect(this.UserGetter.getUser.callCount).to.equal(1)
expect(
this.UserGetter.getUser.calledWith({ 'overleaf.id': this.v1UserId })
).to.equal(true)
done()
})
beforeEach(async function () {
await this.FeaturesUpdater.promises.doSyncFromV1(this.v1UserId)
})
it('should call refreshFeatures', function (done) {
this.call(() => {
expect(this.FeaturesUpdater.refreshFeatures.callCount).to.equal(1)
expect(
this.FeaturesUpdater.refreshFeatures.calledWith(this.user_id)
).to.equal(true)
done()
})
})
it('should not produce an error', function (done) {
this.call(err => {
expect(err).to.not.exist
done()
})
it('should update the user with the merged features', function () {
expect(
this.UserFeaturesUpdater.promises.updateFeatures
).to.have.been.calledWith(this.user._id, this.Settings.features.all)
})
})
describe('when getUser produces an error', function () {
beforeEach(function () {
this.UserGetter.getUser = sinon
.stub()
.callsArgWith(2, new Error('woops'))
this.UserGetter.promises.getUser.rejects(new Error('woops'))
})
it('should not call refreshFeatures', function () {
expect(this.FeaturesUpdater.refreshFeatures.callCount).to.equal(0)
})
it('should produce an error', function (done) {
this.call(err => {
expect(err).to.exist
done()
})
it('should propagate the error', async function () {
const someId = 9090
await expect(this.FeaturesUpdater.promises.doSyncFromV1(someId)).to.be
.rejected
expect(this.UserFeaturesUpdater.promises.updateFeatures).not.to.have
.been.called
})
})
describe('when getUser does not find a user', function () {
beforeEach(function () {
this.UserGetter.getUser = sinon.stub().callsArgWith(2, null, null)
beforeEach(async function () {
const someOtherId = 987
await this.FeaturesUpdater.promises.doSyncFromV1(someOtherId)
})
it('should not call refreshFeatures', function (done) {
this.call(() => {
expect(this.FeaturesUpdater.refreshFeatures.callCount).to.equal(0)
done()
})
it('should not update the user', function () {
expect(this.UserFeaturesUpdater.promises.updateFeatures).not.to.have
.been.called
})
it('should not produce an error', function (done) {
this.call(err => {
expect(err).to.not.exist
done()
})
})
})
})
describe('isFeatureSetBetter', function () {
it('simple comparisons', function () {
const result1 = this.FeaturesUpdater.isFeatureSetBetter(
{
dropbox: true,
},
{
dropbox: false,
}
)
assert.isTrue(result1)
const result2 = this.FeaturesUpdater.isFeatureSetBetter(
{
dropbox: false,
},
{
dropbox: true,
}
)
assert.isFalse(result2)
})
it('compound comparisons with same features', function () {
const result1 = this.FeaturesUpdater.isFeatureSetBetter(
{
collaborators: 9,
dropbox: true,
},
{
collaborators: 10,
dropbox: true,
}
)
assert.isFalse(result1)
const result2 = this.FeaturesUpdater.isFeatureSetBetter(
{
collaborators: -1,
dropbox: true,
},
{
collaborators: 10,
dropbox: true,
}
)
assert.isTrue(result2)
const result3 = this.FeaturesUpdater.isFeatureSetBetter(
{
collaborators: -1,
compileTimeout: 60,
dropbox: true,
},
{
collaborators: 10,
compileTimeout: 60,
dropbox: true,
}
)
assert.isTrue(result3)
})
})
})
@@ -2,7 +2,8 @@ const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const chai = require('chai')
const { expect } = chai
const modulePath =
const MODULE_PATH =
'../../../../app/src/Features/Subscription/SubscriptionHandler'
const mockRecurlySubscriptions = {
@@ -47,6 +48,8 @@ const mockSubscriptionChanges = {
describe('SubscriptionHandler', function () {
beforeEach(function () {
this.callback = sinon.stub()
this.Settings = {
plans: [
{
@@ -85,6 +88,7 @@ describe('SubscriptionHandler', function () {
getBillingInfo: sinon.stub().yields(),
getAccountPastDueInvoices: sinon.stub().yields(),
attemptInvoiceCollection: sinon.stub().yields(),
listAccountActiveSubscriptions: sinon.stub().yields(null, []),
}
this.RecurlyClient = {
changeSubscriptionByUuid: sinon
@@ -118,7 +122,7 @@ describe('SubscriptionHandler', function () {
shouldPlanChangeAtTermEnd: sinon.stub(),
}
this.SubscriptionHandler = SandboxedModule.require(modulePath, {
this.SubscriptionHandler = SandboxedModule.require(MODULE_PATH, {
requires: {
'./RecurlyWrapper': this.RecurlyWrapper,
'./RecurlyClient': this.RecurlyClient,
@@ -134,23 +138,15 @@ describe('SubscriptionHandler', function () {
'./SubscriptionHelper': this.SubscriptionHelper,
},
})
this.SubscriptionHandler.syncSubscriptionToUser = sinon
.stub()
.callsArgWith(2)
})
describe('createSubscription', function () {
beforeEach(function () {
this.callback = sinon.stub()
this.subscriptionDetails = {
cvv: '123',
number: '12345',
}
this.recurlyTokenIds = { billing: '45555666' }
this.SubscriptionHandler.validateNoSubscriptionInRecurly = sinon
.stub()
.yields(null, true)
})
describe('successfully', function () {
@@ -182,9 +178,9 @@ describe('SubscriptionHandler', function () {
describe('when there is already a subscription in Recurly', function () {
beforeEach(function () {
this.SubscriptionHandler.validateNoSubscriptionInRecurly = sinon
.stub()
.yields(null, false)
this.RecurlyWrapper.listAccountActiveSubscriptions.yields(null, [
this.subscription,
])
this.SubscriptionHandler.createSubscription(
this.user,
this.subscriptionDetails,
@@ -235,9 +231,9 @@ describe('SubscriptionHandler', function () {
callback(null, this.user)
}
this.plan_code = 'collaborator'
this.SubscriptionHelper.shouldPlanChangeAtTermEnd = sinon
.stub()
.returns(shouldPlanChangeAtTermEnd)
this.SubscriptionHelper.shouldPlanChangeAtTermEnd.returns(
shouldPlanChangeAtTermEnd
)
this.LimitationsManager.userHasV2Subscription.callsArgWith(
1,
null,
@@ -277,14 +273,13 @@ describe('SubscriptionHandler', function () {
callback(null, this.user)
}
this.plan_code = 'collaborator'
this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns(null)
this.PlansLocator.findLocalPlanInSettings.returns(null)
this.LimitationsManager.userHasV2Subscription.callsArgWith(
1,
null,
true,
this.subscription
)
this.callback = sinon.stub()
this.SubscriptionHandler.updateSubscription(
this.user,
this.plan_code,
@@ -322,9 +317,7 @@ describe('SubscriptionHandler', function () {
it('should redirect to the subscription dashboard', function () {
this.RecurlyClient.changeSubscriptionByUuid.called.should.equal(false)
this.SubscriptionHandler.syncSubscriptionToUser.called.should.equal(
false
)
this.SubscriptionUpdater.syncSubscription.called.should.equal(false)
})
})
@@ -561,18 +554,11 @@ describe('SubscriptionHandler', function () {
})
describe('validateNoSubscriptionInRecurly', function () {
beforeEach(function () {
this.subscriptions = []
this.RecurlyWrapper.listAccountActiveSubscriptions = sinon
.stub()
.yields(null, this.subscriptions)
this.SubscriptionUpdater.syncSubscription = sinon.stub().yields()
this.callback = sinon.stub()
})
describe('with no subscription in recurly', function () {
describe('with a subscription in recurly', function () {
beforeEach(function () {
this.subscriptions.push((this.subscription = { mock: 'subscription' }))
this.RecurlyWrapper.listAccountActiveSubscriptions.yields(null, [
this.subscription,
])
this.SubscriptionHandler.validateNoSubscriptionInRecurly(
this.user_id,
this.callback
@@ -596,7 +582,7 @@ describe('SubscriptionHandler', function () {
})
})
describe('with a subscription in recurly', function () {
describe('with no subscription in recurly', function () {
beforeEach(function () {
this.SubscriptionHandler.validateNoSubscriptionInRecurly(
this.user_id,
@@ -7,12 +7,14 @@ const { ObjectId } = require('mongodb')
describe('SubscriptionUpdater', function () {
beforeEach(function () {
this.recurlyPlan = { planCode: 'recurly-plan' }
this.recurlySubscription = {
uuid: '1238uoijdasjhd',
plan: {
plan_code: 'kjhsakjds',
plan_code: this.recurlyPlan.planCode,
},
}
this.adminUser = { _id: (this.adminuser_id = '5208dd34438843e2db000007') }
this.otherUserId = '5208dd34438842e2db000005'
this.allUserIds = ['13213', 'dsadas', 'djsaiud89']
@@ -21,7 +23,7 @@ describe('SubscriptionUpdater', function () {
admin_id: this.adminUser._id,
manager_ids: [this.adminUser._id],
member_ids: [],
save: sinon.stub().callsArgWith(0),
save: sinon.stub().resolves(),
planCode: 'student_or_something',
}
this.user_id = this.adminuser_id
@@ -31,7 +33,7 @@ describe('SubscriptionUpdater', function () {
admin_id: this.adminUser._id,
manager_ids: [this.adminUser._id],
member_ids: this.allUserIds,
save: sinon.stub().callsArgWith(0),
save: sinon.stub().resolves(),
groupPlan: true,
planCode: 'group_subscription',
}
@@ -40,17 +42,11 @@ describe('SubscriptionUpdater', function () {
admin_id: this.adminUser._id,
manager_ids: [this.adminUser._id],
member_ids: [this.otherUserId],
save: sinon.stub().callsArgWith(0),
save: sinon.stub().resolves(),
groupPlan: true,
planCode: 'better_group_subscription',
}
this.updateStub = sinon.stub().callsArgWith(2, null)
this.updateManyStub = sinon.stub().callsArgWith(2, null)
this.findOneAndUpdateStub = sinon
.stub()
.callsArgWith(2, null, this.subscription)
const subscription = this.subscription
this.SubscriptionModel = class {
constructor(opts) {
@@ -61,62 +57,93 @@ describe('SubscriptionUpdater', function () {
}
save() {
return subscription
return Promise.resolve(subscription)
}
}
this.SubscriptionModel.deleteOne = sinon.stub().yields()
this.SubscriptionModel.updateOne = this.updateStub
this.SubscriptionModel.updateMany = this.updateManyStub
this.SubscriptionModel.findOneAndUpdate = this.findOneAndUpdateStub
this.SubscriptionModel.deleteOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves() })
this.SubscriptionModel.updateOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves() })
this.SubscriptionModel.updateMany = sinon
.stub()
.returns({ exec: sinon.stub().resolves() })
this.SubscriptionModel.findOneAndUpdate = sinon.stub().returns({
exec: sinon.stub().resolves(this.subscription),
})
this.SubscriptionLocator = {
getUsersSubscription: sinon.stub(),
getGroupSubscriptionMemberOf: sinon.stub(),
getMemberSubscriptions: sinon.stub().yields(null, []),
promises: {
getMemberSubscriptions: sinon.stub().returns([this.groupSubscription]),
getUsersSubscription: sinon.stub(),
getGroupSubscriptionMemberOf: sinon.stub(),
getMemberSubscriptions: sinon.stub().resolves([]),
getSubscription: sinon.stub(),
},
}
this.SubscriptionLocator.promises.getSubscription
.withArgs(this.subscription._id)
.resolves(this.subscription)
this.Settings = {
defaultPlanCode: 'personal',
defaultFeatures: { default: 'features' },
plans: [
this.recurlyPlan,
{ planCode: this.subscription.planCode, features: {} },
{
planCode: this.groupSubscription.planCode,
features: {
collaborators: 10,
compileTimeout: 60,
dropbox: true,
},
},
{
planCode: this.betterGroupSubscription.planCode,
features: {
collaborators: -1,
compileTimeout: 240,
dropbox: true,
},
},
],
}
this.UserFeaturesUpdater = { updateFeatures: sinon.stub().yields() }
this.PlansLocator = { findLocalPlanInSettings: sinon.stub().returns({}) }
this.UserGetter = {
getUsers(memberIds, projection, callback) {
const users = memberIds.map(id => ({ _id: id }))
callback(null, users)
this.UserFeaturesUpdater = {
promises: {
updateFeatures: sinon.stub().resolves(),
},
}
this.ReferalFeatures = {
promises: {
getBonusFeatures: sinon.stub().resolves(),
},
getUser: sinon.stub(),
}
this.ReferalFeatures = { getBonusFeatures: sinon.stub().callsArgWith(1) }
this.Modules = { hooks: { fire: sinon.stub().callsArgWith(2, null, null) } }
this.FeaturesUpdater = {
refreshFeatures: sinon.stub().yields(),
planCodeToFeatures: sinon.stub(),
promises: {
refreshFeatures: sinon.stub().resolves({}),
},
}
this.DeletedSubscription = {
findOneAndUpdate: sinon.stub().yields(),
findOneAndUpdate: sinon.stub().returns({ exec: sinon.stub().resolves() }),
}
this.AnalyticsManager = {
setUserPropertyForUser: sinon.stub(),
}
this.SubscriptionUpdater = SandboxedModule.require(modulePath, {
requires: {
mongodb: { ObjectId },
'../../models/Subscription': {
Subscription: this.SubscriptionModel,
},
'./UserFeaturesUpdater': this.UserFeaturesUpdater,
'./SubscriptionLocator': this.SubscriptionLocator,
'../User/UserGetter': this.UserGetter,
'./PlansLocator': this.PlansLocator,
'@overleaf/settings': this.Settings,
'../../infrastructure/mongodb': { db: {}, ObjectId },
'./FeaturesUpdater': this.FeaturesUpdater,
@@ -129,266 +156,184 @@ describe('SubscriptionUpdater', function () {
})
describe('updateAdmin', function () {
it('should update the subscription admin', function (done) {
it('should update the subscription admin', async function () {
this.subscription.groupPlan = true
this.SubscriptionUpdater.updateAdmin(
await this.SubscriptionUpdater.promises.updateAdmin(
this.subscription,
this.otherUserId,
err => {
if (err != null) {
return done(err)
}
const query = {
_id: ObjectId(this.subscription._id),
customAccount: true,
}
const update = {
$set: { admin_id: ObjectId(this.otherUserId) },
$addToSet: { manager_ids: ObjectId(this.otherUserId) },
}
this.SubscriptionModel.updateOne.should.have.been.calledOnce
this.SubscriptionModel.updateOne.should.have.been.calledWith(
query,
update
)
done()
}
this.otherUserId
)
const query = {
_id: ObjectId(this.subscription._id),
customAccount: true,
}
const update = {
$set: { admin_id: ObjectId(this.otherUserId) },
$addToSet: { manager_ids: ObjectId(this.otherUserId) },
}
this.SubscriptionModel.updateOne.should.have.been.calledOnce
this.SubscriptionModel.updateOne.should.have.been.calledWith(
query,
update
)
})
it('should remove the manager for non-group subscriptions', function (done) {
this.SubscriptionUpdater.updateAdmin(
it('should remove the manager for non-group subscriptions', async function () {
await this.SubscriptionUpdater.promises.updateAdmin(
this.subscription,
this.otherUserId,
err => {
if (err != null) {
return done(err)
}
const query = {
_id: ObjectId(this.subscription._id),
customAccount: true,
}
const update = {
$set: {
admin_id: ObjectId(this.otherUserId),
manager_ids: [ObjectId(this.otherUserId)],
},
}
this.SubscriptionModel.updateOne.should.have.been.calledOnce
this.SubscriptionModel.updateOne.should.have.been.calledWith(
query,
update
)
done()
}
this.otherUserId
)
const query = {
_id: ObjectId(this.subscription._id),
customAccount: true,
}
const update = {
$set: {
admin_id: ObjectId(this.otherUserId),
manager_ids: [ObjectId(this.otherUserId)],
},
}
this.SubscriptionModel.updateOne.should.have.been.calledOnce
this.SubscriptionModel.updateOne.should.have.been.calledWith(
query,
update
)
})
})
describe('syncSubscription', function () {
beforeEach(function () {
this.SubscriptionLocator.getUsersSubscription.callsArgWith(
1,
null,
this.SubscriptionLocator.promises.getUsersSubscription.resolves(
this.subscription
)
this.SubscriptionUpdater.updateSubscriptionFromRecurly = sinon
.stub()
.yields()
})
it('should update the subscription if the user already is admin of one', function (done) {
this.SubscriptionUpdater.syncSubscription(
it('should update the subscription if the user already is admin of one', async function () {
await this.SubscriptionUpdater.promises.syncSubscription(
this.recurlySubscription,
this.adminUser._id,
err => {
if (err != null) {
return done(err)
}
this.SubscriptionLocator.getUsersSubscription
.calledWith(this.adminUser._id)
.should.equal(true)
done()
}
this.adminUser._id
)
this.SubscriptionLocator.promises.getUsersSubscription
.calledWith(this.adminUser._id)
.should.equal(true)
})
it('should not call updateFeatures with group subscription if recurly subscription is not expired', function (done) {
this.SubscriptionUpdater.syncSubscription(
it('should not call updateFeatures with group subscription if recurly subscription is not expired', async function () {
await this.SubscriptionUpdater.promises.syncSubscription(
this.recurlySubscription,
this.adminUser._id,
err => {
if (err != null) {
return done(err)
}
this.SubscriptionLocator.getUsersSubscription
.calledWith(this.adminUser._id)
.should.equal(true)
this.UserFeaturesUpdater.updateFeatures.called.should.equal(false)
done()
}
this.adminUser._id
)
this.SubscriptionLocator.promises.getUsersSubscription
.calledWith(this.adminUser._id)
.should.equal(true)
this.UserFeaturesUpdater.promises.updateFeatures.called.should.equal(
false
)
})
})
describe('updateSubscriptionFromRecurly', function () {
beforeEach(function () {
this.FeaturesUpdater.refreshFeatures = sinon.stub().callsArgWith(2)
})
afterEach(function () {
this.subscription.member_ids = []
})
it('should update the subscription with token etc when not expired', function (done) {
this.SubscriptionUpdater.updateSubscriptionFromRecurly(
it('should update the subscription with token etc when not expired', async function () {
await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
this.recurlySubscription,
this.subscription,
{},
err => {
if (err != null) {
return done(err)
}
this.subscription.recurlySubscription_id.should.equal(
this.recurlySubscription.uuid
)
this.subscription.planCode.should.equal(
this.recurlySubscription.plan.plan_code
)
this.subscription.save.called.should.equal(true)
this.FeaturesUpdater.refreshFeatures
.calledWith(this.adminUser._id)
.should.equal(true)
done()
}
{}
)
this.subscription.recurlySubscription_id.should.equal(
this.recurlySubscription.uuid
)
this.subscription.planCode.should.equal(
this.recurlySubscription.plan.plan_code
)
this.subscription.save.called.should.equal(true)
this.FeaturesUpdater.promises.refreshFeatures
.calledWith(this.adminUser._id)
.should.equal(true)
})
it('should remove the subscription when expired', function (done) {
it('should remove the subscription when expired', async function () {
this.recurlySubscription.state = 'expired'
this.SubscriptionUpdater.updateSubscriptionFromRecurly(
await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
this.recurlySubscription,
this.subscription,
{},
err => {
if (err != null) {
return done(err)
}
done()
}
{}
)
})
it('should update all the users features', function (done) {
it('should update all the users features', async function () {
this.subscription.member_ids = this.allUserIds
this.SubscriptionUpdater.updateSubscriptionFromRecurly(
await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
this.recurlySubscription,
this.subscription,
{},
err => {
if (err != null) {
return done(err)
}
this.FeaturesUpdater.refreshFeatures
.calledWith(this.adminUser._id)
.should.equal(true)
this.FeaturesUpdater.refreshFeatures
.calledWith(this.allUserIds[0])
.should.equal(true)
this.FeaturesUpdater.refreshFeatures
.calledWith(this.allUserIds[1])
.should.equal(true)
this.FeaturesUpdater.refreshFeatures
.calledWith(this.allUserIds[2])
.should.equal(true)
done()
}
{}
)
this.FeaturesUpdater.promises.refreshFeatures
.calledWith(this.adminUser._id)
.should.equal(true)
this.FeaturesUpdater.promises.refreshFeatures
.calledWith(this.allUserIds[0])
.should.equal(true)
this.FeaturesUpdater.promises.refreshFeatures
.calledWith(this.allUserIds[1])
.should.equal(true)
this.FeaturesUpdater.promises.refreshFeatures
.calledWith(this.allUserIds[2])
.should.equal(true)
})
it('should set group to true and save how many members can be added to group', function (done) {
this.PlansLocator.findLocalPlanInSettings
.withArgs(this.recurlySubscription.plan.plan_code)
.returns({ groupPlan: true, membersLimit: 5 })
this.SubscriptionUpdater.updateSubscriptionFromRecurly(
it('should set group to true and save how many members can be added to group', async function () {
this.recurlyPlan.groupPlan = true
this.recurlyPlan.membersLimit = 5
await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
this.recurlySubscription,
this.subscription,
{},
err => {
if (err != null) {
return done(err)
}
this.subscription.membersLimit.should.equal(5)
this.subscription.groupPlan.should.equal(true)
this.subscription.member_ids.should.deep.equal([
this.subscription.admin_id,
])
done()
}
{}
)
this.subscription.membersLimit.should.equal(5)
this.subscription.groupPlan.should.equal(true)
this.subscription.member_ids.should.deep.equal([
this.subscription.admin_id,
])
})
it('should delete and replace subscription when downgrading from group to individual plan', function (done) {
this.PlansLocator.findLocalPlanInSettings
.withArgs(this.recurlySubscription.plan.plan_code)
.returns({ groupPlan: false })
this.SubscriptionUpdater._deleteAndReplaceSubscriptionFromRecurly = sinon
.stub()
.yields()
this.SubscriptionUpdater.updateSubscriptionFromRecurly(
it('should delete and replace subscription when downgrading from group to individual plan', async function () {
this.recurlyPlan.groupPlan = false
await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
this.recurlySubscription,
this.groupSubscription,
{},
err => {
if (err != null) {
return done(err)
}
done()
}
{}
)
})
it('should not set group to true or set groupPlan', function (done) {
this.SubscriptionUpdater.updateSubscriptionFromRecurly(
it('should not set group to true or set groupPlan', async function () {
await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
this.recurlySubscription,
this.subscription,
{},
err => {
if (err != null) {
return done(err)
}
assert.notEqual(this.subscription.membersLimit, 5)
assert.notEqual(this.subscription.groupPlan, true)
done()
}
{}
)
assert.notEqual(this.subscription.membersLimit, 5)
assert.notEqual(this.subscription.groupPlan, true)
})
describe('when the plan allows adding more seats', function () {
beforeEach(function () {
this.membersLimitAddOn = 'add_on1'
this.PlansLocator.findLocalPlanInSettings
.withArgs(this.recurlySubscription.plan.plan_code)
.returns({
groupPlan: true,
membersLimit: 5,
membersLimitAddOn: this.membersLimitAddOn,
})
this.recurlyPlan.groupPlan = true
this.recurlyPlan.membersLimit = 5
this.recurlyPlan.membersLimitAddOn = this.membersLimitAddOn
})
function expectMembersLimit(limit) {
it('should set the membersLimit accordingly', function (done) {
this.SubscriptionUpdater.updateSubscriptionFromRecurly(
it('should set the membersLimit accordingly', async function () {
await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
this.recurlySubscription,
this.subscription,
{},
error => {
if (error) return done(error)
expect(this.subscription.membersLimit).to.equal(limit)
done()
}
{}
)
expect(this.subscription.membersLimit).to.equal(limit)
})
}
@@ -431,117 +376,34 @@ describe('SubscriptionUpdater', function () {
})
describe('addUserToGroup', function () {
beforeEach(function () {
this.FeaturesUpdater.refreshFeatures = sinon.stub().yields(null, {})
this.FeaturesUpdater.isFeatureSetBetter = sinon.stub()
this.FeaturesUpdater.isFeatureSetBetter
.withArgs(
{
collaborators: 10,
compileTimeout: 60,
dropbox: true,
},
{}
)
.returns(true)
this.FeaturesUpdater.isFeatureSetBetter
.withArgs(
{
collaborators: -1,
compileTimeout: 240,
dropbox: true,
},
{
collaborators: 10,
compileTimeout: 60,
dropbox: true,
}
)
.returns(true)
this.FeaturesUpdater.isFeatureSetBetter
.withArgs(
{
collaborators: -1,
compileTimeout: 240,
dropbox: true,
},
{}
)
.returns(true)
this.PlansLocator.findLocalPlanInSettings = sinon.stub()
this.PlansLocator.findLocalPlanInSettings
.withArgs(this.groupSubscription.planCode)
.returns({
planCode: this.groupSubscription.planCode,
features: {
collaborators: 10,
compileTimeout: 60,
dropbox: true,
},
})
this.PlansLocator.findLocalPlanInSettings
.withArgs(this.betterGroupSubscription.planCode)
.returns({
planCode: this.betterGroupSubscription.planCode,
features: {
collaborators: -1,
compileTimeout: 240,
dropbox: true,
},
})
it('should add the user ids to the group as a set', async function () {
await this.SubscriptionUpdater.promises.addUserToGroup(
this.subscription._id,
this.otherUserId
)
const searchOps = { _id: this.subscription._id }
const insertOperation = {
$addToSet: { member_ids: this.otherUserId },
}
this.SubscriptionModel.updateOne
.calledWith(searchOps, insertOperation)
.should.equal(true)
})
it('should add the user ids to the group as a set', function (done) {
this.SubscriptionUpdater.addUserToGroup(
it('should update the users features', async function () {
await this.SubscriptionUpdater.promises.addUserToGroup(
this.subscription._id,
this.otherUserId,
() => {
const searchOps = { _id: this.subscription._id }
const insertOperation = {
$addToSet: { member_ids: this.otherUserId },
}
this.updateStub
.calledWith(searchOps, insertOperation)
.should.equal(true)
done()
}
)
})
it('should update the users features', function (done) {
this.SubscriptionUpdater.addUserToGroup(
this.subscription._id,
this.otherUserId,
() => {
this.FeaturesUpdater.refreshFeatures
.calledWith(this.otherUserId)
.should.equal(true)
done()
}
)
})
it('should set the group plan code user property', function (done) {
this.SubscriptionUpdater.addUserToGroup(
this.subscription._id,
this.otherUserId,
() => {
sinon.assert.calledWith(
this.AnalyticsManager.setUserPropertyForUser,
this.otherUserId,
'group-subscription-plan-code',
'group_subscription'
)
done()
}
this.otherUserId
)
this.FeaturesUpdater.promises.refreshFeatures
.calledWith(this.otherUserId)
.should.equal(true)
})
it('should set the group plan code user property to the best plan with 1 group subscription', async function () {
this.SubscriptionLocator.promises.getMemberSubscriptions = sinon
.stub()
this.SubscriptionLocator.promises.getMemberSubscriptions
.withArgs(this.otherUserId)
.returns([this.groupSubscription])
.resolves([this.groupSubscription])
await this.SubscriptionUpdater.promises.addUserToGroup(
this.groupSubscription._id,
this.otherUserId
@@ -555,10 +417,9 @@ describe('SubscriptionUpdater', function () {
})
it('should set the group plan code user property to the best plan with 2 group subscriptions', async function () {
this.SubscriptionLocator.promises.getMemberSubscriptions = sinon
.stub()
this.SubscriptionLocator.promises.getMemberSubscriptions
.withArgs(this.otherUserId)
.returns([this.groupSubscription, this.betterGroupSubscription])
.resolves([this.groupSubscription, this.betterGroupSubscription])
await this.SubscriptionUpdater.promises.addUserToGroup(
this.betterGroupSubscription._id,
this.otherUserId
@@ -572,10 +433,9 @@ describe('SubscriptionUpdater', function () {
})
it('should set the group plan code user property to the best plan with 2 group subscriptions in reverse order', async function () {
this.SubscriptionLocator.promises.getMemberSubscriptions = sinon
.stub()
this.SubscriptionLocator.promises.getMemberSubscriptions
.withArgs(this.otherUserId)
.returns([this.betterGroupSubscription, this.groupSubscription])
.resolves([this.betterGroupSubscription, this.groupSubscription])
await this.SubscriptionUpdater.promises.addUserToGroup(
this.betterGroupSubscription._id,
this.otherUserId
@@ -591,95 +451,84 @@ describe('SubscriptionUpdater', function () {
describe('removeUserFromGroups', function () {
beforeEach(function () {
this.FeaturesUpdater.refreshFeatures = sinon.stub().yields(null, {})
this.FeaturesUpdater.isFeatureSetBetter = sinon.stub().returns(true)
this.UserGetter.getUser.yields(null, {})
this.fakeSubscriptions = [{ _id: 'fake-id-1' }, { _id: 'fake-id-2' }]
this.SubscriptionLocator.getMemberSubscriptions.yields(
null,
this.SubscriptionLocator.promises.getMemberSubscriptions.resolves(
this.fakeSubscriptions
)
this.SubscriptionLocator.promises.getMemberSubscriptions.returns([])
})
it('should pull the users id from the group', function (done) {
this.SubscriptionUpdater.removeUserFromGroup(
it('should pull the users id from the group', async function () {
await this.SubscriptionUpdater.promises.removeUserFromGroup(
this.subscription._id,
this.otherUserId
)
const removeOperation = { $pull: { member_ids: this.otherUserId } }
this.SubscriptionModel.updateOne
.calledWith({ _id: this.subscription._id }, removeOperation)
.should.equal(true)
})
it('should set the group plan code user property when removing user from group', async function () {
await this.SubscriptionUpdater.promises.removeUserFromGroup(
this.subscription._id,
this.otherUserId
)
sinon.assert.calledWith(
this.AnalyticsManager.setUserPropertyForUser,
this.otherUserId,
() => {
const removeOperation = { $pull: { member_ids: this.otherUserId } }
this.updateStub
.calledWith({ _id: this.subscription._id }, removeOperation)
.should.equal(true)
done()
}
'group-subscription-plan-code',
null
)
})
it('should set the group plan code user property when removing user from group', function (done) {
this.SubscriptionUpdater.removeUserFromGroup(
this.subscription._id,
it('should set the group plan code user property when removing user from all groups', async function () {
await this.SubscriptionUpdater.promises.removeUserFromAllGroups(
this.otherUserId
)
sinon.assert.calledWith(
this.AnalyticsManager.setUserPropertyForUser,
this.otherUserId,
() => {
sinon.assert.calledWith(
this.AnalyticsManager.setUserPropertyForUser,
this.otherUserId,
'group-subscription-plan-code',
null
)
done()
}
'group-subscription-plan-code',
null
)
})
it('should set the group plan code user property when removing user from all groups', function (done) {
this.SubscriptionUpdater.removeUserFromAllGroups(this.otherUserId, () => {
sinon.assert.calledWith(
this.AnalyticsManager.setUserPropertyForUser,
this.otherUserId,
'group-subscription-plan-code',
null
)
done()
})
})
it('should pull the users id from all groups', function (done) {
this.SubscriptionUpdater.removeUserFromAllGroups(this.otherUserId, () => {
const filter = { _id: ['fake-id-1', 'fake-id-2'] }
const removeOperation = { $pull: { member_ids: this.otherUserId } }
sinon.assert.calledWith(this.updateManyStub, filter, removeOperation)
done()
})
})
it('should update the users features', function (done) {
this.SubscriptionUpdater.removeUserFromGroup(
this.subscription._id,
this.otherUserId,
() => {
this.FeaturesUpdater.refreshFeatures
.calledWith(this.otherUserId)
.should.equal(true)
done()
}
it('should pull the users id from all groups', async function () {
await this.SubscriptionUpdater.promises.removeUserFromAllGroups(
this.otherUserId
)
const filter = { _id: ['fake-id-1', 'fake-id-2'] }
const removeOperation = { $pull: { member_ids: this.otherUserId } }
sinon.assert.calledWith(
this.SubscriptionModel.updateMany,
filter,
removeOperation
)
})
it('should update the users features', async function () {
await this.SubscriptionUpdater.promises.removeUserFromGroup(
this.subscription._id,
this.otherUserId
)
this.FeaturesUpdater.promises.refreshFeatures
.calledWith(this.otherUserId)
.should.equal(true)
})
})
describe('deleteSubscription', function () {
beforeEach(function (done) {
beforeEach(async function () {
this.subscription = {
_id: ObjectId().toString(),
mock: 'subscription',
admin_id: ObjectId(),
member_ids: [ObjectId(), ObjectId(), ObjectId()],
}
this.SubscriptionLocator.getSubscription = sinon
.stub()
.yields(null, this.subscription)
this.FeaturesUpdater.refreshFeatures = sinon.stub().yields()
this.SubscriptionUpdater.deleteSubscription(this.subscription, {}, done)
await this.SubscriptionUpdater.promises.deleteSubscription(
this.subscription,
{}
)
})
it('should remove the subscription', function () {
@@ -689,14 +538,14 @@ describe('SubscriptionUpdater', function () {
})
it('should downgrade the admin_id', function () {
this.FeaturesUpdater.refreshFeatures
this.FeaturesUpdater.promises.refreshFeatures
.calledWith(this.subscription.admin_id)
.should.equal(true)
})
it('should downgrade all of the members', function () {
for (const userId of this.subscription.member_ids) {
this.FeaturesUpdater.refreshFeatures
this.FeaturesUpdater.promises.refreshFeatures
.calledWith(userId)
.should.equal(true)
}