Merge pull request #3993 from overleaf/jel-reconfirmation-script

Add script to refresh features for users not reconfirmed

GitOrigin-RevId: d49f496dc6f1997763d54c6d8f41f7c3634b8b2d
This commit is contained in:
Jessica Lawshe 2021-05-11 09:08:29 -05:00 committed by Copybot
parent 1d94ef5b54
commit 2bf126af68
7 changed files with 385 additions and 1 deletions

View file

@ -69,6 +69,18 @@ const InstitutionsAPI = {
)
},
getUsersNeedingReconfirmationsLapsedProcessed(callback) {
makeAffiliationRequest(
{
method: 'GET',
path: '/api/v2/institutions/need_reconfirmation_lapsed_processed',
defaultErrorMessage:
'Could not get users that need reconfirmations lapsed processed',
},
(error, body) => callback(error, body || [])
)
},
addAffiliation(userId, email, affiliationOptions, callback) {
if (!callback) {
// affiliationOptions is optional
@ -189,6 +201,19 @@ const InstitutionsAPI = {
callback
)
},
sendUsersWithReconfirmationsLapsedProcessed(users, callback) {
makeAffiliationRequest(
{
method: 'POST',
path: '/api/v2/institutions/reconfirmation_lapsed_processed',
body: { users },
defaultErrorMessage:
'Could not update reconfirmation_lapsed_processed_at',
},
(error, body) => callback(error, body || [])
)
},
}
var makeAffiliationRequest = function (requestOptions, callback) {

View file

@ -0,0 +1,89 @@
const _ = require('lodash')
const { ObjectId, waitForDb } = require('../../infrastructure/mongodb')
const async = require('async')
const logger = require('logger-sharelatex')
const FeaturesUpdater = require('../Subscription/FeaturesUpdater')
const InstitutionsAPI = require('./InstitutionsAPI')
const ASYNC_LIMIT = 10
const processLapsedLogger = {
refreshedUsers: [],
failedToRefresh: [],
printSummary: () => {
logger.log(
`Reconfirmations lapsed processed. ${processLapsedLogger.refreshedUsers.length} successfull and ${processLapsedLogger.failedToRefresh.length} failed.`,
{
refreshedUsers: processLapsedLogger.refreshedUsers,
failedToRefresh: processLapsedLogger.failedToRefresh,
}
)
},
}
function _validateUserIdList(userIds) {
if (!Array.isArray(userIds)) throw new Error('users is not an array')
userIds.forEach(userId => {
if (!ObjectId.isValid(userId)) throw new Error('user ID not valid')
})
}
function _refreshUser(userId, callback) {
FeaturesUpdater.refreshFeatures(userId, error => {
if (error) {
logger.warn(`Failed to refresh features for ${userId}`, error)
processLapsedLogger.failedToRefresh.push(userId)
} else {
processLapsedLogger.refreshedUsers.push(userId)
}
return callback()
})
}
async function _loopRefreshUsers(userIds) {
await new Promise((resolve, reject) => {
async.eachLimit(userIds, ASYNC_LIMIT, _refreshUser, error => {
if (error) return reject(error)
resolve()
})
})
}
async function processLapsed() {
logger.log('Begin processing lapsed reconfirmations')
await waitForDb()
const result = await InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed()
const userIds = _.get(result, ['data', 'users'])
_validateUserIdList(userIds)
logger.log(
`Starting to process ${userIds.length} users with lapsed reconfirmations`
)
await _loopRefreshUsers(userIds)
processLapsedLogger.printSummary()
try {
logger.log('Updating reconfirmations lapsed processed dates')
await InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed(
processLapsedLogger.refreshedUsers
)
} catch (error) {
logger.log('Error updating features_refreshed_at', error)
}
logger.log('Done processing lapsed reconfirmations')
return {
refreshedUsers: processLapsedLogger.refreshedUsers,
failedToRefresh: processLapsedLogger.failedToRefresh,
}
}
const InstitutionsReconfirmationHandler = {
processLapsed,
}
module.exports = InstitutionsReconfirmationHandler

View file

@ -0,0 +1,10 @@
const InstitutionsReconfirmationHandler = require('../app/src/Features/Institutions/InstitutionsReconfirmationHandler')
InstitutionsReconfirmationHandler.processLapsed()
.then(() => {
process.exit(0)
})
.catch(error => {
console.error(error)
process.exit(1)
})

View file

@ -0,0 +1,70 @@
const { expect } = require('chai')
const Settings = require('settings-sharelatex')
const UserHelper = require('./helpers/UserHelper')
const MockV1ApiClass = require('./mocks/MockV1Api')
const InstitutionsReconfirmationHandler = require('../../../app/src/Features/Institutions/InstitutionsReconfirmationHandler')
let MockV1Api
let userHelper = new UserHelper()
before(function () {
MockV1Api = MockV1ApiClass.instance()
})
describe('InstitutionsReconfirmationHandler', function () {
const institutionUsers = []
let result
beforeEach(async function () {
// create institution
const domain = 'institution-1.com'
const maxConfirmationMonths = 6
MockV1Api.createInstitution({
commonsAccount: true,
confirmed: true,
hostname: domain,
maxConfirmationMonths,
})
// create users affiliated with institution
async function _createInstitutionUserPastReconfirmation() {
userHelper = await UserHelper.createUser()
const userId = userHelper.user._id
// add the affiliation
userHelper = await UserHelper.loginUser(
userHelper.getDefaultEmailPassword()
)
const institutionEmail = `${userId}@${domain}`
await userHelper.addEmailAndConfirm(userId, institutionEmail)
institutionUsers.push(userId)
// backdate confirmation
await userHelper.changeConfirmedToPastReconfirmation(
userId,
institutionEmail,
maxConfirmationMonths
)
// verify user has features before script run
const result = await UserHelper.getUser(
{ _id: userHelper.user._id },
{ features: 1 }
)
expect(result.user.features).to.deep.equal(Settings.features.professional)
return userId
}
await _createInstitutionUserPastReconfirmation()
await _createInstitutionUserPastReconfirmation()
await _createInstitutionUserPastReconfirmation()
result = await InstitutionsReconfirmationHandler.processLapsed()
})
it('should refresh features', async function () {
expect(result.failedToRefresh.length).to.equal(0)
expect(result.refreshedUsers.length).to.equal(3)
})
})

View file

@ -61,7 +61,9 @@ class MockV1Api extends AbstractMockApi {
options.id = id // include ID so that it is included in APIs
this.institutions[id] = { ...options }
if (options && options.hostname) {
this.addInstitutionDomain(id, options.hostname)
this.addInstitutionDomain(id, options.hostname, {
confirmed: options.confirmed,
})
}
return id
}
@ -241,6 +243,26 @@ class MockV1Api extends AbstractMockApi {
res.sendStatus(204)
})
this.app.post(
'/api/v2/institutions/reconfirmation_lapsed_processed',
(req, res) => {
res.sendStatus(200)
}
)
this.app.get(
'/api/v2/institutions/need_reconfirmation_lapsed_processed',
(req, res) => {
const usersWithAffiliations = []
Object.keys(this.affiliations).forEach(userId => {
if (this.affiliations[userId].length > 0) {
usersWithAffiliations.push(userId)
}
})
res.json({ data: { users: usersWithAffiliations } })
}
)
this.app.get('/api/v2/brands/:slug', (req, res) => {
let brand
if ((brand = this.brands[req.params.slug])) {

View file

@ -188,6 +188,33 @@ describe('InstitutionsAPI', function () {
})
})
describe('getUsersNeedingReconfirmationsLapsedProcessed', function () {
it('get the list of users', function (done) {
this.request.callsArgWith(1, null, { statusCode: 200 })
this.InstitutionsAPI.getUsersNeedingReconfirmationsLapsedProcessed(
error => {
expect(error).not.to.exist
this.request.calledOnce.should.equal(true)
const requestOptions = this.request.lastCall.args[0]
const expectedUrl = `v1.url/api/v2/institutions/need_reconfirmation_lapsed_processed`
requestOptions.url.should.equal(expectedUrl)
requestOptions.method.should.equal('GET')
done()
}
)
})
it('handle error', function (done) {
this.request.callsArgWith(1, null, { statusCode: 500 })
this.InstitutionsAPI.getUsersNeedingReconfirmationsLapsedProcessed(
error => {
expect(error).to.exist
done()
}
)
})
})
describe('addAffiliation', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 201 })
@ -333,4 +360,37 @@ describe('InstitutionsAPI', function () {
)
})
})
describe('sendUsersWithReconfirmationsLapsedProcessed', function () {
const users = ['abc123', 'def456']
it('sends the list of users', function (done) {
this.request.callsArgWith(1, null, { statusCode: 200 })
this.InstitutionsAPI.sendUsersWithReconfirmationsLapsedProcessed(
users,
error => {
expect(error).not.to.exist
this.request.calledOnce.should.equal(true)
const requestOptions = this.request.lastCall.args[0]
const expectedUrl =
'v1.url/api/v2/institutions/reconfirmation_lapsed_processed'
requestOptions.url.should.equal(expectedUrl)
requestOptions.method.should.equal('POST')
expect(requestOptions.body).to.deep.equal({ users })
done()
}
)
})
it('handle error', function (done) {
this.request.callsArgWith(1, null, { statusCode: 500 })
this.InstitutionsAPI.sendUsersWithReconfirmationsLapsedProcessed(
users,
error => {
expect(error).to.exist
done()
}
)
})
})
})

View file

@ -0,0 +1,108 @@
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const { ObjectId } = require('mongodb')
const { expect } = require('chai')
const modulePath = path.join(
__dirname,
'../../../../app/src/Features/Institutions/InstitutionsReconfirmationHandler'
)
describe('InstitutionsReconfirmationHandler', function () {
beforeEach(function () {
this.InstitutionsReconfirmationHandler = SandboxedModule.require(
modulePath,
{
requires: {
'../../infrastructure/mongodb': (this.mongodb = {
ObjectId,
waitForDb: sinon.stub().resolves(),
}),
'../Subscription/FeaturesUpdater': (this.FeaturesUpdater = {
refreshFeatures: sinon.stub(),
}),
'./InstitutionsAPI': (this.InstitutionsAPI = {
promises: {
getUsersNeedingReconfirmationsLapsedProcessed: sinon.stub(),
sendUsersWithReconfirmationsLapsedProcessed: sinon.stub(),
},
}),
},
}
)
})
describe('userId list', function () {
it('should throw an error if IDs not an array', async function () {
let error
try {
await this.InstitutionsReconfirmationHandler.processLapsed()
} catch (e) {
error = e
}
expect(error).to.exist
expect(error.message).to.equal('users is not an array')
})
it('should throw an error if IDs not valid ObjectIds', async function () {
this.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed.resolves(
{
data: { users: ['not an objectid'] },
}
)
let error
try {
await this.InstitutionsReconfirmationHandler.processLapsed()
} catch (e) {
error = e
}
expect(error).to.exist
expect(error.message).to.equal('user ID not valid')
})
})
it('should log users that have refreshFeatures errors', async function () {
const anError = new Error('oops')
const aUserId = '5efb8b6e9b647b0027e4c0b0'
this.FeaturesUpdater.refreshFeatures.yields(anError)
this.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed.resolves(
{
data: { users: [aUserId] },
}
)
this.InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed.resolves()
let error, result
try {
result = await this.InstitutionsReconfirmationHandler.processLapsed()
} catch (e) {
error = e
}
expect(error).to.not.exist
expect(result.failedToRefresh.length).to.equal(1)
expect(result.failedToRefresh[0]).to.equal(aUserId)
expect(result.refreshedUsers.length).to.equal(0)
})
it('should log but not return errors from sendUsersWithReconfirmationsLapsedProcessed', async function () {
const anError = new Error('oops')
const aUserId = '5efb8b6e9b647b0027e4c0b0'
this.FeaturesUpdater.refreshFeatures.yields()
this.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed.resolves(
{
data: { users: [aUserId] },
}
)
this.InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed.rejects(
anError
)
let error, result
try {
result = await this.InstitutionsReconfirmationHandler.processLapsed()
} catch (e) {
error = e
}
expect(error).to.not.exist
expect(result.refreshedUsers.length).to.equal(1)
expect(result.refreshedUsers[0]).to.equal(aUserId)
expect(result.failedToRefresh.length).to.equal(0)
})
})