AnalyticsManagerTests.js 13 KB

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