ConnectedUsersManagerTests.js 19 KB

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