feat: adding gating based on entitlement when calculating institutional features, and adding tests to ensure (#35351)

GitOrigin-RevId: 897ce7610cd08c77be6a90aad8b953d38213573a
This commit is contained in:
Jimmy Domagala-Tang
2026-09-17 11:26:12 +00:00
committed by Copybot
parent f9a1952228
commit dd53057708
5 changed files with 155 additions and 13 deletions
@@ -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 () {