SplitTestHandler.test.mjs 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107
  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. })
  446. describe('labs phase assignment (split test)', function () {
  447. beforeEach(function (ctx) {
  448. ctx.AnalyticsManager.getIdsFromSession.returns({
  449. userId: 'abc123abc123',
  450. })
  451. ctx.cachedSplitTests.set(
  452. 'labs-experiment',
  453. makeSplitTest('labs-experiment', { phase: 'labs' })
  454. )
  455. })
  456. it('should assign to variant when user is in labs program and has opted into the experiment', async function (ctx) {
  457. ctx.SplitTestUserGetter.promises.getUser.resolves({
  458. _id: new ObjectId('abc123abc123abc123abc123'),
  459. splitTests: {},
  460. labsProgram: true,
  461. labsExperiments: ['labs-experiment'],
  462. })
  463. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  464. ctx.req,
  465. ctx.res,
  466. 'labs-experiment'
  467. )
  468. expect(assignment.variant).to.equal('variant-1')
  469. })
  470. it('should assign to default when user is in labs program but has not opted into the experiment', async function (ctx) {
  471. ctx.SplitTestUserGetter.promises.getUser.resolves({
  472. _id: new ObjectId('abc123abc123abc123abc123'),
  473. splitTests: {},
  474. labsProgram: true,
  475. labsExperiments: ['some-other-experiment'],
  476. })
  477. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  478. ctx.req,
  479. ctx.res,
  480. 'labs-experiment'
  481. )
  482. expect(assignment.variant).to.equal('default')
  483. })
  484. it('should assign to default when user is not in labs program', async function (ctx) {
  485. ctx.SplitTestUserGetter.promises.getUser.resolves({
  486. _id: new ObjectId('abc123abc123abc123abc123'),
  487. splitTests: {},
  488. labsProgram: false,
  489. labsExperiments: [],
  490. })
  491. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  492. ctx.req,
  493. ctx.res,
  494. 'labs-experiment'
  495. )
  496. expect(assignment.variant).to.equal('default')
  497. })
  498. it('should assign to default when user has no labsExperiments field', async function (ctx) {
  499. ctx.SplitTestUserGetter.promises.getUser.resolves({
  500. _id: new ObjectId('abc123abc123abc123abc123'),
  501. splitTests: {},
  502. labsProgram: true,
  503. })
  504. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  505. ctx.req,
  506. ctx.res,
  507. 'labs-experiment'
  508. )
  509. expect(assignment.variant).to.equal('default')
  510. })
  511. it('should assign to variant when under user limit', async function (ctx) {
  512. ctx.cachedSplitTests.set(
  513. 'labs-experiment',
  514. makeSplitTest('labs-experiment', {
  515. phase: 'labs',
  516. userLimit: 10,
  517. userCount: 5,
  518. })
  519. )
  520. ctx.SplitTestUserGetter.promises.getUser.resolves({
  521. _id: new ObjectId('abc123abc123abc123abc123'),
  522. splitTests: {},
  523. labsProgram: true,
  524. labsExperiments: ['labs-experiment'],
  525. })
  526. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  527. ctx.req,
  528. ctx.res,
  529. 'labs-experiment'
  530. )
  531. expect(assignment.variant).to.equal('variant-1')
  532. })
  533. it('should assign enrolled user to variant even when user limit is reached', async function (ctx) {
  534. ctx.cachedSplitTests.set(
  535. 'labs-experiment',
  536. makeSplitTest('labs-experiment', {
  537. phase: 'labs',
  538. userLimit: 10,
  539. userCount: 10,
  540. })
  541. )
  542. ctx.SplitTestUserGetter.promises.getUser.resolves({
  543. _id: new ObjectId('abc123abc123abc123abc123'),
  544. splitTests: {},
  545. labsProgram: true,
  546. labsExperiments: ['labs-experiment'],
  547. })
  548. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  549. ctx.req,
  550. ctx.res,
  551. 'labs-experiment'
  552. )
  553. expect(assignment.variant).to.equal('variant-1')
  554. })
  555. it('should allow already-assigned user even when limit is reached', async function (ctx) {
  556. ctx.cachedSplitTests.set(
  557. 'labs-experiment',
  558. makeSplitTest('labs-experiment', {
  559. phase: 'labs',
  560. userLimit: 10,
  561. userCount: 10,
  562. })
  563. )
  564. ctx.SplitTestUserGetter.promises.getUser.resolves({
  565. _id: new ObjectId('abc123abc123abc123abc123'),
  566. splitTests: {
  567. 'labs-experiment': [
  568. {
  569. variantName: 'variant-1',
  570. versionNumber: 1,
  571. assignedAt: new Date(),
  572. phase: 'labs',
  573. },
  574. ],
  575. },
  576. labsProgram: true,
  577. labsExperiments: ['labs-experiment'],
  578. })
  579. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  580. ctx.req,
  581. ctx.res,
  582. 'labs-experiment'
  583. )
  584. expect(assignment.variant).to.equal('variant-1')
  585. })
  586. })
  587. describe('_loadSplitTestInfoInLocals labsDetails population', function () {
  588. beforeEach(function (ctx) {
  589. ctx.AnalyticsManager.getIdsFromSession.returns({
  590. userId: 'abc123abc123',
  591. })
  592. })
  593. it('populates labsDetails for a labs-phase split test', async function (ctx) {
  594. const createdAt = new Date('2024-06-15T12:00:00.000Z')
  595. ctx.cachedSplitTests.set('labs-info-test', {
  596. name: 'labs-info-test',
  597. labsTitle: 'My Labs Feature',
  598. labsDescription: 'A great feature',
  599. labsIcon: 'star',
  600. badgeInfo: { labs: { url: 'https://example.com/survey' } },
  601. versions: [
  602. {
  603. active: true,
  604. analyticsEnabled: true,
  605. phase: 'labs',
  606. versionNumber: 1,
  607. createdAt,
  608. variants: [
  609. {
  610. name: 'variant-1',
  611. rolloutPercent: 100,
  612. rolloutStripes: [{ start: 0, end: 100 }],
  613. },
  614. ],
  615. },
  616. ],
  617. })
  618. await ctx.SplitTestHandler.promises.getAssignment(
  619. ctx.req,
  620. ctx.res,
  621. 'labs-info-test'
  622. )
  623. expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
  624. ctx.res.locals,
  625. 'labs-info-test',
  626. sinon.match({
  627. phase: 'labs',
  628. labsDetails: sinon.match({
  629. isFull: false,
  630. versionCreatedAt: createdAt.toISOString(),
  631. title: 'My Labs Feature',
  632. description: 'A great feature',
  633. icon: 'star',
  634. surveyLink: 'https://example.com/survey',
  635. }),
  636. })
  637. )
  638. })
  639. it('handles missing labsTitle defensively', async function (ctx) {
  640. const createdAt = new Date('2024-01-01T00:00:00.000Z')
  641. ctx.cachedSplitTests.set('my-labs-experiment', {
  642. name: 'my-labs-experiment',
  643. versions: [
  644. {
  645. active: true,
  646. analyticsEnabled: true,
  647. phase: 'labs',
  648. versionNumber: 1,
  649. createdAt,
  650. variants: [
  651. {
  652. name: 'variant-1',
  653. rolloutPercent: 100,
  654. rolloutStripes: [{ start: 0, end: 100 }],
  655. },
  656. ],
  657. },
  658. ],
  659. })
  660. await ctx.SplitTestHandler.promises.getAssignment(
  661. ctx.req,
  662. ctx.res,
  663. 'my-labs-experiment'
  664. )
  665. expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
  666. ctx.res.locals,
  667. 'my-labs-experiment',
  668. sinon.match({
  669. labsDetails: sinon.match({ title: '' }),
  670. })
  671. )
  672. })
  673. it('sets description to empty string when labsDescription is absent', async function (ctx) {
  674. ctx.cachedSplitTests.set('labs-nodesc', {
  675. name: 'labs-nodesc',
  676. labsTitle: 'No Description',
  677. versions: [
  678. {
  679. active: true,
  680. analyticsEnabled: true,
  681. phase: 'labs',
  682. versionNumber: 1,
  683. createdAt: new Date(),
  684. variants: [
  685. {
  686. name: 'variant-1',
  687. rolloutPercent: 100,
  688. rolloutStripes: [{ start: 0, end: 100 }],
  689. },
  690. ],
  691. },
  692. ],
  693. })
  694. await ctx.SplitTestHandler.promises.getAssignment(
  695. ctx.req,
  696. ctx.res,
  697. 'labs-nodesc'
  698. )
  699. expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
  700. ctx.res.locals,
  701. 'labs-nodesc',
  702. sinon.match({ labsDetails: sinon.match({ description: '' }) })
  703. )
  704. })
  705. it('marks isFull true when userCount >= userLimit', async function (ctx) {
  706. ctx.cachedSplitTests.set('labs-full', {
  707. name: 'labs-full',
  708. versions: [
  709. {
  710. active: true,
  711. analyticsEnabled: true,
  712. phase: 'labs',
  713. versionNumber: 1,
  714. createdAt: new Date(),
  715. variants: [
  716. {
  717. name: 'variant-1',
  718. rolloutPercent: 100,
  719. rolloutStripes: [{ start: 0, end: 100 }],
  720. userLimit: 10,
  721. userCount: 10,
  722. },
  723. ],
  724. },
  725. ],
  726. })
  727. await ctx.SplitTestHandler.promises.getAssignment(
  728. ctx.req,
  729. ctx.res,
  730. 'labs-full'
  731. )
  732. expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
  733. ctx.res.locals,
  734. 'labs-full',
  735. sinon.match({ labsDetails: sinon.match({ isFull: true }) })
  736. )
  737. })
  738. it('marks isFull false when userCount < userLimit', async function (ctx) {
  739. ctx.cachedSplitTests.set('labs-not-full', {
  740. name: 'labs-not-full',
  741. versions: [
  742. {
  743. active: true,
  744. analyticsEnabled: true,
  745. phase: 'labs',
  746. versionNumber: 1,
  747. createdAt: new Date(),
  748. variants: [
  749. {
  750. name: 'variant-1',
  751. rolloutPercent: 100,
  752. rolloutStripes: [{ start: 0, end: 100 }],
  753. userLimit: 10,
  754. userCount: 5,
  755. },
  756. ],
  757. },
  758. ],
  759. })
  760. await ctx.SplitTestHandler.promises.getAssignment(
  761. ctx.req,
  762. ctx.res,
  763. 'labs-not-full'
  764. )
  765. expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
  766. ctx.res.locals,
  767. 'labs-not-full',
  768. sinon.match({ labsDetails: sinon.match({ isFull: false }) })
  769. )
  770. })
  771. it('marks isFull false when no userLimit is set', async function (ctx) {
  772. ctx.cachedSplitTests.set('labs-no-limit', {
  773. name: 'labs-no-limit',
  774. versions: [
  775. {
  776. active: true,
  777. analyticsEnabled: true,
  778. phase: 'labs',
  779. versionNumber: 1,
  780. createdAt: new Date(),
  781. variants: [
  782. {
  783. name: 'variant-1',
  784. rolloutPercent: 100,
  785. rolloutStripes: [{ start: 0, end: 100 }],
  786. },
  787. ],
  788. },
  789. ],
  790. })
  791. await ctx.SplitTestHandler.promises.getAssignment(
  792. ctx.req,
  793. ctx.res,
  794. 'labs-no-limit'
  795. )
  796. expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
  797. ctx.res.locals,
  798. 'labs-no-limit',
  799. sinon.match({ labsDetails: sinon.match({ isFull: false }) })
  800. )
  801. })
  802. it('does not set labsDetails for a release-phase split test', async function (ctx) {
  803. await ctx.SplitTestHandler.promises.getAssignment(
  804. ctx.req,
  805. ctx.res,
  806. 'active-test'
  807. )
  808. expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
  809. ctx.res.locals,
  810. 'active-test',
  811. sinon.match(info => info.labsDetails === undefined)
  812. )
  813. })
  814. })
  815. describe('labs phase assignment (gradual rollout)', function () {
  816. beforeEach(function (ctx) {
  817. ctx.AnalyticsManager.getIdsFromSession.returns({
  818. userId: 'abc123abc123',
  819. })
  820. ctx.cachedSplitTests.set(
  821. 'labs-experiment-gr',
  822. makeSplitTest('labs-experiment-gr', {
  823. phase: 'labs',
  824. analyticsEnabled: false,
  825. })
  826. )
  827. })
  828. it('should assign to variant when user is in labs program and has opted in', async function (ctx) {
  829. ctx.SplitTestUserGetter.promises.getUser.resolves({
  830. _id: new ObjectId('abc123abc123abc123abc123'),
  831. splitTests: {},
  832. labsProgram: true,
  833. labsExperiments: ['labs-experiment-gr'],
  834. })
  835. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  836. ctx.req,
  837. ctx.res,
  838. 'labs-experiment-gr'
  839. )
  840. expect(assignment.variant).to.equal('variant-1')
  841. })
  842. it('should assign to default when user has not opted in', async function (ctx) {
  843. ctx.SplitTestUserGetter.promises.getUser.resolves({
  844. _id: new ObjectId('abc123abc123abc123abc123'),
  845. splitTests: {},
  846. labsProgram: true,
  847. labsExperiments: [],
  848. })
  849. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  850. ctx.req,
  851. ctx.res,
  852. 'labs-experiment-gr'
  853. )
  854. expect(assignment.variant).to.equal('default')
  855. })
  856. it('should assign to default when user is not in labs program', async function (ctx) {
  857. ctx.SplitTestUserGetter.promises.getUser.resolves({
  858. _id: new ObjectId('abc123abc123abc123abc123'),
  859. splitTests: {},
  860. labsProgram: false,
  861. labsExperiments: [],
  862. })
  863. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  864. ctx.req,
  865. ctx.res,
  866. 'labs-experiment-gr'
  867. )
  868. expect(assignment.variant).to.equal('default')
  869. })
  870. it('should assign to variant when under user limit', async function (ctx) {
  871. ctx.cachedSplitTests.set(
  872. 'labs-experiment-gr',
  873. makeSplitTest('labs-experiment-gr', {
  874. phase: 'labs',
  875. analyticsEnabled: false,
  876. userLimit: 10,
  877. userCount: 5,
  878. })
  879. )
  880. ctx.SplitTestUserGetter.promises.getUser.resolves({
  881. _id: new ObjectId('abc123abc123abc123abc123'),
  882. splitTests: {},
  883. labsProgram: true,
  884. labsExperiments: ['labs-experiment-gr'],
  885. })
  886. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  887. ctx.req,
  888. ctx.res,
  889. 'labs-experiment-gr'
  890. )
  891. expect(assignment.variant).to.equal('variant-1')
  892. })
  893. it('should assign enrolled user to variant even when user limit is reached', async function (ctx) {
  894. ctx.cachedSplitTests.set(
  895. 'labs-experiment-gr',
  896. makeSplitTest('labs-experiment-gr', {
  897. phase: 'labs',
  898. analyticsEnabled: false,
  899. userLimit: 10,
  900. userCount: 10,
  901. })
  902. )
  903. ctx.SplitTestUserGetter.promises.getUser.resolves({
  904. _id: new ObjectId('abc123abc123abc123abc123'),
  905. splitTests: {},
  906. labsProgram: true,
  907. labsExperiments: ['labs-experiment-gr'],
  908. })
  909. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  910. ctx.req,
  911. ctx.res,
  912. 'labs-experiment-gr'
  913. )
  914. expect(assignment.variant).to.equal('variant-1')
  915. })
  916. it('should allow already-assigned user even when limit is reached', async function (ctx) {
  917. ctx.cachedSplitTests.set(
  918. 'labs-experiment-gr',
  919. makeSplitTest('labs-experiment-gr', {
  920. phase: 'labs',
  921. analyticsEnabled: false,
  922. userLimit: 10,
  923. userCount: 10,
  924. })
  925. )
  926. ctx.SplitTestUserGetter.promises.getUser.resolves({
  927. _id: new ObjectId('abc123abc123abc123abc123'),
  928. splitTests: {
  929. 'labs-experiment-gr': [
  930. {
  931. variantName: 'variant-1',
  932. versionNumber: 1,
  933. assignedAt: new Date(),
  934. phase: 'labs',
  935. },
  936. ],
  937. },
  938. labsProgram: true,
  939. labsExperiments: ['labs-experiment-gr'],
  940. })
  941. const assignment = await ctx.SplitTestHandler.promises.getAssignment(
  942. ctx.req,
  943. ctx.res,
  944. 'labs-experiment-gr'
  945. )
  946. expect(assignment.variant).to.equal('variant-1')
  947. })
  948. })
  949. })
  950. function makeSplitTest(
  951. name,
  952. {
  953. active = true,
  954. analyticsEnabled = active,
  955. phase = 'release',
  956. versionNumber = 1,
  957. userLimit = undefined,
  958. userCount = undefined,
  959. } = {}
  960. ) {
  961. const variant = {
  962. name: 'variant-1',
  963. rolloutPercent: 100,
  964. rolloutStripes: [{ start: 0, end: 100 }],
  965. }
  966. if (userLimit !== undefined) {
  967. variant.userLimit = userLimit
  968. }
  969. if (userCount !== undefined) {
  970. variant.userCount = userCount
  971. }
  972. return {
  973. name,
  974. versions: [
  975. {
  976. active,
  977. analyticsEnabled,
  978. phase,
  979. versionNumber,
  980. variants: [variant],
  981. },
  982. ],
  983. }
  984. }