FeaturesUpdater.test.mjs 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918
  1. import { vi, expect } from 'vitest'
  2. import sinon from 'sinon'
  3. import mongodb from 'mongodb-legacy'
  4. import { AI_ADD_ON_CODE } from '../../../../app/src/Features/Subscription/AiHelper.mjs'
  5. const { ObjectId } = mongodb
  6. const MODULE_PATH = '../../../../app/src/Features/Subscription/FeaturesUpdater'
  7. describe('FeaturesUpdater', function () {
  8. beforeEach(async function (ctx) {
  9. ctx.renewalDate = new Date('2099-04-01T00:00:00Z')
  10. ctx.v1UserId = 12345
  11. ctx.user = {
  12. _id: new ObjectId(),
  13. features: {},
  14. overleaf: { id: ctx.v1UserId },
  15. }
  16. ctx.aiAddOn = { addOnCode: AI_ADD_ON_CODE, quantity: 1 }
  17. ctx.subscriptions = {
  18. individual: {
  19. planCode: 'individual-plan',
  20. groupPlan: false,
  21. recurlySubscription_id: 'sub-individual',
  22. recurlyStatus: { state: 'active' },
  23. },
  24. group1: { planCode: 'group-plan-1', groupPlan: true },
  25. group2: { planCode: 'group-plan-2', groupPlan: true },
  26. noDropbox: { planCode: 'no-dropbox' },
  27. individualPlusAiAddOn: {
  28. planCode: 'individual-plan',
  29. addOns: [ctx.aiAddOn],
  30. },
  31. groupPlusAiAddOn: {
  32. planCode: 'group-plan-1',
  33. groupPlan: true,
  34. addOns: [ctx.aiAddOn],
  35. },
  36. }
  37. ctx.UserFeaturesUpdater = {
  38. promises: {
  39. updateFeatures: sinon
  40. .stub()
  41. .resolves({ features: { some: 'features' }, featuresChanged: true }),
  42. },
  43. }
  44. ctx.SubscriptionLocator = {
  45. promises: {
  46. getUsersSubscription: sinon.stub(),
  47. getGroupSubscriptionsMemberOf: sinon.stub(),
  48. },
  49. }
  50. ctx.SubscriptionLocator.promises.getUsersSubscription
  51. .withArgs(ctx.user._id)
  52. .resolves(ctx.subscriptions.individual)
  53. ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
  54. .withArgs(ctx.user._id)
  55. .resolves([ctx.subscriptions.group1, ctx.subscriptions.group2])
  56. ctx.Settings = {
  57. defaultFeatures: { default: 'features' },
  58. plans: [
  59. { planCode: 'individual-plan', features: { individual: 'features' } },
  60. { planCode: 'group-plan-1', features: { group1: 'features' } },
  61. { planCode: 'group-plan-2', features: { group2: 'features' } },
  62. { planCode: 'no-dropbox', features: { dropbox: false } },
  63. ],
  64. features: {
  65. all: {
  66. default: 'features',
  67. individual: 'features',
  68. group1: 'features',
  69. group2: 'features',
  70. institutions: 'features',
  71. grandfathered: 'features',
  72. bonus: 'features',
  73. },
  74. },
  75. writefull: {
  76. overleafApiUrl: 'https://www.writefull.com',
  77. quotaTierGranted: 'unlimited',
  78. },
  79. aiFeatures: {
  80. freeQuota: 'free',
  81. standardQuota: 'standard',
  82. basicQuota: 'basic',
  83. unlimitedQuota: 'unlimited',
  84. },
  85. quotaGrants: {
  86. ai: {
  87. free: 5,
  88. basic: 5,
  89. standard: 10,
  90. unlimited: 200,
  91. },
  92. },
  93. }
  94. ctx.ReferalFeatures = {
  95. promises: {
  96. getBonusFeatures: sinon.stub().resolves({ bonus: 'features' }),
  97. },
  98. }
  99. ctx.V1SubscriptionManager = {
  100. getGrandfatheredFeaturesForV1User: sinon.stub(),
  101. }
  102. ctx.V1SubscriptionManager.getGrandfatheredFeaturesForV1User
  103. .withArgs(ctx.v1UserId)
  104. .returns({ grandfathered: 'features' })
  105. ctx.InstitutionsFeatures = {
  106. promises: {
  107. getInstitutionsFeatures: sinon
  108. .stub()
  109. .resolves({ institutions: 'features' }),
  110. },
  111. }
  112. ctx.UserGetter = {
  113. promises: {
  114. getUser: sinon.stub().resolves(null),
  115. getWritefullData: sinon.stub().resolves(null),
  116. },
  117. }
  118. ctx.UserGetter.promises.getUser.withArgs(ctx.user._id).resolves(ctx.user)
  119. ctx.UserGetter.promises.getUser
  120. .withArgs({ 'overleaf.id': ctx.v1UserId })
  121. .resolves(ctx.user)
  122. ctx.AnalyticsManager = {
  123. setUserPropertyForUserInBackground: sinon.stub(),
  124. }
  125. ctx.Modules = {
  126. promises: { hooks: { fire: sinon.stub().resolves([]) } },
  127. }
  128. ctx.Modules.promises.hooks.fire
  129. .withArgs('getPaymentFromRecordPromise', ctx.subscriptions.individual)
  130. .resolves([
  131. {
  132. subscription: {
  133. state: 'active',
  134. periodEnd: ctx.renewalDate,
  135. },
  136. },
  137. ])
  138. ctx.SubscriptionViewModelBuilder = {
  139. promises: {
  140. getUsersSubscriptionDetails: sinon.stub().resolves({
  141. bestSubscription: { type: 'individual' },
  142. individualSubscription: ctx.subscriptions.individual,
  143. memberGroupSubscriptions: [],
  144. managedGroupSubscriptions: [],
  145. }),
  146. },
  147. }
  148. ctx.Queues = {
  149. getQueue: sinon.stub().returns({
  150. add: sinon.stub().resolves(),
  151. }),
  152. }
  153. ctx.SplitTestHandler = {
  154. promises: {
  155. featureFlagEnabledForUser: sinon.stub().resolves(false),
  156. },
  157. }
  158. ctx.GroupPolicy = {
  159. find: sinon.stub().returns({ exec: sinon.stub().resolves([]) }),
  160. }
  161. vi.doMock(
  162. '../../../../app/src/Features/Subscription/UserFeaturesUpdater',
  163. () => ({
  164. default: ctx.UserFeaturesUpdater,
  165. })
  166. )
  167. vi.doMock(
  168. '../../../../app/src/Features/Subscription/SubscriptionLocator',
  169. () => ({
  170. default: ctx.SubscriptionLocator,
  171. })
  172. )
  173. vi.doMock('@overleaf/settings', () => ({
  174. default: ctx.Settings,
  175. }))
  176. vi.doMock('../../../../app/src/Features/Referal/ReferalFeatures', () => ({
  177. default: ctx.ReferalFeatures,
  178. }))
  179. vi.doMock(
  180. '../../../../app/src/Features/Subscription/V1SubscriptionManager',
  181. () => ({
  182. default: ctx.V1SubscriptionManager,
  183. })
  184. )
  185. vi.doMock(
  186. '../../../../app/src/Features/Institutions/InstitutionsFeatures',
  187. () => ({
  188. default: ctx.InstitutionsFeatures,
  189. })
  190. )
  191. vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
  192. default: ctx.UserGetter,
  193. }))
  194. vi.doMock(
  195. '../../../../app/src/Features/Analytics/AnalyticsManager',
  196. () => ({
  197. default: ctx.AnalyticsManager,
  198. })
  199. )
  200. vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
  201. default: ctx.Modules,
  202. }))
  203. vi.doMock(
  204. '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder',
  205. () => ({
  206. default: ctx.SubscriptionViewModelBuilder,
  207. })
  208. )
  209. vi.doMock('../../../../app/src/infrastructure/Queues', () => ({
  210. default: ctx.Queues,
  211. }))
  212. vi.doMock('../../../../app/src/models/Subscription', () => ({}))
  213. vi.doMock('../../../../app/src/models/GroupPolicy', () => ({
  214. GroupPolicy: ctx.GroupPolicy,
  215. }))
  216. vi.doMock('@overleaf/fetch-utils', () => ({
  217. fetchNothing: sinon.stub().resolves(),
  218. }))
  219. vi.doMock(
  220. '../../../../app/src/Features/SplitTests/SplitTestHandler',
  221. () => ({
  222. default: ctx.SplitTestHandler,
  223. })
  224. )
  225. ctx.FeaturesUpdater = (await import(MODULE_PATH)).default
  226. })
  227. describe('computeFeatures', function () {
  228. describe('when userFeaturesDisabled is true for individual plan', function () {
  229. beforeEach(function (ctx) {
  230. ctx.SubscriptionLocator.promises.getUsersSubscription
  231. .withArgs(ctx.user._id)
  232. .resolves({
  233. planCode: 'individual-plan',
  234. userFeaturesDisabled: true,
  235. groupPlan: false,
  236. addOns: [ctx.aiAddOn],
  237. })
  238. })
  239. it('removes all individual plan features', async function (ctx) {
  240. const features = await ctx.FeaturesUpdater.promises.computeFeatures(
  241. ctx.user._id
  242. )
  243. expect(features).to.deep.equal({ default: 'features' })
  244. })
  245. })
  246. describe('when userFeaturesDisabled is true for group plan', function () {
  247. beforeEach(function (ctx) {
  248. const groupSubscription = {
  249. planCode: 'group-plan-1',
  250. userFeaturesDisabled: true,
  251. groupPlan: true,
  252. addOns: [ctx.aiAddOn],
  253. }
  254. ctx.SubscriptionLocator.promises.getUsersSubscription
  255. .withArgs(ctx.user._id)
  256. .resolves(groupSubscription)
  257. ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
  258. .withArgs(ctx.user._id)
  259. .resolves([groupSubscription])
  260. })
  261. it('removes all group plan features', async function (ctx) {
  262. const features = await ctx.FeaturesUpdater.promises.computeFeatures(
  263. ctx.user._id
  264. )
  265. expect(features).to.deep.equal({ default: 'features' })
  266. })
  267. })
  268. beforeEach(function (ctx) {
  269. ctx.SubscriptionLocator.promises.getUsersSubscription
  270. .withArgs(ctx.user._id)
  271. .resolves(null)
  272. ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
  273. .withArgs(ctx.user._id)
  274. .resolves([])
  275. ctx.ReferalFeatures.promises.getBonusFeatures.resolves({})
  276. ctx.V1SubscriptionManager.getGrandfatheredFeaturesForV1User
  277. .withArgs(ctx.v1UserId)
  278. .returns({})
  279. ctx.InstitutionsFeatures.promises.getInstitutionsFeatures.resolves({})
  280. })
  281. describe('individual subscriber', function () {
  282. beforeEach(function (ctx) {
  283. ctx.SubscriptionLocator.promises.getUsersSubscription
  284. .withArgs(ctx.user._id)
  285. .resolves(ctx.subscriptions.individual)
  286. })
  287. it('returns the individual features', async function (ctx) {
  288. const features = await ctx.FeaturesUpdater.promises.computeFeatures(
  289. ctx.user._id
  290. )
  291. expect(features).to.deep.equal({
  292. default: 'features',
  293. individual: 'features',
  294. })
  295. })
  296. })
  297. describe('group admin', function () {
  298. beforeEach(function (ctx) {
  299. ctx.SubscriptionLocator.promises.getUsersSubscription
  300. .withArgs(ctx.user._id)
  301. .resolves(ctx.subscriptions.group1)
  302. })
  303. it("doesn't return the group features", async function (ctx) {
  304. const features = await ctx.FeaturesUpdater.promises.computeFeatures(
  305. ctx.user._id
  306. )
  307. expect(features).to.deep.equal({
  308. default: 'features',
  309. })
  310. })
  311. })
  312. describe('group member', function () {
  313. beforeEach(function (ctx) {
  314. ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
  315. .withArgs(ctx.user._id)
  316. .resolves([ctx.subscriptions.group1])
  317. })
  318. it('returns the group features', async function (ctx) {
  319. const features = await ctx.FeaturesUpdater.promises.computeFeatures(
  320. ctx.user._id
  321. )
  322. expect(features).to.deep.equal({
  323. default: 'features',
  324. group1: 'features',
  325. })
  326. })
  327. })
  328. describe('individual subscription + AI add-on', function () {
  329. beforeEach(function (ctx) {
  330. ctx.SubscriptionLocator.promises.getUsersSubscription
  331. .withArgs(ctx.user._id)
  332. .resolves(ctx.subscriptions.individualPlusAiAddOn)
  333. })
  334. it('returns the individual features and the AI error assistant', async function (ctx) {
  335. const features = await ctx.FeaturesUpdater.promises.computeFeatures(
  336. ctx.user._id
  337. )
  338. expect(features).to.deep.equal({
  339. default: 'features',
  340. individual: 'features',
  341. aiErrorAssistant: true,
  342. aiUsageQuota: 'unlimited',
  343. })
  344. })
  345. })
  346. describe('group admin + AI add-on', function () {
  347. beforeEach(function (ctx) {
  348. ctx.SubscriptionLocator.promises.getUsersSubscription
  349. .withArgs(ctx.user._id)
  350. .resolves(ctx.subscriptions.groupPlusAiAddOn)
  351. })
  352. it('returns the AI error assistant only', async function (ctx) {
  353. const features = await ctx.FeaturesUpdater.promises.computeFeatures(
  354. ctx.user._id
  355. )
  356. expect(features).to.deep.equal({
  357. default: 'features',
  358. aiErrorAssistant: true,
  359. aiUsageQuota: 'unlimited',
  360. })
  361. })
  362. })
  363. describe('group member + AI add-on', function () {
  364. beforeEach(function (ctx) {
  365. ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
  366. .withArgs(ctx.user._id)
  367. .resolves([ctx.subscriptions.groupPlusAiAddOn])
  368. })
  369. it('returns the group features without the AI features', async function (ctx) {
  370. const features = await ctx.FeaturesUpdater.promises.computeFeatures(
  371. ctx.user._id
  372. )
  373. expect(features).to.deep.equal({
  374. default: 'features',
  375. group1: 'features',
  376. })
  377. })
  378. })
  379. })
  380. describe('refreshFeatures', function () {
  381. it('should return features and featuresChanged', async function (ctx) {
  382. const { features, featuresChanged } =
  383. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  384. expect(features).to.exist
  385. expect(featuresChanged).to.exist
  386. })
  387. describe('normally', function () {
  388. beforeEach(async function (ctx) {
  389. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  390. })
  391. it('should update the user with the merged features', function (ctx) {
  392. expect(
  393. ctx.UserFeaturesUpdater.promises.updateFeatures
  394. ).to.have.been.calledWith(ctx.user._id, ctx.Settings.features.all)
  395. })
  396. it('should send the corresponding feature set user property', function (ctx) {
  397. expect(
  398. ctx.AnalyticsManager.setUserPropertyForUserInBackground
  399. ).to.have.been.calledWith(ctx.user._id, 'feature-set', 'all')
  400. })
  401. it('should sync subscription properties to customer.io', function (ctx) {
  402. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  403. 'setUserProperties',
  404. ctx.user._id,
  405. sinon.match({
  406. plan_type: 'individual',
  407. display_plan_type: 'individual',
  408. ai_plan: 'none',
  409. next_renewal_date: Math.floor(ctx.renewalDate.getTime() / 1000),
  410. expiry_date: '',
  411. group: false,
  412. commons: false,
  413. individual_subscription: true,
  414. payment_provider: 'recurly',
  415. features: sinon.match.object,
  416. })
  417. )
  418. })
  419. it('should not set trial_end_date when no trial is active', function (ctx) {
  420. const call = ctx.Modules.promises.hooks.fire
  421. .getCalls()
  422. .find(c => c.args[0] === 'setUserProperties')
  423. expect(call).to.exist
  424. expect(call.args[2]).to.not.have.property('trial_end_date')
  425. })
  426. })
  427. describe('when the individual subscription is on a trial', function () {
  428. beforeEach(async function (ctx) {
  429. ctx.trialEndsAt = new Date('2099-05-01T00:00:00Z')
  430. const trialingSubscription = {
  431. ...ctx.subscriptions.individual,
  432. recurlyStatus: {
  433. state: 'active',
  434. trialEndsAt: ctx.trialEndsAt,
  435. },
  436. }
  437. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  438. {
  439. bestSubscription: { type: 'individual' },
  440. individualSubscription: trialingSubscription,
  441. memberGroupSubscriptions: [],
  442. managedGroupSubscriptions: [],
  443. }
  444. )
  445. ctx.Modules.promises.hooks.fire
  446. .withArgs('getPaymentFromRecordPromise', trialingSubscription)
  447. .resolves([
  448. {
  449. subscription: {
  450. state: 'active',
  451. periodEnd: ctx.renewalDate,
  452. },
  453. },
  454. ])
  455. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  456. })
  457. it('should sync trial_end_date to customer.io', function (ctx) {
  458. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  459. 'setUserProperties',
  460. ctx.user._id,
  461. sinon.match({
  462. trial_end_date: Math.floor(ctx.trialEndsAt.getTime() / 1000),
  463. })
  464. )
  465. })
  466. })
  467. describe('when the individual subscription uses stripe', function () {
  468. beforeEach(async function (ctx) {
  469. const stripeSubscription = {
  470. ...ctx.subscriptions.individual,
  471. paymentProvider: { service: 'stripe-us', state: 'active' },
  472. }
  473. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  474. {
  475. bestSubscription: { type: 'individual' },
  476. individualSubscription: stripeSubscription,
  477. memberGroupSubscriptions: [],
  478. managedGroupSubscriptions: [],
  479. }
  480. )
  481. ctx.Modules.promises.hooks.fire
  482. .withArgs('getPaymentFromRecordPromise', stripeSubscription)
  483. .resolves([
  484. {
  485. subscription: {
  486. state: 'active',
  487. periodEnd: ctx.renewalDate,
  488. },
  489. },
  490. ])
  491. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  492. })
  493. it('should report stripe as the payment_provider', function (ctx) {
  494. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  495. 'setUserProperties',
  496. ctx.user._id,
  497. sinon.match({
  498. payment_provider: 'stripe',
  499. individual_subscription: true,
  500. })
  501. )
  502. })
  503. })
  504. describe('when the user has a commons institution licence', function () {
  505. beforeEach(async function (ctx) {
  506. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  507. {
  508. bestSubscription: { type: 'commons' },
  509. individualSubscription: null,
  510. memberGroupSubscriptions: [],
  511. managedGroupSubscriptions: [],
  512. currentInstitutionsWithLicence: [{ id: 1, name: 'Uni' }],
  513. }
  514. )
  515. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  516. })
  517. it('should sync commons=true to customer.io', function (ctx) {
  518. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  519. 'setUserProperties',
  520. ctx.user._id,
  521. sinon.match({
  522. commons: true,
  523. group: false,
  524. individual_subscription: false,
  525. })
  526. )
  527. })
  528. })
  529. describe('when the user has commons and an individual AI add-on', function () {
  530. beforeEach(async function (ctx) {
  531. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  532. {
  533. bestSubscription: {
  534. type: 'individual',
  535. plan: { planCode: 'individual-plan' },
  536. },
  537. individualSubscription: ctx.subscriptions.individual,
  538. memberGroupSubscriptions: [],
  539. managedGroupSubscriptions: [],
  540. currentInstitutionsWithLicence: [{ id: 1, name: 'Uni' }],
  541. }
  542. )
  543. ctx.Modules.promises.hooks.fire
  544. .withArgs('getPaymentFromRecordPromise', ctx.subscriptions.individual)
  545. .resolves([
  546. {
  547. subscription: {
  548. state: 'active',
  549. periodEnd: ctx.renewalDate,
  550. addOns: [{ code: AI_ADD_ON_CODE }],
  551. },
  552. },
  553. ])
  554. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  555. })
  556. it('should set commons, individual_subscription, and ai-assist-add-on together', function (ctx) {
  557. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  558. 'setUserProperties',
  559. ctx.user._id,
  560. sinon.match({
  561. commons: true,
  562. individual_subscription: true,
  563. group: false,
  564. ai_plan: 'ai-assist-add-on',
  565. })
  566. )
  567. })
  568. })
  569. describe('when the user has no subscription', function () {
  570. beforeEach(async function (ctx) {
  571. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  572. {
  573. bestSubscription: null,
  574. individualSubscription: null,
  575. memberGroupSubscriptions: [],
  576. managedGroupSubscriptions: [],
  577. }
  578. )
  579. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  580. })
  581. it('should sync false subscription flags and no payment_provider', function (ctx) {
  582. const call = ctx.Modules.promises.hooks.fire
  583. .getCalls()
  584. .find(c => c.args[0] === 'setUserProperties')
  585. expect(call).to.exist
  586. expect(call.args[2]).to.include({
  587. group: false,
  588. commons: false,
  589. individual_subscription: false,
  590. })
  591. expect(call.args[2]).to.not.have.property('payment_provider')
  592. expect(call.args[2]).to.not.have.property('trial_end_date')
  593. })
  594. })
  595. describe('when the individual subscription has a pending cancellation', function () {
  596. beforeEach(async function (ctx) {
  597. const pendingCancellationSubscription = {
  598. ...ctx.subscriptions.individual,
  599. recurlyStatus: { state: 'canceled' },
  600. }
  601. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  602. {
  603. bestSubscription: { type: 'individual' },
  604. individualSubscription: pendingCancellationSubscription,
  605. memberGroupSubscriptions: [],
  606. managedGroupSubscriptions: [],
  607. }
  608. )
  609. ctx.Modules.promises.hooks.fire
  610. .withArgs(
  611. 'getPaymentFromRecordPromise',
  612. pendingCancellationSubscription
  613. )
  614. .resolves([
  615. {
  616. subscription: {
  617. state: 'canceled',
  618. periodEnd: ctx.renewalDate,
  619. },
  620. },
  621. ])
  622. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  623. })
  624. it('should sync expiry_date and blank next_renewal_date in customer.io', function (ctx) {
  625. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  626. 'setUserProperties',
  627. ctx.user._id,
  628. sinon.match({
  629. plan_type: 'individual',
  630. display_plan_type: 'individual',
  631. ai_plan: 'none',
  632. next_renewal_date: '',
  633. expiry_date: Math.floor(ctx.renewalDate.getTime() / 1000),
  634. features: sinon.match.object,
  635. })
  636. )
  637. })
  638. })
  639. describe('when the user is in a group subscription', function () {
  640. beforeEach(async function (ctx) {
  641. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  642. {
  643. bestSubscription: {
  644. type: 'group',
  645. plan: {
  646. planCode: 'group-plan-1',
  647. groupPlan: true,
  648. membersLimit: 5,
  649. },
  650. subscription: {
  651. teamName: 'Team Alpha',
  652. },
  653. },
  654. memberGroupSubscriptions: [
  655. {
  656. planCode: 'group-plan-1',
  657. teamName: 'Team Alpha',
  658. membersLimit: 8,
  659. },
  660. ],
  661. managedGroupSubscriptions: [],
  662. individualSubscription: null,
  663. }
  664. )
  665. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  666. })
  667. it('should sync groupSize to customer.io', function (ctx) {
  668. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  669. 'setUserProperties',
  670. ctx.user._id,
  671. sinon.match({
  672. plan_type: 'group-standard',
  673. display_plan_type: 'Group Standard',
  674. plan_term_label: 'monthly',
  675. ai_plan: 'none',
  676. group_ai_enabled: true,
  677. group_size: 8,
  678. next_renewal_date: '',
  679. expiry_date: '',
  680. group: true,
  681. commons: false,
  682. individual_subscription: false,
  683. payment_provider: 'recurly',
  684. features: sinon.match.object,
  685. overleaf_id: ctx.user._id,
  686. })
  687. )
  688. })
  689. })
  690. describe('when the group subscription has a policy that blocks AI', function () {
  691. beforeEach(async function (ctx) {
  692. const policyId = new ObjectId()
  693. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  694. {
  695. bestSubscription: {
  696. type: 'group',
  697. plan: {
  698. planCode: 'group-plan-1',
  699. groupPlan: true,
  700. membersLimit: 5,
  701. },
  702. subscription: { teamName: 'Team Alpha' },
  703. },
  704. memberGroupSubscriptions: [
  705. {
  706. planCode: 'group-plan-1',
  707. teamName: 'Team Alpha',
  708. membersLimit: 8,
  709. groupPolicy: policyId,
  710. },
  711. ],
  712. managedGroupSubscriptions: [],
  713. individualSubscription: null,
  714. }
  715. )
  716. ctx.GroupPolicy.find.returns({
  717. exec: sinon
  718. .stub()
  719. .resolves([{ _id: policyId, userCannotUseAIFeatures: true }]),
  720. })
  721. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  722. })
  723. it('should set group_ai_enabled to false', function (ctx) {
  724. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  725. 'setUserProperties',
  726. ctx.user._id,
  727. sinon.match({ group_ai_enabled: false })
  728. )
  729. })
  730. })
  731. describe('when the user is in a stripe group subscription', function () {
  732. beforeEach(async function (ctx) {
  733. ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
  734. {
  735. bestSubscription: {
  736. type: 'group',
  737. plan: {
  738. planCode: 'group-plan-1',
  739. groupPlan: true,
  740. membersLimit: 5,
  741. },
  742. subscription: {
  743. teamName: 'Team Alpha',
  744. },
  745. },
  746. memberGroupSubscriptions: [
  747. {
  748. planCode: 'group-plan-1',
  749. teamName: 'Team Alpha',
  750. membersLimit: 8,
  751. paymentProvider: { service: 'stripe-uk' },
  752. },
  753. ],
  754. managedGroupSubscriptions: [],
  755. individualSubscription: null,
  756. }
  757. )
  758. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  759. })
  760. it('should derive payment_provider from the group subscription', function (ctx) {
  761. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  762. 'setUserProperties',
  763. ctx.user._id,
  764. sinon.match({
  765. payment_provider: 'stripe',
  766. group: true,
  767. individual_subscription: false,
  768. })
  769. )
  770. })
  771. })
  772. describe('with a non-standard feature set', async function () {
  773. beforeEach(async function (ctx) {
  774. ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
  775. .withArgs(ctx.user._id)
  776. .resolves(null)
  777. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  778. })
  779. it('should send mixed feature set user property', function (ctx) {
  780. sinon.assert.calledWith(
  781. ctx.AnalyticsManager.setUserPropertyForUserInBackground,
  782. ctx.user._id,
  783. 'feature-set',
  784. 'mixed'
  785. )
  786. })
  787. })
  788. describe('when losing dropbox feature', async function () {
  789. beforeEach(async function (ctx) {
  790. ctx.user.features = { dropbox: true }
  791. ctx.SubscriptionLocator.promises.getUsersSubscription
  792. .withArgs(ctx.user._id)
  793. .resolves(ctx.subscriptions.noDropbox)
  794. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  795. })
  796. it('should fire module hook to unlink dropbox', function (ctx) {
  797. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  798. 'removeDropbox',
  799. ctx.user._id,
  800. 'test'
  801. )
  802. })
  803. })
  804. })
  805. describe('doSyncFromV1', function () {
  806. describe('when all goes well', function () {
  807. beforeEach(async function (ctx) {
  808. await ctx.FeaturesUpdater.promises.doSyncFromV1(ctx.v1UserId)
  809. })
  810. it('should update the user with the merged features', function (ctx) {
  811. expect(
  812. ctx.UserFeaturesUpdater.promises.updateFeatures
  813. ).to.have.been.calledWith(ctx.user._id, ctx.Settings.features.all)
  814. })
  815. })
  816. describe('when getUser produces an error', function () {
  817. beforeEach(function (ctx) {
  818. ctx.UserGetter.promises.getUser.rejects(new Error('woops'))
  819. })
  820. it('should propagate the error', async function (ctx) {
  821. const someId = 9090
  822. await expect(ctx.FeaturesUpdater.promises.doSyncFromV1(someId)).to.be
  823. .rejected
  824. expect(ctx.UserFeaturesUpdater.promises.updateFeatures).not.to.have.been
  825. .called
  826. })
  827. })
  828. describe('when getUser does not find a user', function () {
  829. beforeEach(async function (ctx) {
  830. const someOtherId = 987
  831. await ctx.FeaturesUpdater.promises.doSyncFromV1(someOtherId)
  832. })
  833. it('should not update the user', function (ctx) {
  834. expect(ctx.UserFeaturesUpdater.promises.updateFeatures).not.to.have.been
  835. .called
  836. })
  837. })
  838. })
  839. })