[web] Cleanup plans-2026-phase-1 - phase 4: rest of backend (#34771)

* Remove `plans-2026-phase-1` from subscription module

* Remove code related to `plans-2026-phase-1`

* Remove `SplitTestUserGetter.getProjection`

* Update tests

* Remove last mentions of `plans-2026-phase-1`

* In test, wait for `_updateCustomerIoSubscriptionProperties` to be done

* Remove `purchaseAddon` test

* Remove AI-addon tests

* Fix `_quotaTierToAllowance` throwing on zero-grant tiers

`!quota` treated `0` as invalid, so `basic` (grant 0) threw. Check `typeof quota !== 'number'` alone. Unmasked once branch removed the split-test branch that skipped this path.

* Extract `waitForCustomerIoSync` helper in FeaturesUpdater tests

* https://github.com/overleaf/internal/pull/34771#discussion_r3552882875

GitOrigin-RevId: 0fe0e41b7a4105a3c4739214e2fffb77e91dbc42
This commit is contained in:
Antoine Clausse
2026-09-17 11:23:39 +00:00
committed by Copybot
parent 414027160d
commit 882ca2f981
8 changed files with 62 additions and 658 deletions
@@ -7,6 +7,16 @@ const { ObjectId } = mongodb
const MODULE_PATH = '../../../../app/src/Features/Subscription/FeaturesUpdater'
// helper awaiting _updateCustomerIoSubscriptionProperties called by refreshFeatures,
// which is otherwise fire-and-forget.
async function waitForCustomerIoSync(ctx) {
await vi.waitFor(() => {
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties'
)
})
}
describe('FeaturesUpdater', function () {
beforeEach(async function (ctx) {
ctx.renewalDate = new Date('2099-04-01T00:00:00Z')
@@ -426,6 +436,7 @@ describe('FeaturesUpdater', function () {
it('should return features and featuresChanged', async function (ctx) {
const { features, featuresChanged } =
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
expect(features).to.exist
expect(featuresChanged).to.exist
})
@@ -433,6 +444,7 @@ describe('FeaturesUpdater', function () {
describe('normally', function () {
beforeEach(async function (ctx) {
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should update the user with the merged features', function (ctx) {
@@ -504,6 +516,7 @@ describe('FeaturesUpdater', function () {
},
])
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should sync trial_end_date to customer.io', function (ctx) {
@@ -542,6 +555,7 @@ describe('FeaturesUpdater', function () {
},
])
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should report stripe as the payment_provider', function (ctx) {
@@ -568,6 +582,7 @@ describe('FeaturesUpdater', function () {
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should sync commons=true to customer.io', function (ctx) {
@@ -609,6 +624,7 @@ describe('FeaturesUpdater', function () {
},
])
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should set commons, individual_subscription, and ai-assist-add-on together', function (ctx) {
@@ -636,6 +652,7 @@ describe('FeaturesUpdater', function () {
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should sync false subscription flags and no payment_provider', function (ctx) {
@@ -682,6 +699,7 @@ describe('FeaturesUpdater', function () {
])
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should sync expiry_date and blank next_renewal_date in customer.io', function (ctx) {
@@ -727,6 +745,7 @@ describe('FeaturesUpdater', function () {
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should sync groupSize to customer.io', function (ctx) {
@@ -785,6 +804,7 @@ describe('FeaturesUpdater', function () {
.resolves([{ _id: policyId, userCannotUseAIFeatures: true }]),
})
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should set group_ai_enabled to false', function (ctx) {
@@ -824,6 +844,7 @@ describe('FeaturesUpdater', function () {
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should derive payment_provider from the group subscription', function (ctx) {
@@ -850,6 +871,7 @@ describe('FeaturesUpdater', function () {
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
@@ -867,6 +889,7 @@ describe('FeaturesUpdater', function () {
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
@@ -884,6 +907,7 @@ describe('FeaturesUpdater', function () {
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
@@ -901,6 +925,7 @@ describe('FeaturesUpdater', function () {
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
@@ -915,6 +940,7 @@ describe('FeaturesUpdater', function () {
.withArgs(ctx.user._id)
.resolves(null)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should send mixed feature set user property', function (ctx) {
@@ -934,6 +960,7 @@ describe('FeaturesUpdater', function () {
.withArgs(ctx.user._id)
.resolves(ctx.subscriptions.noDropbox)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should fire module hook to unlink dropbox', function (ctx) {
@@ -913,152 +913,6 @@ describe('SubscriptionController', function () {
})
})
describe('purchaseAddon', function () {
beforeEach(function (ctx) {
ctx.SessionManager.getSessionUser.returns(ctx.user) // Make sure getSessionUser returns the user
ctx.next = sinon.stub()
ctx.req.params = { addOnCode: AI_ADD_ON_CODE } // Mock add-on code
})
it('should return 200 on successful purchase of AI add-on', async function (ctx) {
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
ctx.res.sendStatus = sinon.spy()
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.have.been.called
expect(
ctx.SubscriptionHandler.promises.purchaseAddon
).to.have.been.calledWith(ctx.user._id, AI_ADD_ON_CODE, 1)
expect(
ctx.FeaturesUpdater.promises.refreshFeatures
).to.have.been.calledWith(ctx.user._id, 'add-on-purchase')
expect(ctx.res.sendStatus).to.have.been.calledWith(200)
expect(ctx.logger.debug).to.have.been.calledWith(
{ userId: ctx.user._id, addOnCode: AI_ADD_ON_CODE },
'purchasing add-ons'
)
})
it('should return 404 if the add-on code is not AI_ADD_ON_CODE', async function (ctx) {
ctx.req.params = { addOnCode: 'some-other-addon' }
ctx.res.sendStatus = sinon.spy()
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.not.have.been
.called
expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
.called
expect(ctx.res.sendStatus).to.have.been.calledWith(404)
})
it('should return 404 when plans-2026-phase-1 split test is enabled', async function (ctx) {
ctx.SplitTestV2Hander.promises.getAssignment.resolves({
variant: 'enabled',
})
ctx.res.sendStatus = sinon.spy()
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.not.have.been
.called
expect(ctx.res.sendStatus).to.have.been.calledWith(404)
})
it('should handle DuplicateAddOnError and send badRequest while sending 200', async function (ctx) {
ctx.req.params.addOnCode = AI_ADD_ON_CODE
ctx.SubscriptionHandler.promises.purchaseAddon.rejects(
new SubscriptionErrors.DuplicateAddOnError()
)
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
expect(ctx.HttpErrorHandler.badRequest).to.have.been.calledWith(
ctx.req,
ctx.res,
'Your subscription already includes this add-on',
{ addon: AI_ADD_ON_CODE }
)
expect(
ctx.FeaturesUpdater.promises.refreshFeatures
).to.have.been.calledWith(ctx.user._id, 'add-on-purchase')
expect(ctx.res.sendStatus).toHaveBeenCalledWith(200)
})
it('should handle PaymentActionRequiredError and return 402 with details', async function (ctx) {
ctx.req.params.addOnCode = AI_ADD_ON_CODE
const paymentError = new SubscriptionErrors.PaymentActionRequiredError({
clientSecret: 'secret123',
publicKey: 'pubkey456',
})
ctx.SubscriptionHandler.promises.purchaseAddon.rejects(paymentError)
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
expect(ctx.res.status).toHaveBeenCalledWith(402)
expect(ctx.res.json).toHaveBeenCalledWith({
message: 'Payment action required',
clientSecret: 'secret123',
publicKey: 'pubkey456',
})
expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
.called
})
it('should refresh features', async function (ctx) {
ctx.req.params.addOnCode = 'assistant'
ctx.SubscriptionHandler.promises.purchaseAddon = sinon.stub().resolves()
ctx.FeaturesUpdater.promises.refreshFeatures = sinon.stub().resolves()
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res)
expect(
ctx.FeaturesUpdater.promises.refreshFeatures.calledWith(
ctx.user._id,
'add-on-purchase'
)
).to.be.true
})
it('should respond with a bad request if the subscription already includes the addOn', async function (ctx) {
ctx.req.params.addOnCode = 'assistant'
ctx.SubscriptionHandler.promises.purchaseAddon = sinon
.stub()
.rejects(new SubscriptionErrors.DuplicateAddOnError())
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res)
expect(
ctx.HttpErrorHandler.badRequest.calledWith(
ctx.req,
ctx.res,
'Your subscription already includes this add-on',
{ addon: 'assistant' }
)
).to.be.true
})
it('should handle MultiplePendingChangesError and return 422 with JSON response', async function (ctx) {
ctx.req.params.addOnCode = AI_ADD_ON_CODE
ctx.SubscriptionHandler.promises.purchaseAddon.rejects(
new SubscriptionErrors.MultiplePendingChangesError()
)
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
expect(ctx.res.status).toHaveBeenCalledWith(422)
expect(ctx.res.json).toHaveBeenCalledWith({
code: 'multiple_pending_changes',
message:
'Cannot complete purchase while there are multiple pending subscription changes. Please contact support.',
})
expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
.called
})
})
describe('removeAddon', function () {
beforeEach(function (ctx) {
ctx.SessionManager.getSessionUser.returns(ctx.user)
@@ -1812,21 +1666,12 @@ describe('SubscriptionController', function () {
describe('previewAddonPurchase', function () {
beforeEach(function (ctx) {
ctx.req = new MockRequest(vi)
ctx.req.params = { addOnCode: 'assistant' }
ctx.req.query = { purchaseReferrer: 'fake-referrer' }
ctx.res = new MockResponse(vi)
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentMethod')
.resolves(['fake-method'])
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(null)
ctx.res.redirect = sinon.stub()
})
it('should redirect with ai-assist-unavailable when plans-2026-phase-1 is enabled', async function (ctx) {
ctx.SplitTestV2Hander.promises.getAssignment.resolves({
variant: 'enabled',
})
ctx.res.redirect = sinon.stub()
it('should redirect with ai-assist-unavailable for the AI add-on', async function (ctx) {
ctx.req.params = { addOnCode: AI_ADD_ON_CODE }
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
@@ -1835,177 +1680,15 @@ describe('SubscriptionController', function () {
)
})
describe('when user has manual or custom subscription', function () {
it('should redirect with ai-assist-unavailable when subscription has customAccount = true', async function (ctx) {
const customSubscription = {
_id: 'sub-123',
customAccount: true,
collectionMethod: 'automatic',
}
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
customSubscription
)
it('should 404 for an unknown add-on', async function (ctx) {
ctx.req.params = { addOnCode: 'unknown-addon' }
ctx.res.redirect = sinon.stub()
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
expect(ctx.res.redirect).to.have.been.calledWith(
'/user/subscription?redirect-reason=ai-assist-unavailable'
)
})
it('should redirect with ai-assist-unavailable when subscription has collectionMethod = manual', async function (ctx) {
const manualSubscription = {
_id: 'sub-123',
customAccount: false,
collectionMethod: 'manual',
}
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
manualSubscription
)
ctx.res.redirect = sinon.stub()
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
expect(ctx.res.redirect).to.have.been.calledWith(
'/user/subscription?redirect-reason=ai-assist-unavailable'
)
})
it('should redirect with ai-assist-unavailable when subscription has both customAccount and manual collection', async function (ctx) {
const customManualSubscription = {
_id: 'sub-123',
customAccount: true,
collectionMethod: 'manual',
}
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
customManualSubscription
)
ctx.res.redirect = sinon.stub()
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
expect(ctx.res.redirect).to.have.been.calledWith(
'/user/subscription?redirect-reason=ai-assist-unavailable'
)
})
})
describe('when user has normal subscription', function () {
it('should proceed with preview when subscription is not manual or custom', async function (ctx) {
const normalSubscription = {
_id: 'sub-123',
customAccount: false,
collectionMethod: 'automatic',
}
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
normalSubscription
)
ctx.PlansLocator.findLocalPlanInSettings.withArgs('assistant').returns({
planCode: 'assistant',
name: 'AI Assist',
annual: false,
})
ctx.res.render = sinon.stub()
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
expect(ctx.res.render).to.have.been.calledWith(
'subscriptions/preview-change',
sinon.match({
changePreview: sinon.match({
change: {
type: 'add-on-purchase',
addOn: { code: 'assistant', name: 'AI Assist' },
},
}),
purchaseReferrer: 'fake-referrer',
redirectedPaymentErrorCode: undefined,
})
)
expect(
ctx.SubscriptionHandler.promises.previewAddonPurchase
).to.have.been.calledWith(ctx.user._id, 'assistant')
expect(
ctx.PlansLocator.findLocalPlanInSettings
).to.have.been.calledWith('assistant')
})
it('should pass redirectedPaymentErrorCode to the view when errorCode query param is present', async function (ctx) {
const normalSubscription = {
_id: 'sub-123',
customAccount: false,
collectionMethod: 'automatic',
}
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
normalSubscription
)
ctx.req.query.errorCode = 'payment_failed'
ctx.res.render = sinon.stub()
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
expect(ctx.res.render).to.have.been.calledWith(
'subscriptions/preview-change',
sinon.match({
changePreview: sinon.match.object,
purchaseReferrer: 'fake-referrer',
redirectedPaymentErrorCode: 'payment_failed',
})
)
})
it('returns 404 when the add-on code is not in the local plan registry', async function (ctx) {
const normalSubscription = {
_id: 'sub-123',
customAccount: false,
collectionMethod: 'automatic',
}
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
normalSubscription
)
ctx.PlansLocator.findLocalPlanInSettings
.withArgs('assistant')
.returns(null)
ctx.res.render = sinon.stub()
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
expect(ctx.HttpErrorHandler.notFound).to.have.been.calledWith(
ctx.req,
ctx.res,
'Unknown add-on: assistant'
)
expect(ctx.res.render).not.to.have.been.called
})
it('should proceed with preview when customAccount is undefined and collectionMethod is automatic', async function (ctx) {
const normalSubscription = {
_id: 'sub-123',
// customAccount: undefined (not set)
collectionMethod: 'automatic',
}
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
normalSubscription
)
ctx.res.render = sinon.stub()
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
expect(ctx.res.render).to.have.been.calledWith(
'subscriptions/preview-change'
)
expect(
ctx.SubscriptionHandler.promises.previewAddonPurchase
).to.have.been.calledWith(ctx.user._id, 'assistant')
})
expect(ctx.HttpErrorHandler.notFound).to.have.been.calledWith(
ctx.req,
ctx.res
)
})
})
})