AnalyticsManagerTests.js 12 KB

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