ConnectedUsersManager.test.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688
  1. import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest'
  2. import path from 'node:path'
  3. import sinon from 'sinon'
  4. import tk from 'timekeeper'
  5. const modulePath = path.join(
  6. import.meta.dirname,
  7. '../../../app/js/ConnectedUsersManager'
  8. )
  9. describe('ConnectedUsersManager', function () {
  10. beforeEach(async function (ctx) {
  11. tk.freeze(new Date())
  12. ctx.settings = {
  13. redis: {
  14. realtime: {
  15. key_schema: {
  16. clientsInProject({ project_id: projectId }) {
  17. return `clients_in_project:${projectId}`
  18. },
  19. connectedUser({ project_id: projectId, client_id: clientId }) {
  20. return `connected_user:${projectId}:${clientId}`
  21. },
  22. projectNotEmptySince({ projectId }) {
  23. return `projectNotEmptySince:{${projectId}}`
  24. },
  25. },
  26. },
  27. },
  28. }
  29. ctx.rClient = {
  30. auth() {},
  31. getdel: sinon.stub(),
  32. scard: sinon.stub(),
  33. set: sinon.stub(),
  34. setex: sinon.stub(),
  35. sadd: sinon.stub(),
  36. get: sinon.stub(),
  37. srem: sinon.stub(),
  38. del: sinon.stub(),
  39. smembers: sinon.stub(),
  40. expire: sinon.stub(),
  41. hset: sinon.stub(),
  42. hgetall: sinon.stub(),
  43. exec: sinon.stub(),
  44. multi: () => {
  45. return ctx.rClient
  46. },
  47. }
  48. ctx.Metrics = {
  49. inc: sinon.stub(),
  50. histogram: sinon.stub(),
  51. }
  52. vi.doMock('@overleaf/settings', () => ({
  53. default: ctx.settings,
  54. }))
  55. vi.doMock('@overleaf/metrics', () => ({
  56. default: ctx.Metrics,
  57. }))
  58. vi.doMock('@overleaf/redis-wrapper', () => ({
  59. default: {
  60. createClient: () => {
  61. return ctx.rClient
  62. },
  63. },
  64. }))
  65. ctx.ConnectedUsersManager = (await import(modulePath)).default
  66. ctx.client_id = '32132132'
  67. ctx.project_id = 'dskjh2u21321'
  68. ctx.user = {
  69. _id: 'user-id-123',
  70. first_name: 'Joe',
  71. last_name: 'Bloggs',
  72. email: 'joe@example.com',
  73. }
  74. ctx.cursorData = {
  75. row: 12,
  76. column: 9,
  77. doc_id: '53c3b8c85fee64000023dc6e',
  78. }
  79. })
  80. afterEach(function () {
  81. tk.reset()
  82. })
  83. describe('updateUserPosition', function () {
  84. beforeEach(function (ctx) {
  85. ctx.rClient.exec.yields(null, [1, 1])
  86. })
  87. it('should set a key with the date and give it a ttl', async function (ctx) {
  88. await new Promise((resolve, reject) => {
  89. ctx.ConnectedUsersManager.updateUserPosition(
  90. ctx.project_id,
  91. ctx.client_id,
  92. ctx.user,
  93. null,
  94. err => {
  95. if (err) return reject(err)
  96. ctx.rClient.hset
  97. .calledWith(
  98. `connected_user:${ctx.project_id}:${ctx.client_id}`,
  99. 'last_updated_at',
  100. Date.now()
  101. )
  102. .should.equal(true)
  103. resolve()
  104. }
  105. )
  106. })
  107. })
  108. it('should set a key with the user_id', async function (ctx) {
  109. await new Promise((resolve, reject) => {
  110. ctx.ConnectedUsersManager.updateUserPosition(
  111. ctx.project_id,
  112. ctx.client_id,
  113. ctx.user,
  114. null,
  115. err => {
  116. if (err) return reject(err)
  117. ctx.rClient.hset
  118. .calledWith(
  119. `connected_user:${ctx.project_id}:${ctx.client_id}`,
  120. 'user_id',
  121. ctx.user._id
  122. )
  123. .should.equal(true)
  124. resolve()
  125. }
  126. )
  127. })
  128. })
  129. it('should set a key with the first_name', async function (ctx) {
  130. await new Promise((resolve, reject) => {
  131. ctx.ConnectedUsersManager.updateUserPosition(
  132. ctx.project_id,
  133. ctx.client_id,
  134. ctx.user,
  135. null,
  136. err => {
  137. if (err) return reject(err)
  138. ctx.rClient.hset
  139. .calledWith(
  140. `connected_user:${ctx.project_id}:${ctx.client_id}`,
  141. 'first_name',
  142. ctx.user.first_name
  143. )
  144. .should.equal(true)
  145. resolve()
  146. }
  147. )
  148. })
  149. })
  150. it('should set a key with the last_name', async function (ctx) {
  151. await new Promise((resolve, reject) => {
  152. ctx.ConnectedUsersManager.updateUserPosition(
  153. ctx.project_id,
  154. ctx.client_id,
  155. ctx.user,
  156. null,
  157. err => {
  158. if (err) return reject(err)
  159. ctx.rClient.hset
  160. .calledWith(
  161. `connected_user:${ctx.project_id}:${ctx.client_id}`,
  162. 'last_name',
  163. ctx.user.last_name
  164. )
  165. .should.equal(true)
  166. resolve()
  167. }
  168. )
  169. })
  170. })
  171. it('should set a key with the email', async function (ctx) {
  172. await new Promise((resolve, reject) => {
  173. ctx.ConnectedUsersManager.updateUserPosition(
  174. ctx.project_id,
  175. ctx.client_id,
  176. ctx.user,
  177. null,
  178. err => {
  179. if (err) return reject(err)
  180. ctx.rClient.hset
  181. .calledWith(
  182. `connected_user:${ctx.project_id}:${ctx.client_id}`,
  183. 'email',
  184. ctx.user.email
  185. )
  186. .should.equal(true)
  187. resolve()
  188. }
  189. )
  190. })
  191. })
  192. it('should push the client_id on to the project list', async function (ctx) {
  193. await new Promise((resolve, reject) => {
  194. ctx.ConnectedUsersManager.updateUserPosition(
  195. ctx.project_id,
  196. ctx.client_id,
  197. ctx.user,
  198. null,
  199. err => {
  200. if (err) return reject(err)
  201. ctx.rClient.sadd
  202. .calledWith(`clients_in_project:${ctx.project_id}`, ctx.client_id)
  203. .should.equal(true)
  204. resolve()
  205. }
  206. )
  207. })
  208. })
  209. it('should add a ttl to the project set so it stays clean', async function (ctx) {
  210. await new Promise((resolve, reject) => {
  211. ctx.ConnectedUsersManager.updateUserPosition(
  212. ctx.project_id,
  213. ctx.client_id,
  214. ctx.user,
  215. null,
  216. err => {
  217. if (err) return reject(err)
  218. ctx.rClient.expire
  219. .calledWith(
  220. `clients_in_project:${ctx.project_id}`,
  221. 24 * 4 * 60 * 60
  222. )
  223. .should.equal(true)
  224. resolve()
  225. }
  226. )
  227. })
  228. })
  229. it('should add a ttl to the connected user so it stays clean', async function (ctx) {
  230. await new Promise((resolve, reject) => {
  231. ctx.ConnectedUsersManager.updateUserPosition(
  232. ctx.project_id,
  233. ctx.client_id,
  234. ctx.user,
  235. null,
  236. err => {
  237. if (err) return reject(err)
  238. ctx.rClient.expire
  239. .calledWith(
  240. `connected_user:${ctx.project_id}:${ctx.client_id}`,
  241. 60 * 15
  242. )
  243. .should.equal(true)
  244. resolve()
  245. }
  246. )
  247. })
  248. })
  249. it('should set the cursor position when provided', async function (ctx) {
  250. await new Promise((resolve, reject) => {
  251. ctx.ConnectedUsersManager.updateUserPosition(
  252. ctx.project_id,
  253. ctx.client_id,
  254. ctx.user,
  255. ctx.cursorData,
  256. err => {
  257. if (err) return reject(err)
  258. ctx.rClient.hset
  259. .calledWith(
  260. `connected_user:${ctx.project_id}:${ctx.client_id}`,
  261. 'cursorData',
  262. JSON.stringify(ctx.cursorData)
  263. )
  264. .should.equal(true)
  265. resolve()
  266. }
  267. )
  268. })
  269. })
  270. describe('editing_session_mode', function () {
  271. const cases = {
  272. 'should bump the metric when connecting to empty room': {
  273. nConnectedClients: 1,
  274. cursorData: null,
  275. labels: {
  276. method: 'connect',
  277. status: 'single',
  278. },
  279. },
  280. 'should bump the metric when connecting to non-empty room': {
  281. nConnectedClients: 2,
  282. cursorData: null,
  283. labels: {
  284. method: 'connect',
  285. status: 'multi',
  286. },
  287. },
  288. 'should bump the metric when updating in empty room': {
  289. nConnectedClients: 1,
  290. cursorData: { row: 42 },
  291. labels: {
  292. method: 'update',
  293. status: 'single',
  294. },
  295. },
  296. 'should bump the metric when updating in non-empty room': {
  297. nConnectedClients: 2,
  298. cursorData: { row: 42 },
  299. labels: {
  300. method: 'update',
  301. status: 'multi',
  302. },
  303. },
  304. }
  305. for (const [
  306. name,
  307. { nConnectedClients, cursorData, labels },
  308. ] of Object.entries(cases)) {
  309. it(name, async function (ctx) {
  310. await new Promise((resolve, reject) => {
  311. ctx.rClient.exec.yields(null, [1, nConnectedClients])
  312. ctx.ConnectedUsersManager.updateUserPosition(
  313. ctx.project_id,
  314. ctx.client_id,
  315. ctx.user,
  316. cursorData,
  317. err => {
  318. if (err) return reject(err)
  319. expect(ctx.Metrics.inc).to.have.been.calledWith(
  320. 'editing_session_mode',
  321. 1,
  322. labels
  323. )
  324. resolve()
  325. }
  326. )
  327. })
  328. })
  329. }
  330. })
  331. })
  332. describe('markUserAsDisconnected', function () {
  333. beforeEach(function (ctx) {
  334. ctx.rClient.exec.yields(null, [1, 0])
  335. })
  336. it('should remove the user from the set', async function (ctx) {
  337. await new Promise((resolve, reject) => {
  338. ctx.ConnectedUsersManager.markUserAsDisconnected(
  339. ctx.project_id,
  340. ctx.client_id,
  341. err => {
  342. if (err) return reject(err)
  343. ctx.rClient.srem
  344. .calledWith(`clients_in_project:${ctx.project_id}`, ctx.client_id)
  345. .should.equal(true)
  346. resolve()
  347. }
  348. )
  349. })
  350. })
  351. it('should delete the connected_user string', async function (ctx) {
  352. await new Promise((resolve, reject) => {
  353. ctx.ConnectedUsersManager.markUserAsDisconnected(
  354. ctx.project_id,
  355. ctx.client_id,
  356. err => {
  357. if (err) return reject(err)
  358. ctx.rClient.del
  359. .calledWith(`connected_user:${ctx.project_id}:${ctx.client_id}`)
  360. .should.equal(true)
  361. resolve()
  362. }
  363. )
  364. })
  365. })
  366. it('should add a ttl to the connected user set so it stays clean', async function (ctx) {
  367. await new Promise((resolve, reject) => {
  368. ctx.ConnectedUsersManager.markUserAsDisconnected(
  369. ctx.project_id,
  370. ctx.client_id,
  371. err => {
  372. if (err) return reject(err)
  373. ctx.rClient.expire
  374. .calledWith(
  375. `clients_in_project:${ctx.project_id}`,
  376. 24 * 4 * 60 * 60
  377. )
  378. .should.equal(true)
  379. resolve()
  380. }
  381. )
  382. })
  383. })
  384. describe('editing_session_mode', function () {
  385. const cases = {
  386. 'should bump the metric when disconnecting from now empty room': {
  387. nConnectedClients: 0,
  388. labels: {
  389. method: 'disconnect',
  390. status: 'empty',
  391. },
  392. },
  393. 'should bump the metric when disconnecting from now single room': {
  394. nConnectedClients: 1,
  395. labels: {
  396. method: 'disconnect',
  397. status: 'single',
  398. },
  399. },
  400. 'should bump the metric when disconnecting from now multi room': {
  401. nConnectedClients: 2,
  402. labels: {
  403. method: 'disconnect',
  404. status: 'multi',
  405. },
  406. },
  407. }
  408. for (const [name, { nConnectedClients, labels }] of Object.entries(
  409. cases
  410. )) {
  411. it(name, async function (ctx) {
  412. await new Promise((resolve, reject) => {
  413. ctx.rClient.exec.yields(null, [1, nConnectedClients])
  414. ctx.ConnectedUsersManager.markUserAsDisconnected(
  415. ctx.project_id,
  416. ctx.client_id,
  417. err => {
  418. if (err) return reject(err)
  419. expect(ctx.Metrics.inc).to.have.been.calledWith(
  420. 'editing_session_mode',
  421. 1,
  422. labels
  423. )
  424. resolve()
  425. }
  426. )
  427. })
  428. })
  429. }
  430. })
  431. describe('projectNotEmptySince', function () {
  432. it('should clear the projectNotEmptySince key when empty and skip metric if not set', async function (ctx) {
  433. await new Promise((resolve, reject) => {
  434. ctx.rClient.exec.yields(null, [1, 0])
  435. ctx.rClient.getdel.yields(null, '')
  436. ctx.ConnectedUsersManager.markUserAsDisconnected(
  437. ctx.project_id,
  438. ctx.client_id,
  439. err => {
  440. if (err) return reject(err)
  441. expect(ctx.rClient.getdel).to.have.been.calledWith(
  442. `projectNotEmptySince:{${ctx.project_id}}`
  443. )
  444. expect(ctx.Metrics.histogram).to.not.have.been.called
  445. resolve()
  446. }
  447. )
  448. })
  449. })
  450. it('should clear the projectNotEmptySince key when empty and record metric if set', async function (ctx) {
  451. await new Promise((resolve, reject) => {
  452. ctx.rClient.exec.onFirstCall().yields(null, [1, 0])
  453. tk.freeze(1_234_000)
  454. ctx.rClient.getdel.yields(null, '1230')
  455. ctx.ConnectedUsersManager.markUserAsDisconnected(
  456. ctx.project_id,
  457. ctx.client_id,
  458. err => {
  459. if (err) return reject(err)
  460. expect(ctx.rClient.getdel).to.have.been.calledWith(
  461. `projectNotEmptySince:{${ctx.project_id}}`
  462. )
  463. expect(ctx.Metrics.histogram).to.have.been.calledWith(
  464. 'project_not_empty_since',
  465. 4,
  466. sinon.match.any,
  467. { status: 'empty' }
  468. )
  469. resolve()
  470. }
  471. )
  472. })
  473. })
  474. it('should set projectNotEmptySince key when single and skip metric if not set before', async function (ctx) {
  475. await new Promise((resolve, reject) => {
  476. ctx.rClient.exec.onFirstCall().yields(null, [1, 1])
  477. tk.freeze(1_233_001) // should ceil up
  478. ctx.rClient.exec.onSecondCall().yields(null, [''])
  479. ctx.ConnectedUsersManager.markUserAsDisconnected(
  480. ctx.project_id,
  481. ctx.client_id,
  482. err => {
  483. if (err) return reject(err)
  484. expect(ctx.rClient.set).to.have.been.calledWith(
  485. `projectNotEmptySince:{${ctx.project_id}}`,
  486. '1234',
  487. 'NX',
  488. 'EX',
  489. 31 * 24 * 60 * 60
  490. )
  491. expect(ctx.Metrics.histogram).to.not.have.been.called
  492. resolve()
  493. }
  494. )
  495. })
  496. })
  497. const cases = {
  498. 'should set projectNotEmptySince key when single and record metric if set before':
  499. {
  500. nConnectedClients: 1,
  501. labels: {
  502. status: 'single',
  503. },
  504. },
  505. 'should set projectNotEmptySince key when multi and record metric if set before':
  506. {
  507. nConnectedClients: 2,
  508. labels: {
  509. status: 'multi',
  510. },
  511. },
  512. }
  513. for (const [name, { nConnectedClients, labels }] of Object.entries(
  514. cases
  515. )) {
  516. it(name, async function (ctx) {
  517. await new Promise((resolve, reject) => {
  518. ctx.rClient.exec.onFirstCall().yields(null, [1, nConnectedClients])
  519. tk.freeze(1_235_000)
  520. ctx.rClient.exec.onSecondCall().yields(null, ['1230'])
  521. ctx.ConnectedUsersManager.markUserAsDisconnected(
  522. ctx.project_id,
  523. ctx.client_id,
  524. err => {
  525. if (err) return reject(err)
  526. expect(ctx.rClient.set).to.have.been.calledWith(
  527. `projectNotEmptySince:{${ctx.project_id}}`,
  528. '1235',
  529. 'NX',
  530. 'EX',
  531. 31 * 24 * 60 * 60
  532. )
  533. expect(ctx.Metrics.histogram).to.have.been.calledWith(
  534. 'project_not_empty_since',
  535. 5,
  536. sinon.match.any,
  537. labels
  538. )
  539. resolve()
  540. }
  541. )
  542. })
  543. })
  544. }
  545. })
  546. })
  547. describe('_getConnectedUser', function () {
  548. it('should return a connected user if there is a user object', async function (ctx) {
  549. await new Promise((resolve, reject) => {
  550. const cursorData = JSON.stringify({ cursorData: { row: 1 } })
  551. ctx.rClient.hgetall.callsArgWith(1, null, {
  552. connected_at: new Date(),
  553. user_id: ctx.user._id,
  554. last_updated_at: `${Date.now()}`,
  555. cursorData,
  556. })
  557. ctx.ConnectedUsersManager._getConnectedUser(
  558. ctx.project_id,
  559. ctx.client_id,
  560. (err, result) => {
  561. if (err) return reject(err)
  562. result.connected.should.equal(true)
  563. result.client_id.should.equal(ctx.client_id)
  564. resolve()
  565. }
  566. )
  567. })
  568. })
  569. it('should return a not connected user if there is no object', async function (ctx) {
  570. await new Promise((resolve, reject) => {
  571. ctx.rClient.hgetall.callsArgWith(1, null, null)
  572. ctx.ConnectedUsersManager._getConnectedUser(
  573. ctx.project_id,
  574. ctx.client_id,
  575. (err, result) => {
  576. if (err) return reject(err)
  577. result.connected.should.equal(false)
  578. result.client_id.should.equal(ctx.client_id)
  579. resolve()
  580. }
  581. )
  582. })
  583. })
  584. it('should return a not connected user if there is an empty object', async function (ctx) {
  585. await new Promise((resolve, reject) => {
  586. ctx.rClient.hgetall.callsArgWith(1, null, {})
  587. ctx.ConnectedUsersManager._getConnectedUser(
  588. ctx.project_id,
  589. ctx.client_id,
  590. (err, result) => {
  591. if (err) return reject(err)
  592. result.connected.should.equal(false)
  593. result.client_id.should.equal(ctx.client_id)
  594. resolve()
  595. }
  596. )
  597. })
  598. })
  599. })
  600. describe('getConnectedUsers', function () {
  601. beforeEach(function (ctx) {
  602. ctx.users = ['1234', '5678', '9123', '8234']
  603. ctx.rClient.smembers.callsArgWith(1, null, ctx.users)
  604. ctx.ConnectedUsersManager._getConnectedUser = sinon.stub()
  605. ctx.ConnectedUsersManager._getConnectedUser
  606. .withArgs(ctx.project_id, ctx.users[0])
  607. .callsArgWith(2, null, {
  608. connected: true,
  609. client_age: 2,
  610. client_id: ctx.users[0],
  611. })
  612. ctx.ConnectedUsersManager._getConnectedUser
  613. .withArgs(ctx.project_id, ctx.users[1])
  614. .callsArgWith(2, null, {
  615. connected: false,
  616. client_age: 1,
  617. client_id: ctx.users[1],
  618. })
  619. ctx.ConnectedUsersManager._getConnectedUser
  620. .withArgs(ctx.project_id, ctx.users[2])
  621. .callsArgWith(2, null, {
  622. connected: true,
  623. client_age: 3,
  624. client_id: ctx.users[2],
  625. })
  626. ctx.ConnectedUsersManager._getConnectedUser
  627. .withArgs(ctx.project_id, ctx.users[3])
  628. .callsArgWith(2, null, {
  629. connected: true,
  630. client_age: 11,
  631. client_id: ctx.users[3],
  632. })
  633. }) // connected but old
  634. it('should only return the users in the list which are still in redis and recently updated', async function (ctx) {
  635. await new Promise((resolve, reject) => {
  636. ctx.ConnectedUsersManager.getConnectedUsers(
  637. ctx.project_id,
  638. (err, users) => {
  639. if (err) return reject(err)
  640. users.length.should.equal(2)
  641. users[0].should.deep.equal({
  642. client_id: ctx.users[0],
  643. client_age: 2,
  644. connected: true,
  645. })
  646. users[1].should.deep.equal({
  647. client_id: ctx.users[2],
  648. client_age: 3,
  649. connected: true,
  650. })
  651. resolve()
  652. }
  653. )
  654. })
  655. })
  656. })
  657. })