SubscriptionControllerTests.js 18 KB

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