SubscriptionGroupController.test.mjs 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026
  1. import { beforeEach, describe, it, vi } from 'vitest'
  2. import sinon from 'sinon'
  3. const modulePath =
  4. '../../../../app/src/Features/Subscription/SubscriptionGroupController'
  5. describe('SubscriptionGroupController', function () {
  6. beforeEach(async function (ctx) {
  7. ctx.user = { _id: '!@312431', email: 'user@email.com' }
  8. ctx.adminUserId = '123jlkj'
  9. ctx.subscriptionId = '123434325412'
  10. ctx.user_email = 'bob@gmail.com'
  11. ctx.req = {
  12. session: {
  13. user: {
  14. _id: ctx.adminUserId,
  15. email: ctx.user_email,
  16. },
  17. },
  18. params: {
  19. subscriptionId: ctx.subscriptionId,
  20. },
  21. query: {},
  22. }
  23. ctx.subscription = {
  24. _id: ctx.subscriptionId,
  25. teamName: 'Cool group',
  26. groupPlan: true,
  27. membersLimit: 5,
  28. planCode: 'group_collaborator_10',
  29. }
  30. ctx.plan = {
  31. canUseFlexibleLicensing: true,
  32. }
  33. ctx.recurlySubscription = {
  34. get isCollectionMethodManual() {
  35. return true
  36. },
  37. }
  38. ctx.previewSubscriptionChangeData = {
  39. change: {},
  40. currency: 'USD',
  41. }
  42. ctx.createSubscriptionChangeData = { adding: 1 }
  43. ctx.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
  44. ctx.SubscriptionGroupHandler = {
  45. promises: {
  46. removeUserFromGroup: sinon.stub().resolves(),
  47. getUsersGroupSubscriptionDetails: sinon.stub().resolves({
  48. subscription: ctx.subscription,
  49. plan: ctx.plan,
  50. paymentProviderSubscription: ctx.recurlySubscription,
  51. }),
  52. previewAddSeatsSubscriptionChange: sinon
  53. .stub()
  54. .resolves(ctx.previewSubscriptionChangeData),
  55. createAddSeatsSubscriptionChange: sinon
  56. .stub()
  57. .resolves(ctx.createSubscriptionChangeData),
  58. ensureFlexibleLicensingEnabled: sinon.stub().resolves(),
  59. ensureSubscriptionIsActive: sinon.stub().resolves(),
  60. ensureSubscriptionCollectionMethodIsNotManual: sinon.stub().resolves(),
  61. ensureSubscriptionHasNoPastDueInvoice: sinon.stub().resolves(),
  62. getGroupPlanUpgradePreview: sinon
  63. .stub()
  64. .resolves(ctx.previewSubscriptionChangeData),
  65. checkBillingInfoExistence: sinon.stub().resolves(ctx.paymentMethod),
  66. updateSubscriptionPaymentTerms: sinon.stub().resolves(),
  67. ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual:
  68. sinon.stub().resolves(),
  69. },
  70. }
  71. ctx.SubscriptionLocator = {
  72. promises: {
  73. getSubscription: sinon.stub().resolves(ctx.subscription),
  74. },
  75. }
  76. ctx.SessionManager = {
  77. getLoggedInUserId(session) {
  78. return session.user._id
  79. },
  80. getSessionUser(session) {
  81. return session.user
  82. },
  83. }
  84. ctx.UserAuditLogHandler = {
  85. promises: {
  86. addEntry: sinon.stub().resolves(),
  87. },
  88. }
  89. ctx.Modules = {
  90. promises: {
  91. hooks: {
  92. fire: sinon.stub().resolves(),
  93. },
  94. },
  95. }
  96. ctx.UserGetter = {
  97. promises: {
  98. getUserEmail: sinon.stub().resolves(ctx.user.email),
  99. },
  100. }
  101. ctx.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
  102. ctx.RecurlyClient = {
  103. promises: {
  104. getPaymentMethod: sinon.stub().resolves(ctx.paymentMethod),
  105. },
  106. }
  107. ctx.SubscriptionController = {}
  108. ctx.SubscriptionModel = { Subscription: {} }
  109. ctx.PlansHelper = {
  110. isProfessionalGroupPlan: sinon.stub().returns(false),
  111. }
  112. ctx.Errors = {
  113. MissingBillingInfoError: class extends Error {},
  114. ManuallyCollectedError: class extends Error {},
  115. InactiveError: class extends Error {},
  116. SubtotalLimitExceededError: class extends Error {},
  117. HasPastDueInvoiceError: class extends Error {},
  118. HasNoAdditionalLicenseWhenManuallyCollectedError: class extends Error {},
  119. PaymentActionRequiredError: class extends Error {
  120. constructor(info) {
  121. super('Payment action required')
  122. this.info = info
  123. }
  124. },
  125. MultiplePendingChangesError: class extends Error {},
  126. }
  127. vi.doMock(
  128. '../../../../app/src/Features/Subscription/SubscriptionGroupHandler',
  129. () => ({
  130. default: ctx.SubscriptionGroupHandler,
  131. })
  132. )
  133. vi.doMock(
  134. '../../../../app/src/Features/Subscription/SubscriptionLocator',
  135. () => ({
  136. default: ctx.SubscriptionLocator,
  137. })
  138. )
  139. vi.doMock(
  140. '../../../../app/src/Features/Authentication/SessionManager',
  141. () => ({
  142. default: ctx.SessionManager,
  143. })
  144. )
  145. vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({
  146. default: ctx.UserAuditLogHandler,
  147. }))
  148. vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
  149. default: ctx.Modules,
  150. }))
  151. vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
  152. default: ctx.UserGetter,
  153. }))
  154. vi.doMock('../../../../app/src/Features/Errors/ErrorController', () => ({
  155. default: (ctx.ErrorController = {
  156. notFound: sinon.stub(),
  157. }),
  158. }))
  159. vi.doMock(
  160. '../../../../app/src/Features/Subscription/SubscriptionController',
  161. () => ({
  162. default: ctx.SubscriptionController,
  163. })
  164. )
  165. vi.doMock(
  166. '../../../../app/src/Features/Subscription/RecurlyClient',
  167. () => ({
  168. default: ctx.RecurlyClient,
  169. })
  170. )
  171. vi.doMock(
  172. '../../../../app/src/Features/Subscription/PlansHelper',
  173. () => ctx.PlansHelper
  174. )
  175. vi.doMock(
  176. '../../../../app/src/Features/Subscription/Errors',
  177. () => ctx.Errors
  178. )
  179. vi.doMock(
  180. '../../../../app/src/models/Subscription',
  181. () => ctx.SubscriptionModel
  182. )
  183. vi.doMock('@overleaf/settings', () => ({
  184. default: { adminUrl: 'https://admin.overleaf.com' },
  185. }))
  186. vi.doMock('@overleaf/logger', () => ({
  187. default: {
  188. err: sinon.stub(),
  189. error: sinon.stub(),
  190. warn: sinon.stub(),
  191. log: sinon.stub(),
  192. debug: sinon.stub(),
  193. },
  194. }))
  195. ctx.Controller = (await import(modulePath)).default
  196. })
  197. describe('removeUserFromGroup', function () {
  198. it('should use the subscription id for the logged in user and take the user id from the params', async function (ctx) {
  199. await new Promise(resolve => {
  200. const userIdToRemove = '31231'
  201. ctx.req.params = { user_id: userIdToRemove }
  202. ctx.req.entity = ctx.subscription
  203. const res = {
  204. sendStatus: () => {
  205. ctx.SubscriptionGroupHandler.promises.removeUserFromGroup
  206. .calledWith(ctx.subscriptionId, userIdToRemove, {
  207. initiatorId: ctx.req.session.user._id,
  208. ipAddress: ctx.req.ip,
  209. })
  210. .should.equal(true)
  211. resolve()
  212. },
  213. }
  214. ctx.Controller.removeUserFromGroup(ctx.req, res, resolve)
  215. })
  216. })
  217. it('should log that the user has been removed', async function (ctx) {
  218. await new Promise(resolve => {
  219. const userIdToRemove = '31231'
  220. ctx.req.params = { user_id: userIdToRemove }
  221. ctx.req.entity = ctx.subscription
  222. const res = {
  223. sendStatus: () => {
  224. sinon.assert.calledWith(
  225. ctx.UserAuditLogHandler.promises.addEntry,
  226. userIdToRemove,
  227. 'remove-from-group-subscription',
  228. ctx.adminUserId,
  229. ctx.req.ip,
  230. { subscriptionId: ctx.subscriptionId }
  231. )
  232. resolve()
  233. },
  234. }
  235. ctx.Controller.removeUserFromGroup(ctx.req, res, resolve)
  236. })
  237. })
  238. it('should call the group SSO hooks with group SSO enabled', async function (ctx) {
  239. await new Promise(resolve => {
  240. const userIdToRemove = '31231'
  241. ctx.req.params = { user_id: userIdToRemove }
  242. ctx.req.entity = ctx.subscription
  243. ctx.Modules.promises.hooks.fire
  244. .withArgs('hasGroupSSOEnabled', ctx.subscription)
  245. .resolves([true])
  246. const res = {
  247. sendStatus: () => {
  248. ctx.Modules.promises.hooks.fire
  249. .calledWith('hasGroupSSOEnabled', ctx.subscription)
  250. .should.equal(true)
  251. ctx.Modules.promises.hooks.fire
  252. .calledWith(
  253. 'unlinkUserFromGroupSSO',
  254. userIdToRemove,
  255. ctx.subscriptionId
  256. )
  257. .should.equal(true)
  258. sinon.assert.calledTwice(ctx.Modules.promises.hooks.fire)
  259. resolve()
  260. },
  261. }
  262. ctx.Controller.removeUserFromGroup(ctx.req, res, resolve)
  263. })
  264. })
  265. it('should call the group SSO hooks with group SSO disabled', async function (ctx) {
  266. await new Promise(resolve => {
  267. const userIdToRemove = '31231'
  268. ctx.req.params = { user_id: userIdToRemove }
  269. ctx.req.entity = ctx.subscription
  270. ctx.Modules.promises.hooks.fire
  271. .withArgs('hasGroupSSOEnabled', ctx.subscription)
  272. .resolves([false])
  273. const res = {
  274. sendStatus: () => {
  275. ctx.Modules.promises.hooks.fire
  276. .calledWith('hasGroupSSOEnabled', ctx.subscription)
  277. .should.equal(true)
  278. sinon.assert.calledOnce(ctx.Modules.promises.hooks.fire)
  279. resolve()
  280. },
  281. }
  282. ctx.Controller.removeUserFromGroup(ctx.req, res, resolve)
  283. })
  284. })
  285. })
  286. describe('removeSelfFromGroup', function () {
  287. it('gets subscription and remove user', async function (ctx) {
  288. await new Promise(resolve => {
  289. ctx.req.query = { subscriptionId: ctx.subscriptionId }
  290. const memberUserIdToremove = 123456789
  291. ctx.req.session.user._id = memberUserIdToremove
  292. const res = {
  293. sendStatus: () => {
  294. sinon.assert.calledWith(
  295. ctx.SubscriptionLocator.promises.getSubscription,
  296. ctx.subscriptionId
  297. )
  298. sinon.assert.calledWith(
  299. ctx.SubscriptionGroupHandler.promises.removeUserFromGroup,
  300. ctx.subscriptionId,
  301. memberUserIdToremove,
  302. {
  303. initiatorId: ctx.req.session.user._id,
  304. ipAddress: ctx.req.ip,
  305. }
  306. )
  307. resolve()
  308. },
  309. }
  310. ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve)
  311. })
  312. })
  313. it('should log that the user has left the subscription', async function (ctx) {
  314. await new Promise(resolve => {
  315. ctx.req.query = { subscriptionId: ctx.subscriptionId }
  316. const memberUserIdToremove = '123456789'
  317. ctx.req.session.user._id = memberUserIdToremove
  318. const res = {
  319. sendStatus: () => {
  320. sinon.assert.calledWith(
  321. ctx.UserAuditLogHandler.promises.addEntry,
  322. memberUserIdToremove,
  323. 'remove-from-group-subscription',
  324. memberUserIdToremove,
  325. ctx.req.ip,
  326. { subscriptionId: ctx.subscriptionId }
  327. )
  328. resolve()
  329. },
  330. }
  331. ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve)
  332. })
  333. })
  334. it('should call the group SSO hooks with group SSO enabled', async function (ctx) {
  335. await new Promise(resolve => {
  336. ctx.req.query = { subscriptionId: ctx.subscriptionId }
  337. const memberUserIdToremove = '123456789'
  338. ctx.req.session.user._id = memberUserIdToremove
  339. ctx.Modules.promises.hooks.fire
  340. .withArgs('hasGroupSSOEnabled', ctx.subscription)
  341. .resolves([true])
  342. const res = {
  343. sendStatus: () => {
  344. ctx.Modules.promises.hooks.fire
  345. .calledWith('hasGroupSSOEnabled', ctx.subscription)
  346. .should.equal(true)
  347. ctx.Modules.promises.hooks.fire
  348. .calledWith(
  349. 'unlinkUserFromGroupSSO',
  350. memberUserIdToremove,
  351. ctx.subscriptionId
  352. )
  353. .should.equal(true)
  354. sinon.assert.calledTwice(ctx.Modules.promises.hooks.fire)
  355. resolve()
  356. },
  357. }
  358. ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve)
  359. })
  360. })
  361. it('should call the group SSO hooks with group SSO disabled', async function (ctx) {
  362. await new Promise(resolve => {
  363. const userIdToRemove = '31231'
  364. ctx.req.session.user._id = userIdToRemove
  365. ctx.req.params = { user_id: userIdToRemove }
  366. ctx.req.entity = ctx.subscription
  367. ctx.Modules.promises.hooks.fire
  368. .withArgs('hasGroupSSOEnabled', ctx.subscription)
  369. .resolves([false])
  370. const res = {
  371. sendStatus: () => {
  372. ctx.Modules.promises.hooks.fire
  373. .calledWith('hasGroupSSOEnabled', ctx.subscription)
  374. .should.equal(true)
  375. sinon.assert.calledOnce(ctx.Modules.promises.hooks.fire)
  376. resolve()
  377. },
  378. }
  379. ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve)
  380. })
  381. })
  382. })
  383. describe('addSeatsToGroupSubscription', function () {
  384. it('should render the "add seats" page', async function (ctx) {
  385. await new Promise((resolve, reject) => {
  386. const res = {
  387. render: (page, props) => {
  388. ctx.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails
  389. .calledWith(ctx.req.session.user._id)
  390. .should.equal(true)
  391. ctx.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled
  392. .calledWith(ctx.plan)
  393. .should.equal(true)
  394. ctx.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive
  395. .calledWith(ctx.subscription)
  396. .should.equal(true)
  397. ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice
  398. .calledWith(ctx.subscription)
  399. .should.equal(true)
  400. ctx.SubscriptionGroupHandler.promises.checkBillingInfoExistence
  401. .calledWith(ctx.recurlySubscription, ctx.adminUserId)
  402. .should.equal(true)
  403. ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual
  404. .calledWith(ctx.recurlySubscription)
  405. .should.equal(true)
  406. page.should.equal('subscriptions/add-seats')
  407. props.subscriptionId.should.equal(ctx.subscriptionId)
  408. props.groupName.should.equal(ctx.subscription.teamName)
  409. props.totalLicenses.should.equal(ctx.subscription.membersLimit)
  410. props.isProfessional.should.equal(false)
  411. props.isCollectionMethodManual.should.equal(true)
  412. resolve()
  413. },
  414. }
  415. ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
  416. })
  417. })
  418. it('should redirect to subscription page when getting subscription details fails', async function (ctx) {
  419. await new Promise(resolve => {
  420. ctx.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails =
  421. sinon.stub().rejects()
  422. const res = {
  423. redirect: url => {
  424. url.should.equal('/user/subscription')
  425. resolve()
  426. },
  427. }
  428. ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
  429. })
  430. })
  431. it('should redirect to subscription page when flexible licensing is not enabled', async function (ctx) {
  432. await new Promise(resolve => {
  433. ctx.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled =
  434. sinon.stub().rejects()
  435. const res = {
  436. redirect: url => {
  437. url.should.equal('/user/subscription')
  438. resolve()
  439. },
  440. }
  441. ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
  442. })
  443. })
  444. it('should redirect to missing billing information page when billing information is missing', async function (ctx) {
  445. await new Promise(resolve => {
  446. ctx.SubscriptionGroupHandler.promises.checkBillingInfoExistence = sinon
  447. .stub()
  448. .throws(new ctx.Errors.MissingBillingInfoError())
  449. const res = {
  450. redirect: url => {
  451. url.should.equal(
  452. '/user/subscription/group/missing-billing-information'
  453. )
  454. resolve()
  455. },
  456. }
  457. ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
  458. })
  459. })
  460. it('should redirect to manually collected subscription error page when collection method is manual and has no additional license add-on', async function (ctx) {
  461. await new Promise(resolve => {
  462. ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual =
  463. sinon
  464. .stub()
  465. .throws(
  466. new ctx.Errors.HasNoAdditionalLicenseWhenManuallyCollectedError()
  467. )
  468. const res = {
  469. redirect: url => {
  470. url.should.equal(
  471. '/user/subscription/group/manually-collected-subscription?error_type=no-additional-license'
  472. )
  473. resolve()
  474. },
  475. }
  476. ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
  477. })
  478. })
  479. it('should redirect to subscription page when subscription is not active', async function (ctx) {
  480. await new Promise(resolve => {
  481. ctx.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive = sinon
  482. .stub()
  483. .rejects()
  484. const res = {
  485. redirect: url => {
  486. url.should.equal('/user/subscription')
  487. resolve()
  488. },
  489. }
  490. ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
  491. })
  492. })
  493. it('should redirect to subscription page when subscription has pending invoice', async function (ctx) {
  494. ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice =
  495. sinon.stub().rejects()
  496. await new Promise(resolve => {
  497. const res = {
  498. redirect: url => {
  499. url.should.equal('/user/subscription')
  500. resolve()
  501. },
  502. }
  503. ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
  504. })
  505. })
  506. })
  507. describe('previewAddSeatsSubscriptionChange', function () {
  508. it('should preview "add seats" change', async function (ctx) {
  509. await new Promise(resolve => {
  510. ctx.req.body = { adding: 2 }
  511. const res = {
  512. json: data => {
  513. ctx.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange
  514. .calledWith(ctx.req.session.user._id, ctx.req.body.adding)
  515. .should.equal(true)
  516. data.should.deep.equal(ctx.previewSubscriptionChangeData)
  517. resolve()
  518. },
  519. }
  520. ctx.Controller.previewAddSeatsSubscriptionChange(ctx.req, res)
  521. })
  522. })
  523. it('should fail previewing "add seats" change', async function (ctx) {
  524. ctx.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange =
  525. sinon.stub().rejects()
  526. ctx.req.body = { adding: 2 }
  527. await new Promise(resolve => {
  528. const res = {
  529. status: statusCode => {
  530. statusCode.should.equal(500)
  531. return {
  532. end: () => {
  533. resolve()
  534. },
  535. }
  536. },
  537. }
  538. ctx.Controller.previewAddSeatsSubscriptionChange(ctx.req, res)
  539. })
  540. })
  541. it('should fail previewing "add seats" change with SubtotalLimitExceededError', async function (ctx) {
  542. await new Promise(resolve => {
  543. ctx.req.body = { adding: 2 }
  544. ctx.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange =
  545. sinon.stub().throws(new ctx.Errors.SubtotalLimitExceededError())
  546. const res = {
  547. status: statusCode => {
  548. statusCode.should.equal(422)
  549. return {
  550. json: data => {
  551. data.should.deep.equal({
  552. code: 'subtotal_limit_exceeded',
  553. adding: ctx.req.body.adding,
  554. })
  555. resolve()
  556. },
  557. }
  558. },
  559. }
  560. ctx.Controller.previewAddSeatsSubscriptionChange(ctx.req, res)
  561. })
  562. })
  563. })
  564. describe('createAddSeatsSubscriptionChange', function () {
  565. it('should apply "add seats" change', async function (ctx) {
  566. await new Promise(resolve => {
  567. ctx.req.body = { adding: 2 }
  568. const res = {
  569. json: data => {
  570. ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange
  571. .calledWith(ctx.req.session.user._id, ctx.req.body.adding)
  572. .should.equal(true)
  573. data.should.deep.equal(ctx.createSubscriptionChangeData)
  574. resolve()
  575. },
  576. }
  577. ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res)
  578. })
  579. })
  580. it('should fail applying "add seats" change', async function (ctx) {
  581. await new Promise(resolve => {
  582. ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange =
  583. sinon.stub().rejects()
  584. const res = {
  585. status: statusCode => {
  586. statusCode.should.equal(500)
  587. return {
  588. end: () => {
  589. resolve()
  590. },
  591. }
  592. },
  593. }
  594. ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res)
  595. })
  596. })
  597. it('should fail applying "add seats" change with SubtotalLimitExceededError', async function (ctx) {
  598. await new Promise(resolve => {
  599. ctx.req.body = { adding: 2 }
  600. ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange =
  601. sinon.stub().throws(new ctx.Errors.SubtotalLimitExceededError())
  602. const res = {
  603. status: statusCode => {
  604. statusCode.should.equal(422)
  605. return {
  606. json: data => {
  607. data.should.deep.equal({
  608. code: 'subtotal_limit_exceeded',
  609. adding: ctx.req.body.adding,
  610. })
  611. resolve()
  612. },
  613. }
  614. },
  615. }
  616. ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res)
  617. })
  618. })
  619. it('should send 402 response with PaymentActionRequiredError', async function (ctx) {
  620. await new Promise(resolve => {
  621. const adding = 2
  622. ctx.req.body = { adding }
  623. const error = new ctx.Errors.PaymentActionRequiredError({
  624. clientSecret: 'secret',
  625. publicKey: 'key',
  626. })
  627. ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange =
  628. sinon.stub().throws(error)
  629. const res = {
  630. status: statusCode => {
  631. statusCode.should.equal(402)
  632. return {
  633. json: data => {
  634. data.should.deep.equal({
  635. message: 'Payment action required',
  636. clientSecret: error.info.clientSecret,
  637. publicKey: error.info.publicKey,
  638. })
  639. resolve()
  640. },
  641. }
  642. },
  643. }
  644. ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res)
  645. })
  646. })
  647. it('should return 422 with MultiplePendingChangesError', async function (ctx) {
  648. await new Promise(resolve => {
  649. ctx.req.body = { adding: 2 }
  650. ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange =
  651. sinon.stub().throws(new ctx.Errors.MultiplePendingChangesError())
  652. const res = {
  653. status: statusCode => {
  654. statusCode.should.equal(422)
  655. return {
  656. end: () => {
  657. resolve()
  658. },
  659. }
  660. },
  661. }
  662. ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res)
  663. })
  664. })
  665. })
  666. describe('submitForm', function () {
  667. it('should build and pass the request body to the sales submit handler', async function (ctx) {
  668. await new Promise(resolve => {
  669. const adding = 100
  670. const poNumber = 'PO123456'
  671. ctx.req.body = { adding, poNumber }
  672. const res = {
  673. sendStatus: code => {
  674. ctx.SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms(
  675. ctx.adminUserId,
  676. ctx.recurlySubscription,
  677. poNumber
  678. )
  679. ctx.Modules.promises.hooks.fire
  680. .calledWith('sendSupportRequest', {
  681. email: ctx.user.email,
  682. subject: 'Sales Contact Form',
  683. message:
  684. '\n' +
  685. '**Overleaf Sales Contact Form:**\n' +
  686. '\n' +
  687. '**Subject:** Self-Serve Group User Increase Request\n' +
  688. '\n' +
  689. `**Estimated Number of Users:** ${adding}\n` +
  690. '\n' +
  691. `**Subscription:** [${ctx.subscriptionId}](https://admin.overleaf.com/admin/subscription/${ctx.subscriptionId})\n` +
  692. '\n' +
  693. `**Current Number of Seats:** ${ctx.subscription.membersLimit}\n` +
  694. '\n' +
  695. `**Plan Code:** ${ctx.subscription.planCode}\n` +
  696. '\n' +
  697. `**PO Number:** ${poNumber}\n` +
  698. '\n' +
  699. `**Message:** This email has been generated on behalf of user with email **${ctx.user.email}** to request an increase in the total number of users for their subscription.`,
  700. inbox: 'sales',
  701. })
  702. .should.equal(true)
  703. sinon.assert.calledOnce(ctx.Modules.promises.hooks.fire)
  704. code.should.equal(204)
  705. resolve()
  706. },
  707. }
  708. ctx.Controller.submitForm(ctx.req, res, resolve)
  709. })
  710. })
  711. it('should include the Salesforce ID line when the subscription has a salesforce_id', async function (ctx) {
  712. await new Promise(resolve => {
  713. const adding = 100
  714. const salesforceId = '0061x000ABCDEFG'
  715. ctx.subscription.salesforce_id = salesforceId
  716. ctx.req.body = { adding }
  717. const res = {
  718. sendStatus: () => {
  719. const { message } =
  720. ctx.Modules.promises.hooks.fire.getCall(0).args[1]
  721. message.should.include(
  722. `**Salesforce ID:** [${salesforceId}](https://digitalscience.lightning.force.com/lightning/r/Opportunity/${salesforceId}/view)\n`
  723. )
  724. resolve()
  725. },
  726. }
  727. ctx.Controller.submitForm(ctx.req, res, resolve)
  728. })
  729. })
  730. })
  731. describe('subscriptionUpgradePage', function () {
  732. it('should render "subscription upgrade" page', async function (ctx) {
  733. await new Promise(resolve => {
  734. const olSubscription = { membersLimit: 1, teamName: 'test team' }
  735. ctx.SubscriptionModel.Subscription.findOne = () => {
  736. return {
  737. exec: () => olSubscription,
  738. }
  739. }
  740. const res = {
  741. render: (page, data) => {
  742. ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview
  743. .calledWith(ctx.req.session.user._id)
  744. .should.equal(true)
  745. page.should.equal('subscriptions/upgrade-group-subscription-react')
  746. data.totalLicenses.should.equal(olSubscription.membersLimit)
  747. data.groupName.should.equal(olSubscription.teamName)
  748. data.changePreview.should.equal(ctx.previewSubscriptionChangeData)
  749. resolve()
  750. },
  751. }
  752. ctx.Controller.subscriptionUpgradePage(ctx.req, res)
  753. })
  754. })
  755. it('should redirect if failed to generate preview', async function (ctx) {
  756. await new Promise(resolve => {
  757. ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon
  758. .stub()
  759. .rejects()
  760. const res = {
  761. redirect: url => {
  762. url.should.equal('/user/subscription')
  763. resolve()
  764. },
  765. }
  766. ctx.Controller.subscriptionUpgradePage(ctx.req, res)
  767. })
  768. })
  769. it('should redirect to missing billing information page when billing information is missing', async function (ctx) {
  770. await new Promise(resolve => {
  771. ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon
  772. .stub()
  773. .throws(new ctx.Errors.MissingBillingInfoError())
  774. const res = {
  775. redirect: url => {
  776. url.should.equal(
  777. '/user/subscription/group/missing-billing-information'
  778. )
  779. resolve()
  780. },
  781. }
  782. ctx.Controller.subscriptionUpgradePage(ctx.req, res)
  783. })
  784. })
  785. it('should redirect to manually collected subscription error page when collection method is manual', async function (ctx) {
  786. await new Promise(resolve => {
  787. ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon
  788. .stub()
  789. .throws(new ctx.Errors.ManuallyCollectedError())
  790. const res = {
  791. redirect: url => {
  792. url.should.equal(
  793. '/user/subscription/group/manually-collected-subscription?error_type=plan-upgrade'
  794. )
  795. resolve()
  796. },
  797. }
  798. ctx.Controller.subscriptionUpgradePage(ctx.req, res)
  799. })
  800. })
  801. it('should redirect to subtotal limit exceeded page', async function (ctx) {
  802. await new Promise(resolve => {
  803. ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon
  804. .stub()
  805. .throws(new ctx.Errors.SubtotalLimitExceededError())
  806. const res = {
  807. redirect: url => {
  808. url.should.equal('/user/subscription/group/subtotal-limit-exceeded')
  809. resolve()
  810. },
  811. }
  812. ctx.Controller.subscriptionUpgradePage(ctx.req, res)
  813. })
  814. })
  815. })
  816. describe('upgradeSubscription', function () {
  817. it('should send 200 response', async function (ctx) {
  818. await new Promise(resolve => {
  819. ctx.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon
  820. .stub()
  821. .resolves()
  822. const res = {
  823. sendStatus: code => {
  824. code.should.equal(200)
  825. resolve()
  826. },
  827. }
  828. ctx.Controller.upgradeSubscription(ctx.req, res)
  829. })
  830. })
  831. it('should send 500 response', async function (ctx) {
  832. await new Promise(resolve => {
  833. ctx.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon
  834. .stub()
  835. .rejects()
  836. const res = {
  837. sendStatus: code => {
  838. code.should.equal(500)
  839. resolve()
  840. },
  841. }
  842. ctx.Controller.upgradeSubscription(ctx.req, res)
  843. })
  844. })
  845. it('should send 402 response with PaymentActionRequiredError', async function (ctx) {
  846. await new Promise(resolve => {
  847. const error = new ctx.Errors.PaymentActionRequiredError({
  848. clientSecret: 'secret',
  849. publicKey: 'public',
  850. })
  851. ctx.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon
  852. .stub()
  853. .rejects(error)
  854. const res = {
  855. status: code => {
  856. code.should.equal(402)
  857. return {
  858. json: data => {
  859. data.should.deep.equal({
  860. message: 'Payment action required',
  861. clientSecret: error.info.clientSecret,
  862. publicKey: error.info.publicKey,
  863. })
  864. resolve()
  865. },
  866. }
  867. },
  868. }
  869. ctx.Controller.upgradeSubscription(ctx.req, res)
  870. })
  871. })
  872. it('should send 422 response with MultiplePendingChangesError', async function (ctx) {
  873. await new Promise(resolve => {
  874. ctx.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon
  875. .stub()
  876. .rejects(new ctx.Errors.MultiplePendingChangesError())
  877. const res = {
  878. status: code => {
  879. code.should.equal(422)
  880. return {
  881. json: data => {
  882. data.should.deep.equal({
  883. code: 'multiple_pending_changes',
  884. message:
  885. 'Cannot upgrade subscription while there are multiple pending subscription changes. Please contact support.',
  886. })
  887. resolve()
  888. },
  889. }
  890. },
  891. }
  892. ctx.Controller.upgradeSubscription(ctx.req, res)
  893. })
  894. })
  895. })
  896. })