refactor(realtime): connection flow to queue

Signed-off-by: BoHong Li <a60814billy@gmail.com>
This commit is contained in:
BoHong Li 2019-05-20 16:04:35 +08:00
parent 0b03b8e9ba
commit 17e82c11c9
No known key found for this signature in database
GPG Key ID: 9696D5590D58290F
9 changed files with 552 additions and 367 deletions

3
app.js
View File

@ -299,6 +299,9 @@ function handleTermSignals () {
}) })
} }
}, 100) }, 100)
setTimeout(() => {
process.exit(1)
}, 5000)
} }
process.on('SIGINT', handleTermSignals) process.on('SIGINT', handleTermSignals)
process.on('SIGTERM', handleTermSignals) process.on('SIGTERM', handleTermSignals)

View File

@ -7,11 +7,20 @@ const EventEmitter = require('events').EventEmitter
*/ */
const QueueEvent = { const QueueEvent = {
Tick: 'Tick' Tick: 'Tick',
Push: 'Push',
Finish: 'Finish'
} }
class ProcessQueue extends EventEmitter { class ProcessQueue extends EventEmitter {
constructor (maximumLength, triggerTimeInterval = 10) { constructor ({
maximumLength = 500,
triggerTimeInterval = 5000,
// execute on push
proactiveMode = true,
// execute next work on finish
continuousMode = true
}) {
super() super()
this.max = maximumLength this.max = maximumLength
this.triggerTime = triggerTimeInterval this.triggerTime = triggerTimeInterval
@ -19,13 +28,21 @@ class ProcessQueue extends EventEmitter {
this.queue = [] this.queue = []
this.lock = false this.lock = false
this.on(QueueEvent.Tick, () => { this.on(QueueEvent.Tick, this.onEventProcessFunc.bind(this))
if (proactiveMode) {
this.on(QueueEvent.Push, this.onEventProcessFunc.bind(this))
}
if (continuousMode) {
this.on(QueueEvent.Finish, this.onEventProcessFunc.bind(this))
}
}
onEventProcessFunc () {
if (this.lock) return if (this.lock) return
this.lock = true this.lock = true
setImmediate(() => { setImmediate(() => {
this.process() this.process()
}) })
})
} }
start () { start () {
@ -62,7 +79,7 @@ class ProcessQueue extends EventEmitter {
this.taskMap.set(id, true) this.taskMap.set(id, true)
this.queue.push(task) this.queue.push(task)
this.start() this.start()
this.emit(QueueEvent.Tick) this.emit(QueueEvent.Push)
return true return true
} }
@ -79,7 +96,7 @@ class ProcessQueue extends EventEmitter {
const finishTask = () => { const finishTask = () => {
this.lock = false this.lock = false
setImmediate(() => { setImmediate(() => {
this.emit(QueueEvent.Tick) this.emit(QueueEvent.Finish)
}) })
} }
task.processingFunc().then(finishTask).catch(finishTask) task.processingFunc().then(finishTask).catch(finishTask)

View File

@ -38,7 +38,8 @@ const realtime = {
maintenance: true maintenance: true
} }
const disconnectProcessQueue = new ProcessQueue(2000, 500) const connectProcessQueue = new ProcessQueue({})
const disconnectProcessQueue = new ProcessQueue({})
const updateDirtyNoteJob = new UpdateDirtyNoteJob(realtime) const updateDirtyNoteJob = new UpdateDirtyNoteJob(realtime)
const cleanDanglingUserJob = new CleanDanglingUserJob(realtime) const cleanDanglingUserJob = new CleanDanglingUserJob(realtime)
const saveRevisionJob = new SaveRevisionJob(realtime) const saveRevisionJob = new SaveRevisionJob(realtime)
@ -97,6 +98,46 @@ function emitCheck (note) {
var users = {} var users = {}
var notes = {} var notes = {}
function getNotePool () {
return notes
}
function isNoteExistsInPool (noteId) {
return !!notes[noteId]
}
function addNote (note) {
if (exports.isNoteExistsInPool(note.id)) return false
notes[note.id] = note
return true
}
function getNotePoolSize () {
return Object.keys(notes).length
}
function deleteNoteFromPool(noteId) {
delete notes[noteId]
}
function deleteAllNoteFromPool() {
Object.keys(notes).forEach(noteId => {
delete notes[noteId]
})
}
function getNoteFromNotePool (noteId) {
return notes[noteId]
}
function getUserPool () {
return users
}
function getUserFromUserPool (userId) {
return users[userId]
}
disconnectProcessQueue.start() disconnectProcessQueue.start()
updateDirtyNoteJob.start() updateDirtyNoteJob.start()
cleanDanglingUserJob.start() cleanDanglingUserJob.start()
@ -265,7 +306,8 @@ function getStatus (callback) {
function isReady () { function isReady () {
return realtime.io && return realtime.io &&
Object.keys(notes).length === 0 && Object.keys(users).length === 0 && Object.keys(notes).length === 0 && Object.keys(users).length === 0 &&
connectionSocketQueue.length === 0 && !isConnectionBusy && !isConnectionBusy &&
connectProcessQueue.queue.length === 0 && !connectProcessQueue.lock &&
disconnectProcessQueue.queue.length === 0 && !disconnectProcessQueue.lock disconnectProcessQueue.queue.length === 0 && !disconnectProcessQueue.lock
} }
@ -327,6 +369,15 @@ function parseNoteIdFromSocket (socket, callback) {
}) })
} }
function parseNoteIdFromSocketAsync (socket) {
return new Promise((resolve, reject) => {
parseNoteIdFromSocket(socket, (err, id) => {
if (err) return reject(err)
resolve(id)
})
})
}
// TODO: test it // TODO: test it
function emitOnlineUsers (socket) { function emitOnlineUsers (socket) {
var noteId = socket.noteId var noteId = socket.noteId
@ -374,48 +425,6 @@ function emitRefresh (socket) {
socket.emit('refresh', out) socket.emit('refresh', out)
} }
// TODO: test it
function isDuplicatedInSocketQueue (queue, socket) {
for (var i = 0; i < queue.length; i++) {
if (queue[i] && queue[i].id === socket.id) {
return true
}
}
return false
}
// TODO: test it
function clearSocketQueue (queue, socket) {
for (var i = 0; i < queue.length; i++) {
if (!queue[i] || queue[i].id === socket.id) {
queue.splice(i, 1)
i--
}
}
}
// TODO: test it
function connectNextSocket () {
setTimeout(function () {
isConnectionBusy = false
if (connectionSocketQueue.length > 0) {
startConnection(connectionSocketQueue[0])
}
}, 1)
}
// TODO: test it
function interruptConnection (socket, noteId, socketId) {
if (notes[noteId]) delete notes[noteId]
if (users[socketId]) delete users[socketId]
if (socket) {
clearSocketQueue(connectionSocketQueue, socket)
} else {
connectionSocketQueue.shift()
}
connectNextSocket()
}
function checkViewPermission (req, note) { function checkViewPermission (req, note) {
if (note.permission === 'private') { if (note.permission === 'private') {
if (req.user && req.user.logged_in && req.user.id === note.owner) { if (req.user && req.user.logged_in && req.user.id === note.owner) {
@ -435,65 +444,14 @@ function checkViewPermission (req, note) {
} }
var isConnectionBusy = false var isConnectionBusy = false
var connectionSocketQueue = []
// TODO: test it // TODO: test it
function finishConnection (socket, noteId, socketId) { async function fetchFullNoteAsync (noteId) {
// if no valid info provided will drop the client return models.Note.findOne({
if (!socket || !notes[noteId] || !users[socketId]) { where: {
return interruptConnection(socket, noteId, socketId) id: noteId
} },
// check view permission include: [{
if (!checkViewPermission(socket.request, notes[noteId])) {
interruptConnection(socket, noteId, socketId)
return failConnection(403, 'connection forbidden', socket)
}
let note = notes[noteId]
let user = users[socketId]
// update user color to author color
if (note.authors[user.userid]) {
user.color = users[socket.id].color = note.authors[user.userid].color
}
note.users[socket.id] = user
note.socks.push(socket)
note.server.addClient(socket)
note.server.setName(socket, user.name)
note.server.setColor(socket, user.color)
// update user note history
updateHistory(user.userid, note)
emitOnlineUsers(socket)
emitRefresh(socket)
// clear finished socket in queue
clearSocketQueue(connectionSocketQueue, socket)
// seek for next socket
connectNextSocket()
if (config.debug) {
let noteId = socket.noteId
logger.info('SERVER connected a client to [' + noteId + ']:')
logger.info(JSON.stringify(user))
// logger.info(notes);
getStatus(function (data) {
logger.info(JSON.stringify(data))
})
}
}
// TODO: test it
function startConnection (socket) {
if (isConnectionBusy) return
isConnectionBusy = true
var noteId = socket.noteId
if (!noteId) {
return failConnection(404, 'note id not found', socket)
}
if (!notes[noteId]) {
var include = [{
model: models.User, model: models.User,
as: 'owner' as: 'owner'
}, { }, {
@ -507,31 +465,13 @@ function startConnection (socket) {
as: 'user' as: 'user'
}] }]
}] }]
})
}
models.Note.findOne({ function buildAuthorProfilesFromNote (noteAuthors) {
where: { const authors = {}
id: noteId noteAuthors.forEach((author) => {
}, const profile = models.User.getProfile(author.user)
include: include
}).then(function (note) {
if (!note) {
return failConnection(404, 'note not found', socket)
}
var owner = note.ownerId
var ownerprofile = note.owner ? models.User.getProfile(note.owner) : null
var lastchangeuser = note.lastchangeuserId
var lastchangeuserprofile = note.lastchangeuser ? models.User.getProfile(note.lastchangeuser) : null
var body = note.content
var createtime = note.createdAt
var updatetime = note.lastchangeAt
var server = new ot.EditorSocketIOServer(body, [], noteId, ifMayEdit, operationCallback)
var authors = {}
for (var i = 0; i < note.authors.length; i++) {
var author = note.authors[i]
var profile = models.User.getProfile(author.user)
if (profile) { if (profile) {
authors[author.userId] = { authors[author.userId] = {
userid: author.userId, userid: author.userId,
@ -540,42 +480,36 @@ function startConnection (socket) {
name: profile.name name: profile.name
} }
} }
} })
return authors
}
notes[noteId] = { function makeNewServerNote (note) {
id: noteId, const authors = buildAuthorProfilesFromNote(note.authors)
return {
id: note.id,
alias: note.alias, alias: note.alias,
title: note.title, title: note.title,
owner: owner, owner: note.ownerId,
ownerprofile: ownerprofile, ownerprofile: note.owner ? models.User.getProfile(note.owner) : null,
permission: note.permission, permission: note.permission,
lastchangeuser: lastchangeuser, lastchangeuser: note.lastchangeuserId,
lastchangeuserprofile: lastchangeuserprofile, lastchangeuserprofile: note.lastchangeuser ? models.User.getProfile(note.lastchangeuser) : null,
socks: [], socks: [],
users: {}, users: {},
tempUsers: {}, tempUsers: {},
createtime: moment(createtime).valueOf(), createtime: moment(note.createdAt).valueOf(),
updatetime: moment(updatetime).valueOf(), updatetime: moment(note.lastchangeAt).valueOf(),
server: server, server: new ot.EditorSocketIOServer(note.content, [], note.id, ifMayEdit, operationCallback),
authors: authors, authors: authors,
authorship: note.authorship authorship: note.authorship
} }
return finishConnection(socket, noteId, socket.id)
}).catch(function (err) {
return failConnection(500, err, socket)
})
} else {
return finishConnection(socket, noteId, socket.id)
}
} }
// TODO: test it // TODO: test it
function failConnection (code, err, socket) { function failConnection (code, err, socket) {
logger.error(err) logger.error(err)
// clear error socket in queue
clearSocketQueue(connectionSocketQueue, socket)
connectNextSocket()
// emit error info // emit error info
socket.emit('info', { socket.emit('info', {
code: code code: code
@ -655,7 +589,7 @@ function updateUserData (socket, user) {
} }
} }
function canEditNote(notePermission, noteOwnerId, currentUserId) { function canEditNote (notePermission, noteOwnerId, currentUserId) {
switch (notePermission) { switch (notePermission) {
case 'freely': case 'freely':
return true return true
@ -736,58 +670,40 @@ function updateHistory (userId, note, time) {
if (note.server) history.updateHistory(userId, noteId, note.server.document, time) if (note.server) history.updateHistory(userId, noteId, note.server.document, time)
} }
function getUserPool () { function getUniqueColorPerNote(noteId, maxAttempt = 10) {
return users
}
function getUserFromUserPool (userId) {
return users[userId]
}
function getNotePool () {
return notes
}
function getNoteFromNotePool (noteId) {
return notes[noteId]
}
// TODO: test it
function connection (socket) {
if (realtime.maintenance) return
exports.parseNoteIdFromSocket(socket, function (err, noteId) {
if (err) {
return exports.failConnection(500, err, socket)
}
if (!noteId) {
return exports.failConnection(404, 'note id not found', socket)
}
if (isDuplicatedInSocketQueue(connectionSocketQueue, socket)) return
// store noteId in this socket session
socket.noteId = noteId
// initialize user data
// random color // random color
var color = randomcolor() let color = randomcolor()
// make sure color not duplicated or reach max random count if (!notes[noteId]) return color
if (notes[noteId]) {
var randomcount = 0 const maxrandomcount = maxAttempt
var maxrandomcount = 10 let randomAttemp = 0
var found = false let found = false
do { do {
Object.keys(notes[noteId].users).forEach(function (userId) { Object.keys(notes[noteId].users).forEach(userId => {
if (notes[noteId].users[userId].color === color) { if (notes[noteId].users[userId].color === color) {
found = true found = true
} }
}) });
if (found) { if (found) {
color = randomcolor() color = randomcolor()
randomcount++ randomAttemp++
} }
} while (found && randomcount < maxrandomcount) } while (found && randomAttemp < maxrandomcount)
return color
}
function queueForConnect (socket) {
connectProcessQueue.push(socket.id, async function () {
try {
const noteId = await exports.parseNoteIdFromSocketAsync(socket)
if (!noteId) {
return exports.failConnection(404, 'note id not found', socket)
} }
// store noteId in this socket session
socket.noteId = noteId
// initialize user data
// random color
var color = getUniqueColorPerNote(noteId)
// create user data // create user data
users[socket.id] = { users[socket.id] = {
id: socket.id, id: socket.id,
@ -802,19 +718,89 @@ function connection (socket) {
type: null type: null
} }
exports.updateUserData(socket, users[socket.id]) exports.updateUserData(socket, users[socket.id])
try {
// start connection if (!isNoteExistsInPool(noteId)) {
connectionSocketQueue.push(socket) const note = await fetchFullNoteAsync(noteId)
exports.startConnection(socket) if (!note) {
logger.error('note not found')
// emit error info
socket.emit('info', {
code: 404
}) })
return socket.disconnect(true)
}
getNotePool()[noteId] = makeNewServerNote(note)
}
// if no valid info provided will drop the client
if (!socket || !notes[noteId] || !users[socket.id]) {
if (notes[noteId]) delete notes[noteId]
if (users[socket.id]) delete users[socket.id]
return
}
// check view permission
if (!exports.checkViewPermission(socket.request, notes[noteId])) {
if (notes[noteId]) delete notes[noteId]
if (users[socket.id]) delete users[socket.id]
logger.error('connection forbidden')
// emit error info
socket.emit('info', {
code: 403
})
return socket.disconnect(true)
}
let note = notes[noteId]
let user = users[socket.id]
// update user color to author color
if (note.authors[user.userid]) {
user.color = users[socket.id].color = note.authors[user.userid].color
}
note.users[socket.id] = user
note.socks.push(socket)
note.server.addClient(socket)
note.server.setName(socket, user.name)
note.server.setColor(socket, user.color)
// update user note history
exports.updateHistory(user.userid, note)
exports.emitOnlineUsers(socket)
exports.emitRefresh(socket)
const socketClient = new RealtimeClientConnection(socket) const socketClient = new RealtimeClientConnection(socket)
socketClient.registerEventHandler() socketClient.registerEventHandler()
if (config.debug) {
let noteId = socket.noteId
logger.info('SERVER connected a client to [' + noteId + ']:')
logger.info(JSON.stringify(user))
// logger.info(notes);
getStatus(function (data) {
logger.info(JSON.stringify(data))
})
}
} catch (err) {
logger.error(err)
// emit error info
socket.emit('info', {
code: 500
})
return socket.disconnect(true)
}
} catch (err) {
return exports.failConnection(500, err, socket)
}
})
}
function connection (socket) {
if (realtime.maintenance) return
queueForConnect(socket)
} }
// TODO: test it // TODO: test it
function terminate () { function terminate () {
disconnectProcessQueue.stop() disconnectProcessQueue.stop()
connectProcessQueue.stop()
updateDirtyNoteJob.stop() updateDirtyNoteJob.stop()
} }
@ -823,25 +809,31 @@ exports.extractNoteIdFromSocket = extractNoteIdFromSocket
exports.parseNoteIdFromSocket = parseNoteIdFromSocket exports.parseNoteIdFromSocket = parseNoteIdFromSocket
exports.updateNote = updateNote exports.updateNote = updateNote
exports.failConnection = failConnection exports.failConnection = failConnection
exports.isDuplicatedInSocketQueue = isDuplicatedInSocketQueue
exports.updateUserData = updateUserData exports.updateUserData = updateUserData
exports.startConnection = startConnection
exports.emitRefresh = emitRefresh exports.emitRefresh = emitRefresh
exports.emitUserStatus = emitUserStatus exports.emitUserStatus = emitUserStatus
exports.emitOnlineUsers = emitOnlineUsers exports.emitOnlineUsers = emitOnlineUsers
exports.checkViewPermission = checkViewPermission exports.checkViewPermission = checkViewPermission
exports.getNoteFromNotePool = getNoteFromNotePool
exports.getUserFromUserPool = getUserFromUserPool exports.getUserFromUserPool = getUserFromUserPool
exports.buildUserOutData = buildUserOutData exports.buildUserOutData = buildUserOutData
exports.getNotePool = getNotePool
exports.emitCheck = emitCheck exports.emitCheck = emitCheck
exports.disconnectSocketOnNote = disconnectSocketOnNote exports.disconnectSocketOnNote = disconnectSocketOnNote
exports.queueForDisconnect = queueForDisconnect exports.queueForDisconnect = queueForDisconnect
exports.terminate = terminate exports.terminate = terminate
exports.getUserPool = getUserPool
exports.updateHistory = updateHistory exports.updateHistory = updateHistory
exports.ifMayEdit = ifMayEdit exports.ifMayEdit = ifMayEdit
exports.parseNoteIdFromSocketAsync = parseNoteIdFromSocketAsync
exports.disconnectProcessQueue = disconnectProcessQueue exports.disconnectProcessQueue = disconnectProcessQueue
exports.notes = notes
exports.users = users exports.users = users
exports.getUserPool = getUserPool
exports.notes = notes
exports.getNotePool = getNotePool
exports.getNotePoolSize = getNotePoolSize
exports.isNoteExistsInPool = isNoteExistsInPool
exports.addNote = addNote
exports.getNoteFromNotePool = getNoteFromNotePool
exports.deleteNoteFromPool = deleteNoteFromPool
exports.deleteAllNoteFromPool = deleteAllNoteFromPool
exports.saveRevisionJob = saveRevisionJob exports.saveRevisionJob = saveRevisionJob

View File

@ -22,7 +22,7 @@ describe('ProcessQueue', function () {
}) })
it('should not accept more than maximum task', () => { it('should not accept more than maximum task', () => {
const queue = new ProcessQueue(2) const queue = new ProcessQueue({ maximumLength: 2 })
const task = { const task = {
id: 1, id: 1,
processingFunc: async () => { processingFunc: async () => {
@ -36,7 +36,7 @@ describe('ProcessQueue', function () {
it('should run task every interval', (done) => { it('should run task every interval', (done) => {
const runningClock = [] const runningClock = []
const queue = new ProcessQueue(2) const queue = new ProcessQueue({ maximumLength: 2 })
const task = async () => { const task = async () => {
runningClock.push(clock.now) runningClock.push(clock.now)
} }
@ -62,7 +62,7 @@ describe('ProcessQueue', function () {
}) })
it('should not crash when repeat stop queue', () => { it('should not crash when repeat stop queue', () => {
const queue = new ProcessQueue(2, 10) const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 10 })
try { try {
queue.stop() queue.stop()
queue.stop() queue.stop()
@ -74,7 +74,7 @@ describe('ProcessQueue', function () {
}) })
it('should run process when queue is empty', (done) => { it('should run process when queue is empty', (done) => {
const queue = new ProcessQueue(2, 100) const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 100 })
const processSpy = sinon.spy(queue, 'process') const processSpy = sinon.spy(queue, 'process')
queue.start() queue.start()
clock.tick(100) clock.tick(100)
@ -85,7 +85,7 @@ describe('ProcessQueue', function () {
}) })
it('should run process although error occurred', (done) => { it('should run process although error occurred', (done) => {
const queue = new ProcessQueue(2, 100) const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 100 })
const failedTask = sinon.spy(async () => { const failedTask = sinon.spy(async () => {
throw new Error('error') throw new Error('error')
}) })
@ -107,7 +107,7 @@ describe('ProcessQueue', function () {
}) })
it('should ignore trigger when event not complete', (done) => { it('should ignore trigger when event not complete', (done) => {
const queue = new ProcessQueue(2, 10) const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 10 })
const processSpy = sinon.spy(queue, 'process') const processSpy = sinon.spy(queue, 'process')
const longTask = async () => { const longTask = async () => {
return new Promise((resolve) => { return new Promise((resolve) => {

View File

@ -0,0 +1,193 @@
/* eslint-env node, mocha */
'use strict'
const assert = require('assert')
const mock = require('mock-require')
const sinon = require('sinon')
const { createFakeLogger } = require('../testDoubles/loggerFake')
const { removeLibModuleCache, makeMockSocket } = require('./utils')
const realtimeJobStub = require('../testDoubles/realtimeJobStub')
describe('realtime#connection', function () {
describe('connection', function () {
let realtime
let modelStub
beforeEach(() => {
removeLibModuleCache()
modelStub = {
Note: {
findOne: sinon.stub()
},
User: {},
Author: {}
}
mock('../../lib/logger', createFakeLogger())
mock('../../lib/history', {})
mock('../../lib/models', modelStub)
mock('../../lib/config', {})
mock('../../lib/realtimeUpdateDirtyNoteJob', realtimeJobStub)
mock('../../lib/realtimeCleanDanglingUserJob', realtimeJobStub)
mock('../../lib/realtimeSaveRevisionJob', realtimeJobStub)
mock('../../lib/ot', require('../testDoubles/otFake'))
realtime = require('../../lib/realtime')
})
afterEach(() => {
mock.stopAll()
sinon.restore()
})
describe('fail', function () {
it('should fast return when server not start', () => {
const mockSocket = makeMockSocket()
realtime.maintenance = true
const spy = sinon.spy(realtime, 'parseNoteIdFromSocket')
realtime.connection(mockSocket)
assert(!spy.called)
})
it('should failed when parse noteId occur error', (done) => {
const mockSocket = makeMockSocket()
realtime.maintenance = false
const parseNoteIdFromSocketSpy = sinon.stub(realtime, 'parseNoteIdFromSocketAsync').callsFake(async (socket) => {
/* eslint-disable-next-line */
throw 'error'
})
const failConnectionSpy = sinon.stub(realtime, 'failConnection')
realtime.connection(mockSocket)
setTimeout(() => {
assert(parseNoteIdFromSocketSpy.called)
assert(failConnectionSpy.calledOnce)
assert.deepStrictEqual(failConnectionSpy.lastCall.args, [500, 'error', mockSocket])
done()
}, 50)
})
it('should failed when noteId not exists', (done) => {
const mockSocket = makeMockSocket()
realtime.maintenance = false
const parseNoteIdFromSocketSpy = sinon.stub(realtime, 'parseNoteIdFromSocketAsync').callsFake(async (socket) => {
return null
})
const failConnectionSpy = sinon.stub(realtime, 'failConnection')
realtime.connection(mockSocket)
setTimeout(() => {
assert(parseNoteIdFromSocketSpy.called)
assert(failConnectionSpy.calledOnce)
assert.deepStrictEqual(failConnectionSpy.lastCall.args, [404, 'note id not found', mockSocket])
done()
}, 50)
})
})
it('should success connect', function (done) {
const mockSocket = makeMockSocket()
const noteId = 'note123'
realtime.maintenance = false
const parseNoteIdFromSocketSpy = sinon.stub(realtime, 'parseNoteIdFromSocketAsync').callsFake(async (socket) => {
return noteId
})
const updateUserDataStub = sinon.stub(realtime, 'updateUserData')
realtime.connection(mockSocket)
setTimeout(() => {
assert.ok(parseNoteIdFromSocketSpy.calledOnce)
assert(updateUserDataStub.calledOnce)
done()
}, 50)
})
describe('flow', function () {
it('should establish connection', function (done) {
const noteId = 'note123'
const mockSocket = makeMockSocket(null, {
noteId: noteId
})
mockSocket.request.user.logged_in = true
mockSocket.request.user.id = 'user1'
mockSocket.noteId = noteId
realtime.maintenance = false
sinon.stub(realtime, 'parseNoteIdFromSocketAsync').callsFake(async (socket) => {
return noteId
})
const updateHistoryStub = sinon.stub(realtime, 'updateHistory')
const emitOnlineUsersStub = sinon.stub(realtime, 'emitOnlineUsers')
const emitRefreshStub = sinon.stub(realtime, 'emitRefresh')
const failConnectionSpy = sinon.spy(realtime, 'failConnection')
let note = {
id: noteId,
authors: [
{
userId: 'user1',
color: 'red',
user: {
id: 'user1',
name: 'Alice'
}
},
{
userId: 'user2',
color: 'blue',
user: {
id: 'user2',
name: 'Bob'
}
}
]
}
modelStub.Note.findOne.returns(Promise.resolve(note))
modelStub.User.getProfile = sinon.stub().callsFake((user) => {
return user
})
sinon.stub(realtime, 'checkViewPermission').returns(true)
realtime.connection(mockSocket)
setTimeout(() => {
assert(modelStub.Note.findOne.calledOnce)
assert.deepStrictEqual(modelStub.Note.findOne.lastCall.args[0].include, [
{
model: modelStub.User,
as: 'owner'
}, {
model: modelStub.User,
as: 'lastchangeuser'
}, {
model: modelStub.Author,
as: 'authors',
include: [{
model: modelStub.User,
as: 'user'
}]
}
])
assert(modelStub.Note.findOne.lastCall.args[0].where.id === noteId)
assert(updateHistoryStub.calledOnce)
assert(emitOnlineUsersStub.calledOnce)
assert(emitRefreshStub.calledOnce)
assert(failConnectionSpy.callCount === 0)
assert(realtime.getNotePool()[noteId].id === noteId)
assert(realtime.getNotePool()[noteId].socks.length === 1)
assert.deepStrictEqual(realtime.getNotePool()[noteId].authors, {
user1: {
userid: 'user1', color: 'red', photo: undefined, name: 'Alice'
},
user2: {
userid: 'user2', color: 'blue', photo: undefined, name: 'Bob'
}
})
assert(Object.keys(realtime.getNotePool()[noteId].users).length === 1)
done()
}, 50)
})
})
})
})

View File

@ -4,13 +4,14 @@
const assert = require('assert') const assert = require('assert')
const mock = require('mock-require') const mock = require('mock-require')
const sinon = require('sinon') const sinon = require('sinon')
const { removeModuleFromRequireCache, makeMockSocket } = require('./utils') const { removeModuleFromRequireCache, makeMockSocket, removeLibModuleCache } = require('./utils')
describe('realtime#update note is dirty timer', function () { describe('realtime#update note is dirty timer', function () {
let realtime let realtime
let clock let clock
beforeEach(() => { beforeEach(() => {
removeLibModuleCache()
clock = sinon.useFakeTimers({ clock = sinon.useFakeTimers({
toFake: ['setInterval'] toFake: ['setInterval']
}) })
@ -69,7 +70,7 @@ describe('realtime#update note is dirty timer', function () {
setTimeout(() => { setTimeout(() => {
assert(note2.server.isDirty === false) assert(note2.server.isDirty === false)
done() done()
}, 5) }, 10)
}) })
it('should not do anything when note missing', function (done) { it('should not do anything when note missing', function (done) {

View File

@ -37,96 +37,6 @@ function removeModuleFromRequireCache (modulePath) {
} }
describe('realtime', function () { describe('realtime', function () {
describe('connection', function () {
let realtime
beforeEach(() => {
mock('../../lib/logger', {
error: () => {
}
})
mock('../../lib/history', {})
mock('../../lib/models', {
Note: {
parseNoteTitle: (data) => (data)
}
})
mock('../../lib/config', {})
realtime = require('../../lib/realtime')
})
afterEach(() => {
removeModuleFromRequireCache('../../lib/realtime')
mock.stopAll()
sinon.restore()
})
describe('fail', function () {
it('should fast return when server not start', () => {
const mockSocket = makeMockSocket()
realtime.maintenance = true
const spy = sinon.spy(realtime, 'parseNoteIdFromSocket')
realtime.connection(mockSocket)
assert(!spy.called)
})
it('should failed when parse noteId occur error', () => {
const mockSocket = makeMockSocket()
realtime.maintenance = false
const parseNoteIdFromSocketSpy = sinon.stub(realtime, 'parseNoteIdFromSocket').callsFake((socket, callback) => {
/* eslint-disable-next-line */
callback('error', null)
})
const failConnectionSpy = sinon.stub(realtime, 'failConnection')
realtime.connection(mockSocket)
assert(parseNoteIdFromSocketSpy.called)
assert(failConnectionSpy.calledOnce)
assert.deepStrictEqual(failConnectionSpy.lastCall.args, [500, 'error', mockSocket])
})
it('should failed when noteId not exists', () => {
const mockSocket = makeMockSocket()
realtime.maintenance = false
const parseNoteIdFromSocketSpy = sinon.stub(realtime, 'parseNoteIdFromSocket').callsFake((socket, callback) => {
/* eslint-disable-next-line */
callback(null, null)
})
const failConnectionSpy = sinon.stub(realtime, 'failConnection')
realtime.connection(mockSocket)
assert(parseNoteIdFromSocketSpy.called)
assert(failConnectionSpy.calledOnce)
assert.deepStrictEqual(failConnectionSpy.lastCall.args, [404, 'note id not found', mockSocket])
})
})
it('should success connect', function () {
const mockSocket = makeMockSocket()
const noteId = 'note123'
realtime.maintenance = false
const parseNoteIdFromSocketSpy = sinon.stub(realtime, 'parseNoteIdFromSocket').callsFake((socket, callback) => {
/* eslint-disable-next-line */
callback(null, noteId)
})
const failConnectionStub = sinon.stub(realtime, 'failConnection')
const updateUserDataStub = sinon.stub(realtime, 'updateUserData')
const startConnectionStub = sinon.stub(realtime, 'startConnection')
realtime.connection(mockSocket)
assert.ok(parseNoteIdFromSocketSpy.calledOnce)
assert(failConnectionStub.called === false)
assert(updateUserDataStub.calledOnce)
assert(startConnectionStub.calledOnce)
assert(mockSocket.on.callCount === 11)
})
})
describe('checkViewPermission', function () { describe('checkViewPermission', function () {
// role -> guest, loggedInUser, loggedInOwner // role -> guest, loggedInUser, loggedInOwner
const viewPermission = { const viewPermission = {

View File

@ -9,6 +9,27 @@ const { makeMockSocket, removeModuleFromRequireCache } = require('./utils')
describe('realtime#socket event', function () { describe('realtime#socket event', function () {
const noteId = 'note123' const noteId = 'note123'
const note = {
id: noteId,
authors: [
{
userId: 'user1',
color: 'red',
user: {
id: 'user1',
name: 'Alice'
}
},
{
userId: 'user2',
color: 'blue',
user: {
id: 'user2',
name: 'Bob'
}
}
]
}
let realtime let realtime
let clientSocket let clientSocket
let modelsMock let modelsMock
@ -16,7 +37,7 @@ describe('realtime#socket event', function () {
let configMock let configMock
let clock let clock
beforeEach(function () { beforeEach(function (done) {
clock = sinon.useFakeTimers({ clock = sinon.useFakeTimers({
toFake: ['setInterval'] toFake: ['setInterval']
}) })
@ -25,9 +46,14 @@ describe('realtime#socket event', function () {
Note: { Note: {
parseNoteTitle: (data) => (data), parseNoteTitle: (data) => (data),
destroy: sinon.stub().returns(Promise.resolve(1)), destroy: sinon.stub().returns(Promise.resolve(1)),
update: sinon.stub().returns(Promise.resolve(1)) update: sinon.stub().returns(Promise.resolve(1)),
} findOne: sinon.stub().returns(Promise.resolve(note))
},
User: {}
} }
modelsMock.User.getProfile = sinon.stub().callsFake((user) => {
return user
})
configMock = { configMock = {
fullversion: '1.5.0', fullversion: '1.5.0',
minimumCompatibleVersion: '1.0.0' minimumCompatibleVersion: '1.0.0'
@ -41,27 +67,50 @@ describe('realtime#socket event', function () {
mock('../../lib/history', {}) mock('../../lib/history', {})
mock('../../lib/models', modelsMock) mock('../../lib/models', modelsMock)
mock('../../lib/config', configMock) mock('../../lib/config', configMock)
mock('../../lib/ot', require('../testDoubles/otFake'))
realtime = require('../../lib/realtime') realtime = require('../../lib/realtime')
// get all socket event handler // get all socket event handler
clientSocket = makeMockSocket() clientSocket = makeMockSocket(null, {
noteId: noteId
})
clientSocket.request.user.logged_in = true
clientSocket.request.user.id = 'user1'
// clientSocket.noteId = noteId
clientSocket.on = function (event, func) { clientSocket.on = function (event, func) {
eventFuncMap.set(event, func) eventFuncMap.set(event, func)
} }
realtime.maintenance = false realtime.maintenance = false
sinon.stub(realtime, 'parseNoteIdFromSocket').callsFake((socket, callback) => {
/* eslint-disable-next-line */ realtime.io = (function () {
callback(null, noteId) const roomMap = new Map()
return {
to: function (roomId) {
if (!roomMap.has(roomId)) {
roomMap.set(roomId, {
emit: sinon.stub()
}) })
}
return roomMap.get(roomId)
}
}
}())
const wrappedFuncs = [] const wrappedFuncs = []
wrappedFuncs.push(sinon.stub(realtime, 'failConnection'))
wrappedFuncs.push(sinon.stub(realtime, 'updateUserData')) wrappedFuncs.push(sinon.stub(realtime, 'updateUserData'))
wrappedFuncs.push(sinon.stub(realtime, 'startConnection')) wrappedFuncs.push(sinon.stub(realtime, 'emitOnlineUsers'))
wrappedFuncs.push(sinon.stub(realtime, 'parseNoteIdFromSocketAsync').returns(Promise.resolve(noteId)))
wrappedFuncs.push(sinon.stub(realtime, 'updateHistory'))
wrappedFuncs.push(sinon.stub(realtime, 'emitRefresh'))
realtime.connection(clientSocket) realtime.connection(clientSocket)
setTimeout(() => {
wrappedFuncs.forEach((wrappedFunc) => { wrappedFuncs.forEach((wrappedFunc) => {
wrappedFunc.restore() wrappedFunc.restore()
}) })
done()
}, 50)
}) })
afterEach(function () { afterEach(function () {
@ -70,6 +119,7 @@ describe('realtime#socket event', function () {
mock.stopAll() mock.stopAll()
sinon.restore() sinon.restore()
clock.restore() clock.restore()
clientSocket = null
}) })
describe('refresh', function () { describe('refresh', function () {
@ -126,7 +176,7 @@ describe('realtime#socket event', function () {
it('should not call emitUserStatus when note not exists', () => { it('should not call emitUserStatus when note not exists', () => {
const userStatusFunc = eventFuncMap.get('user status') const userStatusFunc = eventFuncMap.get('user status')
const emitUserStatusStub = sinon.stub(realtime, 'emitUserStatus') const emitUserStatusStub = sinon.stub(realtime, 'emitUserStatus')
realtime.notes = {} realtime.deleteAllNoteFromPool()
realtime.users[clientSocket.id] = {} realtime.users[clientSocket.id] = {}
const userData = { const userData = {
idle: true, idle: true,
@ -232,6 +282,7 @@ describe('realtime#socket event', function () {
it('should not return user list when note not exists', function () { it('should not return user list when note not exists', function () {
const onlineUsersFunc = eventFuncMap.get('online users') const onlineUsersFunc = eventFuncMap.get('online users')
realtime.deleteAllNoteFromPool()
onlineUsersFunc() onlineUsersFunc()
assert(clientSocket.emit.called === false) assert(clientSocket.emit.called === false)
}) })
@ -256,12 +307,13 @@ describe('realtime#socket event', function () {
const userChangedFunc = eventFuncMap.get('user changed') const userChangedFunc = eventFuncMap.get('user changed')
const updateUserDataStub = sinon.stub(realtime, 'updateUserData') const updateUserDataStub = sinon.stub(realtime, 'updateUserData')
const emitOnlineUsersStub = sinon.stub(realtime, 'emitOnlineUsers') const emitOnlineUsersStub = sinon.stub(realtime, 'emitOnlineUsers')
realtime.deleteAllNoteFromPool()
userChangedFunc() userChangedFunc()
assert(updateUserDataStub.called === false) assert(updateUserDataStub.called === false)
assert(emitOnlineUsersStub.called === false) assert(emitOnlineUsersStub.called === false)
}) })
it('should direct return when note not exists', function () { it('should direct return when note\'s users not exists', function () {
const userChangedFunc = eventFuncMap.get('user changed') const userChangedFunc = eventFuncMap.get('user changed')
realtime.notes[noteId] = { realtime.notes[noteId] = {
users: {} users: {}
@ -414,29 +466,28 @@ describe('realtime#socket event', function () {
} }
} }
realtime.notes[noteId] = { realtime.deleteAllNoteFromPool()
realtime.addNote({
id: noteId,
owner: ownerId owner: ownerId
} })
realtime.io = {
to: function () {
return {
emit: sinon.stub()
}
}
}
checkViewPermissionSpy = sinon.spy(realtime, 'checkViewPermission') checkViewPermissionSpy = sinon.spy(realtime, 'checkViewPermission')
permissionFunc = eventFuncMap.get('permission') permissionFunc = eventFuncMap.get('permission')
}) })
it('should disconnect when lose view permission', function (done) { it('should disconnect when lose view permission', function (done) {
realtime.notes[noteId].permission = 'editable' realtime.getNoteFromNotePool(noteId).permission = 'editable'
realtime.notes[noteId].socks = [clientSocket, undefined, otherClient] realtime.getNoteFromNotePool(noteId).socks = [clientSocket, undefined, otherClient]
permissionFunc('private') permissionFunc('private')
setTimeout(() => { setTimeout(() => {
// should change note permission to private
assert(modelsMock.Note.update.calledOnce)
assert(modelsMock.Note.update.lastCall.args[0].permission === 'private')
assert(modelsMock.Note.update.lastCall.args[1].where.id === noteId)
// should check all connected client
assert(checkViewPermissionSpy.callCount === 2) assert(checkViewPermissionSpy.callCount === 2)
assert(otherClient.emit.calledOnce) assert(otherClient.emit.calledOnce)
assert(otherClient.disconnect.calledOnce) assert(otherClient.disconnect.calledOnce)

View File

@ -0,0 +1,18 @@
'use strict'
const sinon = require('sinon')
class EditorSocketIOServerFake {
constructor () {
this.addClient = sinon.stub()
this.onOperation = sinon.stub()
this.onGetOperations = sinon.stub()
this.updateSelection = sinon.stub()
this.setName = sinon.stub()
this.setColor = sinon.stub()
this.getClient = sinon.stub()
this.onDisconnect = sinon.stub()
}
}
exports.EditorSocketIOServer = EditorSocketIOServerFake