[web] avoid UserAnalyticsIdCache for split-test assignments (#34803)

* [web] split-tests: add helpers to using an already fetched mongo user

* [web] split-tests: add ignoreOverrides option to getAssignment

* [web] split-tests: reuse already fetched mongo users where possible

* [web] notifications: fetch users in bulk for split-test assignment

* [web] split-tests: use session for assignment where possible

* [web] split-tests: pass req/res down into domain-capture hook

* [web] split-tests: explicitly throw early

* [web] split-tests: refine jsdoc types

* [web] mark analyticsId and labsProgram as required in user model

* [web] split-tests: refine jsdoc types

* Revert "[web] split-tests: pass req/res down into domain-capture hook"

This reverts commit 897a158384cd2b3e416fc34495b20d1e2a0e68f0.

GitOrigin-RevId: ea4afd15c2506f369827b5193fd16d220d3c7d0e
This commit is contained in:
Jakob Ackermann
2026-06-23 08:06:42 +00:00
committed by Copybot
parent 6b084424b5
commit 1cc18dd0f0
17 changed files with 362 additions and 75 deletions
@@ -6,15 +6,16 @@ import Features from '../../../app/src/infrastructure/Features.mjs'
describe('mongoose', function () {
describe('User', function () {
const email = 'wombat@potato.net'
const defaultArgs = { analyticsId: '8055c676-bcc7-4e64-a66f-8069f9a0bd92' }
it('allows the creation of a user', async function () {
await expect(User.create({ email })).to.be.fulfilled
await expect(User.create({ email, ...defaultArgs })).to.be.fulfilled
await expect(User.findOne({ email }, { _id: 1 })).to.eventually.exist
})
it('does not allow the creation of multiple users with the same email', async function () {
await expect(User.create({ email })).to.be.fulfilled
await expect(User.create({ email })).to.be.rejected
await expect(User.create({ email, ...defaultArgs })).to.be.fulfilled
await expect(User.create({ email, ...defaultArgs })).to.be.rejected
await expect(User.countDocuments({ email })).to.eventually.equal(1)
})
@@ -38,6 +39,7 @@ describe('mongoose', function () {
},
],
},
...defaultArgs,
})
).to.be.fulfilled
@@ -55,7 +57,10 @@ describe('mongoose', function () {
this.skip()
}
user = await User.create({ email: 'wombat@potato.net' })
user = await User.create({
email: 'wombat@potato.net',
analyticsId: '8055c676-bcc7-4e64-a66f-8069f9a0bd92',
})
})
it('allows the creation of a subscription', async function () {
@@ -51,9 +51,11 @@ describe('ProjectHelper', function () {
ctx.SplitTestHandler = {
promises: {
getAssignmentForUser: vi.fn().mockResolvedValue({ variant: 'default' }),
featureFlagEnabled: vi.fn().mockResolvedValue(false),
},
}
ctx.req = {}
ctx.res = {}
vi.doMock('mongodb-legacy', () => ({
default: { ObjectId },
@@ -159,7 +161,11 @@ describe('ProjectHelper', function () {
describe('getAllowedImagesForUser', function () {
it('marks alpha only images as not allowed when the user is anonymous', async function (ctx) {
const images = await ctx.ProjectHelper.getAllowedImagesForUser(null)
const images = await ctx.ProjectHelper.getAllowedImagesForUser(
ctx.req,
ctx.res,
null
)
const imageNames = _mapToAllowed(images)
expect(imageNames).to.deep.equal([
{ imageName: 'texlive-full:2018.1', allowed: true },
@@ -170,7 +176,11 @@ describe('ProjectHelper', function () {
})
it('marks monthly labs images as not allowed when the user is anonymous', async function (ctx) {
const images = await ctx.ProjectHelper.getAllowedImagesForUser(null)
const images = await ctx.ProjectHelper.getAllowedImagesForUser(
ctx.req,
ctx.res,
null
)
const imageNames = _mapToAllowed(images)
expect(imageNames).to.deep.equal([
{ imageName: 'texlive-full:2018.1', allowed: true },
@@ -181,10 +191,12 @@ describe('ProjectHelper', function () {
})
it('marks monthly labs images as allowed when the user is enrolled', async function (ctx) {
ctx.SplitTestHandler.promises.getAssignmentForUser.mockResolvedValue({
variant: 'enabled',
})
const images = await ctx.ProjectHelper.getAllowedImagesForUser(ctx.user)
ctx.SplitTestHandler.promises.featureFlagEnabled.mockResolvedValue(true)
const images = await ctx.ProjectHelper.getAllowedImagesForUser(
ctx.req,
ctx.res,
ctx.user
)
const imageNames = _mapToAllowed(images)
expect(imageNames).to.deep.equal([
{ imageName: 'texlive-full:2018.1', allowed: true },
@@ -195,7 +207,11 @@ describe('ProjectHelper', function () {
})
it('marks alpha only images as not allowed when when the user is not admin', async function (ctx) {
const images = await ctx.ProjectHelper.getAllowedImagesForUser(ctx.user)
const images = await ctx.ProjectHelper.getAllowedImagesForUser(
ctx.req,
ctx.res,
ctx.user
)
const imageNames = _mapToAllowed(images)
expect(imageNames).to.deep.equal([
{ imageName: 'texlive-full:2018.1', allowed: true },
@@ -207,6 +223,8 @@ describe('ProjectHelper', function () {
it('returns all images when the user is admin', async function (ctx) {
const images = await ctx.ProjectHelper.getAllowedImagesForUser(
ctx.req,
ctx.res,
ctx.adminUser
)
const imageNames = _mapToAllowed(images)
@@ -137,7 +137,7 @@ describe('ProjectListController', function () {
ctx.SplitTestHandler = {
promises: {
getAssignment: sinon.stub().resolves({ variant: 'default' }),
featureFlagEnabledForUser: sinon.stub().resolves(false),
featureFlagEnabled: sinon.stub().resolves(false),
hasUserBeenAssignedToVariant: sinon.stub().resolves(false),
},
}
@@ -139,6 +139,7 @@ describe('SplitTestHandler', function () {
beforeEach(async function (ctx) {
ctx.user = {
_id: new ObjectId(),
analyticsId: 'analytics-id',
splitTests: {
'active-test': [
{
@@ -256,9 +257,101 @@ describe('SplitTestHandler', function () {
})
})
describe('mongo user variants', function () {
beforeEach(function (ctx) {
ctx.mongoUser = {
_id: new ObjectId(),
analyticsId: 'analytics-id',
splitTests: {},
}
// a feature flag whose enabled variant covers 100% of users
ctx.cachedSplitTests.set('my-flag', {
name: 'my-flag',
versions: [
{
active: true,
analyticsEnabled: true,
phase: 'release',
versionNumber: 1,
variants: [
{
name: 'enabled',
rolloutPercent: 100,
rolloutStripes: [{ start: 0, end: 100 }],
},
],
},
],
})
})
describe('getActiveAssignmentsForMongoUser', function () {
it('returns the assignments without re-fetching the user', async function (ctx) {
const assignments =
await ctx.SplitTestHandler.promises.getActiveAssignmentsForMongoUser(
ctx.mongoUser
)
expect(assignments['active-test']).to.deep.equal({
variantName: 'variant-1',
phase: 'release',
versionNumber: 2,
})
expect(ctx.SplitTestUserGetter.promises.getUser).to.not.have.been.called
})
it('throws when _id is missing from the projection', async function (ctx) {
await expect(
ctx.SplitTestHandler.promises.getActiveAssignmentsForMongoUser({
analyticsId: 'analytics-id',
})
).to.be.rejectedWith('bug: include db.users._id in projection')
})
it('throws when analyticsId is missing from the projection', async function (ctx) {
await expect(
ctx.SplitTestHandler.promises.getActiveAssignmentsForMongoUser({
_id: new ObjectId(),
})
).to.be.rejectedWith('bug: include db.users.analyticsId in projection')
})
})
describe('getAssignmentForMongoUser', function () {
it('returns the assignment without re-fetching the user', async function (ctx) {
const assignment =
await ctx.SplitTestHandler.promises.getAssignmentForMongoUser(
ctx.mongoUser,
'my-flag'
)
expect(assignment.variant).to.equal('enabled')
expect(ctx.SplitTestUserGetter.promises.getUser).to.not.have.been.called
})
})
describe('featureFlagEnabledForMongoUser', function () {
it('returns true when the assigned variant is enabled', async function (ctx) {
const enabled =
await ctx.SplitTestHandler.promises.featureFlagEnabledForMongoUser(
ctx.mongoUser,
'my-flag'
)
expect(enabled).to.be.true
})
it('returns false when the assigned variant is not enabled', async function (ctx) {
const enabled =
await ctx.SplitTestHandler.promises.featureFlagEnabledForMongoUser(
ctx.mongoUser,
'active-test'
)
expect(enabled).to.be.false
})
})
})
describe('with a user without assignments', function () {
beforeEach(async function (ctx) {
ctx.user = { _id: new ObjectId() }
ctx.user = { _id: new ObjectId(), analyticsId: 'analytics-id' }
ctx.SplitTestUserGetter.promises.getUser.resolves(ctx.user)
ctx.assignments =
await ctx.SplitTestHandler.promises.getActiveAssignmentsForUser(
@@ -421,6 +514,35 @@ describe('SplitTestHandler', function () {
})
})
describe('getAssignment query overrides', function () {
beforeEach(function (ctx) {
ctx.AnalyticsManager.getIdsFromSession.returns({
userId: 'abc123abc123',
})
// 'active-test' would compute to 'variant-1'; override it to 'default'
ctx.req.query = { 'active-test': 'default' }
})
it('applies a query-string override by default', async function (ctx) {
const { variant } = await ctx.SplitTestHandler.promises.getAssignment(
ctx.req,
ctx.res,
'active-test'
)
expect(variant).to.equal('default')
})
it('ignores query-string overrides when ignoreOverrides is set', async function (ctx) {
const { variant } = await ctx.SplitTestHandler.promises.getAssignment(
ctx.req,
ctx.res,
'active-test',
{ ignoreOverrides: true }
)
expect(variant).to.equal('variant-1')
})
})
describe('variant user limits', function () {
beforeEach(function (ctx) {
ctx.AnalyticsManager.getIdsFromSession.returns({
@@ -163,7 +163,7 @@ describe('FeaturesUpdater', function () {
ctx.SplitTestHandler = {
promises: {
featureFlagEnabledForUser: sinon.stub().resolves(false),
featureFlagEnabledForMongoUser: sinon.stub().resolves(false),
},
}
@@ -78,7 +78,7 @@ describe('AiFeatureUsageRateLimiter', function () {
ctx.SplitTestHandler = {
promises: {
featureFlagEnabledForUser: sinon.stub().resolves(true),
featureFlagEnabledForMongoUser: sinon.stub().resolves(true),
},
}
@@ -62,7 +62,7 @@ describe('WorkbenchRateLimiter', function () {
ctx.SplitTestHandler = {
promises: {
getAssignmentForUser: sinon.stub(),
featureFlagEnabledForUser: sinon.stub().resolves(true),
featureFlagEnabledForMongoUser: sinon.stub().resolves(true),
},
}
ctx.SplitTestHandler.promises.getAssignmentForUser