Files
overleaf-cep/services/real-time/test/unit/js/ConnectedUsersManager.test.js
Andrew Rumble 3073c94522 Merge pull request #30215 from overleaf/ar/convert-real-time-to-esm
[real-time] convert real time to esm

GitOrigin-RevId: 7cc530cc977549d3274be42585735e1fd72cad5f
2026-01-13 09:06:30 +00:00

689 lines
20 KiB
JavaScript

import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest'
import path from 'node:path'
import sinon from 'sinon'
import tk from 'timekeeper'
const modulePath = path.join(
import.meta.dirname,
'../../../app/js/ConnectedUsersManager'
)
describe('ConnectedUsersManager', function () {
beforeEach(async function (ctx) {
tk.freeze(new Date())
ctx.settings = {
redis: {
realtime: {
key_schema: {
clientsInProject({ project_id: projectId }) {
return `clients_in_project:${projectId}`
},
connectedUser({ project_id: projectId, client_id: clientId }) {
return `connected_user:${projectId}:${clientId}`
},
projectNotEmptySince({ projectId }) {
return `projectNotEmptySince:{${projectId}}`
},
},
},
},
}
ctx.rClient = {
auth() {},
getdel: sinon.stub(),
scard: sinon.stub(),
set: sinon.stub(),
setex: sinon.stub(),
sadd: sinon.stub(),
get: sinon.stub(),
srem: sinon.stub(),
del: sinon.stub(),
smembers: sinon.stub(),
expire: sinon.stub(),
hset: sinon.stub(),
hgetall: sinon.stub(),
exec: sinon.stub(),
multi: () => {
return ctx.rClient
},
}
ctx.Metrics = {
inc: sinon.stub(),
histogram: sinon.stub(),
}
vi.doMock('@overleaf/settings', () => ({
default: ctx.settings,
}))
vi.doMock('@overleaf/metrics', () => ({
default: ctx.Metrics,
}))
vi.doMock('@overleaf/redis-wrapper', () => ({
default: {
createClient: () => {
return ctx.rClient
},
},
}))
ctx.ConnectedUsersManager = (await import(modulePath)).default
ctx.client_id = '32132132'
ctx.project_id = 'dskjh2u21321'
ctx.user = {
_id: 'user-id-123',
first_name: 'Joe',
last_name: 'Bloggs',
email: 'joe@example.com',
}
ctx.cursorData = {
row: 12,
column: 9,
doc_id: '53c3b8c85fee64000023dc6e',
}
})
afterEach(function () {
tk.reset()
})
describe('updateUserPosition', function () {
beforeEach(function (ctx) {
ctx.rClient.exec.yields(null, [1, 1])
})
it('should set a key with the date and give it a ttl', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.ConnectedUsersManager.updateUserPosition(
ctx.project_id,
ctx.client_id,
ctx.user,
null,
err => {
if (err) return reject(err)
ctx.rClient.hset
.calledWith(
`connected_user:${ctx.project_id}:${ctx.client_id}`,
'last_updated_at',
Date.now()
)
.should.equal(true)
resolve()
}
)
})
})
it('should set a key with the user_id', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.ConnectedUsersManager.updateUserPosition(
ctx.project_id,
ctx.client_id,
ctx.user,
null,
err => {
if (err) return reject(err)
ctx.rClient.hset
.calledWith(
`connected_user:${ctx.project_id}:${ctx.client_id}`,
'user_id',
ctx.user._id
)
.should.equal(true)
resolve()
}
)
})
})
it('should set a key with the first_name', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.ConnectedUsersManager.updateUserPosition(
ctx.project_id,
ctx.client_id,
ctx.user,
null,
err => {
if (err) return reject(err)
ctx.rClient.hset
.calledWith(
`connected_user:${ctx.project_id}:${ctx.client_id}`,
'first_name',
ctx.user.first_name
)
.should.equal(true)
resolve()
}
)
})
})
it('should set a key with the last_name', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.ConnectedUsersManager.updateUserPosition(
ctx.project_id,
ctx.client_id,
ctx.user,
null,
err => {
if (err) return reject(err)
ctx.rClient.hset
.calledWith(
`connected_user:${ctx.project_id}:${ctx.client_id}`,
'last_name',
ctx.user.last_name
)
.should.equal(true)
resolve()
}
)
})
})
it('should set a key with the email', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.ConnectedUsersManager.updateUserPosition(
ctx.project_id,
ctx.client_id,
ctx.user,
null,
err => {
if (err) return reject(err)
ctx.rClient.hset
.calledWith(
`connected_user:${ctx.project_id}:${ctx.client_id}`,
'email',
ctx.user.email
)
.should.equal(true)
resolve()
}
)
})
})
it('should push the client_id on to the project list', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.ConnectedUsersManager.updateUserPosition(
ctx.project_id,
ctx.client_id,
ctx.user,
null,
err => {
if (err) return reject(err)
ctx.rClient.sadd
.calledWith(`clients_in_project:${ctx.project_id}`, ctx.client_id)
.should.equal(true)
resolve()
}
)
})
})
it('should add a ttl to the project set so it stays clean', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.ConnectedUsersManager.updateUserPosition(
ctx.project_id,
ctx.client_id,
ctx.user,
null,
err => {
if (err) return reject(err)
ctx.rClient.expire
.calledWith(
`clients_in_project:${ctx.project_id}`,
24 * 4 * 60 * 60
)
.should.equal(true)
resolve()
}
)
})
})
it('should add a ttl to the connected user so it stays clean', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.ConnectedUsersManager.updateUserPosition(
ctx.project_id,
ctx.client_id,
ctx.user,
null,
err => {
if (err) return reject(err)
ctx.rClient.expire
.calledWith(
`connected_user:${ctx.project_id}:${ctx.client_id}`,
60 * 15
)
.should.equal(true)
resolve()
}
)
})
})
it('should set the cursor position when provided', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.ConnectedUsersManager.updateUserPosition(
ctx.project_id,
ctx.client_id,
ctx.user,
ctx.cursorData,
err => {
if (err) return reject(err)
ctx.rClient.hset
.calledWith(
`connected_user:${ctx.project_id}:${ctx.client_id}`,
'cursorData',
JSON.stringify(ctx.cursorData)
)
.should.equal(true)
resolve()
}
)
})
})
describe('editing_session_mode', function () {
const cases = {
'should bump the metric when connecting to empty room': {
nConnectedClients: 1,
cursorData: null,
labels: {
method: 'connect',
status: 'single',
},
},
'should bump the metric when connecting to non-empty room': {
nConnectedClients: 2,
cursorData: null,
labels: {
method: 'connect',
status: 'multi',
},
},
'should bump the metric when updating in empty room': {
nConnectedClients: 1,
cursorData: { row: 42 },
labels: {
method: 'update',
status: 'single',
},
},
'should bump the metric when updating in non-empty room': {
nConnectedClients: 2,
cursorData: { row: 42 },
labels: {
method: 'update',
status: 'multi',
},
},
}
for (const [
name,
{ nConnectedClients, cursorData, labels },
] of Object.entries(cases)) {
it(name, async function (ctx) {
await new Promise((resolve, reject) => {
ctx.rClient.exec.yields(null, [1, nConnectedClients])
ctx.ConnectedUsersManager.updateUserPosition(
ctx.project_id,
ctx.client_id,
ctx.user,
cursorData,
err => {
if (err) return reject(err)
expect(ctx.Metrics.inc).to.have.been.calledWith(
'editing_session_mode',
1,
labels
)
resolve()
}
)
})
})
}
})
})
describe('markUserAsDisconnected', function () {
beforeEach(function (ctx) {
ctx.rClient.exec.yields(null, [1, 0])
})
it('should remove the user from the set', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.ConnectedUsersManager.markUserAsDisconnected(
ctx.project_id,
ctx.client_id,
err => {
if (err) return reject(err)
ctx.rClient.srem
.calledWith(`clients_in_project:${ctx.project_id}`, ctx.client_id)
.should.equal(true)
resolve()
}
)
})
})
it('should delete the connected_user string', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.ConnectedUsersManager.markUserAsDisconnected(
ctx.project_id,
ctx.client_id,
err => {
if (err) return reject(err)
ctx.rClient.del
.calledWith(`connected_user:${ctx.project_id}:${ctx.client_id}`)
.should.equal(true)
resolve()
}
)
})
})
it('should add a ttl to the connected user set so it stays clean', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.ConnectedUsersManager.markUserAsDisconnected(
ctx.project_id,
ctx.client_id,
err => {
if (err) return reject(err)
ctx.rClient.expire
.calledWith(
`clients_in_project:${ctx.project_id}`,
24 * 4 * 60 * 60
)
.should.equal(true)
resolve()
}
)
})
})
describe('editing_session_mode', function () {
const cases = {
'should bump the metric when disconnecting from now empty room': {
nConnectedClients: 0,
labels: {
method: 'disconnect',
status: 'empty',
},
},
'should bump the metric when disconnecting from now single room': {
nConnectedClients: 1,
labels: {
method: 'disconnect',
status: 'single',
},
},
'should bump the metric when disconnecting from now multi room': {
nConnectedClients: 2,
labels: {
method: 'disconnect',
status: 'multi',
},
},
}
for (const [name, { nConnectedClients, labels }] of Object.entries(
cases
)) {
it(name, async function (ctx) {
await new Promise((resolve, reject) => {
ctx.rClient.exec.yields(null, [1, nConnectedClients])
ctx.ConnectedUsersManager.markUserAsDisconnected(
ctx.project_id,
ctx.client_id,
err => {
if (err) return reject(err)
expect(ctx.Metrics.inc).to.have.been.calledWith(
'editing_session_mode',
1,
labels
)
resolve()
}
)
})
})
}
})
describe('projectNotEmptySince', function () {
it('should clear the projectNotEmptySince key when empty and skip metric if not set', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.rClient.exec.yields(null, [1, 0])
ctx.rClient.getdel.yields(null, '')
ctx.ConnectedUsersManager.markUserAsDisconnected(
ctx.project_id,
ctx.client_id,
err => {
if (err) return reject(err)
expect(ctx.rClient.getdel).to.have.been.calledWith(
`projectNotEmptySince:{${ctx.project_id}}`
)
expect(ctx.Metrics.histogram).to.not.have.been.called
resolve()
}
)
})
})
it('should clear the projectNotEmptySince key when empty and record metric if set', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.rClient.exec.onFirstCall().yields(null, [1, 0])
tk.freeze(1_234_000)
ctx.rClient.getdel.yields(null, '1230')
ctx.ConnectedUsersManager.markUserAsDisconnected(
ctx.project_id,
ctx.client_id,
err => {
if (err) return reject(err)
expect(ctx.rClient.getdel).to.have.been.calledWith(
`projectNotEmptySince:{${ctx.project_id}}`
)
expect(ctx.Metrics.histogram).to.have.been.calledWith(
'project_not_empty_since',
4,
sinon.match.any,
{ status: 'empty' }
)
resolve()
}
)
})
})
it('should set projectNotEmptySince key when single and skip metric if not set before', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.rClient.exec.onFirstCall().yields(null, [1, 1])
tk.freeze(1_233_001) // should ceil up
ctx.rClient.exec.onSecondCall().yields(null, [''])
ctx.ConnectedUsersManager.markUserAsDisconnected(
ctx.project_id,
ctx.client_id,
err => {
if (err) return reject(err)
expect(ctx.rClient.set).to.have.been.calledWith(
`projectNotEmptySince:{${ctx.project_id}}`,
'1234',
'NX',
'EX',
31 * 24 * 60 * 60
)
expect(ctx.Metrics.histogram).to.not.have.been.called
resolve()
}
)
})
})
const cases = {
'should set projectNotEmptySince key when single and record metric if set before':
{
nConnectedClients: 1,
labels: {
status: 'single',
},
},
'should set projectNotEmptySince key when multi and record metric if set before':
{
nConnectedClients: 2,
labels: {
status: 'multi',
},
},
}
for (const [name, { nConnectedClients, labels }] of Object.entries(
cases
)) {
it(name, async function (ctx) {
await new Promise((resolve, reject) => {
ctx.rClient.exec.onFirstCall().yields(null, [1, nConnectedClients])
tk.freeze(1_235_000)
ctx.rClient.exec.onSecondCall().yields(null, ['1230'])
ctx.ConnectedUsersManager.markUserAsDisconnected(
ctx.project_id,
ctx.client_id,
err => {
if (err) return reject(err)
expect(ctx.rClient.set).to.have.been.calledWith(
`projectNotEmptySince:{${ctx.project_id}}`,
'1235',
'NX',
'EX',
31 * 24 * 60 * 60
)
expect(ctx.Metrics.histogram).to.have.been.calledWith(
'project_not_empty_since',
5,
sinon.match.any,
labels
)
resolve()
}
)
})
})
}
})
})
describe('_getConnectedUser', function () {
it('should return a connected user if there is a user object', async function (ctx) {
await new Promise((resolve, reject) => {
const cursorData = JSON.stringify({ cursorData: { row: 1 } })
ctx.rClient.hgetall.callsArgWith(1, null, {
connected_at: new Date(),
user_id: ctx.user._id,
last_updated_at: `${Date.now()}`,
cursorData,
})
ctx.ConnectedUsersManager._getConnectedUser(
ctx.project_id,
ctx.client_id,
(err, result) => {
if (err) return reject(err)
result.connected.should.equal(true)
result.client_id.should.equal(ctx.client_id)
resolve()
}
)
})
})
it('should return a not connected user if there is no object', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.rClient.hgetall.callsArgWith(1, null, null)
ctx.ConnectedUsersManager._getConnectedUser(
ctx.project_id,
ctx.client_id,
(err, result) => {
if (err) return reject(err)
result.connected.should.equal(false)
result.client_id.should.equal(ctx.client_id)
resolve()
}
)
})
})
it('should return a not connected user if there is an empty object', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.rClient.hgetall.callsArgWith(1, null, {})
ctx.ConnectedUsersManager._getConnectedUser(
ctx.project_id,
ctx.client_id,
(err, result) => {
if (err) return reject(err)
result.connected.should.equal(false)
result.client_id.should.equal(ctx.client_id)
resolve()
}
)
})
})
})
describe('getConnectedUsers', function () {
beforeEach(function (ctx) {
ctx.users = ['1234', '5678', '9123', '8234']
ctx.rClient.smembers.callsArgWith(1, null, ctx.users)
ctx.ConnectedUsersManager._getConnectedUser = sinon.stub()
ctx.ConnectedUsersManager._getConnectedUser
.withArgs(ctx.project_id, ctx.users[0])
.callsArgWith(2, null, {
connected: true,
client_age: 2,
client_id: ctx.users[0],
})
ctx.ConnectedUsersManager._getConnectedUser
.withArgs(ctx.project_id, ctx.users[1])
.callsArgWith(2, null, {
connected: false,
client_age: 1,
client_id: ctx.users[1],
})
ctx.ConnectedUsersManager._getConnectedUser
.withArgs(ctx.project_id, ctx.users[2])
.callsArgWith(2, null, {
connected: true,
client_age: 3,
client_id: ctx.users[2],
})
ctx.ConnectedUsersManager._getConnectedUser
.withArgs(ctx.project_id, ctx.users[3])
.callsArgWith(2, null, {
connected: true,
client_age: 11,
client_id: ctx.users[3],
})
}) // connected but old
it('should only return the users in the list which are still in redis and recently updated', async function (ctx) {
await new Promise((resolve, reject) => {
ctx.ConnectedUsersManager.getConnectedUsers(
ctx.project_id,
(err, users) => {
if (err) return reject(err)
users.length.should.equal(2)
users[0].should.deep.equal({
client_id: ctx.users[0],
client_age: 2,
connected: true,
})
users[1].should.deep.equal({
client_id: ctx.users[2],
client_age: 3,
connected: true,
})
resolve()
}
)
})
})
})
})