SubscriptionControllerTests.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
  1. const SandboxedModule = require('sandboxed-module')
  2. const sinon = require('sinon')
  3. const { assert, expect } = require('chai')
  4. const MockRequest = require('../helpers/MockRequest')
  5. const MockResponse = require('../helpers/MockResponse')
  6. const modulePath =
  7. '../../../../app/src/Features/Subscription/SubscriptionController'
  8. const SubscriptionErrors = require('../../../../app/src/Features/Subscription/Errors')
  9. const mockSubscriptions = {
  10. 'subscription-123-active': {
  11. uuid: 'subscription-123-active',
  12. plan: {
  13. name: 'Gold',
  14. plan_code: 'gold',
  15. },
  16. current_period_ends_at: new Date(),
  17. state: 'active',
  18. unit_amount_in_cents: 999,
  19. account: {
  20. account_code: 'user-123',
  21. },
  22. },
  23. }
  24. describe('SubscriptionController', function () {
  25. beforeEach(function () {
  26. this.user = {
  27. email: 'tom@yahoo.com',
  28. _id: 'one',
  29. signUpDate: new Date('2000-10-01'),
  30. emails: [{ email: 'tom@yahoo.com', confirmedAt: new Date('2000-10-02') }],
  31. }
  32. this.activeRecurlySubscription =
  33. mockSubscriptions['subscription-123-active']
  34. this.SessionManager = {
  35. getLoggedInUser: sinon.stub().callsArgWith(1, null, this.user),
  36. getLoggedInUserId: sinon.stub().returns(this.user._id),
  37. getSessionUser: sinon.stub().returns(this.user),
  38. isUserLoggedIn: sinon.stub().returns(true),
  39. }
  40. this.SubscriptionHandler = {
  41. createSubscription: sinon.stub().callsArgWith(3),
  42. updateSubscription: sinon.stub().callsArgWith(3),
  43. reactivateSubscription: sinon.stub().callsArgWith(1),
  44. cancelSubscription: sinon.stub().callsArgWith(1),
  45. syncSubscription: sinon.stub().yields(),
  46. attemptPaypalInvoiceCollection: sinon.stub().yields(),
  47. startFreeTrial: sinon.stub(),
  48. promises: {
  49. createSubscription: sinon.stub().resolves(),
  50. updateSubscription: sinon.stub().resolves(),
  51. reactivateSubscription: sinon.stub().resolves(),
  52. cancelSubscription: sinon.stub().resolves(),
  53. pauseSubscription: sinon.stub().resolves(),
  54. resumeSubscription: sinon.stub().resolves(),
  55. syncSubscription: sinon.stub().resolves(),
  56. attemptPaypalInvoiceCollection: sinon.stub().resolves(),
  57. startFreeTrial: sinon.stub().resolves(),
  58. },
  59. }
  60. this.LimitationsManager = {
  61. hasPaidSubscription: sinon.stub(),
  62. userHasSubscription: sinon
  63. .stub()
  64. .yields(null, { hasSubscription: false }),
  65. promises: {
  66. hasPaidSubscription: sinon.stub().resolves(),
  67. userHasSubscription: sinon.stub().resolves({ hasSubscription: false }),
  68. },
  69. }
  70. this.SubscriptionViewModelBuilder = {
  71. buildUsersSubscriptionViewModel: sinon.stub().callsArgWith(1, null, {}),
  72. buildPlansList: sinon.stub(),
  73. promises: {
  74. buildUsersSubscriptionViewModel: sinon.stub().resolves({}),
  75. getBestSubscription: sinon.stub().resolves({}),
  76. },
  77. buildPlansListForSubscriptionDash: sinon
  78. .stub()
  79. .returns({ plans: [], planCodesChangingAtTermEnd: [] }),
  80. }
  81. this.settings = {
  82. coupon_codes: {
  83. upgradeToAnnualPromo: {
  84. student: 'STUDENTCODEHERE',
  85. collaborator: 'COLLABORATORCODEHERE',
  86. },
  87. },
  88. groupPlanModalOptions: {
  89. plan_codes: [],
  90. currencies: [
  91. {
  92. display: 'GBP (£)',
  93. code: 'GBP',
  94. },
  95. ],
  96. sizes: ['42'],
  97. usages: [{ code: 'foo', display: 'Foo' }],
  98. },
  99. apis: {
  100. recurly: {
  101. subdomain: 'sl',
  102. },
  103. },
  104. siteUrl: 'http://de.overleaf.dev:3000',
  105. }
  106. this.AuthorizationManager = {
  107. promises: {
  108. isUserSiteAdmin: sinon.stub().resolves(false),
  109. },
  110. }
  111. this.GeoIpLookup = {
  112. isValidCurrencyParam: sinon.stub().returns(true),
  113. getCurrencyCode: sinon.stub().yields('USD', 'US'),
  114. promises: {
  115. getCurrencyCode: sinon.stub().resolves({
  116. countryCode: 'US',
  117. currencyCode: 'USD',
  118. }),
  119. },
  120. }
  121. this.UserGetter = {
  122. getUser: sinon.stub().callsArgWith(2, null, this.user),
  123. promises: {
  124. getUser: sinon.stub().resolves(this.user),
  125. },
  126. }
  127. this.SplitTestV2Hander = {
  128. promises: {
  129. getAssignment: sinon.stub().resolves({ variant: 'default' }),
  130. },
  131. }
  132. this.Features = {
  133. hasFeature: sinon.stub().returns(false),
  134. }
  135. this.SubscriptionController = SandboxedModule.require(modulePath, {
  136. requires: {
  137. '../Authorization/AuthorizationManager': this.AuthorizationManager,
  138. '../SplitTests/SplitTestHandler': this.SplitTestV2Hander,
  139. '../Authentication/SessionManager': this.SessionManager,
  140. './SubscriptionHandler': this.SubscriptionHandler,
  141. './SubscriptionHelper': this.SubscriptionHelper,
  142. './SubscriptionViewModelBuilder': this.SubscriptionViewModelBuilder,
  143. './LimitationsManager': this.LimitationsManager,
  144. '../../infrastructure/GeoIpLookup': this.GeoIpLookup,
  145. '@overleaf/settings': this.settings,
  146. '../User/UserGetter': this.UserGetter,
  147. './RecurlyWrapper': (this.RecurlyWrapper = {
  148. updateAccountEmailAddress: sinon.stub().yields(),
  149. }),
  150. './RecurlyEventHandler': {
  151. sendRecurlyAnalyticsEvent: sinon.stub().resolves(),
  152. },
  153. './FeaturesUpdater': (this.FeaturesUpdater = {
  154. promises: {
  155. hasFeaturesViaWritefull: sinon.stub().resolves(false),
  156. refreshFeatures: sinon.stub().resolves({ features: {} }),
  157. },
  158. }),
  159. './GroupPlansData': (this.GroupPlansData = {}),
  160. './V1SubscriptionManager': (this.V1SubscriptionManager = {}),
  161. '../Errors/HttpErrorHandler': (this.HttpErrorHandler = {
  162. unprocessableEntity: sinon.stub().callsFake((req, res, message) => {
  163. res.status(422)
  164. res.json({ message })
  165. }),
  166. badRequest: sinon.stub().callsFake((req, res, message) => {
  167. res.status(400)
  168. res.json({ message })
  169. }),
  170. }),
  171. './Errors': SubscriptionErrors,
  172. '../Analytics/AnalyticsManager': (this.AnalyticsManager = {
  173. recordEventForUser: sinon.stub(),
  174. recordEventForUserInBackground: sinon.stub(),
  175. recordEventForSession: sinon.stub(),
  176. setUserPropertyForUser: sinon.stub(),
  177. }),
  178. '../../infrastructure/Modules': (this.Modules = {
  179. promises: { hooks: { fire: sinon.stub().resolves() } },
  180. }),
  181. '../../infrastructure/Features': this.Features,
  182. '../../util/currency': (this.currency = {
  183. formatCurrency: sinon.stub(),
  184. }),
  185. '../../models/User': {
  186. User: {
  187. findById: sinon.stub().resolves(this.user),
  188. },
  189. },
  190. },
  191. })
  192. this.res = new MockResponse()
  193. this.req = new MockRequest()
  194. this.req.body = {}
  195. this.req.query = { planCode: '123123' }
  196. this.stubbedCurrencyCode = 'GBP'
  197. })
  198. describe('successfulSubscription', function () {
  199. it('without a personal subscription', function (done) {
  200. this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
  201. {}
  202. )
  203. this.res.redirect = url => {
  204. url.should.equal('/user/subscription/plans')
  205. done()
  206. }
  207. this.SubscriptionController.successfulSubscription(this.req, this.res)
  208. })
  209. it('with a personal subscription', function (done) {
  210. this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
  211. {
  212. personalSubscription: 'foo',
  213. }
  214. )
  215. this.res.render = (url, variables) => {
  216. url.should.equal('subscriptions/successful-subscription-react')
  217. assert.deepEqual(variables, {
  218. title: 'thank_you',
  219. personalSubscription: 'foo',
  220. postCheckoutRedirect: undefined,
  221. user: {
  222. _id: this.user._id,
  223. features: this.user.features,
  224. },
  225. })
  226. done()
  227. }
  228. this.SubscriptionController.successfulSubscription(this.req, this.res)
  229. })
  230. it('with an error', function (done) {
  231. this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
  232. undefined
  233. )
  234. this.SubscriptionController.successfulSubscription(
  235. this.req,
  236. this.res,
  237. error => {
  238. assert.isNotNull(error)
  239. done()
  240. }
  241. )
  242. })
  243. })
  244. describe('userSubscriptionPage', function () {
  245. beforeEach(function (done) {
  246. this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
  247. {
  248. personalSubscription: (this.personalSubscription = {
  249. 'personal-subscription': 'mock',
  250. }),
  251. memberGroupSubscriptions: (this.memberGroupSubscriptions = {
  252. 'group-subscriptions': 'mock',
  253. }),
  254. }
  255. )
  256. this.SubscriptionViewModelBuilder.buildPlansList.returns(
  257. (this.plans = { plans: 'mock' })
  258. )
  259. this.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash.returns(
  260. {
  261. plans: this.plans,
  262. planCodesChangingAtTermEnd: [],
  263. }
  264. )
  265. this.LimitationsManager.promises.userHasSubscription.resolves({
  266. hasSubscription: false,
  267. })
  268. this.res.render = (view, data) => {
  269. this.data = data
  270. expect(view).to.equal('subscriptions/dashboard-react')
  271. done()
  272. }
  273. this.SubscriptionController.userSubscriptionPage(this.req, this.res, done)
  274. })
  275. it('should load the personal, groups and v1 subscriptions', function () {
  276. expect(this.data.personalSubscription).to.deep.equal(
  277. this.personalSubscription
  278. )
  279. expect(this.data.memberGroupSubscriptions).to.deep.equal(
  280. this.memberGroupSubscriptions
  281. )
  282. })
  283. it('should load the user', function () {
  284. expect(this.data.user).to.deep.equal(this.user)
  285. })
  286. it('should load the plans', function () {
  287. expect(this.data.plans).to.deep.equal(this.plans)
  288. })
  289. it('should load an empty list of groups with settings available', function () {
  290. expect(this.data.groupSettingsEnabledFor).to.deep.equal([])
  291. })
  292. })
  293. describe('updateAccountEmailAddress via put', function () {
  294. it('should send the user and subscriptionId to RecurlyWrapper', function () {
  295. this.res.sendStatus = sinon.spy()
  296. this.SubscriptionController.updateAccountEmailAddress(this.req, this.res)
  297. this.RecurlyWrapper.updateAccountEmailAddress
  298. .calledWith(this.user._id, this.user.email)
  299. .should.equal(true)
  300. })
  301. it('should respond with 200', function () {
  302. this.res.sendStatus = sinon.spy()
  303. this.SubscriptionController.updateAccountEmailAddress(this.req, this.res)
  304. this.res.sendStatus.calledWith(200).should.equal(true)
  305. })
  306. it('should send the error to the next handler when updating recurly account email fails', function (done) {
  307. this.RecurlyWrapper.updateAccountEmailAddress.yields(new Error())
  308. this.next = sinon.spy(error => {
  309. expect(error).instanceOf(Error)
  310. done()
  311. })
  312. this.SubscriptionController.updateAccountEmailAddress(
  313. this.req,
  314. this.res,
  315. this.next
  316. )
  317. })
  318. })
  319. describe('reactivateSubscription', function () {
  320. describe('when the user has permission', function () {
  321. beforeEach(function (done) {
  322. this.res = {
  323. redirect() {
  324. done()
  325. },
  326. }
  327. this.req.assertPermission = sinon.stub()
  328. this.next = sinon.stub().callsFake(error => {
  329. done(error)
  330. })
  331. sinon.spy(this.res, 'redirect')
  332. this.SubscriptionController.reactivateSubscription(
  333. this.req,
  334. this.res,
  335. this.next
  336. )
  337. })
  338. it('should assert the user has permission to reactivate their subscription', function (done) {
  339. this.req.assertPermission
  340. .calledWith('reactivate-subscription')
  341. .should.equal(true)
  342. done()
  343. })
  344. it('should tell the handler to reactivate this user', function (done) {
  345. this.SubscriptionHandler.reactivateSubscription
  346. .calledWith(this.user)
  347. .should.equal(true)
  348. done()
  349. })
  350. it('should redurect to the subscription page', function (done) {
  351. this.res.redirect.calledWith('/user/subscription').should.equal(true)
  352. done()
  353. })
  354. })
  355. describe('when the user does not have permission', function () {
  356. beforeEach(function (done) {
  357. this.res = {
  358. redirect() {
  359. done()
  360. },
  361. }
  362. this.req.assertPermission = sinon.stub().throws()
  363. this.next = sinon.stub().callsFake(() => {
  364. done()
  365. })
  366. sinon.spy(this.res, 'redirect')
  367. this.SubscriptionController.reactivateSubscription(
  368. this.req,
  369. this.res,
  370. this.next
  371. )
  372. })
  373. it('should not reactivate the user', function (done) {
  374. this.req.assertPermission = sinon.stub().throws()
  375. this.SubscriptionHandler.reactivateSubscription.called.should.equal(
  376. false
  377. )
  378. done()
  379. })
  380. it('should call next with an error', function (done) {
  381. this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
  382. done()
  383. })
  384. })
  385. })
  386. describe('pauseSubscription', function () {
  387. it('should throw an error if no pause length is provided', async function () {
  388. this.res = new MockResponse()
  389. this.req = new MockRequest()
  390. this.next = sinon.stub()
  391. await this.SubscriptionController.pauseSubscription(
  392. this.req,
  393. this.res,
  394. this.next
  395. )
  396. expect(this.res.statusCode).to.equal(400)
  397. })
  398. it('should throw an error if an invalid pause length is provided', async function () {
  399. this.res = new MockResponse()
  400. this.req = new MockRequest()
  401. this.req.params = { pauseCycles: -10 }
  402. this.next = sinon.stub()
  403. await this.SubscriptionController.pauseSubscription(
  404. this.req,
  405. this.res,
  406. this.next
  407. )
  408. expect(this.res.statusCode).to.equal(400)
  409. })
  410. it('should return a 200 when requesting a pause', async function () {
  411. this.res = new MockResponse()
  412. this.req = new MockRequest()
  413. this.req.params = { pauseCycles: 3 }
  414. this.next = sinon.stub()
  415. await this.SubscriptionController.pauseSubscription(
  416. this.req,
  417. this.res,
  418. this.next
  419. )
  420. expect(this.res.statusCode).to.equal(200)
  421. })
  422. })
  423. describe('resumeSubscription', function () {
  424. it('should return a 200 when resuming a subscription', async function () {
  425. this.res = new MockResponse()
  426. this.req = new MockRequest()
  427. this.next = sinon.stub()
  428. await this.SubscriptionController.resumeSubscription(
  429. this.req,
  430. this.res,
  431. this.next
  432. )
  433. expect(this.res.statusCode).to.equal(200)
  434. })
  435. })
  436. describe('cancelSubscription', function () {
  437. beforeEach(function (done) {
  438. this.res = {
  439. redirect() {
  440. done()
  441. },
  442. }
  443. sinon.spy(this.res, 'redirect')
  444. this.SubscriptionController.cancelSubscription(this.req, this.res)
  445. })
  446. it('should tell the handler to cancel this user', function (done) {
  447. this.SubscriptionHandler.cancelSubscription
  448. .calledWith(this.user)
  449. .should.equal(true)
  450. done()
  451. })
  452. it('should redurect to the subscription page', function (done) {
  453. this.res.redirect
  454. .calledWith('/user/subscription/canceled')
  455. .should.equal(true)
  456. done()
  457. })
  458. })
  459. describe('recurly callback', function () {
  460. describe('with a sync subscription request', function () {
  461. beforeEach(function (done) {
  462. this.req = {
  463. body: {
  464. expired_subscription_notification: {
  465. account: {
  466. account_code: this.user._id,
  467. },
  468. subscription: {
  469. uuid: this.activeRecurlySubscription.uuid,
  470. plan: {
  471. plan_code: 'collaborator',
  472. state: 'active',
  473. },
  474. },
  475. },
  476. },
  477. }
  478. this.res = {
  479. sendStatus() {
  480. done()
  481. },
  482. }
  483. sinon.spy(this.res, 'sendStatus')
  484. this.SubscriptionController.recurlyCallback(this.req, this.res)
  485. })
  486. it('should tell the SubscriptionHandler to process the recurly callback', function (done) {
  487. this.SubscriptionHandler.syncSubscription.called.should.equal(true)
  488. done()
  489. })
  490. it('should send a 200', function (done) {
  491. this.res.sendStatus.calledWith(200)
  492. done()
  493. })
  494. })
  495. describe('with a billing info updated request', function () {
  496. beforeEach(function (done) {
  497. this.req = {
  498. body: {
  499. billing_info_updated_notification: {
  500. account: {
  501. account_code: 'mock-account-code',
  502. },
  503. },
  504. },
  505. }
  506. this.res = {
  507. sendStatus() {
  508. done()
  509. },
  510. }
  511. sinon.spy(this.res, 'sendStatus')
  512. this.SubscriptionController.recurlyCallback(this.req, this.res)
  513. })
  514. it('should call attemptPaypalInvoiceCollection', function (done) {
  515. this.SubscriptionHandler.attemptPaypalInvoiceCollection
  516. .calledWith('mock-account-code')
  517. .should.equal(true)
  518. done()
  519. })
  520. it('should send a 200', function (done) {
  521. this.res.sendStatus.calledWith(200)
  522. done()
  523. })
  524. })
  525. describe('with a non-actionable request', function () {
  526. beforeEach(function (done) {
  527. this.user.id = this.activeRecurlySubscription.account.account_code
  528. this.req = {
  529. body: {
  530. renewed_subscription_notification: {
  531. account: {
  532. account_code: this.user._id,
  533. },
  534. subscription: {
  535. uuid: this.activeRecurlySubscription.uuid,
  536. plan: {
  537. plan_code: 'collaborator',
  538. state: 'active',
  539. },
  540. },
  541. },
  542. },
  543. }
  544. this.res = {
  545. sendStatus() {
  546. done()
  547. },
  548. }
  549. sinon.spy(this.res, 'sendStatus')
  550. this.SubscriptionController.recurlyCallback(this.req, this.res)
  551. })
  552. it('should not call the subscriptionshandler', function () {
  553. this.SubscriptionHandler.syncSubscription.called.should.equal(false)
  554. this.SubscriptionHandler.attemptPaypalInvoiceCollection.called.should.equal(
  555. false
  556. )
  557. })
  558. it('should respond with a 200 status', function () {
  559. this.res.sendStatus.calledWith(200)
  560. })
  561. })
  562. })
  563. })