FeaturesUpdater.test.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  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.v1UserId = 12345
  10. ctx.user = {
  11. _id: new ObjectId(),
  12. features: {},
  13. overleaf: { id: ctx.v1UserId },
  14. }
  15. ctx.aiAddOn = { addOnCode: AI_ADD_ON_CODE, quantity: 1 }
  16. ctx.subscriptions = {
  17. individual: { planCode: 'individual-plan' },
  18. group1: { planCode: 'group-plan-1', groupPlan: true },
  19. group2: { planCode: 'group-plan-2', groupPlan: true },
  20. noDropbox: { planCode: 'no-dropbox' },
  21. individualPlusAiAddOn: {
  22. planCode: 'individual-plan',
  23. addOns: [ctx.aiAddOn],
  24. },
  25. groupPlusAiAddOn: {
  26. planCode: 'group-plan-1',
  27. groupPlan: true,
  28. addOns: [ctx.aiAddOn],
  29. },
  30. }
  31. ctx.UserFeaturesUpdater = {
  32. promises: {
  33. updateFeatures: sinon
  34. .stub()
  35. .resolves({ features: { some: 'features' }, featuresChanged: true }),
  36. },
  37. }
  38. ctx.SubscriptionLocator = {
  39. promises: {
  40. getUsersSubscription: sinon.stub(),
  41. getGroupSubscriptionsMemberOf: sinon.stub(),
  42. },
  43. }
  44. ctx.SubscriptionLocator.promises.getUsersSubscription
  45. .withArgs(ctx.user._id)
  46. .resolves(ctx.subscriptions.individual)
  47. ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
  48. .withArgs(ctx.user._id)
  49. .resolves([ctx.subscriptions.group1, ctx.subscriptions.group2])
  50. ctx.Settings = {
  51. defaultFeatures: { default: 'features' },
  52. plans: [
  53. { planCode: 'individual-plan', features: { individual: 'features' } },
  54. { planCode: 'group-plan-1', features: { group1: 'features' } },
  55. { planCode: 'group-plan-2', features: { group2: 'features' } },
  56. { planCode: 'no-dropbox', features: { dropbox: false } },
  57. ],
  58. features: {
  59. all: {
  60. default: 'features',
  61. individual: 'features',
  62. group1: 'features',
  63. group2: 'features',
  64. institutions: 'features',
  65. grandfathered: 'features',
  66. bonus: 'features',
  67. },
  68. },
  69. writefull: {
  70. overleafApiUrl: 'https://www.writefull.com',
  71. quotaTierGranted: 'unlimited',
  72. },
  73. aiFeatures: {
  74. freeTrialQuota: 'basic',
  75. unlimitedQuota: 'unlimited',
  76. },
  77. }
  78. ctx.ReferalFeatures = {
  79. promises: {
  80. getBonusFeatures: sinon.stub().resolves({ bonus: 'features' }),
  81. },
  82. }
  83. ctx.V1SubscriptionManager = {
  84. getGrandfatheredFeaturesForV1User: sinon.stub(),
  85. }
  86. ctx.V1SubscriptionManager.getGrandfatheredFeaturesForV1User
  87. .withArgs(ctx.v1UserId)
  88. .returns({ grandfathered: 'features' })
  89. ctx.InstitutionsFeatures = {
  90. promises: {
  91. getInstitutionsFeatures: sinon
  92. .stub()
  93. .resolves({ institutions: 'features' }),
  94. },
  95. }
  96. ctx.UserGetter = {
  97. promises: {
  98. getUser: sinon.stub().resolves(null),
  99. },
  100. }
  101. ctx.UserGetter.promises.getUser.withArgs(ctx.user._id).resolves(ctx.user)
  102. ctx.UserGetter.promises.getUser
  103. .withArgs({ 'overleaf.id': ctx.v1UserId })
  104. .resolves(ctx.user)
  105. ctx.AnalyticsManager = {
  106. setUserPropertyForUserInBackground: sinon.stub(),
  107. }
  108. ctx.Modules = {
  109. promises: { hooks: { fire: sinon.stub().resolves() } },
  110. }
  111. ctx.SubscriptionViewModelBuilder = {
  112. promises: {
  113. getUsersSubscriptionDetails: sinon.stub().resolves({
  114. bestSubscription: { type: 'individual' },
  115. }),
  116. },
  117. }
  118. ctx.Queues = {
  119. getQueue: sinon.stub().returns({
  120. add: sinon.stub().resolves(),
  121. }),
  122. }
  123. vi.doMock(
  124. '../../../../app/src/Features/Subscription/UserFeaturesUpdater',
  125. () => ({
  126. default: ctx.UserFeaturesUpdater,
  127. })
  128. )
  129. vi.doMock(
  130. '../../../../app/src/Features/Subscription/SubscriptionLocator',
  131. () => ({
  132. default: ctx.SubscriptionLocator,
  133. })
  134. )
  135. vi.doMock('@overleaf/settings', () => ({
  136. default: ctx.Settings,
  137. }))
  138. vi.doMock('../../../../app/src/Features/Referal/ReferalFeatures', () => ({
  139. default: ctx.ReferalFeatures,
  140. }))
  141. vi.doMock(
  142. '../../../../app/src/Features/Subscription/V1SubscriptionManager',
  143. () => ({
  144. default: ctx.V1SubscriptionManager,
  145. })
  146. )
  147. vi.doMock(
  148. '../../../../app/src/Features/Institutions/InstitutionsFeatures',
  149. () => ({
  150. default: ctx.InstitutionsFeatures,
  151. })
  152. )
  153. vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
  154. default: ctx.UserGetter,
  155. }))
  156. vi.doMock(
  157. '../../../../app/src/Features/Analytics/AnalyticsManager',
  158. () => ({
  159. default: ctx.AnalyticsManager,
  160. })
  161. )
  162. vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
  163. default: ctx.Modules,
  164. }))
  165. vi.doMock(
  166. '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder',
  167. () => ({
  168. default: ctx.SubscriptionViewModelBuilder,
  169. })
  170. )
  171. vi.doMock('../../../../app/src/infrastructure/Queues', () => ({
  172. default: ctx.Queues,
  173. }))
  174. vi.doMock('../../../../app/src/models/Subscription', () => ({}))
  175. vi.doMock('@overleaf/fetch-utils', () => ({
  176. fetchNothing: sinon.stub().resolves(),
  177. }))
  178. ctx.FeaturesUpdater = (await import(MODULE_PATH)).default
  179. })
  180. describe('computeFeatures', function () {
  181. describe('when userFeaturesDisabled is true for individual plan', function () {
  182. beforeEach(function (ctx) {
  183. ctx.SubscriptionLocator.promises.getUsersSubscription
  184. .withArgs(ctx.user._id)
  185. .resolves({
  186. planCode: 'individual-plan',
  187. userFeaturesDisabled: true,
  188. groupPlan: false,
  189. addOns: [ctx.aiAddOn],
  190. })
  191. })
  192. it('removes all individual plan features', async function (ctx) {
  193. const features = await ctx.FeaturesUpdater.promises.computeFeatures(
  194. ctx.user._id
  195. )
  196. expect(features).to.deep.equal({ default: 'features' })
  197. })
  198. })
  199. describe('when userFeaturesDisabled is true for group plan', function () {
  200. beforeEach(function (ctx) {
  201. const groupSubscription = {
  202. planCode: 'group-plan-1',
  203. userFeaturesDisabled: true,
  204. groupPlan: true,
  205. addOns: [ctx.aiAddOn],
  206. }
  207. ctx.SubscriptionLocator.promises.getUsersSubscription
  208. .withArgs(ctx.user._id)
  209. .resolves(groupSubscription)
  210. ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
  211. .withArgs(ctx.user._id)
  212. .resolves([groupSubscription])
  213. })
  214. it('removes all group plan features', async function (ctx) {
  215. const features = await ctx.FeaturesUpdater.promises.computeFeatures(
  216. ctx.user._id
  217. )
  218. expect(features).to.deep.equal({ default: 'features' })
  219. })
  220. })
  221. beforeEach(function (ctx) {
  222. ctx.SubscriptionLocator.promises.getUsersSubscription
  223. .withArgs(ctx.user._id)
  224. .resolves(null)
  225. ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
  226. .withArgs(ctx.user._id)
  227. .resolves([])
  228. ctx.ReferalFeatures.promises.getBonusFeatures.resolves({})
  229. ctx.V1SubscriptionManager.getGrandfatheredFeaturesForV1User
  230. .withArgs(ctx.v1UserId)
  231. .returns({})
  232. ctx.InstitutionsFeatures.promises.getInstitutionsFeatures.resolves({})
  233. })
  234. describe('individual subscriber', function () {
  235. beforeEach(function (ctx) {
  236. ctx.SubscriptionLocator.promises.getUsersSubscription
  237. .withArgs(ctx.user._id)
  238. .resolves(ctx.subscriptions.individual)
  239. })
  240. it('returns the individual features', async function (ctx) {
  241. const features = await ctx.FeaturesUpdater.promises.computeFeatures(
  242. ctx.user._id
  243. )
  244. expect(features).to.deep.equal({
  245. default: 'features',
  246. individual: 'features',
  247. })
  248. })
  249. })
  250. describe('group admin', function () {
  251. beforeEach(function (ctx) {
  252. ctx.SubscriptionLocator.promises.getUsersSubscription
  253. .withArgs(ctx.user._id)
  254. .resolves(ctx.subscriptions.group1)
  255. })
  256. it("doesn't return the group features", async function (ctx) {
  257. const features = await ctx.FeaturesUpdater.promises.computeFeatures(
  258. ctx.user._id
  259. )
  260. expect(features).to.deep.equal({
  261. default: 'features',
  262. })
  263. })
  264. })
  265. describe('group member', function () {
  266. beforeEach(function (ctx) {
  267. ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
  268. .withArgs(ctx.user._id)
  269. .resolves([ctx.subscriptions.group1])
  270. })
  271. it('returns the group features', async function (ctx) {
  272. const features = await ctx.FeaturesUpdater.promises.computeFeatures(
  273. ctx.user._id
  274. )
  275. expect(features).to.deep.equal({
  276. default: 'features',
  277. group1: 'features',
  278. })
  279. })
  280. })
  281. describe('individual subscription + AI add-on', function () {
  282. beforeEach(function (ctx) {
  283. ctx.SubscriptionLocator.promises.getUsersSubscription
  284. .withArgs(ctx.user._id)
  285. .resolves(ctx.subscriptions.individualPlusAiAddOn)
  286. })
  287. it('returns the individual features and the AI error assistant', 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. aiErrorAssistant: true,
  295. aiUsageQuota: 'unlimited',
  296. })
  297. })
  298. })
  299. describe('group admin + AI add-on', function () {
  300. beforeEach(function (ctx) {
  301. ctx.SubscriptionLocator.promises.getUsersSubscription
  302. .withArgs(ctx.user._id)
  303. .resolves(ctx.subscriptions.groupPlusAiAddOn)
  304. })
  305. it('returns the AI error assistant only', async function (ctx) {
  306. const features = await ctx.FeaturesUpdater.promises.computeFeatures(
  307. ctx.user._id
  308. )
  309. expect(features).to.deep.equal({
  310. default: 'features',
  311. aiErrorAssistant: true,
  312. aiUsageQuota: 'unlimited',
  313. })
  314. })
  315. })
  316. describe('group member + AI add-on', function () {
  317. beforeEach(function (ctx) {
  318. ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
  319. .withArgs(ctx.user._id)
  320. .resolves([ctx.subscriptions.groupPlusAiAddOn])
  321. })
  322. it('returns the group features without the AI features', async function (ctx) {
  323. const features = await ctx.FeaturesUpdater.promises.computeFeatures(
  324. ctx.user._id
  325. )
  326. expect(features).to.deep.equal({
  327. default: 'features',
  328. group1: 'features',
  329. })
  330. })
  331. })
  332. })
  333. describe('refreshFeatures', function () {
  334. it('should return features and featuresChanged', async function (ctx) {
  335. const { features, featuresChanged } =
  336. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  337. expect(features).to.exist
  338. expect(featuresChanged).to.exist
  339. })
  340. describe('normally', function () {
  341. beforeEach(async function (ctx) {
  342. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  343. })
  344. it('should update the user with the merged features', function (ctx) {
  345. expect(
  346. ctx.UserFeaturesUpdater.promises.updateFeatures
  347. ).to.have.been.calledWith(ctx.user._id, ctx.Settings.features.all)
  348. })
  349. it('should send the corresponding feature set user property', function (ctx) {
  350. expect(
  351. ctx.AnalyticsManager.setUserPropertyForUserInBackground
  352. ).to.have.been.calledWith(ctx.user._id, 'feature-set', 'all')
  353. })
  354. it('should sync features to customer.io', function (ctx) {
  355. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  356. 'setUserProperties',
  357. ctx.user._id,
  358. {
  359. features: ctx.Settings.features.all,
  360. 'best-subscription-type': 'individual',
  361. }
  362. )
  363. })
  364. })
  365. describe('with a non-standard feature set', async function () {
  366. beforeEach(async function (ctx) {
  367. ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
  368. .withArgs(ctx.user._id)
  369. .resolves(null)
  370. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  371. })
  372. it('should send mixed feature set user property', function (ctx) {
  373. sinon.assert.calledWith(
  374. ctx.AnalyticsManager.setUserPropertyForUserInBackground,
  375. ctx.user._id,
  376. 'feature-set',
  377. 'mixed'
  378. )
  379. })
  380. })
  381. describe('when losing dropbox feature', async function () {
  382. beforeEach(async function (ctx) {
  383. ctx.user.features = { dropbox: true }
  384. ctx.SubscriptionLocator.promises.getUsersSubscription
  385. .withArgs(ctx.user._id)
  386. .resolves(ctx.subscriptions.noDropbox)
  387. await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
  388. })
  389. it('should fire module hook to unlink dropbox', function (ctx) {
  390. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  391. 'removeDropbox',
  392. ctx.user._id,
  393. 'test'
  394. )
  395. })
  396. })
  397. })
  398. describe('doSyncFromV1', function () {
  399. describe('when all goes well', function () {
  400. beforeEach(async function (ctx) {
  401. await ctx.FeaturesUpdater.promises.doSyncFromV1(ctx.v1UserId)
  402. })
  403. it('should update the user with the merged features', function (ctx) {
  404. expect(
  405. ctx.UserFeaturesUpdater.promises.updateFeatures
  406. ).to.have.been.calledWith(ctx.user._id, ctx.Settings.features.all)
  407. })
  408. })
  409. describe('when getUser produces an error', function () {
  410. beforeEach(function (ctx) {
  411. ctx.UserGetter.promises.getUser.rejects(new Error('woops'))
  412. })
  413. it('should propagate the error', async function (ctx) {
  414. const someId = 9090
  415. await expect(ctx.FeaturesUpdater.promises.doSyncFromV1(someId)).to.be
  416. .rejected
  417. expect(ctx.UserFeaturesUpdater.promises.updateFeatures).not.to.have.been
  418. .called
  419. })
  420. })
  421. describe('when getUser does not find a user', function () {
  422. beforeEach(async function (ctx) {
  423. const someOtherId = 987
  424. await ctx.FeaturesUpdater.promises.doSyncFromV1(someOtherId)
  425. })
  426. it('should not update the user', function (ctx) {
  427. expect(ctx.UserFeaturesUpdater.promises.updateFeatures).not.to.have.been
  428. .called
  429. })
  430. })
  431. })
  432. })