SplitTestHandler.test.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. import { vi, assert, expect } from 'vitest'
  2. import Path from 'node:path'
  3. import sinon from 'sinon'
  4. import mongodb from 'mongodb-legacy'
  5. import MockRequest from '../helpers/MockRequest.mjs'
  6. import MockResponse from '../helpers/MockResponse.mjs'
  7. const { ObjectId } = mongodb
  8. const MODULE_PATH = Path.join(
  9. import.meta.dirname,
  10. '../../../../app/src/Features/SplitTests/SplitTestHandler'
  11. )
  12. describe('SplitTestHandler', function () {
  13. let Features
  14. beforeEach(async function (ctx) {
  15. ctx.splitTests = [
  16. makeSplitTest('active-test', { versionNumber: 2 }),
  17. makeSplitTest('not-active-test', { active: false }),
  18. makeSplitTest('legacy-test'),
  19. makeSplitTest('no-analytics-test-1', { analyticsEnabled: false }),
  20. makeSplitTest('no-analytics-test-2', {
  21. analyticsEnabled: false,
  22. versionNumber: 2,
  23. }),
  24. ]
  25. ctx.cachedSplitTests = new Map()
  26. for (const splitTest of ctx.splitTests) {
  27. ctx.cachedSplitTests.set(splitTest.name, splitTest)
  28. }
  29. ctx.SplitTest = {
  30. find: sinon.stub().returns({
  31. exec: sinon.stub().resolves(ctx.splitTests),
  32. }),
  33. }
  34. ctx.SplitTestCache = {
  35. get: sinon.stub().resolves({}),
  36. }
  37. ctx.SplitTestCache.get.resolves(ctx.cachedSplitTests)
  38. ctx.Settings = {
  39. moduleImportSequence: [],
  40. overleaf: {},
  41. devToolbar: {
  42. enabled: false,
  43. },
  44. }
  45. ctx.AnalyticsManager = {
  46. getIdsFromSession: sinon.stub(),
  47. setUserPropertyForAnalyticsId: sinon.stub().resolves(),
  48. }
  49. ctx.LocalsHelper = {
  50. setSplitTestVariant: sinon.stub(),
  51. setSplitTestInfo: sinon.stub(),
  52. }
  53. ctx.SplitTestSessionHandler = {
  54. collectSessionStats: sinon.stub(),
  55. getCachedVariant: sinon.stub(),
  56. setVariantInCache: sinon.stub(),
  57. }
  58. ctx.SplitTestUserGetter = {
  59. promises: {
  60. getUser: sinon.stub().resolves(null),
  61. },
  62. }
  63. ctx.SessionManager = {
  64. isUserLoggedIn: sinon.stub().returns(false),
  65. }
  66. Features = {
  67. hasFeature: vi.fn().mockReturnValue(true),
  68. }
  69. vi.doMock('../../../../app/src/infrastructure/Features', () => ({
  70. default: Features,
  71. }))
  72. vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
  73. default: ctx.UserGetter,
  74. }))
  75. vi.doMock('../../../../app/src/Features/SplitTests/SplitTestCache', () => ({
  76. default: ctx.SplitTestCache,
  77. }))
  78. vi.doMock('../../../../app/src/models/SplitTest', () => ({
  79. SplitTest: ctx.SplitTest,
  80. }))
  81. vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
  82. default: {},
  83. }))
  84. vi.doMock(
  85. '../../../../app/src/Features/Analytics/AnalyticsManager',
  86. () => ({
  87. default: ctx.AnalyticsManager,
  88. })
  89. )
  90. vi.doMock('../../../../app/src/Features/SplitTests/LocalsHelper', () => ({
  91. default: ctx.LocalsHelper,
  92. }))
  93. vi.doMock(
  94. '../../../../app/src/Features/SplitTests/SplitTestSessionHandler',
  95. () => ({
  96. default: ctx.SplitTestSessionHandler,
  97. })
  98. )
  99. vi.doMock(
  100. '../../../../app/src/Features/SplitTests/SplitTestUserGetter',
  101. () => ({
  102. default: ctx.SplitTestUserGetter,
  103. })
  104. )
  105. vi.doMock(
  106. '../../../../app/src/Features/Authentication/SessionManager',
  107. () => ({
  108. default: ctx.SessionManager,
  109. })
  110. )
  111. vi.doMock('@overleaf/settings', () => ({
  112. default: ctx.Settings,
  113. }))
  114. ctx.SplitTestHandler = (await import(MODULE_PATH)).default
  115. ctx.req = new MockRequest(vi)
  116. ctx.res = new MockResponse(vi)
  117. })
  118. describe('with an existing user', function () {
  119. beforeEach(async function (ctx) {
  120. ctx.user = {
  121. _id: new ObjectId(),
  122. splitTests: {
  123. 'active-test': [
  124. {
  125. variantName: 'default',
  126. versionNumber: 1,
  127. assignedAt: 'active-test-assigned-at',
  128. },
  129. ],
  130. 'legacy-test': 'legacy-variant',
  131. 'inactive-test': [{ variantName: 'trythis' }],
  132. 'unknown-test': [{ variantName: 'trythis' }],
  133. 'no-analytics-test-2': [
  134. {
  135. variantName: 'some-variant',
  136. versionNumber: 1,
  137. assignedAt: 'no-analytics-assigned-at',
  138. },
  139. ],
  140. },
  141. }
  142. ctx.SplitTestUserGetter.promises.getUser.resolves(ctx.user)
  143. ctx.SessionManager.isUserLoggedIn.returns(true)
  144. ctx.assignments =
  145. await ctx.SplitTestHandler.promises.getActiveAssignmentsForUser(
  146. ctx.user._id
  147. )
  148. ctx.explicitAssignments =
  149. await ctx.SplitTestHandler.promises.getActiveAssignmentsForUser(
  150. ctx.user._id,
  151. false,
  152. true
  153. )
  154. ctx.assignedToActiveTest =
  155. await ctx.SplitTestHandler.promises.hasUserBeenAssignedToVariant(
  156. ctx.req,
  157. ctx.user._id,
  158. 'active-test',
  159. 'variant-1'
  160. )
  161. ctx.assignedToActiveTestAnyVersion =
  162. await ctx.SplitTestHandler.promises.hasUserBeenAssignedToVariant(
  163. ctx.req,
  164. ctx.user._id,
  165. 'active-test',
  166. 'variant-1',
  167. true
  168. )
  169. })
  170. it('handles the legacy assignment format', function (ctx) {
  171. expect(ctx.assignments['legacy-test']).to.deep.equal({
  172. variantName: 'variant-1',
  173. phase: 'release',
  174. versionNumber: 1,
  175. })
  176. })
  177. it('returns the current assignment for each active test', function (ctx) {
  178. expect(ctx.assignments['active-test']).to.deep.equal({
  179. variantName: 'variant-1',
  180. phase: 'release',
  181. versionNumber: 2,
  182. })
  183. })
  184. it('returns the explicit assignment for each active test', function (ctx) {
  185. expect(ctx.explicitAssignments['active-test']).to.deep.equal({
  186. variantName: 'variant-1',
  187. phase: 'release',
  188. versionNumber: 2,
  189. assignedAt: 'active-test-assigned-at',
  190. })
  191. })
  192. it('returns the current assignment for tests with analytics disabled', function (ctx) {
  193. expect(ctx.assignments['no-analytics-test-1']).to.deep.equal({
  194. variantName: 'variant-1',
  195. phase: 'release',
  196. versionNumber: 1,
  197. })
  198. })
  199. it('returns the current assignment for tests with analytics disabled that had previous assignments', function (ctx) {
  200. expect(ctx.assignments['no-analytics-test-2']).to.deep.equal({
  201. variantName: 'variant-1',
  202. phase: 'release',
  203. versionNumber: 2,
  204. })
  205. })
  206. it('shows user has been assigned to previous version of variant', function (ctx) {
  207. expect(ctx.assignedToActiveTestAnyVersion).to.be.true
  208. })
  209. it('shows user has not been explicitly assigned to current version of variant', function (ctx) {
  210. expect(ctx.assignedToActiveTest).to.be.false
  211. })
  212. it('does not return assignments for unknown tests', function (ctx) {
  213. expect(ctx.assignments).not.to.have.property('unknown-test')
  214. })
  215. })
  216. describe('with an non-existent user', function () {
  217. beforeEach(async function (ctx) {
  218. const unknownUserId = new ObjectId()
  219. ctx.assignments =
  220. await ctx.SplitTestHandler.promises.getActiveAssignmentsForUser(
  221. unknownUserId
  222. )
  223. })
  224. it('returns empty assignments', function (ctx) {
  225. expect(ctx.assignments).to.deep.equal({})
  226. })
  227. })
  228. describe('with a user without assignments', function () {
  229. beforeEach(async function (ctx) {
  230. ctx.user = { _id: new ObjectId() }
  231. ctx.SplitTestUserGetter.promises.getUser.resolves(ctx.user)
  232. ctx.assignments =
  233. await ctx.SplitTestHandler.promises.getActiveAssignmentsForUser(
  234. ctx.user._id
  235. )
  236. ctx.explicitAssignments =
  237. await ctx.SplitTestHandler.promises.getActiveAssignmentsForUser(
  238. ctx.user._id,
  239. false,
  240. true
  241. )
  242. ctx.assignedToActiveTest =
  243. await ctx.SplitTestHandler.promises.hasUserBeenAssignedToVariant(
  244. ctx.req,
  245. ctx.user._id,
  246. 'active-test',
  247. 'variant-1'
  248. )
  249. })
  250. it('returns current assignments', function (ctx) {
  251. expect(ctx.assignments).to.deep.equal({
  252. 'active-test': {
  253. phase: 'release',
  254. variantName: 'variant-1',
  255. versionNumber: 2,
  256. },
  257. 'legacy-test': {
  258. phase: 'release',
  259. variantName: 'variant-1',
  260. versionNumber: 1,
  261. },
  262. 'no-analytics-test-1': {
  263. phase: 'release',
  264. variantName: 'variant-1',
  265. versionNumber: 1,
  266. },
  267. 'no-analytics-test-2': {
  268. phase: 'release',
  269. variantName: 'variant-1',
  270. versionNumber: 2,
  271. },
  272. })
  273. })
  274. it('shows user not assigned to variant', function (ctx) {
  275. expect(ctx.assignedToActiveTest).to.be.false
  276. })
  277. })
  278. describe('with settings overrides', function () {
  279. beforeEach(function (ctx) {
  280. ctx.Settings.splitTestOverrides = {
  281. 'my-test-name': 'foo-1',
  282. }
  283. })
  284. it('should not use the override when in SaaS mode', async function (ctx) {
  285. ctx.AnalyticsManager.getIdsFromSession.returns({
  286. userId: 'abc123abc123',
  287. })
  288. ctx.SplitTestCache.get.resolves(
  289. new Map([
  290. [
  291. 'my-test-name',
  292. {
  293. name: 'my-test-name',
  294. versions: [
  295. {
  296. versionNumber: 0,
  297. active: true,
  298. variants: [
  299. {
  300. name: '100-percent-variant',
  301. rolloutPercent: 100,
  302. rolloutStripes: [{ start: 0, end: 100 }],
  303. },
  304. ],
  305. },
  306. ],
  307. },
  308. ],
  309. ])
  310. )
  311. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  312. ctx.req,
  313. ctx.res,
  314. 'my-test-name'
  315. )
  316. assert.equal('100-percent-variant', assignment.variant)
  317. })
  318. it('should use the override when not in SaaS mode', async function (ctx) {
  319. ctx.Settings.splitTestOverrides = {
  320. 'my-test-name': 'foo-1',
  321. }
  322. ctx.Settings.overleaf = undefined
  323. Features.hasFeature.mockImplementation(function (feature) {
  324. return feature !== 'saas'
  325. })
  326. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  327. ctx.req,
  328. ctx.res,
  329. 'my-test-name'
  330. )
  331. assert.equal('foo-1', assignment.variant)
  332. })
  333. it('should use default when not in SaaS mode and no override is provided', async function (ctx) {
  334. ctx.Settings.splitTestOverrides = {}
  335. ctx.Settings.overleaf = undefined
  336. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  337. ctx.req,
  338. ctx.res,
  339. 'my-test-name'
  340. )
  341. assert.equal('default', assignment.variant)
  342. })
  343. })
  344. describe('save assignments to res.locals', function () {
  345. beforeEach(function (ctx) {
  346. ctx.AnalyticsManager.getIdsFromSession.returns({
  347. userId: 'abc123abc123',
  348. })
  349. })
  350. it('when in SaaS mode it should set the variant', async function (ctx) {
  351. await ctx.SplitTestHandler.promises.getAssignment(
  352. ctx.req,
  353. ctx.res,
  354. 'active-test'
  355. )
  356. expect(ctx.LocalsHelper.setSplitTestVariant).to.have.been.calledWith(
  357. ctx.res.locals,
  358. 'active-test',
  359. 'variant-1'
  360. )
  361. })
  362. it('when not in SaaS mode it should set the default variant', async function (ctx) {
  363. Features.hasFeature.mockImplementation(function (feature) {
  364. return feature !== 'saas'
  365. })
  366. await ctx.SplitTestHandler.promises.getAssignment(
  367. ctx.req,
  368. ctx.res,
  369. 'active-test'
  370. )
  371. expect(ctx.LocalsHelper.setSplitTestVariant).to.have.been.calledWith(
  372. ctx.res.locals,
  373. 'active-test',
  374. 'default'
  375. )
  376. })
  377. })
  378. describe('variant user limits', function () {
  379. beforeEach(function (ctx) {
  380. ctx.AnalyticsManager.getIdsFromSession.returns({
  381. userId: 'abc123abc123',
  382. })
  383. ctx.SplitTestUserGetter.promises.getUser.resolves({
  384. _id: new ObjectId('abc123abc123abc123abc123'),
  385. splitTests: {},
  386. })
  387. })
  388. it('should assign to variant when under limit', async function (ctx) {
  389. ctx.cachedSplitTests.set(
  390. 'active-test',
  391. makeSplitTest('active-test', { userLimit: 100, userCount: 50 })
  392. )
  393. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  394. ctx.req,
  395. ctx.res,
  396. 'active-test'
  397. )
  398. expect(assignment.variant).to.equal('variant-1')
  399. })
  400. it('should assign to default when limit reached', async function (ctx) {
  401. ctx.cachedSplitTests.set(
  402. 'active-test',
  403. makeSplitTest('active-test', { userLimit: 100, userCount: 100 })
  404. )
  405. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  406. ctx.req,
  407. ctx.res,
  408. 'active-test'
  409. )
  410. expect(assignment.variant).to.equal('default')
  411. })
  412. it('should not apply limits when no limit configured', async function (ctx) {
  413. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  414. ctx.req,
  415. ctx.res,
  416. 'active-test'
  417. )
  418. expect(assignment.variant).to.equal('variant-1')
  419. })
  420. it('should allow already assigned users even when limit reached', async function (ctx) {
  421. ctx.cachedSplitTests.set(
  422. 'active-test',
  423. makeSplitTest('active-test', { userLimit: 100, userCount: 100 })
  424. )
  425. ctx.SplitTestUserGetter.promises.getUser.resolves({
  426. _id: new ObjectId('abc123abc123abc123abc123'),
  427. splitTests: {
  428. 'active-test': [
  429. {
  430. variantName: 'variant-1',
  431. versionNumber: 1,
  432. assignedAt: new Date(),
  433. phase: 'release',
  434. },
  435. ],
  436. },
  437. })
  438. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  439. ctx.req,
  440. ctx.res,
  441. 'active-test'
  442. )
  443. expect(assignment.variant).to.equal('variant-1')
  444. })
  445. it('should assign to default if userCount is undefined', async function (ctx) {
  446. ctx.cachedSplitTests.set(
  447. 'active-test',
  448. makeSplitTest('active-test', { userLimit: 100, userCount: undefined })
  449. )
  450. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  451. ctx.req,
  452. ctx.res,
  453. 'active-test'
  454. )
  455. expect(assignment.variant).to.equal('default')
  456. })
  457. })
  458. })
  459. function makeSplitTest(
  460. name,
  461. {
  462. active = true,
  463. analyticsEnabled = active,
  464. phase = 'release',
  465. versionNumber = 1,
  466. userLimit = undefined,
  467. userCount = undefined,
  468. } = {}
  469. ) {
  470. const variant = {
  471. name: 'variant-1',
  472. rolloutPercent: 100,
  473. rolloutStripes: [{ start: 0, end: 100 }],
  474. }
  475. if (userLimit !== undefined) {
  476. variant.userLimit = userLimit
  477. }
  478. if (userCount !== undefined) {
  479. variant.userCount = userCount
  480. }
  481. return {
  482. name,
  483. versions: [
  484. {
  485. active,
  486. analyticsEnabled,
  487. phase,
  488. versionNumber,
  489. variants: [variant],
  490. },
  491. ],
  492. }
  493. }