AnalyticsManager.test.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. import { vi, assert } from 'vitest'
  2. import path from 'node:path'
  3. import sinon from 'sinon'
  4. import MockRequest from '../helpers/MockRequest.mjs'
  5. import MockResponse from '../helpers/MockResponse.mjs'
  6. import mongodb from 'mongodb-legacy'
  7. const { ObjectId } = mongodb
  8. const MODULE_PATH = path.join(
  9. import.meta.dirname,
  10. '../../../../app/src/Features/Analytics/AnalyticsManager'
  11. )
  12. vi.mock('../../../../app/src/infrastructure/Metrics.mjs', () => ({
  13. default: {
  14. analyticsQueue: {
  15. inc: vi.fn(),
  16. },
  17. },
  18. }))
  19. describe('AnalyticsManager', function () {
  20. beforeEach(async function (ctx) {
  21. ctx.fakeUserId = 'dbfc9438d14996f73dd172fb'
  22. ctx.analyticsId = 'ecdb935a-52f3-4f91-aebc-7a70d2ffbb55'
  23. ctx.Settings = {
  24. analytics: { enabled: true, hashedEmailSalt: 'salt' },
  25. }
  26. ctx.analyticsEventsQueue = {
  27. add: sinon.stub().resolves(),
  28. process: sinon.stub().resolves(),
  29. }
  30. ctx.analyticsEditingSessionQueue = {
  31. add: sinon.stub().resolves(),
  32. process: sinon.stub().resolves(),
  33. }
  34. ctx.onboardingEmailsQueue = {
  35. add: sinon.stub().resolves(),
  36. process: sinon.stub().resolves(),
  37. }
  38. ctx.analyticsUserPropertiesQueue = {
  39. add: sinon.stub().resolves(),
  40. process: sinon.stub().resolves(),
  41. }
  42. ctx.analyticsAccountMappingQueue = {
  43. add: sinon.stub().resolves(),
  44. process: sinon.stub().resolves(),
  45. }
  46. ctx.analyticsEmailChangeQueue = {
  47. add: sinon.stub().resolves(),
  48. process: sinon.stub().resolves(),
  49. }
  50. ctx.analyticsPackageUsageQueue = {
  51. add: sinon.stub().resolves(),
  52. process: sinon.stub().resolves(),
  53. }
  54. ctx.Queues = {
  55. getQueue: queueName => {
  56. switch (queueName) {
  57. case 'analytics-events':
  58. return ctx.analyticsEventsQueue
  59. case 'analytics-editing-sessions':
  60. return ctx.analyticsEditingSessionQueue
  61. case 'emails-onboarding':
  62. return ctx.onboardingEmailsQueue
  63. case 'analytics-user-properties':
  64. return ctx.analyticsUserPropertiesQueue
  65. case 'analytics-account-mapping':
  66. return ctx.analyticsAccountMappingQueue
  67. case 'analytics-email-change':
  68. return ctx.analyticsEmailChangeQueue
  69. case 'analytics-package-usage':
  70. return ctx.analyticsPackageUsageQueue
  71. default:
  72. throw new Error('Unexpected queue name')
  73. }
  74. },
  75. createScheduledJob: sinon.stub().resolves(),
  76. }
  77. ctx.backgroundRequest = sinon.stub().yields()
  78. ctx.request = sinon.stub().yields()
  79. vi.doMock('@overleaf/settings', () => ({
  80. default: ctx.Settings,
  81. }))
  82. vi.doMock('../../../../app/src/infrastructure/Queues', () => ({
  83. default: ctx.Queues,
  84. }))
  85. vi.doMock(
  86. '../../../../app/src/Features/Analytics/UserAnalyticsDataCache',
  87. () => ({
  88. default: (ctx.UserAnalyticsDataCache = {
  89. getAnalyticsId: sinon.stub().resolves(ctx.analyticsId),
  90. getAnalyticsData: sinon
  91. .stub()
  92. .resolves({ analyticsId: ctx.analyticsId, labsProgram: false }),
  93. }),
  94. })
  95. )
  96. ctx.AnalyticsManager = (await import(MODULE_PATH)).default
  97. })
  98. describe('ignores when', function () {
  99. it('user is smoke test user', function (ctx) {
  100. ctx.Settings.smokeTest = { userId: ctx.fakeUserId }
  101. ctx.AnalyticsManager.identifyUser(ctx.fakeUserId, '')
  102. sinon.assert.notCalled(ctx.Queues.createScheduledJob)
  103. })
  104. it('analytics service is disabled', function (ctx) {
  105. ctx.Settings.analytics.enabled = false
  106. ctx.AnalyticsManager.identifyUser(ctx.fakeUserId, '')
  107. sinon.assert.notCalled(ctx.Queues.createScheduledJob)
  108. })
  109. it('userId is missing', function (ctx) {
  110. ctx.AnalyticsManager.identifyUser(undefined, ctx.analyticsId)
  111. sinon.assert.notCalled(ctx.Queues.createScheduledJob)
  112. })
  113. it('analyticsId is missing', function (ctx) {
  114. ctx.AnalyticsManager.identifyUser(new ObjectId(ctx.fakeUserId), undefined)
  115. sinon.assert.notCalled(ctx.Queues.createScheduledJob)
  116. })
  117. it('analyticsId is not a valid UUID', function (ctx) {
  118. ctx.AnalyticsManager.identifyUser(
  119. new ObjectId(ctx.fakeUserId),
  120. ctx.fakeUserId
  121. )
  122. sinon.assert.notCalled(ctx.Queues.createScheduledJob)
  123. })
  124. it('userId and analyticsId are the same Mongo ID', function (ctx) {
  125. ctx.AnalyticsManager.identifyUser(
  126. new ObjectId(ctx.fakeUserId),
  127. new ObjectId(ctx.fakeUserId)
  128. )
  129. sinon.assert.notCalled(ctx.Queues.createScheduledJob)
  130. })
  131. it('editing session segmentation is not valid', function (ctx) {
  132. ctx.AnalyticsManager.updateEditingSession(
  133. ctx.fakeUserId,
  134. '789ghi',
  135. 'fr',
  136. { '<alert>': 'foo' }
  137. )
  138. expect(ctx.logger.info).toHaveBeenCalled()
  139. sinon.assert.notCalled(ctx.analyticsEditingSessionQueue.add)
  140. })
  141. it('event is not valid', async function (ctx) {
  142. await ctx.AnalyticsManager.recordEventForUser(
  143. ctx.fakeUserId,
  144. 'not an event!'
  145. )
  146. expect(ctx.logger.info).toHaveBeenCalled()
  147. sinon.assert.notCalled(ctx.analyticsEventsQueue.add)
  148. })
  149. it('event segmentation is not valid', async function (ctx) {
  150. await ctx.AnalyticsManager.recordEventForUser(
  151. ctx.fakeUserId,
  152. 'an_event',
  153. { 'not_a!': 'Valid Segmentation' }
  154. )
  155. expect(ctx.logger.info).toHaveBeenCalled()
  156. sinon.assert.notCalled(ctx.analyticsEventsQueue.add)
  157. })
  158. it('user property name is not valid', async function (ctx) {
  159. await ctx.AnalyticsManager.setUserPropertyForUser(
  160. ctx.fakeUserId,
  161. 'an invalid property',
  162. 'a_value'
  163. )
  164. expect(ctx.logger.info).toHaveBeenCalled()
  165. sinon.assert.notCalled(ctx.analyticsUserPropertiesQueue.add)
  166. })
  167. it('user property value is not valid', async function (ctx) {
  168. await ctx.AnalyticsManager.setUserPropertyForUser(
  169. ctx.fakeUserId,
  170. 'a_property',
  171. 'an invalid value'
  172. )
  173. expect(ctx.logger.info).toHaveBeenCalled()
  174. sinon.assert.notCalled(ctx.analyticsUserPropertiesQueue.add)
  175. })
  176. })
  177. describe('queues the appropriate message for', function () {
  178. it('identifyUser', function (ctx) {
  179. const analyticsId = 'bd101c4c-722f-4204-9e2d-8303e5d9c120'
  180. ctx.AnalyticsManager.identifyUser(ctx.fakeUserId, analyticsId, true)
  181. expect(ctx.logger.info).not.toHaveBeenCalled()
  182. sinon.assert.calledWithMatch(
  183. ctx.Queues.createScheduledJob,
  184. 'analytics-events',
  185. {
  186. name: 'identify',
  187. data: {
  188. userId: ctx.fakeUserId,
  189. analyticsId,
  190. isNewUser: true,
  191. createdAt: sinon.match.date,
  192. },
  193. },
  194. 60000
  195. )
  196. })
  197. it('recordEventForUser', async function (ctx) {
  198. const event = 'fake-event'
  199. await ctx.AnalyticsManager.recordEventForUser(ctx.fakeUserId, event, null)
  200. expect(ctx.logger.info).not.toHaveBeenCalled()
  201. sinon.assert.calledWithMatch(ctx.analyticsEventsQueue.add, 'event', {
  202. analyticsId: ctx.analyticsId,
  203. event,
  204. segmentation: null,
  205. isLoggedIn: true,
  206. })
  207. })
  208. it('updateEditingSession', function (ctx) {
  209. const projectId = '789ghi'
  210. const countryCode = 'fr'
  211. const segmentation = { editorType: 'abc' }
  212. ctx.AnalyticsManager.updateEditingSession(
  213. ctx.fakeUserId,
  214. projectId,
  215. countryCode,
  216. segmentation
  217. )
  218. expect(ctx.logger.info).not.toHaveBeenCalled()
  219. sinon.assert.calledWithMatch(
  220. ctx.analyticsEditingSessionQueue.add,
  221. 'editing-session',
  222. {
  223. userId: ctx.fakeUserId,
  224. projectId,
  225. countryCode,
  226. segmentation,
  227. }
  228. )
  229. })
  230. it('empty field in event segmentation', async function (ctx) {
  231. const timings = null
  232. await ctx.AnalyticsManager.recordEventForUser(
  233. ctx.fakeUserId,
  234. 'an_event',
  235. { compileTime: timings?.compileE2E }
  236. )
  237. expect(ctx.logger.info).not.toHaveBeenCalled()
  238. sinon.assert.calledWithMatch(ctx.analyticsEventsQueue.add, 'event', {
  239. analyticsId: ctx.analyticsId,
  240. event: 'an_event',
  241. segmentation: { compileTime: undefined },
  242. isLoggedIn: true,
  243. })
  244. })
  245. it('empty space in event segmentation value', async function (ctx) {
  246. await ctx.AnalyticsManager.recordEventForUser(
  247. ctx.fakeUserId,
  248. 'an_event',
  249. { segment: 'a value with spaces' }
  250. )
  251. expect(ctx.logger.info).not.toHaveBeenCalled()
  252. sinon.assert.calledWithMatch(ctx.analyticsEventsQueue.add, 'event', {
  253. analyticsId: ctx.analyticsId,
  254. event: 'an_event',
  255. segmentation: { segment: 'a value with spaces' },
  256. isLoggedIn: true,
  257. })
  258. })
  259. it('percent sign in event segmentation value', async function (ctx) {
  260. await ctx.AnalyticsManager.recordEventForUser(
  261. ctx.fakeUserId,
  262. 'an_event',
  263. { segment: 'a value with escaped comma %2C' }
  264. )
  265. expect(ctx.logger.info).not.toHaveBeenCalled()
  266. sinon.assert.calledWithMatch(ctx.analyticsEventsQueue.add, 'event', {
  267. analyticsId: ctx.analyticsId,
  268. event: 'an_event',
  269. segmentation: { segment: 'a value with escaped comma %2C' },
  270. isLoggedIn: true,
  271. })
  272. })
  273. it('boolean field in event segmentation', async function (ctx) {
  274. await ctx.AnalyticsManager.recordEventForUser(
  275. ctx.fakeUserId,
  276. 'an_event',
  277. { isAutoCompile: false }
  278. )
  279. expect(ctx.logger.info).not.toHaveBeenCalled()
  280. sinon.assert.calledWithMatch(ctx.analyticsEventsQueue.add, 'event', {
  281. analyticsId: ctx.analyticsId,
  282. event: 'an_event',
  283. segmentation: { isAutoCompile: false },
  284. isLoggedIn: true,
  285. })
  286. })
  287. it('account mapping', async function (ctx) {
  288. const message = {
  289. source: 'salesforce',
  290. sourceEntity: 'account',
  291. sourceEntityId: 'abc123abc123abc123',
  292. target: 'v1',
  293. targetEntity: 'university',
  294. targetEntityId: 1,
  295. createdAt: '2021-01-01T00:00:00Z',
  296. }
  297. await ctx.AnalyticsManager.registerAccountMapping(message)
  298. sinon.assert.calledWithMatch(
  299. ctx.analyticsAccountMappingQueue.add,
  300. 'account-mapping',
  301. message
  302. )
  303. })
  304. it('email change', async function (ctx) {
  305. const message = {
  306. userId: ctx.fakeUserId,
  307. email: 'test@example.com',
  308. createdAt: '2021-01-01T00:00:00Z',
  309. action: 'created',
  310. emailCreatedAt: '2021-01-01T00:00:00Z',
  311. isPrimary: false,
  312. }
  313. ctx.AnalyticsManager.registerEmailChange(message)
  314. const convertedMessage = {
  315. ...message,
  316. emailConfirmedAt: undefined,
  317. emailDeletedAt: undefined,
  318. email:
  319. '1778d425d64c5259ef7b574a2488647eb51ca739a0b16bfa0e2e3e16fff362db', // sha256 hash of email + salt
  320. }
  321. sinon.assert.calledWithMatch(
  322. ctx.analyticsEmailChangeQueue.add,
  323. 'email-change',
  324. convertedMessage
  325. )
  326. })
  327. })
  328. describe('AnalyticsIdMiddleware', function () {
  329. beforeEach(async function (ctx) {
  330. vi.resetModules()
  331. ctx.userId = '123abc'
  332. ctx.analyticsId = 'bccd308c-5d72-426e-a106-662e88557795'
  333. vi.doMock('@overleaf/settings', () => ({
  334. default: {
  335. analytics: { hashedEmailSalt: 'test-salt' },
  336. },
  337. }))
  338. vi.doMock('../../../../app/src/infrastructure/Queues', () => ({
  339. default: {
  340. getQueue: queueName => {
  341. switch (queueName) {
  342. case 'analytics-events':
  343. return ctx.analyticsEventsQueue
  344. case 'analytics-editing-sessions':
  345. return ctx.analyticsEditingSessionQueue
  346. case 'emails-onboarding':
  347. return ctx.onboardingEmailsQueue
  348. case 'analytics-user-properties':
  349. return ctx.analyticsUserPropertiesQueue
  350. case 'analytics-account-mapping':
  351. return ctx.analyticsAccountMappingQueue
  352. case 'analytics-email-change':
  353. return ctx.analyticsEmailChangeQueue
  354. case 'analytics-package-usage':
  355. return ctx.analyticsPackageUsageQueue
  356. default:
  357. throw new Error('Unexpected queue name')
  358. }
  359. },
  360. },
  361. }))
  362. vi.doMock(
  363. '../../../../app/src/Features/Analytics/UserAnalyticsDataCache',
  364. () => ({
  365. default: (ctx.UserAnalyticsDataCache = {
  366. getAnalyticsId: sinon.stub().resolves(ctx.analyticsId),
  367. getAnalyticsData: sinon
  368. .stub()
  369. .resolves({ analyticsId: ctx.analyticsId, labsProgram: false }),
  370. }),
  371. })
  372. )
  373. vi.doMock('node:crypto', () => ({
  374. default: {
  375. randomUUID: () => ctx.analyticsId,
  376. },
  377. }))
  378. ctx.AnalyticsManager = (await import(MODULE_PATH)).default
  379. ctx.req = new MockRequest(vi)
  380. ctx.req.session = {}
  381. ctx.res = new MockResponse(vi)
  382. ctx.next = () => {}
  383. })
  384. it('sets session.analyticsId with no user in session', async function (ctx) {
  385. await ctx.AnalyticsManager.analyticsIdMiddleware(
  386. ctx.req,
  387. ctx.res,
  388. ctx.next
  389. )
  390. assert.equal(ctx.analyticsId, ctx.req.session.analyticsId)
  391. })
  392. it('does not update analyticsId when existing, with no user in session', async function (ctx) {
  393. ctx.req.session.analyticsId = 'foo'
  394. await ctx.AnalyticsManager.analyticsIdMiddleware(
  395. ctx.req,
  396. ctx.res,
  397. ctx.next
  398. )
  399. assert.equal('foo', ctx.req.session.analyticsId)
  400. })
  401. it('sets session.analyticsId with a logged in user in session having an analyticsId', async function (ctx) {
  402. ctx.req.session.user = {
  403. _id: ctx.userId,
  404. analyticsId: ctx.analyticsId,
  405. }
  406. await ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => {
  407. assert.equal(ctx.analyticsId, ctx.req.session.analyticsId)
  408. })
  409. })
  410. it('sets session.analyticsId with a legacy user session without an analyticsId', async function (ctx) {
  411. ctx.UserAnalyticsDataCache.getAnalyticsId.resolves(ctx.userId)
  412. ctx.req.session.user = {
  413. _id: ctx.userId,
  414. analyticsId: undefined,
  415. }
  416. await ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => {
  417. assert.equal(ctx.userId, ctx.req.session.analyticsId)
  418. })
  419. })
  420. it('updates session.analyticsId with a legacy user session without an analyticsId if different', async function (ctx) {
  421. ctx.UserAnalyticsDataCache.getAnalyticsId.resolves(ctx.userId)
  422. ctx.req.session.user = {
  423. _id: ctx.userId,
  424. analyticsId: undefined,
  425. }
  426. ctx.req.analyticsId = 'foo'
  427. ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => {
  428. assert.equal(ctx.userId, ctx.req.session.analyticsId)
  429. })
  430. })
  431. it('does not update session.analyticsId with a legacy user session without an analyticsId if same', async function (ctx) {
  432. ctx.UserAnalyticsDataCache.getAnalyticsId.resolves(ctx.userId)
  433. ctx.req.session.user = {
  434. _id: ctx.userId,
  435. analyticsId: undefined,
  436. }
  437. ctx.req.analyticsId = ctx.userId
  438. await ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => {
  439. assert.equal(ctx.userId, ctx.req.session.analyticsId)
  440. })
  441. })
  442. })
  443. })