SplitTestHandlerTests.js 14 KB

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