SubscriptionControllerTests.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  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. getWritefullData: sinon
  126. .stub()
  127. .resolves({ isPremium: false, premiumSource: null }),
  128. },
  129. }
  130. this.SplitTestV2Hander = {
  131. promises: {
  132. getAssignment: sinon.stub().resolves({ variant: 'default' }),
  133. },
  134. }
  135. this.Features = {
  136. hasFeature: sinon.stub().returns(false),
  137. }
  138. this.SubscriptionController = SandboxedModule.require(modulePath, {
  139. requires: {
  140. '../Authorization/AuthorizationManager': this.AuthorizationManager,
  141. '../SplitTests/SplitTestHandler': this.SplitTestV2Hander,
  142. '../Authentication/SessionManager': this.SessionManager,
  143. './SubscriptionHandler': this.SubscriptionHandler,
  144. './SubscriptionHelper': this.SubscriptionHelper,
  145. './SubscriptionViewModelBuilder': this.SubscriptionViewModelBuilder,
  146. './LimitationsManager': this.LimitationsManager,
  147. '../../infrastructure/GeoIpLookup': this.GeoIpLookup,
  148. '@overleaf/settings': this.settings,
  149. '../User/UserGetter': this.UserGetter,
  150. './RecurlyWrapper': (this.RecurlyWrapper = {
  151. promises: {
  152. updateAccountEmailAddress: sinon.stub().resolves(),
  153. },
  154. }),
  155. './RecurlyEventHandler': {
  156. sendRecurlyAnalyticsEvent: sinon.stub().resolves(),
  157. },
  158. './FeaturesUpdater': (this.FeaturesUpdater = {
  159. promises: {
  160. refreshFeatures: sinon.stub().resolves({ features: {} }),
  161. },
  162. }),
  163. './GroupPlansData': (this.GroupPlansData = {}),
  164. './V1SubscriptionManager': (this.V1SubscriptionManager = {}),
  165. '../Errors/HttpErrorHandler': (this.HttpErrorHandler = {
  166. unprocessableEntity: sinon.stub().callsFake((req, res, message) => {
  167. res.status(422)
  168. res.json({ message })
  169. }),
  170. badRequest: sinon.stub().callsFake((req, res, message) => {
  171. res.status(400)
  172. res.json({ message })
  173. }),
  174. }),
  175. './Errors': SubscriptionErrors,
  176. '../Analytics/AnalyticsManager': (this.AnalyticsManager = {
  177. recordEventForUser: sinon.stub(),
  178. recordEventForUserInBackground: sinon.stub(),
  179. recordEventForSession: sinon.stub(),
  180. setUserPropertyForUser: sinon.stub(),
  181. }),
  182. '../../infrastructure/Modules': (this.Modules = {
  183. promises: { hooks: { fire: sinon.stub().resolves() } },
  184. }),
  185. '../../infrastructure/Features': this.Features,
  186. '../../util/currency': (this.currency = {
  187. formatCurrency: sinon.stub(),
  188. }),
  189. '../../models/User': {
  190. User: {
  191. findById: sinon.stub().resolves(this.user),
  192. },
  193. },
  194. },
  195. })
  196. this.res = new MockResponse()
  197. this.req = new MockRequest()
  198. this.req.body = {}
  199. this.req.query = { planCode: '123123' }
  200. this.stubbedCurrencyCode = 'GBP'
  201. })
  202. describe('successfulSubscription', function () {
  203. it('without a personal subscription', function (done) {
  204. this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
  205. {}
  206. )
  207. this.res.redirect = url => {
  208. url.should.equal('/user/subscription/plans')
  209. done()
  210. }
  211. this.SubscriptionController.successfulSubscription(this.req, this.res)
  212. })
  213. it('with a personal subscription', function (done) {
  214. this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
  215. {
  216. personalSubscription: 'foo',
  217. }
  218. )
  219. this.res.render = (url, variables) => {
  220. url.should.equal('subscriptions/successful-subscription-react')
  221. assert.deepEqual(variables, {
  222. title: 'thank_you',
  223. personalSubscription: 'foo',
  224. postCheckoutRedirect: undefined,
  225. user: {
  226. _id: this.user._id,
  227. features: this.user.features,
  228. },
  229. })
  230. done()
  231. }
  232. this.SubscriptionController.successfulSubscription(this.req, this.res)
  233. })
  234. it('with an error', function (done) {
  235. this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
  236. undefined
  237. )
  238. this.SubscriptionController.successfulSubscription(
  239. this.req,
  240. this.res,
  241. error => {
  242. assert.isNotNull(error)
  243. done()
  244. }
  245. )
  246. })
  247. })
  248. describe('userSubscriptionPage', function () {
  249. beforeEach(function (done) {
  250. this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
  251. {
  252. personalSubscription: (this.personalSubscription = {
  253. 'personal-subscription': 'mock',
  254. }),
  255. memberGroupSubscriptions: (this.memberGroupSubscriptions = {
  256. 'group-subscriptions': 'mock',
  257. }),
  258. }
  259. )
  260. this.SubscriptionViewModelBuilder.buildPlansList.returns(
  261. (this.plans = { plans: 'mock' })
  262. )
  263. this.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash.returns(
  264. {
  265. plans: this.plans,
  266. planCodesChangingAtTermEnd: [],
  267. }
  268. )
  269. this.LimitationsManager.promises.userHasSubscription.resolves({
  270. hasSubscription: false,
  271. })
  272. this.res.render = (view, data) => {
  273. this.data = data
  274. expect(view).to.equal('subscriptions/dashboard-react')
  275. done()
  276. }
  277. this.SubscriptionController.userSubscriptionPage(this.req, this.res, done)
  278. })
  279. it('should load the personal, groups and v1 subscriptions', function () {
  280. expect(this.data.personalSubscription).to.deep.equal(
  281. this.personalSubscription
  282. )
  283. expect(this.data.memberGroupSubscriptions).to.deep.equal(
  284. this.memberGroupSubscriptions
  285. )
  286. })
  287. it('should load the user', function () {
  288. expect(this.data.user).to.deep.equal(this.user)
  289. })
  290. it('should load the plans', function () {
  291. expect(this.data.plans).to.deep.equal(this.plans)
  292. })
  293. it('should load an empty list of groups with settings available', function () {
  294. expect(this.data.groupSettingsEnabledFor).to.deep.equal([])
  295. })
  296. })
  297. describe('updateAccountEmailAddress via put', function () {
  298. it('should send the user and subscriptionId to RecurlyWrapper', async function () {
  299. this.res.sendStatus = sinon.spy()
  300. await this.SubscriptionController.updateAccountEmailAddress(
  301. this.req,
  302. this.res
  303. )
  304. this.RecurlyWrapper.promises.updateAccountEmailAddress
  305. .calledWith(this.user._id, this.user.email)
  306. .should.equal(true)
  307. })
  308. it('should respond with 200', async function () {
  309. this.res.sendStatus = sinon.spy()
  310. await this.SubscriptionController.updateAccountEmailAddress(
  311. this.req,
  312. this.res
  313. )
  314. this.res.sendStatus.calledWith(200).should.equal(true)
  315. })
  316. it('should send the error to the next handler when updating recurly account email fails', async function () {
  317. this.RecurlyWrapper.promises.updateAccountEmailAddress.rejects(
  318. new Error()
  319. )
  320. this.next = sinon.spy(error => {
  321. expect(error).to.be.instanceOf(Error)
  322. })
  323. await this.SubscriptionController.updateAccountEmailAddress(
  324. this.req,
  325. this.res,
  326. this.next
  327. )
  328. expect(this.next.calledOnce).to.be.true
  329. })
  330. })
  331. describe('reactivateSubscription', function () {
  332. describe('when the user has permission', function () {
  333. beforeEach(function (done) {
  334. this.res = {
  335. redirect() {
  336. done()
  337. },
  338. }
  339. this.req.assertPermission = sinon.stub()
  340. this.next = sinon.stub().callsFake(error => {
  341. done(error)
  342. })
  343. sinon.spy(this.res, 'redirect')
  344. this.SubscriptionController.reactivateSubscription(
  345. this.req,
  346. this.res,
  347. this.next
  348. )
  349. })
  350. it('should assert the user has permission to reactivate their subscription', function (done) {
  351. this.req.assertPermission
  352. .calledWith('reactivate-subscription')
  353. .should.equal(true)
  354. done()
  355. })
  356. it('should tell the handler to reactivate this user', function (done) {
  357. this.SubscriptionHandler.reactivateSubscription
  358. .calledWith(this.user)
  359. .should.equal(true)
  360. done()
  361. })
  362. it('should redurect to the subscription page', function (done) {
  363. this.res.redirect.calledWith('/user/subscription').should.equal(true)
  364. done()
  365. })
  366. })
  367. describe('when the user does not have permission', function () {
  368. beforeEach(function (done) {
  369. this.res = {
  370. redirect() {
  371. done()
  372. },
  373. }
  374. this.req.assertPermission = sinon.stub().throws()
  375. this.next = sinon.stub().callsFake(() => {
  376. done()
  377. })
  378. sinon.spy(this.res, 'redirect')
  379. this.SubscriptionController.reactivateSubscription(
  380. this.req,
  381. this.res,
  382. this.next
  383. )
  384. })
  385. it('should not reactivate the user', function (done) {
  386. this.req.assertPermission = sinon.stub().throws()
  387. this.SubscriptionHandler.reactivateSubscription.called.should.equal(
  388. false
  389. )
  390. done()
  391. })
  392. it('should call next with an error', function (done) {
  393. this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
  394. done()
  395. })
  396. })
  397. })
  398. describe('pauseSubscription', function () {
  399. it('should throw an error if no pause length is provided', async function () {
  400. this.res = new MockResponse()
  401. this.req = new MockRequest()
  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 throw an error if an invalid pause length is provided', async function () {
  411. this.res = new MockResponse()
  412. this.req = new MockRequest()
  413. this.req.params = { pauseCycles: -10 }
  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(400)
  421. })
  422. it('should return a 200 when requesting a pause', async function () {
  423. this.res = new MockResponse()
  424. this.req = new MockRequest()
  425. this.req.params = { pauseCycles: 3 }
  426. this.next = sinon.stub()
  427. await this.SubscriptionController.pauseSubscription(
  428. this.req,
  429. this.res,
  430. this.next
  431. )
  432. expect(this.res.statusCode).to.equal(200)
  433. })
  434. })
  435. describe('resumeSubscription', function () {
  436. it('should return a 200 when resuming a subscription', async function () {
  437. this.res = new MockResponse()
  438. this.req = new MockRequest()
  439. this.next = sinon.stub()
  440. await this.SubscriptionController.resumeSubscription(
  441. this.req,
  442. this.res,
  443. this.next
  444. )
  445. expect(this.res.statusCode).to.equal(200)
  446. })
  447. })
  448. describe('cancelSubscription', function () {
  449. beforeEach(function (done) {
  450. this.res = {
  451. redirect() {
  452. done()
  453. },
  454. }
  455. sinon.spy(this.res, 'redirect')
  456. this.SubscriptionController.cancelSubscription(this.req, this.res)
  457. })
  458. it('should tell the handler to cancel this user', function (done) {
  459. this.SubscriptionHandler.cancelSubscription
  460. .calledWith(this.user)
  461. .should.equal(true)
  462. done()
  463. })
  464. it('should redurect to the subscription page', function (done) {
  465. this.res.redirect
  466. .calledWith('/user/subscription/canceled')
  467. .should.equal(true)
  468. done()
  469. })
  470. })
  471. describe('recurly callback', function () {
  472. describe('with a sync subscription request', function () {
  473. beforeEach(function (done) {
  474. this.req = {
  475. body: {
  476. expired_subscription_notification: {
  477. account: {
  478. account_code: this.user._id,
  479. },
  480. subscription: {
  481. uuid: this.activeRecurlySubscription.uuid,
  482. plan: {
  483. plan_code: 'collaborator',
  484. state: 'active',
  485. },
  486. },
  487. },
  488. },
  489. }
  490. this.res = {
  491. sendStatus() {
  492. done()
  493. },
  494. }
  495. sinon.spy(this.res, 'sendStatus')
  496. this.SubscriptionController.recurlyCallback(this.req, this.res)
  497. })
  498. it('should tell the SubscriptionHandler to process the recurly callback', function (done) {
  499. this.SubscriptionHandler.syncSubscription.called.should.equal(true)
  500. done()
  501. })
  502. it('should send a 200', function (done) {
  503. this.res.sendStatus.calledWith(200)
  504. done()
  505. })
  506. })
  507. describe('with a billing info updated request', function () {
  508. beforeEach(function (done) {
  509. this.req = {
  510. body: {
  511. billing_info_updated_notification: {
  512. account: {
  513. account_code: 'mock-account-code',
  514. },
  515. },
  516. },
  517. }
  518. this.res = {
  519. sendStatus() {
  520. done()
  521. },
  522. }
  523. sinon.spy(this.res, 'sendStatus')
  524. this.SubscriptionController.recurlyCallback(this.req, this.res)
  525. })
  526. it('should call attemptPaypalInvoiceCollection', function (done) {
  527. this.SubscriptionHandler.attemptPaypalInvoiceCollection
  528. .calledWith('mock-account-code')
  529. .should.equal(true)
  530. done()
  531. })
  532. it('should send a 200', function (done) {
  533. this.res.sendStatus.calledWith(200)
  534. done()
  535. })
  536. })
  537. describe('with a non-actionable request', function () {
  538. beforeEach(function (done) {
  539. this.user.id = this.activeRecurlySubscription.account.account_code
  540. this.req = {
  541. body: {
  542. renewed_subscription_notification: {
  543. account: {
  544. account_code: this.user._id,
  545. },
  546. subscription: {
  547. uuid: this.activeRecurlySubscription.uuid,
  548. plan: {
  549. plan_code: 'collaborator',
  550. state: 'active',
  551. },
  552. },
  553. },
  554. },
  555. }
  556. this.res = {
  557. sendStatus() {
  558. done()
  559. },
  560. }
  561. sinon.spy(this.res, 'sendStatus')
  562. this.SubscriptionController.recurlyCallback(this.req, this.res)
  563. })
  564. it('should not call the subscriptionshandler', function () {
  565. this.SubscriptionHandler.syncSubscription.called.should.equal(false)
  566. this.SubscriptionHandler.attemptPaypalInvoiceCollection.called.should.equal(
  567. false
  568. )
  569. })
  570. it('should respond with a 200 status', function () {
  571. this.res.sendStatus.calledWith(200)
  572. })
  573. })
  574. })
  575. })