SubscriptionController.test.mjs 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540
  1. import { vi, assert, expect } from 'vitest'
  2. import sinon from 'sinon'
  3. import MockRequest from '../helpers/MockRequest.mjs'
  4. import MockResponse from '../helpers/MockResponse.mjs'
  5. import SubscriptionErrors from '../../../../app/src/Features/Subscription/Errors.mjs'
  6. import SubscriptionHelper from '../../../../app/src/Features/Subscription/SubscriptionHelper.mjs'
  7. import { AI_ADD_ON_CODE } from '../../../../app/src/Features/Subscription/AiHelper.mjs'
  8. const modulePath =
  9. '../../../../app/src/Features/Subscription/SubscriptionController.mjs'
  10. const mockSubscriptions = {
  11. 'subscription-123-active': {
  12. uuid: 'subscription-123-active',
  13. plan: {
  14. name: 'Gold',
  15. plan_code: 'gold',
  16. },
  17. current_period_ends_at: new Date(),
  18. state: 'active',
  19. unit_amount_in_cents: 999,
  20. account: {
  21. account_code: 'user-123',
  22. },
  23. },
  24. }
  25. describe('SubscriptionController', function () {
  26. beforeEach(async function (ctx) {
  27. ctx.logger = {
  28. debug: sinon.stub(),
  29. warn: sinon.stub(),
  30. }
  31. vi.doMock('@overleaf/logger', () => ({
  32. default: ctx.logger,
  33. }))
  34. ctx.user = {
  35. email: 'tom@yahoo.com',
  36. _id: 'one',
  37. signUpDate: new Date('2000-10-01'),
  38. emails: [{ email: 'tom@yahoo.com', confirmedAt: new Date('2000-10-02') }],
  39. }
  40. ctx.activeRecurlySubscription = mockSubscriptions['subscription-123-active']
  41. ctx.SessionManager = {
  42. getLoggedInUser: sinon.stub().callsArgWith(1, null, ctx.user),
  43. getLoggedInUserId: sinon.stub().returns(ctx.user._id),
  44. getSessionUser: sinon.stub().returns(ctx.user),
  45. isUserLoggedIn: sinon.stub().returns(true),
  46. }
  47. ctx.SubscriptionHandler = {
  48. createSubscription: sinon.stub().callsArgWith(3),
  49. updateSubscription: sinon.stub().callsArgWith(3),
  50. reactivateSubscription: sinon.stub().callsArgWith(1),
  51. cancelSubscription: sinon.stub().callsArgWith(1),
  52. syncSubscription: sinon.stub().yields(),
  53. attemptPaypalInvoiceCollection: sinon.stub().yields(),
  54. startFreeTrial: sinon.stub(),
  55. promises: {
  56. createSubscription: sinon.stub().resolves(),
  57. updateSubscription: sinon.stub().resolves(),
  58. reactivateSubscription: sinon.stub().resolves(),
  59. cancelSubscription: sinon.stub().resolves(),
  60. pauseSubscription: sinon.stub().resolves(),
  61. resumeSubscription: sinon.stub().resolves(),
  62. syncSubscription: sinon.stub().resolves(),
  63. attemptPaypalInvoiceCollection: sinon.stub().resolves(),
  64. startFreeTrial: sinon.stub().resolves(),
  65. purchaseAddon: sinon.stub().resolves(),
  66. previewAddonPurchase: sinon.stub().resolves({
  67. subscription: {
  68. currency: 'USD',
  69. netTerms: 0,
  70. periodEnd: new Date(),
  71. taxRate: 0,
  72. },
  73. immediateCharge: { amount: 0 },
  74. nextPlanCode: 'professional',
  75. nextPlanName: 'Professional',
  76. nextPlanPrice: 2000,
  77. nextAddOns: [],
  78. subtotal: 2000,
  79. tax: 0,
  80. total: 2000,
  81. }),
  82. },
  83. }
  84. ctx.LimitationsManager = {
  85. hasPaidSubscription: sinon.stub(),
  86. userHasSubscription: sinon
  87. .stub()
  88. .yields(null, { hasSubscription: false }),
  89. promises: {
  90. hasPaidSubscription: sinon.stub().resolves(),
  91. userHasSubscription: sinon.stub().resolves({ hasSubscription: false }),
  92. },
  93. }
  94. ctx.SubscriptionViewModelBuilder = {
  95. buildUsersSubscriptionViewModel: sinon.stub().callsArgWith(1, null, {}),
  96. buildPlansList: sinon.stub(),
  97. promises: {
  98. buildUsersSubscriptionViewModel: sinon.stub().resolves({}),
  99. },
  100. buildPlansListForSubscriptionDash: sinon
  101. .stub()
  102. .returns({ plans: [], planCodesChangingAtTermEnd: [] }),
  103. }
  104. ctx.settings = {
  105. coupon_codes: {
  106. upgradeToAnnualPromo: {
  107. student: 'STUDENTCODEHERE',
  108. collaborator: 'COLLABORATORCODEHERE',
  109. },
  110. },
  111. groupPlanModalOptions: {
  112. plan_codes: [],
  113. currencies: [
  114. {
  115. display: 'GBP (£)',
  116. code: 'GBP',
  117. },
  118. ],
  119. sizes: ['42'],
  120. usages: [{ code: 'foo', display: 'Foo' }],
  121. },
  122. apis: {
  123. recurly: {
  124. subdomain: 'sl',
  125. },
  126. },
  127. siteUrl: 'http://de.overleaf.dev:3000',
  128. }
  129. ctx.AuthorizationManager = {
  130. promises: {
  131. isUserSiteAdmin: sinon.stub().resolves(false),
  132. },
  133. }
  134. ctx.GeoIpLookup = {
  135. isValidCurrencyParam: sinon.stub().returns(true),
  136. getCurrencyCode: sinon.stub().yields('USD', 'US'),
  137. promises: {
  138. getCurrencyCode: sinon.stub().resolves({
  139. countryCode: 'US',
  140. currencyCode: 'USD',
  141. }),
  142. },
  143. }
  144. ctx.UserGetter = {
  145. getUser: sinon.stub().callsArgWith(2, null, ctx.user),
  146. promises: {
  147. getUser: sinon.stub().resolves(ctx.user),
  148. getWritefullData: sinon
  149. .stub()
  150. .resolves({ isPremium: false, premiumSource: null }),
  151. },
  152. }
  153. ctx.SplitTestV2Hander = {
  154. promises: {
  155. getAssignment: sinon.stub().resolves({ variant: 'default' }),
  156. },
  157. }
  158. ctx.Features = {
  159. hasFeature: sinon.stub().returns(false),
  160. }
  161. vi.doMock(
  162. '../../../../app/src/Features/Authorization/AuthorizationManager',
  163. () => ({
  164. default: ctx.AuthorizationManager,
  165. })
  166. )
  167. vi.doMock(
  168. '../../../../app/src/Features/SplitTests/SplitTestHandler',
  169. () => ({
  170. default: ctx.SplitTestV2Hander,
  171. })
  172. )
  173. vi.doMock(
  174. '../../../../app/src/Features/Authentication/SessionManager',
  175. () => ({
  176. default: ctx.SessionManager,
  177. })
  178. )
  179. vi.doMock(
  180. '../../../../app/src/Features/Subscription/SubscriptionHandler',
  181. () => ({
  182. default: ctx.SubscriptionHandler,
  183. })
  184. )
  185. vi.doMock(
  186. '../../../../app/src/Features/Subscription/SubscriptionHelper',
  187. () => ({
  188. default: SubscriptionHelper,
  189. })
  190. )
  191. vi.doMock(
  192. '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder',
  193. () => ({
  194. default: ctx.SubscriptionViewModelBuilder,
  195. })
  196. )
  197. vi.doMock(
  198. '../../../../app/src/Features/Subscription/LimitationsManager',
  199. () => ({
  200. default: ctx.LimitationsManager,
  201. })
  202. )
  203. vi.doMock('../../../../app/src/infrastructure/GeoIpLookup', () => ({
  204. default: ctx.GeoIpLookup,
  205. }))
  206. vi.doMock('@overleaf/settings', () => ({
  207. default: ctx.settings,
  208. }))
  209. vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
  210. default: ctx.UserGetter,
  211. }))
  212. vi.doMock(
  213. '../../../../app/src/Features/Subscription/RecurlyWrapper',
  214. () => ({
  215. default: (ctx.RecurlyWrapper = {
  216. promises: {
  217. updateAccountEmailAddress: sinon.stub().resolves(),
  218. getSubscription: sinon.stub().resolves({}),
  219. },
  220. }),
  221. })
  222. )
  223. vi.doMock(
  224. '../../../../app/src/Features/Subscription/RecurlyEventHandler',
  225. () => ({
  226. default: {
  227. sendRecurlyAnalyticsEvent: sinon.stub().resolves(),
  228. },
  229. })
  230. )
  231. vi.doMock(
  232. '../../../../app/src/Features/Subscription/FeaturesUpdater',
  233. () => ({
  234. default: (ctx.FeaturesUpdater = {
  235. promises: {
  236. refreshFeatures: sinon.stub().resolves({ features: {} }),
  237. },
  238. }),
  239. })
  240. )
  241. vi.doMock(
  242. '../../../../app/src/Features/Subscription/GroupPlansData',
  243. () => ({
  244. default: (ctx.GroupPlansData = {}),
  245. })
  246. )
  247. vi.doMock(
  248. '../../../../app/src/Features/Subscription/V1SubscriptionManager',
  249. () => ({
  250. default: (ctx.V1SubscriptionManager = {}),
  251. })
  252. )
  253. vi.doMock('../../../../app/src/Features/Errors/HttpErrorHandler', () => ({
  254. default: (ctx.HttpErrorHandler = {
  255. unprocessableEntity: sinon.stub().callsFake((req, res, message) => {
  256. res.status(422)
  257. res.json({ message })
  258. }),
  259. badRequest: sinon.stub().callsFake((req, res, message) => {
  260. res.status(400)
  261. res.json({ message })
  262. }),
  263. }),
  264. }))
  265. vi.doMock('../../../../app/src/Features/Subscription/Errors', () => ({
  266. default: SubscriptionErrors,
  267. }))
  268. vi.doMock(
  269. '../../../../app/src/Features/Analytics/AnalyticsManager',
  270. () => ({
  271. default: (ctx.AnalyticsManager = {
  272. recordEventForUser: sinon.stub(),
  273. recordEventForUserInBackground: sinon.stub(),
  274. recordEventForSession: sinon.stub(),
  275. setUserPropertyForUser: sinon.stub(),
  276. }),
  277. })
  278. )
  279. vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
  280. default: (ctx.Modules = {
  281. promises: { hooks: { fire: sinon.stub().resolves() } },
  282. }),
  283. }))
  284. vi.doMock('../../../../app/src/infrastructure/Features', () => ({
  285. default: ctx.Features,
  286. }))
  287. vi.doMock('../../../../app/src/util/currency', () => ({
  288. default: (ctx.currency = {
  289. formatCurrency: sinon.stub(),
  290. }),
  291. }))
  292. vi.doMock('../../../../app/src/models/User', () => ({
  293. User: {
  294. findById: sinon.stub().resolves(ctx.user),
  295. },
  296. }))
  297. vi.doMock(
  298. '../../../../app/src/Features/Subscription/SubscriptionLocator',
  299. () => ({
  300. default: (ctx.SubscriptionLocator = {
  301. promises: {
  302. getUsersSubscription: sinon.stub().resolves(null),
  303. },
  304. }),
  305. })
  306. )
  307. vi.doMock(
  308. '../../../../app/src/Features/Authorization/PermissionsManager',
  309. () => ({
  310. default: (ctx.PermissionsManager = {
  311. promises: {
  312. checkUserPermissions: sinon.stub().resolves(true),
  313. },
  314. }),
  315. })
  316. )
  317. vi.doMock(
  318. '../../../../app/src/Features/Subscription/RecurlyClient',
  319. () => ({
  320. default: (ctx.RecurlyClient = {
  321. promises: {
  322. getAddOn: sinon.stub().resolves({
  323. code: 'ai-assistant',
  324. name: 'AI Assistant',
  325. }),
  326. },
  327. }),
  328. })
  329. )
  330. vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({
  331. default: (ctx.PlansLocator = {
  332. findLocalPlanInSettings: sinon.stub().returns({
  333. annual: false,
  334. }),
  335. }),
  336. }))
  337. ctx.SubscriptionController = (await import(modulePath)).default
  338. ctx.res = new MockResponse(vi)
  339. ctx.req = new MockRequest(vi)
  340. ctx.req.body = {}
  341. ctx.req.query = { planCode: '123123' }
  342. ctx.stubbedCurrencyCode = 'GBP'
  343. })
  344. describe('successfulSubscription', function () {
  345. it('without a personal subscription', async function (ctx) {
  346. await new Promise(resolve => {
  347. ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
  348. {}
  349. )
  350. ctx.res.redirect = url => {
  351. url.should.equal('/user/subscription/plans')
  352. resolve()
  353. }
  354. ctx.SubscriptionController.successfulSubscription(ctx.req, ctx.res)
  355. })
  356. })
  357. it('with a personal subscription', async function (ctx) {
  358. await new Promise(resolve => {
  359. ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
  360. {
  361. personalSubscription: 'foo',
  362. }
  363. )
  364. ctx.res.render = (url, variables) => {
  365. url.should.equal('subscriptions/successful-subscription-react')
  366. assert.deepEqual(variables, {
  367. title: 'thank_you',
  368. personalSubscription: 'foo',
  369. postCheckoutRedirect: undefined,
  370. user: {
  371. _id: ctx.user._id,
  372. features: ctx.user.features,
  373. },
  374. })
  375. resolve()
  376. }
  377. ctx.SubscriptionController.successfulSubscription(ctx.req, ctx.res)
  378. })
  379. })
  380. it('with an error', async function (ctx) {
  381. await new Promise(resolve => {
  382. ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
  383. undefined
  384. )
  385. ctx.SubscriptionController.successfulSubscription(
  386. ctx.req,
  387. ctx.res,
  388. error => {
  389. assert.isNotNull(error)
  390. resolve()
  391. }
  392. )
  393. })
  394. })
  395. })
  396. describe('userSubscriptionPage', function () {
  397. beforeEach(async function (ctx) {
  398. await new Promise((resolve, reject) => {
  399. ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
  400. {
  401. personalSubscription: (ctx.personalSubscription = {
  402. 'personal-subscription': 'mock',
  403. }),
  404. memberGroupSubscriptions: (ctx.memberGroupSubscriptions = {
  405. 'group-subscriptions': 'mock',
  406. }),
  407. }
  408. )
  409. ctx.SubscriptionViewModelBuilder.buildPlansList.returns(
  410. (ctx.plans = { plans: 'mock' })
  411. )
  412. ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash.returns(
  413. {
  414. plans: ctx.plans,
  415. planCodesChangingAtTermEnd: [],
  416. }
  417. )
  418. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  419. hasSubscription: false,
  420. })
  421. ctx.res.render = (view, data) => {
  422. ctx.data = data
  423. expect(view).to.equal('subscriptions/dashboard-react')
  424. resolve()
  425. }
  426. ctx.SubscriptionController.userSubscriptionPage(
  427. ctx.req,
  428. ctx.res,
  429. ctx.rejectOnError(reject)
  430. )
  431. })
  432. })
  433. it('should load the personal, groups and v1 subscriptions', function (ctx) {
  434. expect(ctx.data.personalSubscription).to.deep.equal(
  435. ctx.personalSubscription
  436. )
  437. expect(ctx.data.memberGroupSubscriptions).to.deep.equal(
  438. ctx.memberGroupSubscriptions
  439. )
  440. })
  441. it('should load the user', function (ctx) {
  442. expect(ctx.data.user).to.deep.equal(ctx.user)
  443. })
  444. it('should load the plans', function (ctx) {
  445. expect(ctx.data.plans).to.deep.equal(ctx.plans)
  446. })
  447. it('should load an empty list of groups with settings available', function (ctx) {
  448. expect(ctx.data.groupSettingsEnabledFor).to.deep.equal([])
  449. })
  450. describe('when errorCode query param is present', function () {
  451. beforeEach(async function (ctx) {
  452. ctx.req.query.errorCode = 'payment_failed'
  453. await new Promise((resolve, reject) => {
  454. ctx.res.render = (view, data) => {
  455. ctx.data = data
  456. expect(view).to.equal('subscriptions/dashboard-react')
  457. resolve()
  458. }
  459. ctx.SubscriptionController.userSubscriptionPage(
  460. ctx.req,
  461. ctx.res,
  462. ctx.rejectOnError(reject)
  463. )
  464. })
  465. })
  466. it('should pass redirectedPaymentErrorCode to the view', function (ctx) {
  467. expect(ctx.data.redirectedPaymentErrorCode).to.equal('payment_failed')
  468. })
  469. })
  470. })
  471. describe('updateAccountEmailAddress via put', function () {
  472. beforeEach(function (ctx) {
  473. ctx.req.body = {
  474. account_email: 'current_account_email@overleaf.com',
  475. }
  476. })
  477. it('should send the user and subscriptionId to "updateAccountEmailAddress" hooks', async function (ctx) {
  478. ctx.res.sendStatus = sinon.spy()
  479. await ctx.SubscriptionController.updateAccountEmailAddress(
  480. ctx.req,
  481. ctx.res
  482. )
  483. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  484. 'updateAccountEmailAddress',
  485. ctx.user._id,
  486. ctx.user.email
  487. )
  488. })
  489. it('should respond with 200', async function (ctx) {
  490. ctx.res.sendStatus = sinon.spy()
  491. await ctx.SubscriptionController.updateAccountEmailAddress(
  492. ctx.req,
  493. ctx.res
  494. )
  495. ctx.res.sendStatus.calledWith(200).should.equal(true)
  496. })
  497. it('should send the error to the next handler when updating recurly account email fails', async function (ctx) {
  498. ctx.Modules.promises.hooks.fire
  499. .withArgs('updateAccountEmailAddress', ctx.user._id, ctx.user.email)
  500. .rejects(new Error())
  501. ctx.next = sinon.spy(error => {
  502. expect(error).to.be.instanceOf(Error)
  503. })
  504. await ctx.SubscriptionController.updateAccountEmailAddress(
  505. ctx.req,
  506. ctx.res,
  507. ctx.next
  508. )
  509. expect(ctx.next.calledOnce).to.be.true
  510. })
  511. })
  512. describe('reactivateSubscription', function () {
  513. describe('when the user has permission', function () {
  514. beforeEach(async function (ctx) {
  515. await new Promise(resolve => {
  516. ctx.res = {
  517. redirect() {
  518. resolve()
  519. },
  520. }
  521. ctx.req.assertPermission = sinon.stub()
  522. ctx.next = sinon.stub().callsFake(error => {
  523. resolve(error)
  524. })
  525. sinon.spy(ctx.res, 'redirect')
  526. ctx.SubscriptionController.reactivateSubscription(
  527. ctx.req,
  528. ctx.res,
  529. ctx.next
  530. )
  531. })
  532. })
  533. it('should assert the user has permission to reactivate their subscription', async function (ctx) {
  534. await new Promise(resolve => {
  535. ctx.req.assertPermission
  536. .calledWith('reactivate-subscription')
  537. .should.equal(true)
  538. resolve()
  539. })
  540. })
  541. it('should tell the handler to reactivate this user', async function (ctx) {
  542. await new Promise(resolve => {
  543. ctx.SubscriptionHandler.reactivateSubscription
  544. .calledWith(ctx.user)
  545. .should.equal(true)
  546. resolve()
  547. })
  548. })
  549. it('should redurect to the subscription page', async function (ctx) {
  550. await new Promise(resolve => {
  551. ctx.res.redirect.calledWith('/user/subscription').should.equal(true)
  552. resolve()
  553. })
  554. })
  555. })
  556. describe('when the user does not have permission', function () {
  557. beforeEach(async function (ctx) {
  558. await new Promise(resolve => {
  559. ctx.res = {
  560. redirect() {
  561. resolve()
  562. },
  563. }
  564. ctx.req.assertPermission = sinon.stub().throws()
  565. ctx.next = sinon.stub().callsFake(() => {
  566. resolve()
  567. })
  568. sinon.spy(ctx.res, 'redirect')
  569. ctx.SubscriptionController.reactivateSubscription(
  570. ctx.req,
  571. ctx.res,
  572. ctx.next
  573. )
  574. })
  575. })
  576. it('should not reactivate the user', async function (ctx) {
  577. await new Promise(resolve => {
  578. ctx.req.assertPermission = sinon.stub().throws()
  579. ctx.SubscriptionHandler.reactivateSubscription.called.should.equal(
  580. false
  581. )
  582. resolve()
  583. })
  584. })
  585. it('should call next with an error', async function (ctx) {
  586. await new Promise(resolve => {
  587. ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
  588. resolve()
  589. })
  590. })
  591. })
  592. })
  593. describe('pauseSubscription', function () {
  594. it('should throw an error if no pause length is provided', async function (ctx) {
  595. ctx.res = new MockResponse(vi)
  596. ctx.req = new MockRequest(vi)
  597. ctx.next = sinon.stub()
  598. await expect(
  599. ctx.SubscriptionController.pauseSubscription(ctx.req, ctx.res, ctx.next)
  600. ).to.be.rejectedWith('Not found')
  601. })
  602. it('should throw an error if an invalid pause length is provided', async function (ctx) {
  603. ctx.res = new MockResponse(vi)
  604. ctx.req = new MockRequest(vi)
  605. ctx.req.params = { pauseCycles: '-10' }
  606. ctx.next = sinon.stub()
  607. await ctx.SubscriptionController.pauseSubscription(
  608. ctx.req,
  609. ctx.res,
  610. ctx.next
  611. )
  612. expect(ctx.res.statusCode).to.equal(400)
  613. })
  614. it('should return a 200 when requesting a pause', async function (ctx) {
  615. ctx.res = new MockResponse(vi)
  616. ctx.req = new MockRequest(vi)
  617. ctx.req.params = { pauseCycles: '3' }
  618. ctx.next = sinon.stub()
  619. await ctx.SubscriptionController.pauseSubscription(
  620. ctx.req,
  621. ctx.res,
  622. ctx.next
  623. )
  624. expect(ctx.res.statusCode).to.equal(200)
  625. })
  626. })
  627. describe('resumeSubscription', function () {
  628. it('should return a 200 when resuming a subscription', async function (ctx) {
  629. ctx.res = new MockResponse(vi)
  630. ctx.req = new MockRequest(vi)
  631. ctx.next = sinon.stub()
  632. await ctx.SubscriptionController.resumeSubscription(
  633. ctx.req,
  634. ctx.res,
  635. ctx.next
  636. )
  637. expect(ctx.res.statusCode).to.equal(200)
  638. })
  639. })
  640. describe('cancelSubscription', function () {
  641. it('should tell the handler to cancel this user', async function (ctx) {
  642. ctx.next = sinon.stub()
  643. await ctx.SubscriptionController.cancelSubscription(
  644. ctx.req,
  645. ctx.res,
  646. ctx.next
  647. )
  648. ctx.SubscriptionHandler.promises.cancelSubscription
  649. .calledWith(ctx.user)
  650. .should.equal(true)
  651. })
  652. it('should return a 200 on success', async function (ctx) {
  653. ctx.next = sinon.stub()
  654. await ctx.SubscriptionController.cancelSubscription(
  655. ctx.req,
  656. ctx.res,
  657. ctx.next
  658. )
  659. expect(ctx.res.statusCode).to.equal(200)
  660. })
  661. it('should call next with error', async function (ctx) {
  662. ctx.SubscriptionHandler.promises.cancelSubscription.rejects(
  663. new Error('cancel error')
  664. )
  665. ctx.next = sinon.stub()
  666. await ctx.SubscriptionController.cancelSubscription(
  667. ctx.req,
  668. ctx.res,
  669. ctx.next
  670. )
  671. ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
  672. })
  673. })
  674. describe('recurly callback', function () {
  675. describe('with a sync subscription request', function () {
  676. beforeEach(async function (ctx) {
  677. await new Promise(resolve => {
  678. ctx.req = {
  679. body: {
  680. expired_subscription_notification: {
  681. account: {
  682. account_code: ctx.user._id,
  683. },
  684. subscription: {
  685. uuid: ctx.activeRecurlySubscription.uuid,
  686. plan: {
  687. plan_code: 'collaborator',
  688. state: 'active',
  689. },
  690. },
  691. },
  692. },
  693. }
  694. ctx.res = {
  695. sendStatus() {
  696. resolve()
  697. },
  698. }
  699. sinon.spy(ctx.res, 'sendStatus')
  700. ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
  701. })
  702. })
  703. it('should tell the SubscriptionHandler to process the recurly callback', async function (ctx) {
  704. await new Promise(resolve => {
  705. ctx.SubscriptionHandler.syncSubscription.called.should.equal(true)
  706. resolve()
  707. })
  708. })
  709. it('should send a 200', async function (ctx) {
  710. await new Promise(resolve => {
  711. ctx.res.sendStatus.calledWith(200)
  712. resolve()
  713. })
  714. })
  715. })
  716. describe('with a billing info updated request', function () {
  717. beforeEach(async function (ctx) {
  718. await new Promise(resolve => {
  719. ctx.req = {
  720. body: {
  721. billing_info_updated_notification: {
  722. account: {
  723. account_code: 'mock-account-code',
  724. },
  725. },
  726. },
  727. }
  728. ctx.res = {
  729. sendStatus() {
  730. resolve()
  731. },
  732. }
  733. sinon.spy(ctx.res, 'sendStatus')
  734. ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
  735. })
  736. })
  737. it('should call attemptPaypalInvoiceCollection', async function (ctx) {
  738. await new Promise(resolve => {
  739. ctx.SubscriptionHandler.attemptPaypalInvoiceCollection
  740. .calledWith('mock-account-code')
  741. .should.equal(true)
  742. resolve()
  743. })
  744. })
  745. it('should send a 200', async function (ctx) {
  746. await new Promise(resolve => {
  747. ctx.res.sendStatus.calledWith(200)
  748. resolve()
  749. })
  750. })
  751. })
  752. describe('with a non-actionable request', function () {
  753. beforeEach(async function (ctx) {
  754. await new Promise(resolve => {
  755. ctx.user.id = ctx.activeRecurlySubscription.account.account_code
  756. ctx.req = {
  757. body: {
  758. renewed_subscription_notification: {
  759. account: {
  760. account_code: ctx.user._id,
  761. },
  762. subscription: {
  763. uuid: ctx.activeRecurlySubscription.uuid,
  764. plan: {
  765. plan_code: 'collaborator',
  766. state: 'active',
  767. },
  768. },
  769. },
  770. },
  771. }
  772. ctx.res = {
  773. sendStatus() {
  774. resolve()
  775. },
  776. }
  777. sinon.spy(ctx.res, 'sendStatus')
  778. ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
  779. })
  780. })
  781. it('should not call the subscriptionshandler', function (ctx) {
  782. ctx.SubscriptionHandler.syncSubscription.called.should.equal(false)
  783. ctx.SubscriptionHandler.attemptPaypalInvoiceCollection.called.should.equal(
  784. false
  785. )
  786. })
  787. it('should respond with a 200 status', function (ctx) {
  788. ctx.res.sendStatus.calledWith(200)
  789. })
  790. })
  791. })
  792. describe('purchaseAddon', function () {
  793. beforeEach(function (ctx) {
  794. ctx.SessionManager.getSessionUser.returns(ctx.user) // Make sure getSessionUser returns the user
  795. ctx.next = sinon.stub()
  796. ctx.req.params = { addOnCode: AI_ADD_ON_CODE } // Mock add-on code
  797. })
  798. it('should return 200 on successful purchase of AI add-on', async function (ctx) {
  799. await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
  800. ctx.res.sendStatus = sinon.spy()
  801. await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
  802. expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.have.been.called
  803. expect(
  804. ctx.SubscriptionHandler.promises.purchaseAddon
  805. ).to.have.been.calledWith(ctx.user._id, AI_ADD_ON_CODE, 1)
  806. expect(
  807. ctx.FeaturesUpdater.promises.refreshFeatures
  808. ).to.have.been.calledWith(ctx.user._id, 'add-on-purchase')
  809. expect(ctx.res.sendStatus).to.have.been.calledWith(200)
  810. expect(ctx.logger.debug).to.have.been.calledWith(
  811. { userId: ctx.user._id, addOnCode: AI_ADD_ON_CODE },
  812. 'purchasing add-ons'
  813. )
  814. })
  815. it('should return 404 if the add-on code is not AI_ADD_ON_CODE', async function (ctx) {
  816. ctx.req.params = { addOnCode: 'some-other-addon' }
  817. ctx.res.sendStatus = sinon.spy()
  818. await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
  819. expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.not.have.been
  820. .called
  821. expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
  822. .called
  823. expect(ctx.res.sendStatus).to.have.been.calledWith(404)
  824. })
  825. it('should handle DuplicateAddOnError and send badRequest while sending 200', async function (ctx) {
  826. ctx.req.params.addOnCode = AI_ADD_ON_CODE
  827. ctx.SubscriptionHandler.promises.purchaseAddon.rejects(
  828. new SubscriptionErrors.DuplicateAddOnError()
  829. )
  830. await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
  831. expect(ctx.HttpErrorHandler.badRequest).to.have.been.calledWith(
  832. ctx.req,
  833. ctx.res,
  834. 'Your subscription already includes this add-on',
  835. { addon: AI_ADD_ON_CODE }
  836. )
  837. expect(
  838. ctx.FeaturesUpdater.promises.refreshFeatures
  839. ).to.have.been.calledWith(ctx.user._id, 'add-on-purchase')
  840. expect(ctx.res.sendStatus).toHaveBeenCalledWith(200)
  841. })
  842. it('should handle PaymentActionRequiredError and return 402 with details', async function (ctx) {
  843. ctx.req.params.addOnCode = AI_ADD_ON_CODE
  844. const paymentError = new SubscriptionErrors.PaymentActionRequiredError({
  845. clientSecret: 'secret123',
  846. publicKey: 'pubkey456',
  847. })
  848. ctx.SubscriptionHandler.promises.purchaseAddon.rejects(paymentError)
  849. await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
  850. expect(ctx.res.status).toHaveBeenCalledWith(402)
  851. expect(ctx.res.json).toHaveBeenCalledWith({
  852. message: 'Payment action required',
  853. clientSecret: 'secret123',
  854. publicKey: 'pubkey456',
  855. })
  856. expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
  857. .called
  858. })
  859. it('should refresh features', async function (ctx) {
  860. ctx.req.params.addOnCode = 'assistant'
  861. ctx.SubscriptionHandler.promises.purchaseAddon = sinon.stub().resolves()
  862. ctx.FeaturesUpdater.promises.refreshFeatures = sinon.stub().resolves()
  863. await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res)
  864. expect(
  865. ctx.FeaturesUpdater.promises.refreshFeatures.calledWith(
  866. ctx.user._id,
  867. 'add-on-purchase'
  868. )
  869. ).to.be.true
  870. })
  871. it('should respond with a bad request if the subscription already includes the addOn', async function (ctx) {
  872. ctx.req.params.addOnCode = 'assistant'
  873. ctx.SubscriptionHandler.promises.purchaseAddon = sinon
  874. .stub()
  875. .rejects(new SubscriptionErrors.DuplicateAddOnError())
  876. await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res)
  877. expect(
  878. ctx.HttpErrorHandler.badRequest.calledWith(
  879. ctx.req,
  880. ctx.res,
  881. 'Your subscription already includes this add-on',
  882. { addon: 'assistant' }
  883. )
  884. ).to.be.true
  885. })
  886. it('should handle MultiplePendingChangesError and return 422 with JSON response', async function (ctx) {
  887. ctx.req.params.addOnCode = AI_ADD_ON_CODE
  888. ctx.SubscriptionHandler.promises.purchaseAddon.rejects(
  889. new SubscriptionErrors.MultiplePendingChangesError()
  890. )
  891. await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
  892. expect(ctx.res.status).toHaveBeenCalledWith(422)
  893. expect(ctx.res.json).toHaveBeenCalledWith({
  894. code: 'multiple_pending_changes',
  895. message:
  896. 'Cannot complete purchase while there are multiple pending subscription changes. Please contact support.',
  897. })
  898. expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
  899. .called
  900. })
  901. })
  902. describe('removeAddon', function () {
  903. beforeEach(function (ctx) {
  904. ctx.SessionManager.getSessionUser.returns(ctx.user)
  905. ctx.next = sinon.stub()
  906. ctx.req.params = { addOnCode: AI_ADD_ON_CODE }
  907. ctx.SubscriptionHandler.promises.removeAddon = sinon.stub().resolves()
  908. })
  909. it('should return 200 on successful removal of AI add-on', async function (ctx) {
  910. ctx.res.sendStatus = sinon.spy()
  911. await ctx.SubscriptionController.removeAddon(ctx.req, ctx.res, ctx.next)
  912. expect(ctx.SubscriptionHandler.promises.removeAddon).to.have.been.called
  913. expect(
  914. ctx.SubscriptionHandler.promises.removeAddon
  915. ).to.have.been.calledWith(ctx.user, AI_ADD_ON_CODE)
  916. expect(ctx.res.sendStatus).to.have.been.calledWith(200)
  917. expect(ctx.logger.debug).to.have.been.calledWith(
  918. { userId: ctx.user._id, addOnCode: AI_ADD_ON_CODE },
  919. 'removing add-ons'
  920. )
  921. })
  922. it('should return 404 if the add-on code is not AI_ADD_ON_CODE', async function (ctx) {
  923. ctx.req.params = { addOnCode: 'some-other-addon' }
  924. ctx.res.sendStatus = sinon.spy()
  925. await ctx.SubscriptionController.removeAddon(ctx.req, ctx.res, ctx.next)
  926. expect(ctx.SubscriptionHandler.promises.removeAddon).to.not.have.been
  927. .called
  928. expect(ctx.res.sendStatus).to.have.been.calledWith(404)
  929. })
  930. it('should handle AddOnNotPresentError and send badRequest', async function (ctx) {
  931. ctx.SubscriptionHandler.promises.removeAddon.rejects(
  932. new SubscriptionErrors.AddOnNotPresentError()
  933. )
  934. await ctx.SubscriptionController.removeAddon(ctx.req, ctx.res, ctx.next)
  935. expect(ctx.HttpErrorHandler.badRequest).to.have.been.calledWith(
  936. ctx.req,
  937. ctx.res,
  938. 'Your subscription does not contain the requested add-on',
  939. { addon: AI_ADD_ON_CODE }
  940. )
  941. })
  942. it('should handle MultiplePendingChangesError and return 422 with JSON response', async function (ctx) {
  943. ctx.SubscriptionHandler.promises.removeAddon.rejects(
  944. new SubscriptionErrors.MultiplePendingChangesError()
  945. )
  946. await ctx.SubscriptionController.removeAddon(ctx.req, ctx.res, ctx.next)
  947. expect(ctx.res.status).toHaveBeenCalledWith(422)
  948. expect(ctx.res.json).toHaveBeenCalledWith({
  949. code: 'multiple_pending_changes',
  950. message:
  951. 'Cannot remove add-on while there are multiple pending subscription changes. Please contact support.',
  952. })
  953. })
  954. })
  955. describe('checkSubscriptionPauseStatus', function () {
  956. beforeEach(function (ctx) {
  957. ctx.user = {
  958. _id: 'user-id-123',
  959. email: 'test@example.com',
  960. }
  961. })
  962. it('should return isPaused: false when user has no subscription', async function (ctx) {
  963. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  964. subscription: null,
  965. })
  966. const result =
  967. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  968. expect(result).to.deep.equal({ isPaused: false })
  969. })
  970. it('should return isPaused: false when subscription has no paymentProvider', async function (ctx) {
  971. const subscription = {
  972. planCode: 'professional',
  973. }
  974. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  975. subscription,
  976. })
  977. const result =
  978. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  979. expect(result).to.deep.equal({ isPaused: false })
  980. })
  981. it('should return isPaused: false when subscription has no subscriptionId', async function (ctx) {
  982. const subscription = {
  983. paymentProvider: {
  984. service: 'stripe',
  985. subscriptionId: null,
  986. },
  987. }
  988. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  989. subscription,
  990. })
  991. const result =
  992. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  993. expect(result).to.deep.equal({ isPaused: false })
  994. })
  995. it('should return isPaused: false when Stripe subscription has no remaining pause cycles', async function (ctx) {
  996. const subscription = {
  997. paymentProvider: {
  998. service: 'stripe',
  999. subscriptionId: 'sub-123',
  1000. },
  1001. }
  1002. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1003. subscription,
  1004. })
  1005. const paymentRecord = {
  1006. subscription: {
  1007. remainingPauseCycles: 0,
  1008. },
  1009. }
  1010. ctx.Modules.promises.hooks.fire
  1011. .withArgs('getPaymentFromRecord', subscription)
  1012. .resolves([paymentRecord])
  1013. const result =
  1014. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1015. expect(result).to.deep.equal({ isPaused: false })
  1016. })
  1017. it('should return isPaused: false when Stripe subscription has no remainingPauseCycles property', async function (ctx) {
  1018. const subscription = {
  1019. paymentProvider: {
  1020. service: 'stripe',
  1021. subscriptionId: 'sub-123',
  1022. },
  1023. }
  1024. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1025. subscription,
  1026. })
  1027. const paymentRecord = {
  1028. subscription: {},
  1029. }
  1030. ctx.Modules.promises.hooks.fire
  1031. .withArgs('getPaymentFromRecord', subscription)
  1032. .resolves([paymentRecord])
  1033. const result =
  1034. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1035. expect(result).to.deep.equal({ isPaused: false })
  1036. })
  1037. it('should return isPaused: true with redirect path when Stripe subscription has remaining pause cycles', async function (ctx) {
  1038. const subscription = {
  1039. paymentProvider: {
  1040. service: 'stripe',
  1041. subscriptionId: 'sub-123',
  1042. },
  1043. }
  1044. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1045. subscription,
  1046. })
  1047. const paymentRecord = {
  1048. subscription: {
  1049. remainingPauseCycles: 2,
  1050. },
  1051. }
  1052. ctx.Modules.promises.hooks.fire
  1053. .withArgs('getPaymentFromRecord', subscription)
  1054. .resolves([paymentRecord])
  1055. const result =
  1056. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1057. expect(result).to.deep.equal({
  1058. isPaused: true,
  1059. redirectPath: '/user/subscription?redirect-reason=subscription-paused',
  1060. })
  1061. })
  1062. it('should return isPaused: true when remainingPauseCycles is exactly 1', async function (ctx) {
  1063. const subscription = {
  1064. paymentProvider: {
  1065. service: 'stripe',
  1066. subscriptionId: 'sub-123',
  1067. },
  1068. }
  1069. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1070. subscription,
  1071. })
  1072. const paymentRecord = {
  1073. subscription: {
  1074. remainingPauseCycles: 1,
  1075. },
  1076. }
  1077. ctx.Modules.promises.hooks.fire
  1078. .withArgs('getPaymentFromRecord', subscription)
  1079. .resolves([paymentRecord])
  1080. const result =
  1081. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1082. expect(result).to.deep.equal({
  1083. isPaused: true,
  1084. redirectPath: '/user/subscription?redirect-reason=subscription-paused',
  1085. })
  1086. })
  1087. it('should return isPaused: false when userHasSubscription throws error', async function (ctx) {
  1088. const error = new Error('Something bad happened')
  1089. ctx.LimitationsManager.promises.userHasSubscription.rejects(error)
  1090. const result =
  1091. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1092. expect(result).to.deep.equal({ isPaused: false })
  1093. })
  1094. it('should return isPaused: false when getPaymentFromRecord throws error', async function (ctx) {
  1095. const subscription = {
  1096. paymentProvider: {
  1097. service: 'stripe',
  1098. subscriptionId: 'sub-123',
  1099. },
  1100. }
  1101. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1102. subscription,
  1103. })
  1104. const error = new Error('Something bad happened')
  1105. ctx.Modules.promises.hooks.fire
  1106. .withArgs('getPaymentFromRecord', subscription)
  1107. .rejects(error)
  1108. const result =
  1109. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1110. expect(result).to.deep.equal({ isPaused: false })
  1111. })
  1112. it('should return isPaused: false when Recurly subscription is not paused', async function (ctx) {
  1113. const subscription = {
  1114. recurlySubscription_id: 'uuid-123',
  1115. recurlyStatus: {
  1116. state: 'active',
  1117. },
  1118. }
  1119. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1120. subscription,
  1121. })
  1122. const result =
  1123. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1124. expect(result).to.deep.equal({ isPaused: false })
  1125. })
  1126. it('should return isPaused: true when Recurly subscription is paused', async function (ctx) {
  1127. const subscription = {
  1128. recurlySubscription_id: 'uuid-123',
  1129. recurlyStatus: {
  1130. state: 'paused',
  1131. },
  1132. }
  1133. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1134. subscription,
  1135. })
  1136. const result =
  1137. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1138. expect(result).to.deep.equal({
  1139. isPaused: true,
  1140. redirectPath: '/user/subscription?redirect-reason=subscription-paused',
  1141. })
  1142. })
  1143. it('should return isPaused: true when Recurly subscription has pending pause cycles', async function (ctx) {
  1144. const subscription = {
  1145. recurlySubscription_id: 'uuid-123',
  1146. recurlyStatus: {
  1147. state: 'active',
  1148. },
  1149. }
  1150. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1151. subscription,
  1152. })
  1153. const recurlySubscriptionData = {
  1154. remaining_pause_cycles: 2,
  1155. }
  1156. ctx.RecurlyWrapper.promises.getSubscription.resolves(
  1157. recurlySubscriptionData
  1158. )
  1159. const result =
  1160. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1161. expect(result).to.deep.equal({
  1162. isPaused: true,
  1163. redirectPath: '/user/subscription?redirect-reason=subscription-paused',
  1164. })
  1165. expect(
  1166. ctx.RecurlyWrapper.promises.getSubscription
  1167. ).to.have.been.calledWith('uuid-123')
  1168. })
  1169. it('should return isPaused: false when Recurly subscription has no remaining pause cycles', async function (ctx) {
  1170. const subscription = {
  1171. recurlySubscription_id: 'uuid-123',
  1172. recurlyStatus: {
  1173. state: 'active',
  1174. },
  1175. }
  1176. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1177. subscription,
  1178. })
  1179. const recurlySubscriptionData = {
  1180. remaining_pause_cycles: 0,
  1181. }
  1182. ctx.RecurlyWrapper.promises.getSubscription.resolves(
  1183. recurlySubscriptionData
  1184. )
  1185. const result =
  1186. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1187. expect(result).to.deep.equal({ isPaused: false })
  1188. })
  1189. it('should return isPaused: false when Recurly subscription has no remaining_pause_cycles property', async function (ctx) {
  1190. const subscription = {
  1191. recurlySubscription_id: 'uuid-123',
  1192. recurlyStatus: {
  1193. state: 'active',
  1194. },
  1195. }
  1196. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1197. subscription,
  1198. })
  1199. const recurlySubscriptionData = {}
  1200. ctx.RecurlyWrapper.promises.getSubscription.resolves(
  1201. recurlySubscriptionData
  1202. )
  1203. const result =
  1204. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1205. expect(result).to.deep.equal({ isPaused: false })
  1206. })
  1207. it('should return isPaused: false when Recurly API call fails', async function (ctx) {
  1208. const subscription = {
  1209. recurlySubscription_id: 'uuid-123',
  1210. recurlyStatus: {
  1211. state: 'active',
  1212. },
  1213. }
  1214. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1215. subscription,
  1216. })
  1217. const error = new Error('Recurly API failed')
  1218. ctx.RecurlyWrapper.promises.getSubscription.rejects(error)
  1219. const result =
  1220. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1221. expect(result).to.deep.equal({ isPaused: false })
  1222. })
  1223. })
  1224. describe('previewAddonPurchase', function () {
  1225. beforeEach(function (ctx) {
  1226. ctx.req = new MockRequest(vi)
  1227. ctx.req.params = { addOnCode: 'assistant' }
  1228. ctx.req.query = { purchaseReferrer: 'fake-referrer' }
  1229. ctx.res = new MockResponse(vi)
  1230. ctx.Modules.promises.hooks.fire
  1231. .withArgs('getPaymentMethod')
  1232. .resolves(['fake-method'])
  1233. ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(null)
  1234. })
  1235. describe('when user has manual or custom subscription', function () {
  1236. it('should redirect with ai-assist-unavailable when subscription has customAccount = true', async function (ctx) {
  1237. const customSubscription = {
  1238. _id: 'sub-123',
  1239. customAccount: true,
  1240. collectionMethod: 'automatic',
  1241. }
  1242. ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
  1243. customSubscription
  1244. )
  1245. ctx.res.redirect = sinon.stub()
  1246. await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
  1247. expect(ctx.res.redirect).to.have.been.calledWith(
  1248. '/user/subscription?redirect-reason=ai-assist-unavailable'
  1249. )
  1250. })
  1251. it('should redirect with ai-assist-unavailable when subscription has collectionMethod = manual', async function (ctx) {
  1252. const manualSubscription = {
  1253. _id: 'sub-123',
  1254. customAccount: false,
  1255. collectionMethod: 'manual',
  1256. }
  1257. ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
  1258. manualSubscription
  1259. )
  1260. ctx.res.redirect = sinon.stub()
  1261. await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
  1262. expect(ctx.res.redirect).to.have.been.calledWith(
  1263. '/user/subscription?redirect-reason=ai-assist-unavailable'
  1264. )
  1265. })
  1266. it('should redirect with ai-assist-unavailable when subscription has both customAccount and manual collection', async function (ctx) {
  1267. const customManualSubscription = {
  1268. _id: 'sub-123',
  1269. customAccount: true,
  1270. collectionMethod: 'manual',
  1271. }
  1272. ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
  1273. customManualSubscription
  1274. )
  1275. ctx.res.redirect = sinon.stub()
  1276. await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
  1277. expect(ctx.res.redirect).to.have.been.calledWith(
  1278. '/user/subscription?redirect-reason=ai-assist-unavailable'
  1279. )
  1280. })
  1281. })
  1282. describe('when user has normal subscription', function () {
  1283. it('should proceed with preview when subscription is not manual or custom', async function (ctx) {
  1284. const normalSubscription = {
  1285. _id: 'sub-123',
  1286. customAccount: false,
  1287. collectionMethod: 'automatic',
  1288. }
  1289. ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
  1290. normalSubscription
  1291. )
  1292. ctx.res.render = sinon.stub()
  1293. await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
  1294. expect(ctx.res.render).to.have.been.calledWith(
  1295. 'subscriptions/preview-change',
  1296. sinon.match({
  1297. changePreview: sinon.match.object,
  1298. purchaseReferrer: 'fake-referrer',
  1299. redirectedPaymentErrorCode: undefined,
  1300. })
  1301. )
  1302. expect(
  1303. ctx.SubscriptionHandler.promises.previewAddonPurchase
  1304. ).to.have.been.calledWith(ctx.user._id, 'assistant')
  1305. })
  1306. it('should pass redirectedPaymentErrorCode to the view when errorCode query param is present', async function (ctx) {
  1307. const normalSubscription = {
  1308. _id: 'sub-123',
  1309. customAccount: false,
  1310. collectionMethod: 'automatic',
  1311. }
  1312. ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
  1313. normalSubscription
  1314. )
  1315. ctx.req.query.errorCode = 'payment_failed'
  1316. ctx.res.render = sinon.stub()
  1317. await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
  1318. expect(ctx.res.render).to.have.been.calledWith(
  1319. 'subscriptions/preview-change',
  1320. sinon.match({
  1321. changePreview: sinon.match.object,
  1322. purchaseReferrer: 'fake-referrer',
  1323. redirectedPaymentErrorCode: 'payment_failed',
  1324. })
  1325. )
  1326. })
  1327. it('should proceed with preview when customAccount is undefined and collectionMethod is automatic', async function (ctx) {
  1328. const normalSubscription = {
  1329. _id: 'sub-123',
  1330. // customAccount: undefined (not set)
  1331. collectionMethod: 'automatic',
  1332. }
  1333. ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
  1334. normalSubscription
  1335. )
  1336. ctx.res.render = sinon.stub()
  1337. await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
  1338. expect(ctx.res.render).to.have.been.calledWith(
  1339. 'subscriptions/preview-change'
  1340. )
  1341. expect(
  1342. ctx.SubscriptionHandler.promises.previewAddonPurchase
  1343. ).to.have.been.calledWith(ctx.user._id, 'assistant')
  1344. })
  1345. })
  1346. })
  1347. })