diff --git a/app.js b/app.js index e2c09a04..36e63b86 100644 --- a/app.js +++ b/app.js @@ -299,6 +299,9 @@ function handleTermSignals () { }) } }, 100) + setTimeout(() => { + process.exit(1) + }, 5000) } process.on('SIGINT', handleTermSignals) process.on('SIGTERM', handleTermSignals) diff --git a/lib/processQueue.js b/lib/processQueue.js index d47b1fc2..d98c4139 100644 --- a/lib/processQueue.js +++ b/lib/processQueue.js @@ -7,11 +7,20 @@ const EventEmitter = require('events').EventEmitter */ const QueueEvent = { - Tick: 'Tick' + Tick: 'Tick', + Push: 'Push', + Finish: 'Finish' } 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() this.max = maximumLength this.triggerTime = triggerTimeInterval @@ -19,12 +28,20 @@ class ProcessQueue extends EventEmitter { this.queue = [] this.lock = false - this.on(QueueEvent.Tick, () => { - if (this.lock) return - this.lock = true - setImmediate(() => { - this.process() - }) + 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 + this.lock = true + setImmediate(() => { + this.process() }) } @@ -62,7 +79,7 @@ class ProcessQueue extends EventEmitter { this.taskMap.set(id, true) this.queue.push(task) this.start() - this.emit(QueueEvent.Tick) + this.emit(QueueEvent.Push) return true } @@ -79,7 +96,7 @@ class ProcessQueue extends EventEmitter { const finishTask = () => { this.lock = false setImmediate(() => { - this.emit(QueueEvent.Tick) + this.emit(QueueEvent.Finish) }) } task.processingFunc().then(finishTask).catch(finishTask) diff --git a/lib/realtime.js b/lib/realtime.js index 4c2c87bc..a4e77784 100644 --- a/lib/realtime.js +++ b/lib/realtime.js @@ -38,7 +38,8 @@ const realtime = { maintenance: true } -const disconnectProcessQueue = new ProcessQueue(2000, 500) +const connectProcessQueue = new ProcessQueue({}) +const disconnectProcessQueue = new ProcessQueue({}) const updateDirtyNoteJob = new UpdateDirtyNoteJob(realtime) const cleanDanglingUserJob = new CleanDanglingUserJob(realtime) const saveRevisionJob = new SaveRevisionJob(realtime) @@ -97,6 +98,46 @@ function emitCheck (note) { var users = {} 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() updateDirtyNoteJob.start() cleanDanglingUserJob.start() @@ -265,7 +306,8 @@ function getStatus (callback) { function isReady () { return realtime.io && 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 } @@ -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 function emitOnlineUsers (socket) { var noteId = socket.noteId @@ -374,48 +425,6 @@ function emitRefresh (socket) { 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) { if (note.permission === 'private') { if (req.user && req.user.logged_in && req.user.id === note.owner) { @@ -435,65 +444,14 @@ function checkViewPermission (req, note) { } var isConnectionBusy = false -var connectionSocketQueue = [] // TODO: test it -function finishConnection (socket, noteId, socketId) { - // if no valid info provided will drop the client - if (!socket || !notes[noteId] || !users[socketId]) { - return interruptConnection(socket, noteId, socketId) - } - // check view permission - 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 = [{ +async function fetchFullNoteAsync (noteId) { + return models.Note.findOne({ + where: { + id: noteId + }, + include: [{ model: models.User, as: 'owner' }, { @@ -507,75 +465,51 @@ function startConnection (socket) { as: 'user' }] }] + }) +} - models.Note.findOne({ - where: { - id: noteId - }, - include: include - }).then(function (note) { - if (!note) { - return failConnection(404, 'note not found', socket) +function buildAuthorProfilesFromNote (noteAuthors) { + const authors = {} + noteAuthors.forEach((author) => { + const profile = models.User.getProfile(author.user) + if (profile) { + authors[author.userId] = { + userid: author.userId, + color: author.color, + photo: profile.photo, + name: profile.name } - var owner = note.ownerId - var ownerprofile = note.owner ? models.User.getProfile(note.owner) : null + } + }) + return authors +} - var lastchangeuser = note.lastchangeuserId - var lastchangeuserprofile = note.lastchangeuser ? models.User.getProfile(note.lastchangeuser) : null +function makeNewServerNote (note) { + const authors = buildAuthorProfilesFromNote(note.authors) - 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) { - authors[author.userId] = { - userid: author.userId, - color: author.color, - photo: profile.photo, - name: profile.name - } - } - } - - notes[noteId] = { - id: noteId, - alias: note.alias, - title: note.title, - owner: owner, - ownerprofile: ownerprofile, - permission: note.permission, - lastchangeuser: lastchangeuser, - lastchangeuserprofile: lastchangeuserprofile, - socks: [], - users: {}, - tempUsers: {}, - createtime: moment(createtime).valueOf(), - updatetime: moment(updatetime).valueOf(), - server: server, - authors: authors, - authorship: note.authorship - } - - return finishConnection(socket, noteId, socket.id) - }).catch(function (err) { - return failConnection(500, err, socket) - }) - } else { - return finishConnection(socket, noteId, socket.id) + return { + id: note.id, + alias: note.alias, + title: note.title, + owner: note.ownerId, + ownerprofile: note.owner ? models.User.getProfile(note.owner) : null, + permission: note.permission, + lastchangeuser: note.lastchangeuserId, + lastchangeuserprofile: note.lastchangeuser ? models.User.getProfile(note.lastchangeuser) : null, + socks: [], + users: {}, + tempUsers: {}, + createtime: moment(note.createdAt).valueOf(), + updatetime: moment(note.lastchangeAt).valueOf(), + server: new ot.EditorSocketIOServer(note.content, [], note.id, ifMayEdit, operationCallback), + authors: authors, + authorship: note.authorship } } // TODO: test it function failConnection (code, err, socket) { logger.error(err) - // clear error socket in queue - clearSocketQueue(connectionSocketQueue, socket) - connectNextSocket() // emit error info socket.emit('info', { code: code @@ -655,7 +589,7 @@ function updateUserData (socket, user) { } } -function canEditNote(notePermission, noteOwnerId, currentUserId) { +function canEditNote (notePermission, noteOwnerId, currentUserId) { switch (notePermission) { case 'freely': return true @@ -736,85 +670,137 @@ function updateHistory (userId, note, time) { if (note.server) history.updateHistory(userId, noteId, note.server.document, time) } -function getUserPool () { - return users +function getUniqueColorPerNote(noteId, maxAttempt = 10) { + // random color + let color = randomcolor() + if (!notes[noteId]) return color + + const maxrandomcount = maxAttempt + let randomAttemp = 0 + let found = false + do { + Object.keys(notes[noteId].users).forEach(userId => { + if (notes[noteId].users[userId].color === color) { + found = true + } + }); + if (found) { + color = randomcolor() + randomAttemp++ + } + } while (found && randomAttemp < maxrandomcount) + return color } -function getUserFromUserPool (userId) { - return users[userId] -} +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 + users[socket.id] = { + id: socket.id, + address: socket.handshake.headers['x-forwarded-for'] || socket.handshake.address, + 'user-agent': socket.handshake.headers['user-agent'], + color: color, + cursor: null, + login: false, + userid: null, + name: null, + idle: false, + type: null + } + exports.updateUserData(socket, users[socket.id]) + try { + if (!isNoteExistsInPool(noteId)) { + const note = await fetchFullNoteAsync(noteId) + 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) -function getNotePool () { - return notes -} + // update user note history + exports.updateHistory(user.userid, note) -function getNoteFromNotePool (noteId) { - return notes[noteId] -} + exports.emitOnlineUsers(socket) + exports.emitRefresh(socket) -// TODO: test it -function connection (socket) { - if (realtime.maintenance) return - exports.parseNoteIdFromSocket(socket, function (err, noteId) { - if (err) { + const socketClient = new RealtimeClientConnection(socket) + 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) } - 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 - var color = randomcolor() - // make sure color not duplicated or reach max random count - if (notes[noteId]) { - var randomcount = 0 - var maxrandomcount = 10 - var found = false - do { - Object.keys(notes[noteId].users).forEach(function (userId) { - if (notes[noteId].users[userId].color === color) { - found = true - } - }) - if (found) { - color = randomcolor() - randomcount++ - } - } while (found && randomcount < maxrandomcount) - } - // create user data - users[socket.id] = { - id: socket.id, - address: socket.handshake.headers['x-forwarded-for'] || socket.handshake.address, - 'user-agent': socket.handshake.headers['user-agent'], - color: color, - cursor: null, - login: false, - userid: null, - name: null, - idle: false, - type: null - } - exports.updateUserData(socket, users[socket.id]) - - // start connection - connectionSocketQueue.push(socket) - exports.startConnection(socket) }) +} - const socketClient = new RealtimeClientConnection(socket) - socketClient.registerEventHandler() +function connection (socket) { + if (realtime.maintenance) return + queueForConnect(socket) } // TODO: test it function terminate () { disconnectProcessQueue.stop() + connectProcessQueue.stop() updateDirtyNoteJob.stop() } @@ -823,25 +809,31 @@ exports.extractNoteIdFromSocket = extractNoteIdFromSocket exports.parseNoteIdFromSocket = parseNoteIdFromSocket exports.updateNote = updateNote exports.failConnection = failConnection -exports.isDuplicatedInSocketQueue = isDuplicatedInSocketQueue exports.updateUserData = updateUserData -exports.startConnection = startConnection exports.emitRefresh = emitRefresh exports.emitUserStatus = emitUserStatus exports.emitOnlineUsers = emitOnlineUsers exports.checkViewPermission = checkViewPermission -exports.getNoteFromNotePool = getNoteFromNotePool exports.getUserFromUserPool = getUserFromUserPool exports.buildUserOutData = buildUserOutData -exports.getNotePool = getNotePool exports.emitCheck = emitCheck exports.disconnectSocketOnNote = disconnectSocketOnNote exports.queueForDisconnect = queueForDisconnect exports.terminate = terminate -exports.getUserPool = getUserPool exports.updateHistory = updateHistory exports.ifMayEdit = ifMayEdit +exports.parseNoteIdFromSocketAsync = parseNoteIdFromSocketAsync exports.disconnectProcessQueue = disconnectProcessQueue -exports.notes = notes 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 diff --git a/test/connectionQueue.test.js b/test/connectionQueue.test.js index ea2e627d..5b4246f2 100644 --- a/test/connectionQueue.test.js +++ b/test/connectionQueue.test.js @@ -22,7 +22,7 @@ describe('ProcessQueue', function () { }) it('should not accept more than maximum task', () => { - const queue = new ProcessQueue(2) + const queue = new ProcessQueue({ maximumLength: 2 }) const task = { id: 1, processingFunc: async () => { @@ -36,7 +36,7 @@ describe('ProcessQueue', function () { it('should run task every interval', (done) => { const runningClock = [] - const queue = new ProcessQueue(2) + const queue = new ProcessQueue({ maximumLength: 2 }) const task = async () => { runningClock.push(clock.now) } @@ -62,7 +62,7 @@ describe('ProcessQueue', function () { }) it('should not crash when repeat stop queue', () => { - const queue = new ProcessQueue(2, 10) + const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 10 }) try { queue.stop() queue.stop() @@ -74,7 +74,7 @@ describe('ProcessQueue', function () { }) 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') queue.start() clock.tick(100) @@ -85,7 +85,7 @@ describe('ProcessQueue', function () { }) 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 () => { throw new Error('error') }) @@ -107,7 +107,7 @@ describe('ProcessQueue', function () { }) 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 longTask = async () => { return new Promise((resolve) => { diff --git a/test/realtime/connection.test.js b/test/realtime/connection.test.js new file mode 100644 index 00000000..50b6d618 --- /dev/null +++ b/test/realtime/connection.test.js @@ -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) + }) + }) + }) +}) diff --git a/test/realtime/dirtyNoteUpdate.test.js b/test/realtime/dirtyNoteUpdate.test.js index 7bb029d9..a3dea190 100644 --- a/test/realtime/dirtyNoteUpdate.test.js +++ b/test/realtime/dirtyNoteUpdate.test.js @@ -4,13 +4,14 @@ const assert = require('assert') const mock = require('mock-require') const sinon = require('sinon') -const { removeModuleFromRequireCache, makeMockSocket } = require('./utils') +const { removeModuleFromRequireCache, makeMockSocket, removeLibModuleCache } = require('./utils') describe('realtime#update note is dirty timer', function () { let realtime let clock beforeEach(() => { + removeLibModuleCache() clock = sinon.useFakeTimers({ toFake: ['setInterval'] }) @@ -69,7 +70,7 @@ describe('realtime#update note is dirty timer', function () { setTimeout(() => { assert(note2.server.isDirty === false) done() - }, 5) + }, 10) }) it('should not do anything when note missing', function (done) { diff --git a/test/realtime/realtime.test.js b/test/realtime/realtime.test.js index 903c9a42..4c6d7f74 100644 --- a/test/realtime/realtime.test.js +++ b/test/realtime/realtime.test.js @@ -37,96 +37,6 @@ function removeModuleFromRequireCache (modulePath) { } 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 () { // role -> guest, loggedInUser, loggedInOwner const viewPermission = { diff --git a/test/realtime/socket-events.test.js b/test/realtime/socket-events.test.js index dae78dcc..60cfc730 100644 --- a/test/realtime/socket-events.test.js +++ b/test/realtime/socket-events.test.js @@ -9,6 +9,27 @@ const { makeMockSocket, removeModuleFromRequireCache } = require('./utils') describe('realtime#socket event', function () { 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 clientSocket let modelsMock @@ -16,7 +37,7 @@ describe('realtime#socket event', function () { let configMock let clock - beforeEach(function () { + beforeEach(function (done) { clock = sinon.useFakeTimers({ toFake: ['setInterval'] }) @@ -25,9 +46,14 @@ describe('realtime#socket event', function () { Note: { parseNoteTitle: (data) => (data), 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 = { fullversion: '1.5.0', minimumCompatibleVersion: '1.0.0' @@ -41,27 +67,50 @@ describe('realtime#socket event', function () { mock('../../lib/history', {}) mock('../../lib/models', modelsMock) mock('../../lib/config', configMock) + mock('../../lib/ot', require('../testDoubles/otFake')) realtime = require('../../lib/realtime') // 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) { eventFuncMap.set(event, func) } realtime.maintenance = false - sinon.stub(realtime, 'parseNoteIdFromSocket').callsFake((socket, callback) => { - /* eslint-disable-next-line */ - callback(null, noteId) - }) + + realtime.io = (function () { + const roomMap = new Map() + return { + to: function (roomId) { + if (!roomMap.has(roomId)) { + roomMap.set(roomId, { + emit: sinon.stub() + }) + } + return roomMap.get(roomId) + } + } + }()) + const wrappedFuncs = [] - wrappedFuncs.push(sinon.stub(realtime, 'failConnection')) 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) - wrappedFuncs.forEach((wrappedFunc) => { - wrappedFunc.restore() - }) + setTimeout(() => { + wrappedFuncs.forEach((wrappedFunc) => { + wrappedFunc.restore() + }) + done() + }, 50) }) afterEach(function () { @@ -70,6 +119,7 @@ describe('realtime#socket event', function () { mock.stopAll() sinon.restore() clock.restore() + clientSocket = null }) describe('refresh', function () { @@ -126,7 +176,7 @@ describe('realtime#socket event', function () { it('should not call emitUserStatus when note not exists', () => { const userStatusFunc = eventFuncMap.get('user status') const emitUserStatusStub = sinon.stub(realtime, 'emitUserStatus') - realtime.notes = {} + realtime.deleteAllNoteFromPool() realtime.users[clientSocket.id] = {} const userData = { idle: true, @@ -232,6 +282,7 @@ describe('realtime#socket event', function () { it('should not return user list when note not exists', function () { const onlineUsersFunc = eventFuncMap.get('online users') + realtime.deleteAllNoteFromPool() onlineUsersFunc() assert(clientSocket.emit.called === false) }) @@ -256,12 +307,13 @@ describe('realtime#socket event', function () { const userChangedFunc = eventFuncMap.get('user changed') const updateUserDataStub = sinon.stub(realtime, 'updateUserData') const emitOnlineUsersStub = sinon.stub(realtime, 'emitOnlineUsers') + realtime.deleteAllNoteFromPool() userChangedFunc() assert(updateUserDataStub.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') realtime.notes[noteId] = { users: {} @@ -414,29 +466,28 @@ describe('realtime#socket event', function () { } } - realtime.notes[noteId] = { + realtime.deleteAllNoteFromPool() + realtime.addNote({ + id: noteId, owner: ownerId - } - - realtime.io = { - to: function () { - return { - emit: sinon.stub() - } - } - } + }) checkViewPermissionSpy = sinon.spy(realtime, 'checkViewPermission') permissionFunc = eventFuncMap.get('permission') }) it('should disconnect when lose view permission', function (done) { - realtime.notes[noteId].permission = 'editable' - realtime.notes[noteId].socks = [clientSocket, undefined, otherClient] + realtime.getNoteFromNotePool(noteId).permission = 'editable' + realtime.getNoteFromNotePool(noteId).socks = [clientSocket, undefined, otherClient] permissionFunc('private') 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(otherClient.emit.calledOnce) assert(otherClient.disconnect.calledOnce) diff --git a/test/testDoubles/otFake.js b/test/testDoubles/otFake.js new file mode 100644 index 00000000..9fd5d3fd --- /dev/null +++ b/test/testDoubles/otFake.js @@ -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