AnalyticsManager.test.mjs 15 KB

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