Files
Verso/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs
T
Antoine Clausse 46715191e3 Merge pull request #28544 from overleaf/ac-some-web-esm-migration-4
[web] Convert some Features files to ES modules (part 4)

GitOrigin-RevId: cf11a7584e39c4d4de08e2f924240e488a4066c4
2025-09-24 08:05:58 +00:00

1561 lines
47 KiB
JavaScript

import { vi, assert, expect } from 'vitest'
import sinon from 'sinon'
import MockRequest from '../helpers/MockRequest.js'
import MockResponse from '../helpers/MockResponse.js'
import SubscriptionErrors from '../../../../app/src/Features/Subscription/Errors.js'
import SubscriptionHelper from '../../../../app/src/Features/Subscription/SubscriptionHelper.js'
import { AI_ADD_ON_CODE } from '../../../../app/src/Features/Subscription/AiHelper.js'
const modulePath =
'../../../../app/src/Features/Subscription/SubscriptionController.mjs'
const mockSubscriptions = {
'subscription-123-active': {
uuid: 'subscription-123-active',
plan: {
name: 'Gold',
plan_code: 'gold',
},
current_period_ends_at: new Date(),
state: 'active',
unit_amount_in_cents: 999,
account: {
account_code: 'user-123',
},
},
}
describe('SubscriptionController', function () {
beforeEach(async function (ctx) {
ctx.logger = {
debug: sinon.stub(),
warn: sinon.stub(),
}
vi.doMock('@overleaf/logger', () => ({
default: ctx.logger,
}))
ctx.user = {
email: 'tom@yahoo.com',
_id: 'one',
signUpDate: new Date('2000-10-01'),
emails: [{ email: 'tom@yahoo.com', confirmedAt: new Date('2000-10-02') }],
}
ctx.activeRecurlySubscription = mockSubscriptions['subscription-123-active']
ctx.SessionManager = {
getLoggedInUser: sinon.stub().callsArgWith(1, null, ctx.user),
getLoggedInUserId: sinon.stub().returns(ctx.user._id),
getSessionUser: sinon.stub().returns(ctx.user),
isUserLoggedIn: sinon.stub().returns(true),
}
ctx.SubscriptionHandler = {
createSubscription: sinon.stub().callsArgWith(3),
updateSubscription: sinon.stub().callsArgWith(3),
reactivateSubscription: sinon.stub().callsArgWith(1),
cancelSubscription: sinon.stub().callsArgWith(1),
syncSubscription: sinon.stub().yields(),
attemptPaypalInvoiceCollection: sinon.stub().yields(),
startFreeTrial: sinon.stub(),
revertPlanChange: sinon.stub(),
promises: {
createSubscription: sinon.stub().resolves(),
updateSubscription: sinon.stub().resolves(),
reactivateSubscription: sinon.stub().resolves(),
cancelSubscription: sinon.stub().resolves(),
pauseSubscription: sinon.stub().resolves(),
resumeSubscription: sinon.stub().resolves(),
syncSubscription: sinon.stub().resolves(),
attemptPaypalInvoiceCollection: sinon.stub().resolves(),
startFreeTrial: sinon.stub().resolves(),
purchaseAddon: sinon.stub().resolves(),
previewAddonPurchase: sinon.stub().resolves({
subscription: {
currency: 'USD',
netTerms: 0,
periodEnd: new Date(),
taxRate: 0,
},
immediateCharge: { amount: 0 },
nextPlanCode: 'professional',
nextPlanName: 'Professional',
nextPlanPrice: 2000,
nextAddOns: [],
subtotal: 2000,
tax: 0,
total: 2000,
}),
revertPlanChange: sinon.stub().resolves(),
},
}
ctx.LimitationsManager = {
hasPaidSubscription: sinon.stub(),
userHasSubscription: sinon
.stub()
.yields(null, { hasSubscription: false }),
promises: {
hasPaidSubscription: sinon.stub().resolves(),
userHasSubscription: sinon.stub().resolves({ hasSubscription: false }),
},
}
ctx.SubscriptionViewModelBuilder = {
buildUsersSubscriptionViewModel: sinon.stub().callsArgWith(1, null, {}),
buildPlansList: sinon.stub(),
promises: {
buildUsersSubscriptionViewModel: sinon.stub().resolves({}),
},
buildPlansListForSubscriptionDash: sinon
.stub()
.returns({ plans: [], planCodesChangingAtTermEnd: [] }),
}
ctx.settings = {
coupon_codes: {
upgradeToAnnualPromo: {
student: 'STUDENTCODEHERE',
collaborator: 'COLLABORATORCODEHERE',
},
},
groupPlanModalOptions: {
plan_codes: [],
currencies: [
{
display: 'GBP (£)',
code: 'GBP',
},
],
sizes: ['42'],
usages: [{ code: 'foo', display: 'Foo' }],
},
apis: {
recurly: {
subdomain: 'sl',
},
},
planReverts: {
enabled: false,
},
siteUrl: 'http://de.overleaf.dev:3000',
}
ctx.AuthorizationManager = {
promises: {
isUserSiteAdmin: sinon.stub().resolves(false),
},
}
ctx.GeoIpLookup = {
isValidCurrencyParam: sinon.stub().returns(true),
getCurrencyCode: sinon.stub().yields('USD', 'US'),
promises: {
getCurrencyCode: sinon.stub().resolves({
countryCode: 'US',
currencyCode: 'USD',
}),
},
}
ctx.UserGetter = {
getUser: sinon.stub().callsArgWith(2, null, ctx.user),
promises: {
getUser: sinon.stub().resolves(ctx.user),
getWritefullData: sinon
.stub()
.resolves({ isPremium: false, premiumSource: null }),
},
}
ctx.SplitTestV2Hander = {
promises: {
getAssignment: sinon.stub().resolves({ variant: 'default' }),
},
}
ctx.Features = {
hasFeature: sinon.stub().returns(false),
}
vi.doMock(
'../../../../app/src/Features/Authorization/AuthorizationManager',
() => ({
default: ctx.AuthorizationManager,
})
)
vi.doMock(
'../../../../app/src/Features/SplitTests/SplitTestHandler',
() => ({
default: ctx.SplitTestV2Hander,
})
)
vi.doMock(
'../../../../app/src/Features/Authentication/SessionManager',
() => ({
default: ctx.SessionManager,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionHandler',
() => ({
default: ctx.SubscriptionHandler,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionHelper',
() => ({
default: SubscriptionHelper,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder',
() => ({
default: ctx.SubscriptionViewModelBuilder,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/LimitationsManager',
() => ({
default: ctx.LimitationsManager,
})
)
vi.doMock('../../../../app/src/infrastructure/GeoIpLookup', () => ({
default: ctx.GeoIpLookup,
}))
vi.doMock('@overleaf/settings', () => ({
default: ctx.settings,
}))
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: ctx.UserGetter,
}))
vi.doMock(
'../../../../app/src/Features/Subscription/RecurlyWrapper',
() => ({
default: (ctx.RecurlyWrapper = {
promises: {
updateAccountEmailAddress: sinon.stub().resolves(),
getSubscription: sinon.stub().resolves({}),
},
}),
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/RecurlyEventHandler',
() => ({
default: {
sendRecurlyAnalyticsEvent: sinon.stub().resolves(),
},
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/FeaturesUpdater',
() => ({
default: (ctx.FeaturesUpdater = {
promises: {
refreshFeatures: sinon.stub().resolves({ features: {} }),
},
}),
})
)
vi.doMock('celebrate', () => ({
default: (ctx.celebrate = {
celebrate: sinon.stub(),
errors: sinon.stub(),
Joi: {
any: sinon.stub(),
extend: sinon.stub(),
},
}),
}))
vi.doMock(
'../../../../app/src/Features/Subscription/GroupPlansData',
() => ({
default: (ctx.GroupPlansData = {}),
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/V1SubscriptionManager',
() => ({
default: (ctx.V1SubscriptionManager = {}),
})
)
vi.doMock('../../../../app/src/Features/Errors/HttpErrorHandler', () => ({
default: (ctx.HttpErrorHandler = {
unprocessableEntity: sinon.stub().callsFake((req, res, message) => {
res.status(422)
res.json({ message })
}),
badRequest: sinon.stub().callsFake((req, res, message) => {
res.status(400)
res.json({ message })
}),
}),
}))
vi.doMock('../../../../app/src/Features/Subscription/Errors', () => ({
default: SubscriptionErrors,
}))
vi.doMock(
'../../../../app/src/Features/Analytics/AnalyticsManager',
() => ({
default: (ctx.AnalyticsManager = {
recordEventForUser: sinon.stub(),
recordEventForUserInBackground: sinon.stub(),
recordEventForSession: sinon.stub(),
setUserPropertyForUser: sinon.stub(),
}),
})
)
vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
default: (ctx.Modules = {
promises: { hooks: { fire: sinon.stub().resolves() } },
}),
}))
vi.doMock('../../../../app/src/infrastructure/Features', () => ({
default: ctx.Features,
}))
vi.doMock('../../../../app/src/util/currency', () => ({
default: (ctx.currency = {
formatCurrency: sinon.stub(),
}),
}))
vi.doMock('../../../../app/src/models/User', () => ({
User: {
findById: sinon.stub().resolves(ctx.user),
},
}))
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionLocator',
() => ({
default: (ctx.SubscriptionLocator = {
promises: {
getUsersSubscription: sinon.stub().resolves(null),
},
}),
})
)
vi.doMock(
'../../../../app/src/Features/Authorization/PermissionsManager',
() => ({
default: (ctx.PermissionsManager = {
promises: {
checkUserPermissions: sinon.stub().resolves(true),
},
}),
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/RecurlyClient',
() => ({
default: (ctx.RecurlyClient = {
promises: {
getAddOn: sinon.stub().resolves({
code: 'ai-assistant',
name: 'AI Assistant',
}),
},
}),
})
)
vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({
default: (ctx.PlansLocator = {
findLocalPlanInSettings: sinon.stub().returns({
annual: false,
}),
}),
}))
ctx.SubscriptionController = (await import(modulePath)).default
ctx.res = new MockResponse()
ctx.req = new MockRequest()
ctx.req.body = {}
ctx.req.query = { planCode: '123123' }
ctx.stubbedCurrencyCode = 'GBP'
})
describe('successfulSubscription', function () {
it('without a personal subscription', async function (ctx) {
await new Promise(resolve => {
ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
{}
)
ctx.res.redirect = url => {
url.should.equal('/user/subscription/plans')
resolve()
}
ctx.SubscriptionController.successfulSubscription(ctx.req, ctx.res)
})
})
it('with a personal subscription', async function (ctx) {
await new Promise(resolve => {
ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
{
personalSubscription: 'foo',
}
)
ctx.res.render = (url, variables) => {
url.should.equal('subscriptions/successful-subscription-react')
assert.deepEqual(variables, {
title: 'thank_you',
personalSubscription: 'foo',
postCheckoutRedirect: undefined,
user: {
_id: ctx.user._id,
features: ctx.user.features,
},
})
resolve()
}
ctx.SubscriptionController.successfulSubscription(ctx.req, ctx.res)
})
})
it('with an error', async function (ctx) {
await new Promise(resolve => {
ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
undefined
)
ctx.SubscriptionController.successfulSubscription(
ctx.req,
ctx.res,
error => {
assert.isNotNull(error)
resolve()
}
)
})
})
})
describe('userSubscriptionPage', function () {
beforeEach(async function (ctx) {
await new Promise((resolve, reject) => {
ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
{
personalSubscription: (ctx.personalSubscription = {
'personal-subscription': 'mock',
}),
memberGroupSubscriptions: (ctx.memberGroupSubscriptions = {
'group-subscriptions': 'mock',
}),
}
)
ctx.SubscriptionViewModelBuilder.buildPlansList.returns(
(ctx.plans = { plans: 'mock' })
)
ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash.returns(
{
plans: ctx.plans,
planCodesChangingAtTermEnd: [],
}
)
ctx.LimitationsManager.promises.userHasSubscription.resolves({
hasSubscription: false,
})
ctx.res.render = (view, data) => {
ctx.data = data
expect(view).to.equal('subscriptions/dashboard-react')
resolve()
}
ctx.SubscriptionController.userSubscriptionPage(
ctx.req,
ctx.res,
ctx.rejectOnError(reject)
)
})
})
it('should load the personal, groups and v1 subscriptions', function (ctx) {
expect(ctx.data.personalSubscription).to.deep.equal(
ctx.personalSubscription
)
expect(ctx.data.memberGroupSubscriptions).to.deep.equal(
ctx.memberGroupSubscriptions
)
})
it('should load the user', function (ctx) {
expect(ctx.data.user).to.deep.equal(ctx.user)
})
it('should load the plans', function (ctx) {
expect(ctx.data.plans).to.deep.equal(ctx.plans)
})
it('should load an empty list of groups with settings available', function (ctx) {
expect(ctx.data.groupSettingsEnabledFor).to.deep.equal([])
})
})
describe('updateAccountEmailAddress via put', function () {
beforeEach(function (ctx) {
ctx.req.body = {
account_email: 'current_account_email@overleaf.com',
}
})
it('should send the user and subscriptionId to "updateAccountEmailAddress" hooks', async function (ctx) {
ctx.res.sendStatus = sinon.spy()
await ctx.SubscriptionController.updateAccountEmailAddress(
ctx.req,
ctx.res
)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'updateAccountEmailAddress',
ctx.user._id,
ctx.user.email
)
})
it('should respond with 200', async function (ctx) {
ctx.res.sendStatus = sinon.spy()
await ctx.SubscriptionController.updateAccountEmailAddress(
ctx.req,
ctx.res
)
ctx.res.sendStatus.calledWith(200).should.equal(true)
})
it('should send the error to the next handler when updating recurly account email fails', async function (ctx) {
ctx.Modules.promises.hooks.fire
.withArgs('updateAccountEmailAddress', ctx.user._id, ctx.user.email)
.rejects(new Error())
ctx.next = sinon.spy(error => {
expect(error).to.be.instanceOf(Error)
})
await ctx.SubscriptionController.updateAccountEmailAddress(
ctx.req,
ctx.res,
ctx.next
)
expect(ctx.next.calledOnce).to.be.true
})
})
describe('reactivateSubscription', function () {
describe('when the user has permission', function () {
beforeEach(async function (ctx) {
await new Promise(resolve => {
ctx.res = {
redirect() {
resolve()
},
}
ctx.req.assertPermission = sinon.stub()
ctx.next = sinon.stub().callsFake(error => {
resolve(error)
})
sinon.spy(ctx.res, 'redirect')
ctx.SubscriptionController.reactivateSubscription(
ctx.req,
ctx.res,
ctx.next
)
})
})
it('should assert the user has permission to reactivate their subscription', async function (ctx) {
await new Promise(resolve => {
ctx.req.assertPermission
.calledWith('reactivate-subscription')
.should.equal(true)
resolve()
})
})
it('should tell the handler to reactivate this user', async function (ctx) {
await new Promise(resolve => {
ctx.SubscriptionHandler.reactivateSubscription
.calledWith(ctx.user)
.should.equal(true)
resolve()
})
})
it('should redurect to the subscription page', async function (ctx) {
await new Promise(resolve => {
ctx.res.redirect.calledWith('/user/subscription').should.equal(true)
resolve()
})
})
})
describe('when the user does not have permission', function () {
beforeEach(async function (ctx) {
await new Promise(resolve => {
ctx.res = {
redirect() {
resolve()
},
}
ctx.req.assertPermission = sinon.stub().throws()
ctx.next = sinon.stub().callsFake(() => {
resolve()
})
sinon.spy(ctx.res, 'redirect')
ctx.SubscriptionController.reactivateSubscription(
ctx.req,
ctx.res,
ctx.next
)
})
})
it('should not reactivate the user', async function (ctx) {
await new Promise(resolve => {
ctx.req.assertPermission = sinon.stub().throws()
ctx.SubscriptionHandler.reactivateSubscription.called.should.equal(
false
)
resolve()
})
})
it('should call next with an error', async function (ctx) {
await new Promise(resolve => {
ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
resolve()
})
})
})
})
describe('pauseSubscription', function () {
it('should throw an error if no pause length is provided', async function (ctx) {
ctx.res = new MockResponse()
ctx.req = new MockRequest()
ctx.next = sinon.stub()
await expect(
ctx.SubscriptionController.pauseSubscription(ctx.req, ctx.res, ctx.next)
).to.be.rejectedWith('Not found')
})
it('should throw an error if an invalid pause length is provided', async function (ctx) {
ctx.res = new MockResponse()
ctx.req = new MockRequest()
ctx.req.params = { pauseCycles: '-10' }
ctx.next = sinon.stub()
await ctx.SubscriptionController.pauseSubscription(
ctx.req,
ctx.res,
ctx.next
)
expect(ctx.res.statusCode).to.equal(400)
})
it('should return a 200 when requesting a pause', async function (ctx) {
ctx.res = new MockResponse()
ctx.req = new MockRequest()
ctx.req.params = { pauseCycles: '3' }
ctx.next = sinon.stub()
await ctx.SubscriptionController.pauseSubscription(
ctx.req,
ctx.res,
ctx.next
)
expect(ctx.res.statusCode).to.equal(200)
})
})
describe('resumeSubscription', function () {
it('should return a 200 when resuming a subscription', async function (ctx) {
ctx.res = new MockResponse()
ctx.req = new MockRequest()
ctx.next = sinon.stub()
await ctx.SubscriptionController.resumeSubscription(
ctx.req,
ctx.res,
ctx.next
)
expect(ctx.res.statusCode).to.equal(200)
})
})
describe('cancelSubscription', function () {
it('should tell the handler to cancel this user', async function (ctx) {
ctx.next = sinon.stub()
await ctx.SubscriptionController.cancelSubscription(
ctx.req,
ctx.res,
ctx.next
)
ctx.SubscriptionHandler.promises.cancelSubscription
.calledWith(ctx.user)
.should.equal(true)
})
it('should return a 200 on success', async function (ctx) {
ctx.next = sinon.stub()
await ctx.SubscriptionController.cancelSubscription(
ctx.req,
ctx.res,
ctx.next
)
expect(ctx.res.statusCode).to.equal(200)
})
it('should call next with error', async function (ctx) {
ctx.SubscriptionHandler.promises.cancelSubscription.rejects(
new Error('cancel error')
)
ctx.next = sinon.stub()
await ctx.SubscriptionController.cancelSubscription(
ctx.req,
ctx.res,
ctx.next
)
ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
})
})
describe('recurly callback', function () {
describe('with a sync subscription request', function () {
beforeEach(async function (ctx) {
await new Promise(resolve => {
ctx.req = {
body: {
expired_subscription_notification: {
account: {
account_code: ctx.user._id,
},
subscription: {
uuid: ctx.activeRecurlySubscription.uuid,
plan: {
plan_code: 'collaborator',
state: 'active',
},
},
},
},
}
ctx.res = {
sendStatus() {
resolve()
},
}
sinon.spy(ctx.res, 'sendStatus')
ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
})
})
it('should tell the SubscriptionHandler to process the recurly callback', async function (ctx) {
await new Promise(resolve => {
ctx.SubscriptionHandler.syncSubscription.called.should.equal(true)
resolve()
})
})
it('should send a 200', async function (ctx) {
await new Promise(resolve => {
ctx.res.sendStatus.calledWith(200)
resolve()
})
})
})
describe('with a billing info updated request', function () {
beforeEach(async function (ctx) {
await new Promise(resolve => {
ctx.req = {
body: {
billing_info_updated_notification: {
account: {
account_code: 'mock-account-code',
},
},
},
}
ctx.res = {
sendStatus() {
resolve()
},
}
sinon.spy(ctx.res, 'sendStatus')
ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
})
})
it('should call attemptPaypalInvoiceCollection', async function (ctx) {
await new Promise(resolve => {
ctx.SubscriptionHandler.attemptPaypalInvoiceCollection
.calledWith('mock-account-code')
.should.equal(true)
resolve()
})
})
it('should send a 200', async function (ctx) {
await new Promise(resolve => {
ctx.res.sendStatus.calledWith(200)
resolve()
})
})
})
describe('with a non-actionable request', function () {
beforeEach(async function (ctx) {
await new Promise(resolve => {
ctx.user.id = ctx.activeRecurlySubscription.account.account_code
ctx.req = {
body: {
renewed_subscription_notification: {
account: {
account_code: ctx.user._id,
},
subscription: {
uuid: ctx.activeRecurlySubscription.uuid,
plan: {
plan_code: 'collaborator',
state: 'active',
},
},
},
},
}
ctx.res = {
sendStatus() {
resolve()
},
}
sinon.spy(ctx.res, 'sendStatus')
ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
})
})
it('should not call the subscriptionshandler', function (ctx) {
ctx.SubscriptionHandler.syncSubscription.called.should.equal(false)
ctx.SubscriptionHandler.attemptPaypalInvoiceCollection.called.should.equal(
false
)
})
it('should respond with a 200 status', function (ctx) {
ctx.res.sendStatus.calledWith(200)
})
})
describe('with a failed payment notification', function () {
describe('with planReverts disabled in settings', function () {
beforeEach(async function (ctx) {
await new Promise(resolve => {
ctx.settings.planReverts = { enabled: false }
ctx.SubscriptionHandler.revertPlanChange = sinon.stub()
ctx.req.body = {
failed_payment_notification: {
transaction: {
subscription_id: 'subscription-123',
},
},
}
ctx.res = {
sendStatus() {
resolve()
},
}
sinon.spy(ctx.res, 'sendStatus')
ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
})
})
it('should not call revertPlanChange', function (ctx) {
expect(ctx.SubscriptionHandler.revertPlanChange.called).to.be.false
})
it('should respond with 200', async function (ctx) {
await new Promise(resolve => {
ctx.res.sendStatus.calledWith(200)
resolve()
})
})
})
describe('with planReverts enabled in settings', function () {
beforeEach(function (ctx) {
ctx.settings.planReverts = { enabled: true }
})
describe('with no valid restore point', function () {
beforeEach(async function (ctx) {
await new Promise(resolve => {
ctx.SubscriptionHandler.getSubscriptionRestorePoint = sinon
.stub()
.yields(null, null)
ctx.SubscriptionHandler.revertPlanChange = sinon.stub()
ctx.req.body = {
failed_payment_notification: {
transaction: {
subscription_id: 'subscription-123',
},
},
}
ctx.res = {
sendStatus() {
resolve()
},
}
sinon.spy(ctx.res, 'sendStatus')
ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
})
})
it('should not call revertPlanChange()', function (ctx) {
expect(ctx.SubscriptionHandler.revertPlanChange.called).to.be.false
})
it('should respond with 200', function (ctx) {
ctx.res.sendStatus.calledWith(200)
})
})
describe('with a valid restore point', function () {
beforeEach(async function (ctx) {
await new Promise(resolve => {
ctx.addOns = [
{
addOnCode: 'addon-1',
quantity: 2,
unitAmountInCents: 500,
},
{
addOnCode: 'addon-2',
quantity: 1,
unitAmountInCents: 600,
},
]
ctx.lastSubscription = {
planCode: 'gold',
addOns: ctx.addOns,
}
ctx.SubscriptionHandler.getSubscriptionRestorePoint = sinon
.stub()
.yields(null, ctx.lastSubscription)
ctx.SubscriptionHandler.revertPlanChange = sinon.stub().yields()
ctx.req.body = {
failed_payment_notification: {
transaction: {
subscription_id: 'subscription-123',
},
},
}
ctx.res = {
sendStatus() {
resolve()
},
}
sinon.spy(ctx.res, 'sendStatus')
ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
})
})
it('should get the subscription restore point', function (ctx) {
expect(
ctx.SubscriptionHandler.getSubscriptionRestorePoint.calledWith(
'subscription-123'
)
).to.be.true
})
it('should call revertPlanChange()', function (ctx) {
expect(
ctx.SubscriptionHandler.revertPlanChange.calledWith(
'subscription-123',
ctx.lastSubscription
)
).to.be.true
})
it('should respond with 200', function (ctx) {
ctx.res.sendStatus.calledWith(200)
})
})
})
})
})
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 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).to.have.been.calledWith(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)
ctx.res.status.calledWith(402).should.equal(true)
ctx.res.json
.calledWith({
message: 'Payment action required',
clientSecret: 'secret123',
publicKey: 'pubkey456',
})
.should.equal(true)
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
})
})
describe('checkSubscriptionPauseStatus', function () {
beforeEach(function (ctx) {
ctx.user = {
_id: 'user-id-123',
email: 'test@example.com',
}
})
it('should return isPaused: false when user has no subscription', async function (ctx) {
ctx.LimitationsManager.promises.userHasSubscription.resolves({
subscription: null,
})
const result =
await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
expect(result).to.deep.equal({ isPaused: false })
})
it('should return isPaused: false when subscription has no paymentProvider', async function (ctx) {
const subscription = {
planCode: 'professional',
}
ctx.LimitationsManager.promises.userHasSubscription.resolves({
subscription,
})
const result =
await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
expect(result).to.deep.equal({ isPaused: false })
})
it('should return isPaused: false when subscription has no subscriptionId', async function (ctx) {
const subscription = {
paymentProvider: {
service: 'stripe',
subscriptionId: null,
},
}
ctx.LimitationsManager.promises.userHasSubscription.resolves({
subscription,
})
const result =
await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
expect(result).to.deep.equal({ isPaused: false })
})
it('should return isPaused: false when Stripe subscription has no remaining pause cycles', async function (ctx) {
const subscription = {
paymentProvider: {
service: 'stripe',
subscriptionId: 'sub-123',
},
}
ctx.LimitationsManager.promises.userHasSubscription.resolves({
subscription,
})
const paymentRecord = {
subscription: {
remainingPauseCycles: 0,
},
}
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentFromRecord', subscription)
.resolves([paymentRecord])
const result =
await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
expect(result).to.deep.equal({ isPaused: false })
})
it('should return isPaused: false when Stripe subscription has no remainingPauseCycles property', async function (ctx) {
const subscription = {
paymentProvider: {
service: 'stripe',
subscriptionId: 'sub-123',
},
}
ctx.LimitationsManager.promises.userHasSubscription.resolves({
subscription,
})
const paymentRecord = {
subscription: {},
}
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentFromRecord', subscription)
.resolves([paymentRecord])
const result =
await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
expect(result).to.deep.equal({ isPaused: false })
})
it('should return isPaused: true with redirect path when Stripe subscription has remaining pause cycles', async function (ctx) {
const subscription = {
paymentProvider: {
service: 'stripe',
subscriptionId: 'sub-123',
},
}
ctx.LimitationsManager.promises.userHasSubscription.resolves({
subscription,
})
const paymentRecord = {
subscription: {
remainingPauseCycles: 2,
},
}
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentFromRecord', subscription)
.resolves([paymentRecord])
const result =
await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
expect(result).to.deep.equal({
isPaused: true,
redirectPath: '/user/subscription?redirect-reason=subscription-paused',
})
})
it('should return isPaused: true when remainingPauseCycles is exactly 1', async function (ctx) {
const subscription = {
paymentProvider: {
service: 'stripe',
subscriptionId: 'sub-123',
},
}
ctx.LimitationsManager.promises.userHasSubscription.resolves({
subscription,
})
const paymentRecord = {
subscription: {
remainingPauseCycles: 1,
},
}
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentFromRecord', subscription)
.resolves([paymentRecord])
const result =
await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
expect(result).to.deep.equal({
isPaused: true,
redirectPath: '/user/subscription?redirect-reason=subscription-paused',
})
})
it('should return isPaused: false when userHasSubscription throws error', async function (ctx) {
const error = new Error('Something bad happened')
ctx.LimitationsManager.promises.userHasSubscription.rejects(error)
const result =
await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
expect(result).to.deep.equal({ isPaused: false })
})
it('should return isPaused: false when getPaymentFromRecord throws error', async function (ctx) {
const subscription = {
paymentProvider: {
service: 'stripe',
subscriptionId: 'sub-123',
},
}
ctx.LimitationsManager.promises.userHasSubscription.resolves({
subscription,
})
const error = new Error('Something bad happened')
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentFromRecord', subscription)
.rejects(error)
const result =
await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
expect(result).to.deep.equal({ isPaused: false })
})
it('should return isPaused: false when Recurly subscription is not paused', async function (ctx) {
const subscription = {
recurlySubscription_id: 'uuid-123',
recurlyStatus: {
state: 'active',
},
}
ctx.LimitationsManager.promises.userHasSubscription.resolves({
subscription,
})
const result =
await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
expect(result).to.deep.equal({ isPaused: false })
})
it('should return isPaused: true when Recurly subscription is paused', async function (ctx) {
const subscription = {
recurlySubscription_id: 'uuid-123',
recurlyStatus: {
state: 'paused',
},
}
ctx.LimitationsManager.promises.userHasSubscription.resolves({
subscription,
})
const result =
await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
expect(result).to.deep.equal({
isPaused: true,
redirectPath: '/user/subscription?redirect-reason=subscription-paused',
})
})
it('should return isPaused: true when Recurly subscription has pending pause cycles', async function (ctx) {
const subscription = {
recurlySubscription_id: 'uuid-123',
recurlyStatus: {
state: 'active',
},
}
ctx.LimitationsManager.promises.userHasSubscription.resolves({
subscription,
})
const recurlySubscriptionData = {
remaining_pause_cycles: 2,
}
ctx.RecurlyWrapper.promises.getSubscription.resolves(
recurlySubscriptionData
)
const result =
await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
expect(result).to.deep.equal({
isPaused: true,
redirectPath: '/user/subscription?redirect-reason=subscription-paused',
})
expect(
ctx.RecurlyWrapper.promises.getSubscription
).to.have.been.calledWith('uuid-123')
})
it('should return isPaused: false when Recurly subscription has no remaining pause cycles', async function (ctx) {
const subscription = {
recurlySubscription_id: 'uuid-123',
recurlyStatus: {
state: 'active',
},
}
ctx.LimitationsManager.promises.userHasSubscription.resolves({
subscription,
})
const recurlySubscriptionData = {
remaining_pause_cycles: 0,
}
ctx.RecurlyWrapper.promises.getSubscription.resolves(
recurlySubscriptionData
)
const result =
await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
expect(result).to.deep.equal({ isPaused: false })
})
it('should return isPaused: false when Recurly subscription has no remaining_pause_cycles property', async function (ctx) {
const subscription = {
recurlySubscription_id: 'uuid-123',
recurlyStatus: {
state: 'active',
},
}
ctx.LimitationsManager.promises.userHasSubscription.resolves({
subscription,
})
const recurlySubscriptionData = {}
ctx.RecurlyWrapper.promises.getSubscription.resolves(
recurlySubscriptionData
)
const result =
await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
expect(result).to.deep.equal({ isPaused: false })
})
it('should return isPaused: false when Recurly API call fails', async function (ctx) {
const subscription = {
recurlySubscription_id: 'uuid-123',
recurlyStatus: {
state: 'active',
},
}
ctx.LimitationsManager.promises.userHasSubscription.resolves({
subscription,
})
const error = new Error('Recurly API failed')
ctx.RecurlyWrapper.promises.getSubscription.rejects(error)
const result =
await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
expect(result).to.deep.equal({ isPaused: false })
})
})
describe('previewAddonPurchase', function () {
beforeEach(function (ctx) {
ctx.req = new MockRequest()
ctx.req.params = { addOnCode: 'assistant' }
ctx.req.query = { purchaseReferrer: 'fake-referrer' }
ctx.res = new MockResponse()
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentMethod')
.resolves(['fake-method'])
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(null)
})
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
)
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 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.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')
})
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')
})
})
})
})