mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 14:13:43 +01:00
feat: adding gating based on entitlement when calculating institutional features, and adding tests to ensure (#35351)
GitOrigin-RevId: 897ce7610cd08c77be6a90aad8b953d38213573a
This commit is contained in:
committed by
Copybot
parent
f9a1952228
commit
dd53057708
@@ -9,9 +9,10 @@ import Features from '../../infrastructure/Features.mjs'
|
||||
async function _getInstitutionsAddons(userId) {
|
||||
if (!Features.hasFeature('saas')) return {}
|
||||
const affiliates =
|
||||
await InstitutionsGetter.promises.getCurrentAffiliations(userId)
|
||||
await InstitutionsGetter.promises.getCurrentEntitledAffiliations(userId)
|
||||
// currently only addOn available to institutions is assist/WF bundle,
|
||||
// which is denoted by the presence of writefullCommonsAccount on the institution
|
||||
|
||||
const hasAssistBundle = affiliates.some(
|
||||
affiliate => affiliate?.institution?.writefullCommonsAccount === true
|
||||
)
|
||||
|
||||
@@ -3,9 +3,41 @@ import UserGetter from '../User/UserGetter.mjs'
|
||||
import UserMembershipsHandler from '../UserMembership/UserMembershipsHandler.mjs'
|
||||
import UserMembershipEntityConfigs from '../UserMembership/UserMembershipEntityConfigs.mjs'
|
||||
|
||||
// The affiliation getters below all derive from UserGetter.getUserFullEmails
|
||||
// and differ only in filter strictness. Terms used in their names:
|
||||
// - "confirmed": email confirmed AND its institution's domain confirmed.
|
||||
// - "current": confirmed AND not past the reconfirmation deadline.
|
||||
// - "entitled": current AND the user holds an institution licence for that
|
||||
// email. Being current is not sufficient: some commons subscriptions only
|
||||
// entitle specific SSO users. Use the entitled getter for licence-gated
|
||||
// benefits so unentitled users don't receive them.
|
||||
|
||||
/**
|
||||
* See "entitled" above. Use for licence-gated benefits (e.g. the institution
|
||||
* plan, the AI assist add-on bundle). Same selection as
|
||||
* getCurrentInstitutionsWithLicence, but returns one affiliation object per
|
||||
* matching email (not deduplicated by institution).
|
||||
* @param {string} userId
|
||||
* @returns {Promise<object[]>} affiliation objects
|
||||
*/
|
||||
async function getCurrentEntitledAffiliations(userId) {
|
||||
const fullEmails = await UserGetter.promises.getUserFullEmails(userId)
|
||||
// emailHasInstitutionLicence already implies a confirmed email at a confirmed
|
||||
// institution that is not past reconfirmation (see InstitutionsHelper), so no
|
||||
// further filtering is needed.
|
||||
return fullEmails
|
||||
.filter(emailData => emailData.emailHasInstitutionLicence)
|
||||
.map(emailData => emailData.affiliation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Does not check entitlement, so may include affiliations the user holds no
|
||||
* licence for; use getCurrentEntitledAffiliations for licence-gated benefits.
|
||||
* @param {string} userId
|
||||
* @returns {Promise<object[]>} affiliation objects
|
||||
*/
|
||||
async function getCurrentAffiliations(userId) {
|
||||
const fullEmails = await UserGetter.promises.getUserFullEmails(userId)
|
||||
// current are those confirmed and not with lapsed reconfirmations
|
||||
return fullEmails
|
||||
.filter(
|
||||
emailData =>
|
||||
@@ -18,22 +50,26 @@ async function getCurrentAffiliations(userId) {
|
||||
.map(emailData => emailData.affiliation)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} userId
|
||||
* @returns {Promise<Array<string|number>>} deduplicated institution ids
|
||||
*/
|
||||
async function getCurrentAndPastAffiliationIds(userId) {
|
||||
let fullEmails = await UserGetter.promises.getUserFullEmails(userId)
|
||||
// current are those confirmed and not with lapsed reconfirmations
|
||||
fullEmails = fullEmails
|
||||
.filter(
|
||||
emailData =>
|
||||
emailData.confirmedAt && emailData.affiliation?.institution?.confirmed
|
||||
)
|
||||
.map(emailData => emailData.affiliation.institution.id)
|
||||
// remove dupes
|
||||
return [...new Set(fullEmails)]
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} userId
|
||||
* @returns {Promise<Array<string|number>>} deduplicated institution ids
|
||||
*/
|
||||
async function getCurrentInstitutionIds(userId) {
|
||||
// current are those confirmed and not with lapsed reconfirmations
|
||||
// only 1 record returned per current institutionId
|
||||
const institutionIds = new Set()
|
||||
const currentAffiliations = await getCurrentAffiliations(userId)
|
||||
currentAffiliations.forEach(affiliation => {
|
||||
@@ -42,9 +78,15 @@ async function getCurrentInstitutionIds(userId) {
|
||||
return [...institutionIds]
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-free affiliation licence is exactly what "entitled" means (see above),
|
||||
* so this selects the same set as getCurrentEntitledAffiliations. The
|
||||
* difference is the return value: institution objects deduplicated per
|
||||
* institution, rather than one affiliation object per matching email.
|
||||
* @param {string} userId
|
||||
* @returns {Promise<object[]>} institution objects, deduplicated
|
||||
*/
|
||||
async function getCurrentInstitutionsWithLicence(userId) {
|
||||
// current are those confirmed and not with lapsed reconfirmations
|
||||
// only 1 record returned per current institution
|
||||
const institutions = {}
|
||||
const currentAffiliations = await getCurrentAffiliations(userId)
|
||||
currentAffiliations.forEach(affiliation => {
|
||||
@@ -55,6 +97,11 @@ async function getCurrentInstitutionsWithLicence(userId) {
|
||||
return Object.values(institutions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loosest getter: no reconfirmation or entitlement filtering (see above).
|
||||
* @param {string} userId
|
||||
* @returns {Promise<object[]>} affiliation objects
|
||||
*/
|
||||
async function getConfirmedAffiliations(userId) {
|
||||
const emailsData = await UserGetter.promises.getUserFullEmails(userId)
|
||||
|
||||
@@ -71,6 +118,12 @@ async function getConfirmedAffiliations(userId) {
|
||||
return confirmedAffiliations
|
||||
}
|
||||
|
||||
/**
|
||||
* Institutions the user administers (via user memberships), not their email
|
||||
* affiliations.
|
||||
* @param {string} userId
|
||||
* @returns {Promise<object[]>} managed institution entities
|
||||
*/
|
||||
async function getManagedInstitutions(userId) {
|
||||
return await UserMembershipsHandler.promises.getEntitiesByUser(
|
||||
UserMembershipEntityConfigs.institution,
|
||||
@@ -89,6 +142,7 @@ const InstitutionsGetter = {
|
||||
|
||||
InstitutionsGetter.promises = {
|
||||
getCurrentAffiliations,
|
||||
getCurrentEntitledAffiliations,
|
||||
getCurrentInstitutionIds,
|
||||
getCurrentInstitutionsWithLicence,
|
||||
getCurrentAndPastAffiliationIds,
|
||||
|
||||
@@ -252,6 +252,8 @@ class MockV1Api extends AbstractMockApi {
|
||||
id: institutionId,
|
||||
name: institutionData.name,
|
||||
commonsAccount: institutionData.commonsAccount,
|
||||
writefullCommonsAccount:
|
||||
institutionData.writefullCommonsAccount || false,
|
||||
isUniversity: !institutionData.institution,
|
||||
ssoBeta: institutionData.sso_beta || false,
|
||||
ssoEnabled: institutionData.sso_enabled || false,
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('InstitutionsFeatures', function () {
|
||||
ctx.PlansLocator = { findLocalPlanInSettings: sinon.stub() }
|
||||
ctx.institutionPlanCode = 'institution_plan_code'
|
||||
ctx.InstitutionsGetter = {
|
||||
promises: { getCurrentAffiliations: sinon.stub().resolves([]) },
|
||||
promises: { getCurrentEntitledAffiliations: sinon.stub().resolves([]) },
|
||||
}
|
||||
|
||||
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
|
||||
@@ -147,10 +147,14 @@ describe('InstitutionsFeatures', function () {
|
||||
expect(features).to.deep.equal({})
|
||||
})
|
||||
|
||||
it('should return ai features if user has any affiliation with add-on bundle', async function (ctx) {
|
||||
ctx.InstitutionsGetter.promises.getCurrentAffiliations = sinon
|
||||
.stub()
|
||||
.resolves([ctx.affiliateWithoutAiBundle, ctx.affiliateWithAiBundle])
|
||||
it('should return ai features if the user is entitled to an affiliation with the add-on bundle', async function (ctx) {
|
||||
// Only affiliations the user is entitled to reach this point, since the
|
||||
// entitlement filtering lives in getCurrentEntitledAffiliations (covered
|
||||
// by its own unit tests).
|
||||
ctx.InstitutionsGetter.promises.getCurrentEntitledAffiliations.resolves([
|
||||
ctx.affiliateWithoutAiBundle,
|
||||
ctx.affiliateWithAiBundle,
|
||||
])
|
||||
ctx.UserGetter.promises.getUserFullEmails.resolves(
|
||||
ctx.emailDataWithLicense
|
||||
)
|
||||
@@ -162,6 +166,25 @@ describe('InstitutionsFeatures', function () {
|
||||
expect(features).to.deep.equal(ctx.testFeaturesWithAiAddon.features)
|
||||
})
|
||||
|
||||
it('should not return ai features if no entitled affiliation has the add-on bundle', async function (ctx) {
|
||||
// The user is entitled to a licence (so gets the institution plan) but
|
||||
// none of their entitled affiliations provide the AI bundle.
|
||||
ctx.InstitutionsGetter.promises.getCurrentEntitledAffiliations.resolves([
|
||||
ctx.affiliateWithoutAiBundle,
|
||||
])
|
||||
ctx.UserGetter.promises.getUserFullEmails.resolves(
|
||||
ctx.emailDataWithLicense
|
||||
)
|
||||
|
||||
const features =
|
||||
await ctx.InstitutionsFeatures.promises.getInstitutionsFeatures(
|
||||
ctx.userId
|
||||
)
|
||||
expect(features).to.deep.equal(ctx.testFeatures.features)
|
||||
expect(features).to.not.have.property('aiUsageQuota')
|
||||
expect(features).to.not.have.property('aiErrorAssistant')
|
||||
})
|
||||
|
||||
it('should return feaures if user has affiliations plan code', async function (ctx) {
|
||||
ctx.UserGetter.promises.getUserFullEmails.resolves(
|
||||
ctx.emailDataWithLicense
|
||||
|
||||
@@ -141,6 +141,68 @@ describe('InstitutionsGetter', function () {
|
||||
ctx.unconfirmedDomainLicensedAffiliation,
|
||||
ctx.unconfirmedEmailLicensedAffiliation,
|
||||
]
|
||||
|
||||
// Fixtures for getCurrentEntitledAffiliations, which keys off
|
||||
// emailHasInstitutionLicence (the user is entitled to a licence with that
|
||||
// institution). That flag is derived by UserGetter via
|
||||
// InstitutionsHelper.emailHasLicence, whose own conditions (confirmed
|
||||
// email, confirmed institution, non-free licence, not past reconfirmation)
|
||||
// are covered in InstitutionHelper.test.mjs.
|
||||
ctx.entitledAffiliation = {
|
||||
emailHasInstitutionLicence: true,
|
||||
affiliation: { institution: { id: 456, confirmed: true } },
|
||||
}
|
||||
ctx.unentitledAffiliation = {
|
||||
emailHasInstitutionLicence: false,
|
||||
affiliation: { institution: { id: 999, confirmed: true } },
|
||||
}
|
||||
})
|
||||
|
||||
describe('getCurrentEntitledAffiliations', function () {
|
||||
it('returns the affiliations for emails the user is entitled to a licence for and filters out the rest', async function (ctx) {
|
||||
ctx.UserGetter.promises.getUserFullEmails.resolves([
|
||||
ctx.entitledAffiliation,
|
||||
ctx.unentitledAffiliation,
|
||||
])
|
||||
const affiliations =
|
||||
await ctx.InstitutionsGetter.promises.getCurrentEntitledAffiliations(
|
||||
ctx.userId
|
||||
)
|
||||
expect(affiliations.map(a => a.institution.id)).to.deep.equal([456])
|
||||
})
|
||||
|
||||
it('returns no affiliations when the user is not entitled to any', async function (ctx) {
|
||||
ctx.UserGetter.promises.getUserFullEmails.resolves([
|
||||
ctx.unentitledAffiliation,
|
||||
])
|
||||
const affiliations =
|
||||
await ctx.InstitutionsGetter.promises.getCurrentEntitledAffiliations(
|
||||
ctx.userId
|
||||
)
|
||||
expect(affiliations).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('handles empty response', async function (ctx) {
|
||||
ctx.UserGetter.promises.getUserFullEmails.resolves([])
|
||||
const affiliations =
|
||||
await ctx.InstitutionsGetter.promises.getCurrentEntitledAffiliations(
|
||||
ctx.userId
|
||||
)
|
||||
expect(affiliations).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('handles errors', async function (ctx) {
|
||||
ctx.UserGetter.promises.getUserFullEmails.rejects(new Error('oops'))
|
||||
let e
|
||||
try {
|
||||
await ctx.InstitutionsGetter.promises.getCurrentEntitledAffiliations(
|
||||
ctx.userId
|
||||
)
|
||||
} catch (error) {
|
||||
e = error
|
||||
}
|
||||
expect(e.message).to.equal('oops')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCurrentInstitutionIds', function () {
|
||||
|
||||
Reference in New Issue
Block a user