mirror of https://github.com/status-im/codimd.git
Merge pull request #1228 from hackmdio/refactor-realtime
Refactor realtime
This commit is contained in:
commit
ba0e4df6b7
|
@ -0,0 +1,8 @@
|
|||
.git/
|
||||
node_modules/
|
||||
docs/
|
||||
test/
|
||||
.sequelizerc.example
|
||||
config.json.example
|
||||
public/build
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
lib/ot
|
||||
public/vendor
|
||||
public/build
|
21
.eslintrc.js
21
.eslintrc.js
|
@ -1,21 +0,0 @@
|
|||
module.exports = {
|
||||
"root": true,
|
||||
"extends": "standard",
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"rules": {
|
||||
// at some point all of these should return to their default "error" state
|
||||
// but right now, this is not a good choice, because too many places are
|
||||
// wrong.
|
||||
"import/first": ["warn"],
|
||||
"indent": ["warn"],
|
||||
"no-multiple-empty-lines": ["warn"],
|
||||
"no-multi-spaces": ["warn"],
|
||||
"object-curly-spacing": ["warn"],
|
||||
"one-var": ["warn"],
|
||||
"quotes": ["warn"],
|
||||
"semi": ["warn"],
|
||||
"space-infix-ops": ["warn"]
|
||||
}
|
||||
};
|
|
@ -27,3 +27,5 @@ public/views/build
|
|||
|
||||
public/uploads/*
|
||||
!public/uploads/.gitkeep
|
||||
/.nyc_output
|
||||
/coverage/
|
||||
|
|
57
.travis.yml
57
.travis.yml
|
@ -1,41 +1,34 @@
|
|||
language: node_js
|
||||
dist: trusty
|
||||
|
||||
node_js:
|
||||
- "lts/carbon"
|
||||
- "lts/dubnium"
|
||||
- "11"
|
||||
- "12"
|
||||
|
||||
dist: xenial
|
||||
cache: yarn
|
||||
env:
|
||||
global:
|
||||
- CXX=g++-4.8
|
||||
- YARN_VERSION=1.3.2
|
||||
|
||||
matrix:
|
||||
fast_finish: true
|
||||
include:
|
||||
- node_js: lts/carbon
|
||||
- node_js: lts/dubnium
|
||||
allow_failures:
|
||||
- node_js: "11"
|
||||
- node_js: "12"
|
||||
|
||||
script:
|
||||
- yarn test:ci
|
||||
- yarn build
|
||||
|
||||
jobs:
|
||||
include:
|
||||
- env: task=npm-test
|
||||
node_js:
|
||||
- 8
|
||||
before_install:
|
||||
- curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version "$YARN_VERSION"
|
||||
- export PATH="$HOME/.yarn/bin:$PATH"
|
||||
- env: task=npm-test
|
||||
node_js:
|
||||
- 10
|
||||
before_install:
|
||||
- curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version "$YARN_VERSION"
|
||||
- export PATH="$HOME/.yarn/bin:$PATH"
|
||||
- env: task=ShellCheck
|
||||
script:
|
||||
- shellcheck bin/heroku bin/setup
|
||||
language: generic
|
||||
- env: task=doctoc
|
||||
install: npm install doctoc
|
||||
- stage: doctoc-check
|
||||
install: npm install -g doctoc
|
||||
if: type = pull_request OR branch = master
|
||||
script:
|
||||
- cp README.md README.md.orig
|
||||
- npm run doctoc
|
||||
- diff -q README.md README.md.orig
|
||||
language: generic
|
||||
- env: task=json-lint
|
||||
addons:
|
||||
apt:
|
||||
packages:
|
||||
- jq
|
||||
script:
|
||||
- npm run jsonlint
|
||||
language: generic
|
||||
node_js: lts/carbon
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
CodiMD
|
||||
===
|
||||
|
||||
[![CodiMD on Gitter][gitter-image]][gitter-url]
|
||||
[![build status][travis-image]][travis-url]
|
||||
[![version][github-version-badge]][github-release-page]
|
||||
[![Gitter][gitter-image]][gitter-url]
|
||||
|
|
55
app.js
55
app.js
|
@ -7,7 +7,6 @@ var ejs = require('ejs')
|
|||
var passport = require('passport')
|
||||
var methodOverride = require('method-override')
|
||||
var cookieParser = require('cookie-parser')
|
||||
var compression = require('compression')
|
||||
var session = require('express-session')
|
||||
var SequelizeStore = require('connect-session-sequelize')(session.Store)
|
||||
var fs = require('fs')
|
||||
|
@ -26,30 +25,33 @@ var response = require('./lib/response')
|
|||
var models = require('./lib/models')
|
||||
var csp = require('./lib/csp')
|
||||
|
||||
function createHttpServer () {
|
||||
if (config.useSSL) {
|
||||
const ca = (function () {
|
||||
let i, len, results
|
||||
results = []
|
||||
for (i = 0, len = config.sslCAPath.length; i < len; i++) {
|
||||
results.push(fs.readFileSync(config.sslCAPath[i], 'utf8'))
|
||||
}
|
||||
return results
|
||||
})()
|
||||
const options = {
|
||||
key: fs.readFileSync(config.sslKeyPath, 'utf8'),
|
||||
cert: fs.readFileSync(config.sslCertPath, 'utf8'),
|
||||
ca: ca,
|
||||
dhparam: fs.readFileSync(config.dhParamPath, 'utf8'),
|
||||
requestCert: false,
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
return require('https').createServer(options, app)
|
||||
} else {
|
||||
return require('http').createServer(app)
|
||||
}
|
||||
}
|
||||
|
||||
// server setup
|
||||
var app = express()
|
||||
var server = null
|
||||
if (config.useSSL) {
|
||||
var ca = (function () {
|
||||
var i, len, results
|
||||
results = []
|
||||
for (i = 0, len = config.sslCAPath.length; i < len; i++) {
|
||||
results.push(fs.readFileSync(config.sslCAPath[i], 'utf8'))
|
||||
}
|
||||
return results
|
||||
})()
|
||||
var options = {
|
||||
key: fs.readFileSync(config.sslKeyPath, 'utf8'),
|
||||
cert: fs.readFileSync(config.sslCertPath, 'utf8'),
|
||||
ca: ca,
|
||||
dhparam: fs.readFileSync(config.dhParamPath, 'utf8'),
|
||||
requestCert: false,
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
server = require('https').createServer(options, app)
|
||||
} else {
|
||||
server = require('http').createServer(app)
|
||||
}
|
||||
var server = createHttpServer()
|
||||
|
||||
// logger
|
||||
app.use(morgan('combined', {
|
||||
|
@ -77,9 +79,6 @@ var sessionStore = new SequelizeStore({
|
|||
db: models.sequelize
|
||||
})
|
||||
|
||||
// compression
|
||||
app.use(compression())
|
||||
|
||||
// use hsts to tell https users stick to this
|
||||
if (config.hsts.enable) {
|
||||
app.use(helmet.hsts({
|
||||
|
@ -279,6 +278,7 @@ process.on('uncaughtException', function (err) {
|
|||
function handleTermSignals () {
|
||||
logger.info('CodiMD has been killed by signal, try to exit gracefully...')
|
||||
realtime.maintenance = true
|
||||
realtime.terminate()
|
||||
// disconnect all socket.io clients
|
||||
Object.keys(io.sockets.sockets).forEach(function (key) {
|
||||
var socket = io.sockets.sockets[key]
|
||||
|
@ -299,6 +299,9 @@ function handleTermSignals () {
|
|||
})
|
||||
}
|
||||
}, 100)
|
||||
setTimeout(() => {
|
||||
process.exit(1)
|
||||
}, 5000)
|
||||
}
|
||||
process.on('SIGINT', handleTermSignals)
|
||||
process.on('SIGTERM', handleTermSignals)
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
FROM node:8.15.1-jessie AS BUILD
|
||||
# use multi-stage build to build frontend javascript
|
||||
WORKDIR /codimd
|
||||
|
||||
COPY . ./
|
||||
|
||||
RUN yarn install --non-interactive --pure-lockfile && \
|
||||
yarn build
|
||||
|
||||
# ----------------------------------------------------
|
||||
# Runtime Stage
|
||||
FROM node:8.15.1 AS RUNTIME
|
||||
|
||||
# build for production
|
||||
ENV NODE_ENV production
|
||||
ENV PATH="/home/codimd/.npm-global/bin:${PATH}"
|
||||
|
||||
# setup isolated user for more security
|
||||
ARG USER_NAME=codimd
|
||||
ARG UID=1500
|
||||
ARG GID=1500
|
||||
|
||||
RUN set +x -ue && \
|
||||
wget https://github.com/hackmdio/portchecker/releases/download/v1.0.1/portchecker-linux-amd64.tar.gz && \
|
||||
tar xvf portchecker-linux-amd64.tar.gz -C /usr/local/bin && \
|
||||
mv /usr/local/bin/portchecker-linux-amd64 /usr/local/bin/pcheck && \
|
||||
# Add user and groupd
|
||||
groupadd --gid $GID $USER_NAME && \
|
||||
useradd --uid $UID --gid $USER_NAME --no-log-init --create-home $USER_NAME && \
|
||||
# setup local npm global directory
|
||||
mkdir /home/codimd/.npm-global && \
|
||||
echo "prefix=/home/codimd/.npm-global/" > /home/codimd/.npmrc && \
|
||||
# setup app dir
|
||||
mkdir /codimd && \
|
||||
# adjust permission
|
||||
chown -R $USER_NAME:$USER_NAME /home/codimd
|
||||
|
||||
# Copy build stage file to runtime
|
||||
COPY --from=BUILD /codimd /codimd
|
||||
RUN chown -R $USER_NAME:$USER_NAME /codimd
|
||||
|
||||
# change running user name
|
||||
USER $USER_NAME
|
||||
# build project
|
||||
WORKDIR /codimd
|
||||
|
||||
RUN set +x -ue && \
|
||||
cliVer=$(cat package.json | grep sequelize-cli | awk '{print substr($1, 2, length($1) - 3)"@"substr($2, 2, length($2) - 3)}') && \
|
||||
npm -g install "$cliVer" && \
|
||||
yarn install --production --non-interactive --pure-lockfile && \
|
||||
yarn cache clean
|
||||
|
||||
VOLUME /codimd/public/uploads
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["/codimd/docker-entrypoint.sh"]
|
|
@ -0,0 +1,7 @@
|
|||
FROM node:8.15.1-jessie
|
||||
|
||||
WORKDIR /codimd
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
VOLUME ['/codimd/node_modules']
|
|
@ -0,0 +1,25 @@
|
|||
version: '3'
|
||||
services:
|
||||
dev-database:
|
||||
image: postgres:11.2
|
||||
environment:
|
||||
POSTGRES_USER: codimd
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: codimd
|
||||
dev-codimd:
|
||||
build:
|
||||
dockerfile: ./deployments/dev-Dockerfile
|
||||
context: ../
|
||||
environment:
|
||||
CMD_DB_URL: postgres://codimd:password@dev-database/codimd
|
||||
volumes:
|
||||
- ../:/codimd
|
||||
- node_modules:/codimd/node_modules
|
||||
- public_build:/codimd/public/build
|
||||
- public_view_build:/codimd/public/views/build
|
||||
ports:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
node_modules:
|
||||
public_build:
|
||||
public_view_build:
|
|
@ -0,0 +1,16 @@
|
|||
version: '3'
|
||||
services:
|
||||
database:
|
||||
image: postgres:11.2
|
||||
environment:
|
||||
POSTGRES_USER: codimd
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: codimd
|
||||
codimd:
|
||||
build:
|
||||
dockerfile: ./deployments/Dockerfile
|
||||
context: ../
|
||||
environment:
|
||||
CMD_DB_URL: postgres://codimd:password@database/codimd
|
||||
ports:
|
||||
- 3000:3000
|
|
@ -0,0 +1,9 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
pcheck -constr "$CMD_DB_URL"
|
||||
|
||||
sequelize db:migrate
|
||||
|
||||
node app.js
|
|
@ -0,0 +1,106 @@
|
|||
'use strict'
|
||||
|
||||
const EventEmitter = require('events').EventEmitter
|
||||
|
||||
/**
|
||||
* Queuing Class for connection queuing
|
||||
*/
|
||||
|
||||
const QueueEvent = {
|
||||
Tick: 'Tick',
|
||||
Push: 'Push',
|
||||
Finish: 'Finish'
|
||||
}
|
||||
|
||||
class ProcessQueue extends EventEmitter {
|
||||
constructor ({
|
||||
maximumLength = 500,
|
||||
triggerTimeInterval = 5000,
|
||||
// execute on push
|
||||
proactiveMode = true,
|
||||
// execute next work on finish
|
||||
continuousMode = true
|
||||
}) {
|
||||
super()
|
||||
this.max = maximumLength
|
||||
this.triggerTime = triggerTimeInterval
|
||||
this.taskMap = new Map()
|
||||
this.queue = []
|
||||
this.lock = false
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.eventTrigger) return
|
||||
this.eventTrigger = setInterval(() => {
|
||||
this.emit(QueueEvent.Tick)
|
||||
}, this.triggerTime)
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (this.eventTrigger) {
|
||||
clearInterval(this.eventTrigger)
|
||||
this.eventTrigger = null
|
||||
}
|
||||
}
|
||||
|
||||
checkTaskIsInQueue (id) {
|
||||
return this.taskMap.has(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* pushWithKey a promisify-task to queue
|
||||
* @param id {string}
|
||||
* @param processingFunc {Function<Promise>}
|
||||
* @returns {boolean} if success return true, otherwise false
|
||||
*/
|
||||
push (id, processingFunc) {
|
||||
if (this.queue.length >= this.max) return false
|
||||
if (this.checkTaskIsInQueue(id)) return false
|
||||
const task = {
|
||||
id: id,
|
||||
processingFunc: processingFunc
|
||||
}
|
||||
this.taskMap.set(id, true)
|
||||
this.queue.push(task)
|
||||
this.start()
|
||||
this.emit(QueueEvent.Push)
|
||||
return true
|
||||
}
|
||||
|
||||
process () {
|
||||
if (this.queue.length <= 0) {
|
||||
this.stop()
|
||||
this.lock = false
|
||||
return
|
||||
}
|
||||
|
||||
const task = this.queue.shift()
|
||||
this.taskMap.delete(task.id)
|
||||
|
||||
const finishTask = () => {
|
||||
this.lock = false
|
||||
setImmediate(() => {
|
||||
this.emit(QueueEvent.Finish)
|
||||
})
|
||||
}
|
||||
task.processingFunc().then(finishTask).catch(finishTask)
|
||||
}
|
||||
}
|
||||
|
||||
exports.ProcessQueue = ProcessQueue
|
1084
lib/realtime.js
1084
lib/realtime.js
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,49 @@
|
|||
'use strict'
|
||||
|
||||
const async = require('async')
|
||||
const config = require('./config')
|
||||
const logger = require('./logger')
|
||||
|
||||
/**
|
||||
* clean when user not in any rooms or user not in connected list
|
||||
*/
|
||||
class CleanDanglingUserJob {
|
||||
constructor (realtime) {
|
||||
this.realtime = realtime
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.timer) return
|
||||
this.timer = setInterval(this.cleanDanglingUser.bind(this), 60000)
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (!this.timer) return
|
||||
clearInterval(this.timer)
|
||||
this.timer = undefined
|
||||
}
|
||||
|
||||
cleanDanglingUser () {
|
||||
const users = this.realtime.getUserPool()
|
||||
async.each(Object.keys(users), (key, callback) => {
|
||||
const socket = this.realtime.io.sockets.connected[key]
|
||||
if ((!socket && users[key]) ||
|
||||
(socket && (!socket.rooms || socket.rooms.length <= 0))) {
|
||||
if (config.debug) {
|
||||
logger.info('cleaner found redundant user: ' + key)
|
||||
}
|
||||
if (!socket) {
|
||||
return callback(null, null)
|
||||
}
|
||||
if (!this.realtime.disconnectProcessQueue.checkTaskIsInQueue(socket.id)) {
|
||||
this.realtime.queueForDisconnect(socket)
|
||||
}
|
||||
}
|
||||
return callback(null, null)
|
||||
}, function (err) {
|
||||
if (err) return logger.error('cleaner error', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
exports.CleanDanglingUserJob = CleanDanglingUserJob
|
|
@ -0,0 +1,232 @@
|
|||
'use strict'
|
||||
|
||||
const get = require('lodash/get')
|
||||
|
||||
const config = require('./config')
|
||||
const models = require('./models')
|
||||
const logger = require('./logger')
|
||||
|
||||
class RealtimeClientConnection {
|
||||
constructor (socket) {
|
||||
this.socket = socket
|
||||
this.realtime = require('./realtime')
|
||||
}
|
||||
|
||||
registerEventHandler () {
|
||||
// received client refresh request
|
||||
this.socket.on('refresh', this.refreshEventHandler.bind(this))
|
||||
// received user status
|
||||
this.socket.on('user status', this.userStatusEventHandler.bind(this))
|
||||
// when a new client disconnect
|
||||
this.socket.on('disconnect', this.disconnectEventHandler.bind(this))
|
||||
// received cursor focus
|
||||
this.socket.on('cursor focus', this.cursorFocusEventHandler.bind(this))
|
||||
// received cursor activity
|
||||
this.socket.on('cursor activity', this.cursorActivityEventHandler.bind(this))
|
||||
// received cursor blur
|
||||
this.socket.on('cursor blur', this.cursorBlurEventHandler.bind(this))
|
||||
// check version
|
||||
this.socket.on('version', this.checkVersionEventHandler.bind(this))
|
||||
// received sync of online users request
|
||||
this.socket.on('online users', this.onlineUsersEventHandler.bind(this))
|
||||
// reveiced when user logout or changed
|
||||
this.socket.on('user changed', this.userChangedEventHandler.bind(this))
|
||||
// delete a note
|
||||
this.socket.on('delete', this.deleteNoteEventHandler.bind(this))
|
||||
// received note permission change request
|
||||
this.socket.on('permission', this.permissionChangeEventHandler.bind(this))
|
||||
}
|
||||
|
||||
isUserLoggedIn () {
|
||||
return this.socket.request.user && this.socket.request.user.logged_in
|
||||
}
|
||||
|
||||
isNoteAndUserExists () {
|
||||
const note = this.realtime.getNoteFromNotePool(this.socket.noteId)
|
||||
const user = this.realtime.getUserFromUserPool(this.socket.id)
|
||||
return note && user
|
||||
}
|
||||
|
||||
isNoteOwner () {
|
||||
const note = this.getCurrentNote()
|
||||
return get(note, 'owner') === this.getCurrentLoggedInUserId()
|
||||
}
|
||||
|
||||
isAnonymousEnable () {
|
||||
// TODO: move this method to config module
|
||||
return config.allowAnonymous || config.allowAnonymousEdits
|
||||
}
|
||||
|
||||
getCurrentUser () {
|
||||
if (!this.socket.id) return
|
||||
return this.realtime.getUserFromUserPool(this.socket.id)
|
||||
}
|
||||
|
||||
getCurrentLoggedInUserId () {
|
||||
return get(this.socket, 'request.user.id')
|
||||
}
|
||||
|
||||
getCurrentNote () {
|
||||
if (!this.socket.noteId) return
|
||||
return this.realtime.getNoteFromNotePool(this.socket.noteId)
|
||||
}
|
||||
|
||||
getNoteChannel () {
|
||||
return this.socket.broadcast.to(this.socket.noteId)
|
||||
}
|
||||
|
||||
async destroyNote (id) {
|
||||
return models.Note.destroy({
|
||||
where: { id: id }
|
||||
})
|
||||
}
|
||||
|
||||
async changeNotePermission (newPermission) {
|
||||
const [changedRows] = await models.Note.update({
|
||||
permission: newPermission
|
||||
}, {
|
||||
where: {
|
||||
id: this.getCurrentNote().id
|
||||
}
|
||||
})
|
||||
if (changedRows !== 1) {
|
||||
throw new Error(`updated permission failed, cannot set permission ${newPermission} to note ${this.getCurrentNote().id}`)
|
||||
}
|
||||
}
|
||||
|
||||
notifyPermissionChanged () {
|
||||
this.realtime.io.to(this.getCurrentNote().id).emit('permission', {
|
||||
permission: this.getCurrentNote().permission
|
||||
})
|
||||
this.getCurrentNote().socks.forEach((sock) => {
|
||||
if (sock) {
|
||||
if (!this.realtime.checkViewPermission(sock.request, this.getCurrentNote())) {
|
||||
sock.emit('info', {
|
||||
code: 403
|
||||
})
|
||||
setTimeout(function () {
|
||||
sock.disconnect(true)
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
refreshEventHandler () {
|
||||
this.realtime.emitRefresh(this.socket)
|
||||
}
|
||||
|
||||
checkVersionEventHandler () {
|
||||
this.socket.emit('version', {
|
||||
version: config.fullversion,
|
||||
minimumCompatibleVersion: config.minimumCompatibleVersion
|
||||
})
|
||||
}
|
||||
|
||||
userStatusEventHandler (data) {
|
||||
if (!this.isNoteAndUserExists()) return
|
||||
const user = this.getCurrentUser()
|
||||
if (config.debug) {
|
||||
logger.info('SERVER received [' + this.socket.noteId + '] user status from [' + this.socket.id + ']: ' + JSON.stringify(data))
|
||||
}
|
||||
if (data) {
|
||||
user.idle = data.idle
|
||||
user.type = data.type
|
||||
}
|
||||
this.realtime.emitUserStatus(this.socket)
|
||||
}
|
||||
|
||||
userChangedEventHandler () {
|
||||
logger.info('user changed')
|
||||
|
||||
const note = this.getCurrentNote()
|
||||
if (!note) return
|
||||
const user = note.users[this.socket.id]
|
||||
if (!user) return
|
||||
|
||||
this.realtime.updateUserData(this.socket, user)
|
||||
this.realtime.emitOnlineUsers(this.socket)
|
||||
}
|
||||
|
||||
onlineUsersEventHandler () {
|
||||
if (!this.isNoteAndUserExists()) return
|
||||
|
||||
const currentNote = this.getCurrentNote()
|
||||
|
||||
const currentNoteOnlineUserList = Object.keys(currentNote.users)
|
||||
.map(key => this.realtime.buildUserOutData(currentNote.users[key]))
|
||||
|
||||
this.socket.emit('online users', {
|
||||
users: currentNoteOnlineUserList
|
||||
})
|
||||
}
|
||||
|
||||
cursorFocusEventHandler (data) {
|
||||
if (!this.isNoteAndUserExists()) return
|
||||
const user = this.getCurrentUser()
|
||||
user.cursor = data
|
||||
const out = this.realtime.buildUserOutData(user)
|
||||
this.getNoteChannel().emit('cursor focus', out)
|
||||
}
|
||||
|
||||
cursorActivityEventHandler (data) {
|
||||
if (!this.isNoteAndUserExists()) return
|
||||
const user = this.getCurrentUser()
|
||||
user.cursor = data
|
||||
const out = this.realtime.buildUserOutData(user)
|
||||
this.getNoteChannel().emit('cursor activity', out)
|
||||
}
|
||||
|
||||
cursorBlurEventHandler () {
|
||||
if (!this.isNoteAndUserExists()) return
|
||||
const user = this.getCurrentUser()
|
||||
user.cursor = null
|
||||
this.getNoteChannel().emit('cursor blur', {
|
||||
id: this.socket.id
|
||||
})
|
||||
}
|
||||
|
||||
deleteNoteEventHandler () {
|
||||
// need login to do more actions
|
||||
if (this.isUserLoggedIn() && this.isNoteAndUserExists()) {
|
||||
const note = this.getCurrentNote()
|
||||
// Only owner can delete note
|
||||
if (note.owner && note.owner === this.getCurrentLoggedInUserId()) {
|
||||
this.destroyNote(note.id)
|
||||
.then((successRows) => {
|
||||
if (!successRows) return
|
||||
this.realtime.disconnectSocketOnNote(note)
|
||||
})
|
||||
.catch(function (err) {
|
||||
return logger.error('delete note failed: ' + err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
permissionChangeEventHandler (permission) {
|
||||
if (!this.isUserLoggedIn()) return
|
||||
if (!this.isNoteAndUserExists()) return
|
||||
|
||||
const note = this.getCurrentNote()
|
||||
// Only owner can change permission
|
||||
if (!this.isNoteOwner()) return
|
||||
if (!this.isAnonymousEnable() && permission === 'freely') return
|
||||
|
||||
this.changeNotePermission(permission)
|
||||
.then(() => {
|
||||
note.permission = permission
|
||||
this.notifyPermissionChanged()
|
||||
})
|
||||
.catch(err => logger.error('update note permission failed: ' + err))
|
||||
}
|
||||
|
||||
disconnectEventHandler () {
|
||||
if (this.realtime.disconnectProcessQueue.checkTaskIsInQueue(this.socket.id)) {
|
||||
return
|
||||
}
|
||||
this.realtime.queueForDisconnect(this.socket)
|
||||
}
|
||||
}
|
||||
|
||||
exports.RealtimeClientConnection = RealtimeClientConnection
|
|
@ -0,0 +1,45 @@
|
|||
'use strict'
|
||||
|
||||
const models = require('./models')
|
||||
const logger = require('./logger')
|
||||
|
||||
/**
|
||||
* clean when user not in any rooms or user not in connected list
|
||||
*/
|
||||
class SaveRevisionJob {
|
||||
constructor (realtime) {
|
||||
this.realtime = realtime
|
||||
this.saverSleep = false
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.timer) return
|
||||
this.timer = setInterval(this.saveRevision.bind(this), 5 * 60 * 1000)
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (!this.timer) return
|
||||
clearInterval(this.timer)
|
||||
this.timer = undefined
|
||||
}
|
||||
|
||||
saveRevision () {
|
||||
if (this.getSaverSleep()) return
|
||||
models.Revision.saveAllNotesRevision((err, notes) => {
|
||||
if (err) return logger.error('revision saver failed: ' + err)
|
||||
if (notes && notes.length <= 0) {
|
||||
this.setSaverSleep(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getSaverSleep () {
|
||||
return this.saverSleep
|
||||
}
|
||||
|
||||
setSaverSleep (val) {
|
||||
this.saverSleep = val
|
||||
}
|
||||
}
|
||||
|
||||
exports.SaveRevisionJob = SaveRevisionJob
|
|
@ -0,0 +1,78 @@
|
|||
'use strict'
|
||||
|
||||
const config = require('./config')
|
||||
const logger = require('./logger')
|
||||
const moment = require('moment')
|
||||
|
||||
class UpdateDirtyNoteJob {
|
||||
constructor (realtime) {
|
||||
this.realtime = realtime
|
||||
}
|
||||
|
||||
start () {
|
||||
if (this.timer) return
|
||||
this.timer = setInterval(this.updateDirtyNotes.bind(this), 1000)
|
||||
}
|
||||
|
||||
stop () {
|
||||
if (!this.timer) return
|
||||
clearInterval(this.timer)
|
||||
this.timer = undefined
|
||||
}
|
||||
|
||||
updateDirtyNotes () {
|
||||
const notes = this.realtime.getNotePool()
|
||||
Object.keys(notes).forEach((key) => {
|
||||
const note = notes[key]
|
||||
this.updateDirtyNote(note)
|
||||
.catch((err) => {
|
||||
logger.error('updateDirtyNote: updater error', err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async updateDirtyNote (note) {
|
||||
const notes = this.realtime.getNotePool()
|
||||
if (!note.server.isDirty) return
|
||||
|
||||
if (config.debug) logger.info('updateDirtyNote: updater found dirty note: ' + note.id)
|
||||
note.server.isDirty = false
|
||||
|
||||
try {
|
||||
const _note = await this.updateNoteAsync(note)
|
||||
// handle when note already been clean up
|
||||
if (!notes[note.id] || !notes[note.id].server) return
|
||||
|
||||
if (!_note) {
|
||||
this.realtime.io.to(note.id).emit('info', {
|
||||
code: 404
|
||||
})
|
||||
logger.error('updateDirtyNote: note not found: ', note.id)
|
||||
this.realtime.disconnectSocketOnNote(note)
|
||||
}
|
||||
|
||||
note.updatetime = moment(_note.lastchangeAt).valueOf()
|
||||
this.realtime.emitCheck(note)
|
||||
} catch (err) {
|
||||
logger.error('updateDirtyNote: note not found: ', note.id)
|
||||
this.realtime.io.to(note.id).emit('info', {
|
||||
code: 404
|
||||
})
|
||||
this.realtime.disconnectSocketOnNote(note)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
updateNoteAsync (note) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.realtime.updateNote(note, (err, _note) => {
|
||||
if (err) {
|
||||
return reject(err)
|
||||
}
|
||||
return resolve(_note)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
exports.UpdateDirtyNoteJob = UpdateDirtyNoteJob
|
51
package.json
51
package.json
|
@ -18,15 +18,18 @@
|
|||
"build": "webpack --config webpack.prod.js --progress --colors --bail",
|
||||
"dev": "webpack --config webpack.dev.js --progress --colors --watch",
|
||||
"doctoc": "doctoc --title='# Table of Contents' README.md",
|
||||
"eslint": "eslint lib public test app.js",
|
||||
"postinstall": "bin/heroku",
|
||||
"jsonlint": "find . -not -path './node_modules/*' -type f -name '*.json' -o -type f -name '*.json.example' | while read json; do echo $json ; jq . $json; done",
|
||||
"standard": "echo 'standard is no longer being used, use `npm run eslint` instead!' && exit 1",
|
||||
"lint": "standard",
|
||||
"jsonlint": "find . -type f -not -ipath \"./node_modules/*\" \\( -name \"*.json\" -o -name \"*.json.*\" \\) | xargs -n 1 -I{} -- bash -c 'echo {}; jq . {} > /dev/null;'",
|
||||
"start": "sequelize db:migrate && node app.js",
|
||||
"test": "npm run-script eslint && npm run-script jsonlint && mocha"
|
||||
"mocha": "mocha --require intelli-espower-loader --exit ./test --recursive",
|
||||
"mocha:ci": "mocha --no-color -R dot --require intelli-espower-loader --exit ./test --recursive",
|
||||
"coverage": "nyc mocha --require intelli-espower-loader --exit --recursive ./test",
|
||||
"coverage:ci": "nyc mocha --no-color -R dot --require intelli-espower-loader --exit --recursive ./test",
|
||||
"test": "npm run-script lint && npm run-script jsonlint && npm run-script coverage",
|
||||
"test:ci": "npm run-script lint && npm run-script jsonlint && npm run-script coverage:ci"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hackmd/codemirror": "^5.46.2",
|
||||
"@hackmd/codemirror": "~5.46.2",
|
||||
"@hackmd/diff-match-patch": "~1.1.1",
|
||||
"@hackmd/idle-js": "~1.0.1",
|
||||
"@hackmd/imgur": "~0.4.1",
|
||||
|
@ -38,6 +41,7 @@
|
|||
"async": "~2.1.4",
|
||||
"aws-sdk": "~2.345.0",
|
||||
"azure-storage": "~2.10.2",
|
||||
"babel-polyfill": "~6.26.0",
|
||||
"base64url": "~3.0.0",
|
||||
"body-parser": "~1.18.3",
|
||||
"bootstrap": "~3.4.0",
|
||||
|
@ -65,7 +69,7 @@
|
|||
"highlight.js": "~9.12.0",
|
||||
"i18n": "~0.8.3",
|
||||
"ionicons": "~2.0.1",
|
||||
"isomorphic-fetch": "^2.2.1",
|
||||
"isomorphic-fetch": "~2.2.1",
|
||||
"jquery": "~3.1.1",
|
||||
"jquery-mousewheel": "~3.1.13",
|
||||
"jquery-ui": "~1.12.1",
|
||||
|
@ -90,8 +94,8 @@
|
|||
"markdown-it-sup": "~1.0.0",
|
||||
"markdown-pdf": "~9.0.0",
|
||||
"mathjax": "~2.7.0",
|
||||
"mattermost-redux": "^5.9.0",
|
||||
"mermaid": "^8.2.3",
|
||||
"mattermost-redux": "~5.9.0",
|
||||
"mermaid": "~8.2.3",
|
||||
"method-override": "~2.3.7",
|
||||
"minimist": "~1.2.0",
|
||||
"minio": "~6.0.0",
|
||||
|
@ -146,33 +150,31 @@
|
|||
"babel-core": "~6.26.3",
|
||||
"babel-loader": "~7.1.4",
|
||||
"babel-plugin-transform-runtime": "~6.23.0",
|
||||
"babel-polyfill": "~6.26.0",
|
||||
"babel-preset-env": "~1.7.0",
|
||||
"babel-runtime": "~6.26.0",
|
||||
"copy-webpack-plugin": "~4.5.2",
|
||||
"css-loader": "~1.0.0",
|
||||
"doctoc": "~1.4.0",
|
||||
"ejs-loader": "~0.3.1",
|
||||
"eslint": "~5.16.0",
|
||||
"eslint-config-standard": "~12.0.0",
|
||||
"eslint-plugin-import": "~2.17.1",
|
||||
"eslint-plugin-node": "~8.0.1",
|
||||
"eslint-plugin-promise": "~4.1.1",
|
||||
"eslint-plugin-standard": "~4.0.0",
|
||||
"exports-loader": "~0.7.0",
|
||||
"expose-loader": "~0.7.5",
|
||||
"file-loader": "~2.0.0",
|
||||
"html-webpack-plugin": "~4.0.0-beta.2",
|
||||
"imports-loader": "~0.8.0",
|
||||
"intelli-espower-loader": "~1.0.1",
|
||||
"jsonlint": "~1.6.2",
|
||||
"less": "~3.9.0",
|
||||
"less-loader": "~4.1.0",
|
||||
"mini-css-extract-plugin": "~0.4.1",
|
||||
"mocha": "~5.2.0",
|
||||
"mock-require": "~3.0.3",
|
||||
"nyc": "~14.0.0",
|
||||
"optimize-css-assets-webpack-plugin": "~5.0.0",
|
||||
"power-assert": "~1.6.1",
|
||||
"script-loader": "~0.7.2",
|
||||
"sequelize-cli": "~5.4.0",
|
||||
"sinon": "~7.3.2",
|
||||
"standard": "~12.0.1",
|
||||
"string-loader": "~0.0.1",
|
||||
"style-loader": "~0.21.0",
|
||||
"uglifyjs-webpack-plugin": "~1.2.7",
|
||||
|
@ -198,5 +200,20 @@
|
|||
"name": "Christoph (Sheogorath) Kern",
|
||||
"email": "codimd@sheogorath.shivering-isles.com"
|
||||
}
|
||||
]
|
||||
],
|
||||
"standard": {
|
||||
"ignore": [
|
||||
"/public/build",
|
||||
"/public/vendor",
|
||||
"/lib/ot"
|
||||
]
|
||||
},
|
||||
"nyc": {
|
||||
"all": true,
|
||||
"include": [
|
||||
"app.js",
|
||||
"lib/**/*.js"
|
||||
],
|
||||
"reporter": "lcov"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-env browser, jquery */
|
||||
/* global CodeMirror, Cookies, moment, Spinner, serverurl,
|
||||
key, Dropbox, ot, hex2rgb, Visibility */
|
||||
key, Dropbox, ot, hex2rgb, Visibility, inlineAttachment */
|
||||
|
||||
import TurndownService from 'turndown'
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* global CodeMirror, $, editor, Cookies */
|
||||
import * as utils from './utils'
|
||||
import config from './config'
|
||||
import statusBarTemplate from './statusbar.html'
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
* Global UI elements references
|
||||
*/
|
||||
/* global $ */
|
||||
|
||||
export const getUIElements = () => ({
|
||||
spinner: $('.ui-spinner'),
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* global CodeMirror, editor */
|
||||
const wrapSymbols = ['*', '_', '~', '^', '+', '=']
|
||||
export function wrapTextWith (editor, cm, symbol) {
|
||||
if (!cm.getSelection()) {
|
||||
|
|
|
@ -0,0 +1,129 @@
|
|||
/* eslint-env node, mocha */
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const { ProcessQueue } = require('../lib/processQueue')
|
||||
|
||||
describe('ProcessQueue', function () {
|
||||
let clock
|
||||
const waitTimeForCheckResult = 50
|
||||
|
||||
beforeEach(() => {
|
||||
clock = sinon.useFakeTimers({
|
||||
toFake: ['setInterval']
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clock.restore()
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('should not accept more than maximum task', () => {
|
||||
const queue = new ProcessQueue({ maximumLength: 2 })
|
||||
|
||||
queue.start()
|
||||
assert(queue.push(1, () => (Promise.resolve())))
|
||||
assert(queue.push(1, () => (Promise.resolve())) === false)
|
||||
})
|
||||
|
||||
it('should run task every interval', (done) => {
|
||||
const runningClock = []
|
||||
const queue = new ProcessQueue({ maximumLength: 2 })
|
||||
const task = async () => {
|
||||
runningClock.push(clock.now)
|
||||
}
|
||||
queue.start()
|
||||
assert(queue.push(1, task))
|
||||
assert(queue.push(2, task))
|
||||
clock.tick(5)
|
||||
setTimeout(() => {
|
||||
clock.tick(5)
|
||||
}, 1)
|
||||
setTimeout(() => {
|
||||
clock.tick(5)
|
||||
}, 2)
|
||||
setTimeout(() => {
|
||||
clock.tick(5)
|
||||
}, 3)
|
||||
|
||||
setTimeout(() => {
|
||||
queue.stop()
|
||||
assert(runningClock.length === 2)
|
||||
done()
|
||||
}, waitTimeForCheckResult)
|
||||
})
|
||||
|
||||
it('should not crash when repeat stop queue', () => {
|
||||
const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 10 })
|
||||
try {
|
||||
queue.stop()
|
||||
queue.stop()
|
||||
queue.stop()
|
||||
assert.ok(true)
|
||||
} catch (e) {
|
||||
assert.fail(e)
|
||||
}
|
||||
})
|
||||
|
||||
it('should run process when queue is empty', (done) => {
|
||||
const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 100 })
|
||||
const processSpy = sinon.spy(queue, 'process')
|
||||
queue.start()
|
||||
clock.tick(100)
|
||||
setTimeout(() => {
|
||||
assert(processSpy.called)
|
||||
done()
|
||||
}, waitTimeForCheckResult)
|
||||
})
|
||||
|
||||
it('should run process although error occurred', (done) => {
|
||||
const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 100 })
|
||||
const failedTask = sinon.spy(async () => {
|
||||
throw new Error('error')
|
||||
})
|
||||
const normalTask = sinon.spy(async () => {
|
||||
})
|
||||
queue.start()
|
||||
assert(queue.push(1, failedTask))
|
||||
assert(queue.push(2, normalTask))
|
||||
clock.tick(100)
|
||||
setTimeout(() => {
|
||||
clock.tick(100)
|
||||
}, 1)
|
||||
setTimeout(() => {
|
||||
// assert(queue.queue.length === 0)
|
||||
assert(failedTask.called)
|
||||
assert(normalTask.called)
|
||||
done()
|
||||
}, waitTimeForCheckResult)
|
||||
})
|
||||
|
||||
it('should ignore trigger when event not complete', (done) => {
|
||||
const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 10 })
|
||||
const processSpy = sinon.spy(queue, 'process')
|
||||
const longTask = async () => {
|
||||
return new Promise((resolve) => {
|
||||
setInterval(() => {
|
||||
resolve()
|
||||
}, 50)
|
||||
})
|
||||
}
|
||||
queue.start()
|
||||
queue.push(1, longTask)
|
||||
clock.tick(10)
|
||||
setTimeout(() => {
|
||||
clock.tick(10)
|
||||
}, 0)
|
||||
setTimeout(() => {
|
||||
clock.tick(10)
|
||||
}, 1)
|
||||
setTimeout(() => {
|
||||
assert(processSpy.callCount === 1)
|
||||
assert(processSpy.calledOnce)
|
||||
done()
|
||||
}, waitTimeForCheckResult)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,69 @@
|
|||
/* eslint-env node, mocha */
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const mock = require('mock-require')
|
||||
const sinon = require('sinon')
|
||||
const { removeModuleFromRequireCache, makeMockSocket } = require('./utils')
|
||||
|
||||
describe('cleanDanglingUser', function () {
|
||||
let clock
|
||||
beforeEach(() => {
|
||||
clock = sinon.useFakeTimers()
|
||||
mock('../../lib/processQueue', require('../testDoubles/ProcessQueueFake'))
|
||||
mock('../../lib/logger', {
|
||||
error: () => {},
|
||||
info: () => {}
|
||||
})
|
||||
mock('../../lib/history', {})
|
||||
mock('../../lib/models', {
|
||||
Revision: {
|
||||
saveAllNotesRevision: () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
mock('../../lib/config', {
|
||||
debug: true
|
||||
})
|
||||
mock('../../lib/realtimeUpdateDirtyNoteJob', require('../testDoubles/realtimeJobStub'))
|
||||
mock('../../lib/realtimeSaveRevisionJob', require('../testDoubles/realtimeJobStub'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clock.restore()
|
||||
removeModuleFromRequireCache('../../lib/realtime')
|
||||
mock.stopAll()
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('should call queueForDisconnectSpy when user is dangling', (done) => {
|
||||
const realtime = require('../../lib/realtime')
|
||||
const queueForDisconnectSpy = sinon.spy(realtime, 'queueForDisconnect')
|
||||
realtime.io = {
|
||||
to: sinon.stub().callsFake(function () {
|
||||
return {
|
||||
emit: sinon.fake()
|
||||
}
|
||||
}),
|
||||
sockets: {
|
||||
connected: {}
|
||||
}
|
||||
}
|
||||
let user1Socket = makeMockSocket()
|
||||
let user2Socket = makeMockSocket()
|
||||
|
||||
user1Socket.rooms.push('room1')
|
||||
|
||||
realtime.io.sockets.connected[user1Socket.id] = user1Socket
|
||||
realtime.io.sockets.connected[user2Socket.id] = user2Socket
|
||||
|
||||
realtime.users[user1Socket.id] = user1Socket
|
||||
realtime.users[user2Socket.id] = user2Socket
|
||||
clock.tick(60000)
|
||||
clock.restore()
|
||||
setTimeout(() => {
|
||||
assert(queueForDisconnectSpy.called)
|
||||
done()
|
||||
}, 50)
|
||||
})
|
||||
})
|
|
@ -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, 'parseNoteIdFromSocketAsync')
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,129 @@
|
|||
/* eslint-env node, mocha */
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const mock = require('mock-require')
|
||||
const sinon = require('sinon')
|
||||
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']
|
||||
})
|
||||
mock('../../lib/logger', {
|
||||
error: () => {
|
||||
}
|
||||
})
|
||||
mock('../../lib/history', {})
|
||||
mock('../../lib/models', {
|
||||
Revision: {
|
||||
saveAllNotesRevision: () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
mock('../../lib/config', {})
|
||||
realtime = require('../../lib/realtime')
|
||||
|
||||
realtime.io = {
|
||||
to: sinon.stub().callsFake(function () {
|
||||
return {
|
||||
emit: sinon.fake()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
removeModuleFromRequireCache('../../lib/realtimeUpdateDirtyNoteJob')
|
||||
removeModuleFromRequireCache('../../lib/realtime')
|
||||
mock.stopAll()
|
||||
clock.restore()
|
||||
})
|
||||
|
||||
it('should update note when note is dirty', (done) => {
|
||||
sinon.stub(realtime, 'updateNote').callsFake(function (note, callback) {
|
||||
callback(null, note)
|
||||
})
|
||||
|
||||
realtime.notes['note1'] = {
|
||||
server: {
|
||||
isDirty: false
|
||||
},
|
||||
socks: []
|
||||
}
|
||||
|
||||
let note2 = {
|
||||
server: {
|
||||
isDirty: true
|
||||
},
|
||||
socks: []
|
||||
}
|
||||
|
||||
realtime.notes['note2'] = note2
|
||||
|
||||
clock.tick(1000)
|
||||
setTimeout(() => {
|
||||
assert(note2.server.isDirty === false)
|
||||
done()
|
||||
}, 10)
|
||||
})
|
||||
|
||||
it('should not do anything when note missing', function (done) {
|
||||
sinon.stub(realtime, 'updateNote').callsFake(function (note, callback) {
|
||||
delete realtime.notes['note']
|
||||
callback(null, note)
|
||||
})
|
||||
|
||||
let note = {
|
||||
server: {
|
||||
isDirty: true
|
||||
},
|
||||
socks: [makeMockSocket()]
|
||||
}
|
||||
realtime.notes['note'] = note
|
||||
|
||||
clock.tick(1000)
|
||||
|
||||
setTimeout(() => {
|
||||
assert(note.server.isDirty === false)
|
||||
assert(note.socks[0].disconnect.called === false)
|
||||
done()
|
||||
}, 50)
|
||||
})
|
||||
|
||||
it('should disconnect all clients when update note error', function (done) {
|
||||
sinon.stub(realtime, 'updateNote').callsFake(function (note, callback) {
|
||||
callback(new Error('some error'), null)
|
||||
})
|
||||
|
||||
realtime.io = {
|
||||
to: sinon.stub().callsFake(function () {
|
||||
return {
|
||||
emit: sinon.fake()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let note = {
|
||||
server: {
|
||||
isDirty: true
|
||||
},
|
||||
socks: [makeMockSocket(), undefined, makeMockSocket()]
|
||||
}
|
||||
realtime.notes['note'] = note
|
||||
|
||||
clock.tick(1000)
|
||||
|
||||
setTimeout(() => {
|
||||
assert(note.server.isDirty === false)
|
||||
assert(note.socks[0].disconnect.called)
|
||||
assert(note.socks[2].disconnect.called)
|
||||
done()
|
||||
}, 50)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,94 @@
|
|||
/* eslint-env node, mocha */
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const mock = require('mock-require')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const { makeMockSocket, removeModuleFromRequireCache } = require('./utils')
|
||||
|
||||
describe('realtime#disconnect', function () {
|
||||
const noteId = 'note1_id'
|
||||
let realtime
|
||||
let updateNoteStub
|
||||
let emitOnlineUsersStub
|
||||
let client
|
||||
|
||||
beforeEach(() => {
|
||||
mock('../../lib/logger', {
|
||||
error: () => {
|
||||
}
|
||||
})
|
||||
mock('../../lib/history', {})
|
||||
mock('../../lib/models', {
|
||||
Revision: {
|
||||
saveAllNotesRevision: () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
mock('../../lib/config', {})
|
||||
|
||||
realtime = require('../../lib/realtime')
|
||||
updateNoteStub = sinon.stub(realtime, 'updateNote').callsFake((note, callback) => {
|
||||
callback(null, note)
|
||||
})
|
||||
emitOnlineUsersStub = sinon.stub(realtime, 'emitOnlineUsers')
|
||||
client = makeMockSocket()
|
||||
client.noteId = noteId
|
||||
|
||||
realtime.users[client.id] = {
|
||||
id: client.id,
|
||||
color: '#ff0000',
|
||||
cursor: null,
|
||||
login: false,
|
||||
userid: null,
|
||||
name: null,
|
||||
idle: false,
|
||||
type: null
|
||||
}
|
||||
|
||||
realtime.getNotePool()[noteId] = {
|
||||
id: noteId,
|
||||
server: {
|
||||
isDirty: true
|
||||
},
|
||||
users: {
|
||||
[client.id]: realtime.users[client.id]
|
||||
},
|
||||
socks: [client]
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
removeModuleFromRequireCache('../../lib/realtime')
|
||||
mock.stopAll()
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('should disconnect success', function (done) {
|
||||
realtime.queueForDisconnect(client)
|
||||
|
||||
setTimeout(() => {
|
||||
assert(typeof realtime.users[client.id] === 'undefined')
|
||||
assert(emitOnlineUsersStub.called)
|
||||
assert(updateNoteStub.called)
|
||||
assert(Object.keys(realtime.users).length === 0)
|
||||
assert(Object.keys(realtime.notes).length === 0)
|
||||
done()
|
||||
}, 5)
|
||||
})
|
||||
|
||||
it('should disconnect success when note is not dirty', function (done) {
|
||||
realtime.notes[noteId].server.isDirty = false
|
||||
realtime.queueForDisconnect(client)
|
||||
|
||||
setTimeout(() => {
|
||||
assert(typeof realtime.users[client.id] === 'undefined')
|
||||
assert(emitOnlineUsersStub.called)
|
||||
assert(updateNoteStub.called === false)
|
||||
assert(Object.keys(realtime.users).length === 0)
|
||||
assert(Object.keys(realtime.notes).length === 0)
|
||||
done()
|
||||
}, 5)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,91 @@
|
|||
/* eslint-env node, mocha */
|
||||
'use strict'
|
||||
|
||||
const mock = require('mock-require')
|
||||
const assert = require('assert')
|
||||
|
||||
const { makeMockSocket } = require('./utils')
|
||||
|
||||
describe('realtime#extractNoteIdFromSocket', function () {
|
||||
beforeEach(() => {
|
||||
mock('../../lib/logger', {})
|
||||
mock('../../lib/history', {})
|
||||
mock('../../lib/models', {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete require.cache[require.resolve('../../lib/realtime')]
|
||||
mock.stopAll()
|
||||
})
|
||||
|
||||
describe('urlPath not set', function () {
|
||||
beforeEach(() => {
|
||||
mock('../../lib/config', {})
|
||||
realtime = require('../../lib/realtime')
|
||||
})
|
||||
|
||||
let realtime
|
||||
|
||||
it('return false if socket or socket.handshake not exists', function () {
|
||||
let noteId = realtime.extractNoteIdFromSocket()
|
||||
assert.strictEqual(false, noteId)
|
||||
|
||||
noteId = realtime.extractNoteIdFromSocket({})
|
||||
assert.strictEqual(false, noteId)
|
||||
})
|
||||
|
||||
it('return false if query not set and referer not set', function () {
|
||||
let noteId = realtime.extractNoteIdFromSocket(makeMockSocket({
|
||||
otherHeader: 1
|
||||
}, {
|
||||
otherQuery: 1
|
||||
}))
|
||||
assert.strictEqual(false, noteId)
|
||||
})
|
||||
|
||||
it('return noteId from query', function () {
|
||||
// Arrange
|
||||
const incomingNoteId = 'myNoteId'
|
||||
const incomingSocket = makeMockSocket(undefined, { noteId: incomingNoteId })
|
||||
|
||||
// Act
|
||||
const noteId = realtime.extractNoteIdFromSocket(incomingSocket)
|
||||
// Assert
|
||||
assert.strictEqual(noteId, incomingNoteId)
|
||||
})
|
||||
|
||||
it('return noteId from old method (referer)', function () {
|
||||
// Arrange
|
||||
const incomingNoteId = 'myNoteId'
|
||||
const incomingSocket = makeMockSocket({
|
||||
referer: `https://localhost:3000/${incomingNoteId}`
|
||||
})
|
||||
|
||||
// Act
|
||||
const noteId = realtime.extractNoteIdFromSocket(incomingSocket)
|
||||
// Assert
|
||||
assert.strictEqual(noteId, incomingNoteId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('urlPath is set', function () {
|
||||
let realtime
|
||||
it('return noteId from old method (referer) and urlPath set', function () {
|
||||
// Arrange
|
||||
const urlPath = 'hello'
|
||||
mock('../../lib/config', {
|
||||
urlPath: urlPath
|
||||
})
|
||||
realtime = require('../../lib/realtime')
|
||||
const incomingNoteId = 'myNoteId'
|
||||
const incomingSocket = makeMockSocket({
|
||||
referer: `https://localhost:3000/${urlPath}/${incomingNoteId}`
|
||||
})
|
||||
|
||||
// Act
|
||||
const noteId = realtime.extractNoteIdFromSocket(incomingSocket)
|
||||
// Assert
|
||||
assert.strictEqual(noteId, incomingNoteId)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,126 @@
|
|||
/* 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 realtimeJobStub = require('../testDoubles/realtimeJobStub')
|
||||
const { removeLibModuleCache, makeMockSocket } = require('./utils')
|
||||
|
||||
describe('realtime#ifMayEdit', function () {
|
||||
let modelsStub
|
||||
beforeEach(() => {
|
||||
removeLibModuleCache()
|
||||
mock('../../lib/config', {})
|
||||
mock('../../lib/logger', createFakeLogger())
|
||||
mock('../../lib/models', modelsStub)
|
||||
mock('../../lib/realtimeUpdateDirtyNoteJob', realtimeJobStub)
|
||||
mock('../../lib/realtimeCleanDanglingUserJob', realtimeJobStub)
|
||||
mock('../../lib/realtimeSaveRevisionJob', realtimeJobStub)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.stopAll()
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const Role = {
|
||||
Guest: 'guest',
|
||||
LoggedIn: 'LoggedIn',
|
||||
Owner: 'Owner'
|
||||
}
|
||||
|
||||
const Permission = {
|
||||
Freely: 'freely',
|
||||
Editable: 'editable',
|
||||
Limited: 'limited',
|
||||
Locked: 'locked',
|
||||
Protected: 'protected',
|
||||
Private: 'private'
|
||||
}
|
||||
|
||||
const testcases = [
|
||||
{ role: Role.Guest, permission: Permission.Freely, canEdit: true },
|
||||
{ role: Role.LoggedIn, permission: Permission.Freely, canEdit: true },
|
||||
{ role: Role.Owner, permission: Permission.Freely, canEdit: true },
|
||||
{ role: Role.Guest, permission: Permission.Editable, canEdit: false },
|
||||
{ role: Role.LoggedIn, permission: Permission.Editable, canEdit: true },
|
||||
{ role: Role.Owner, permission: Permission.Editable, canEdit: true },
|
||||
{ role: Role.Guest, permission: Permission.Limited, canEdit: false },
|
||||
{ role: Role.LoggedIn, permission: Permission.Limited, canEdit: true },
|
||||
{ role: Role.Owner, permission: Permission.Limited, canEdit: true },
|
||||
{ role: Role.Guest, permission: Permission.Locked, canEdit: false },
|
||||
{ role: Role.LoggedIn, permission: Permission.Locked, canEdit: false },
|
||||
{ role: Role.Owner, permission: Permission.Locked, canEdit: true },
|
||||
{ role: Role.Guest, permission: Permission.Protected, canEdit: false },
|
||||
{ role: Role.LoggedIn, permission: Permission.Protected, canEdit: false },
|
||||
{ role: Role.Owner, permission: Permission.Protected, canEdit: true },
|
||||
{ role: Role.Guest, permission: Permission.Private, canEdit: false },
|
||||
{ role: Role.LoggedIn, permission: Permission.Private, canEdit: false },
|
||||
{ role: Role.Owner, permission: Permission.Private, canEdit: true }
|
||||
]
|
||||
|
||||
const noteOwnerId = 'owner'
|
||||
const loggedInUserId = 'user1'
|
||||
const noteId = 'noteId'
|
||||
|
||||
testcases.forEach((tc) => {
|
||||
it(`${tc.role} ${tc.canEdit ? 'can' : 'can\'t'} edit note with permission ${tc.permission}`, function () {
|
||||
const client = makeMockSocket()
|
||||
const note = {
|
||||
permission: tc.permission,
|
||||
owner: noteOwnerId
|
||||
}
|
||||
if (tc.role === Role.LoggedIn) {
|
||||
client.request.user.logged_in = true
|
||||
client.request.user.id = loggedInUserId
|
||||
} else if (tc.role === Role.Owner) {
|
||||
client.request.user.logged_in = true
|
||||
client.request.user.id = noteOwnerId
|
||||
}
|
||||
client.noteId = noteId
|
||||
const realtime = require('../../lib/realtime')
|
||||
realtime.getNotePool()[noteId] = note
|
||||
const callback = sinon.stub()
|
||||
realtime.ifMayEdit(client, callback)
|
||||
assert(callback.calledOnce)
|
||||
assert(callback.lastCall.args[0] === tc.canEdit)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set lsatchangeuser to null if guest edit operation', function () {
|
||||
const note = {
|
||||
permission: Permission.Freely
|
||||
}
|
||||
const client = makeMockSocket()
|
||||
client.noteId = noteId
|
||||
const callback = sinon.stub()
|
||||
client.origin = 'operation'
|
||||
const realtime = require('../../lib/realtime')
|
||||
realtime.getNotePool()[noteId] = note
|
||||
realtime.ifMayEdit(client, callback)
|
||||
assert(callback.calledOnce)
|
||||
assert(callback.lastCall.args[0])
|
||||
assert(note.lastchangeuser === null)
|
||||
})
|
||||
|
||||
it('should set lastchangeuser to logged_in user id if user edit', function () {
|
||||
const note = {
|
||||
permission: Permission.Freely
|
||||
}
|
||||
const client = makeMockSocket()
|
||||
client.noteId = noteId
|
||||
client.request.user.logged_in = true
|
||||
client.request.user.id = loggedInUserId
|
||||
const callback = sinon.stub()
|
||||
client.origin = 'operation'
|
||||
const realtime = require('../../lib/realtime')
|
||||
realtime.getNotePool()[noteId] = note
|
||||
realtime.ifMayEdit(client, callback)
|
||||
assert(callback.calledOnce)
|
||||
assert(callback.lastCall.args[0])
|
||||
assert(note.lastchangeuser === loggedInUserId)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,115 @@
|
|||
/* eslint-env node, mocha */
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const mock = require('mock-require')
|
||||
|
||||
const { makeMockSocket, removeModuleFromRequireCache } = require('./utils')
|
||||
|
||||
describe('realtime#parseNoteIdFromSocketAsync', function () {
|
||||
let realtime
|
||||
|
||||
beforeEach(() => {
|
||||
mock('../../lib/logger', {})
|
||||
mock('../../lib/history', {})
|
||||
mock('../../lib/models', {
|
||||
Note: {
|
||||
parseNoteId: function (noteId, callback) {
|
||||
callback(null, noteId)
|
||||
}
|
||||
}
|
||||
})
|
||||
mock('../../lib/config', {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
removeModuleFromRequireCache('../../lib/realtime')
|
||||
mock.stopAll()
|
||||
})
|
||||
|
||||
it('should return null when socket not send noteId', async function () {
|
||||
realtime = require('../../lib/realtime')
|
||||
const mockSocket = makeMockSocket()
|
||||
try {
|
||||
const notes = await realtime.parseNoteIdFromSocketAsync(mockSocket)
|
||||
assert(notes === null)
|
||||
} catch (err) {
|
||||
assert.fail('should not occur any error')
|
||||
}
|
||||
})
|
||||
|
||||
describe('noteId exists', function () {
|
||||
beforeEach(() => {
|
||||
mock('../../lib/models', {
|
||||
Note: {
|
||||
parseNoteId: function (noteId, callback) {
|
||||
callback(null, noteId)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
it('should return noteId when noteId exists', async function () {
|
||||
realtime = require('../../lib/realtime')
|
||||
const noteId = '123456'
|
||||
const mockSocket = makeMockSocket(undefined, {
|
||||
noteId: noteId
|
||||
})
|
||||
realtime = require('../../lib/realtime')
|
||||
let parsedNoteId
|
||||
try {
|
||||
parsedNoteId = await realtime.parseNoteIdFromSocketAsync(mockSocket)
|
||||
} catch (err) {
|
||||
assert.fail(`should not occur any error ${err} `)
|
||||
}
|
||||
assert(parsedNoteId === noteId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('noteId not exists', function () {
|
||||
beforeEach(() => {
|
||||
mock('../../lib/models', {
|
||||
Note: {
|
||||
parseNoteId: function (noteId, callback) {
|
||||
callback(null, null)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
it('should return null when noteId not exists', async function () {
|
||||
realtime = require('../../lib/realtime')
|
||||
const noteId = '123456'
|
||||
const mockSocket = makeMockSocket(undefined, {
|
||||
noteId: noteId
|
||||
})
|
||||
realtime = require('../../lib/realtime')
|
||||
const parsedNoteId = await realtime.parseNoteIdFromSocketAsync(mockSocket)
|
||||
assert(parsedNoteId === null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parse note error', function () {
|
||||
beforeEach(() => {
|
||||
mock('../../lib/models', {
|
||||
Note: {
|
||||
parseNoteId: function (noteId, callback) {
|
||||
/* eslint-disable-next-line */
|
||||
callback('error', null)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
it('should return error when noteId parse error', async function () {
|
||||
realtime = require('../../lib/realtime')
|
||||
const noteId = '123456'
|
||||
const mockSocket = makeMockSocket(undefined, {
|
||||
noteId: noteId
|
||||
})
|
||||
realtime = require('../../lib/realtime')
|
||||
try {
|
||||
await realtime.parseNoteIdFromSocketAsync(mockSocket)
|
||||
} catch (err) {
|
||||
assert(err === 'error')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,73 @@
|
|||
'use strict'
|
||||
|
||||
/* eslint-env node, mocha */
|
||||
|
||||
const mock = require('mock-require')
|
||||
const assert = require('assert')
|
||||
|
||||
describe('realtime', function () {
|
||||
describe('checkViewPermission', function () {
|
||||
// role -> guest, loggedInUser, loggedInOwner
|
||||
const viewPermission = {
|
||||
freely: [true, true, true],
|
||||
editable: [true, true, true],
|
||||
limited: [false, true, true],
|
||||
locked: [true, true, true],
|
||||
protected: [false, true, true],
|
||||
private: [false, false, true]
|
||||
}
|
||||
const loggedInUserId = 'user1_id'
|
||||
const ownerUserId = 'user2_id'
|
||||
const guestReq = {}
|
||||
const loggedInUserReq = {
|
||||
user: {
|
||||
id: loggedInUserId,
|
||||
logged_in: true
|
||||
}
|
||||
}
|
||||
const loggedInOwnerReq = {
|
||||
user: {
|
||||
id: ownerUserId,
|
||||
logged_in: true
|
||||
}
|
||||
}
|
||||
|
||||
const note = {
|
||||
owner: ownerUserId
|
||||
}
|
||||
|
||||
let realtime
|
||||
|
||||
beforeEach(() => {
|
||||
mock('../../lib/logger', {
|
||||
error: () => {
|
||||
}
|
||||
})
|
||||
mock('../../lib/history', {})
|
||||
mock('../../lib/models', {
|
||||
Note: {
|
||||
parseNoteTitle: (data) => (data)
|
||||
}
|
||||
})
|
||||
mock('../../lib/config', {})
|
||||
realtime = require('../../lib/realtime')
|
||||
})
|
||||
|
||||
Object.keys(viewPermission).forEach(function (permission) {
|
||||
describe(permission, function () {
|
||||
beforeEach(() => {
|
||||
note.permission = permission
|
||||
})
|
||||
it('guest permission test', function () {
|
||||
assert(realtime.checkViewPermission(guestReq, note) === viewPermission[permission][0])
|
||||
})
|
||||
it('loggedIn User permission test', function () {
|
||||
assert(realtime.checkViewPermission(loggedInUserReq, note) === viewPermission[permission][1])
|
||||
})
|
||||
it('loggedIn Owner permission test', function () {
|
||||
assert(realtime.checkViewPermission(loggedInOwnerReq, note) === viewPermission[permission][2])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,70 @@
|
|||
/* eslint-env node, mocha */
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const mock = require('mock-require')
|
||||
const sinon = require('sinon')
|
||||
const { removeModuleFromRequireCache, removeLibModuleCache } = require('./utils')
|
||||
|
||||
describe('save revision job', function () {
|
||||
let clock
|
||||
let mockModels
|
||||
let realtime
|
||||
beforeEach(() => {
|
||||
removeLibModuleCache()
|
||||
mockModels = {
|
||||
Revision: {
|
||||
saveAllNotesRevision: sinon.stub()
|
||||
}
|
||||
}
|
||||
clock = sinon.useFakeTimers()
|
||||
mock('../../lib/processQueue', require('../testDoubles/ProcessQueueFake'))
|
||||
mock('../../lib/logger', {
|
||||
error: () => {},
|
||||
info: () => {}
|
||||
})
|
||||
mock('../../lib/history', {})
|
||||
mock('../../lib/models', mockModels)
|
||||
mock('../../lib/config', {
|
||||
debug: true
|
||||
})
|
||||
mock('../../lib/realtimeUpdateDirtyNoteJob', require('../testDoubles/realtimeJobStub'))
|
||||
mock('../../lib/realtimeCleanDanglingUserJob', require('../testDoubles/realtimeJobStub'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clock.restore()
|
||||
removeModuleFromRequireCache('../../lib/realtime')
|
||||
removeModuleFromRequireCache('../../lib/realtimeSaveRevisionJob')
|
||||
mock.stopAll()
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('should execute save revision job every 5 min', (done) => {
|
||||
mockModels.Revision.saveAllNotesRevision.callsFake((callback) => {
|
||||
callback(null, [])
|
||||
})
|
||||
realtime = require('../../lib/realtime')
|
||||
clock.tick(5 * 60 * 1000)
|
||||
clock.restore()
|
||||
setTimeout(() => {
|
||||
assert(mockModels.Revision.saveAllNotesRevision.called)
|
||||
assert(realtime.saveRevisionJob.getSaverSleep() === true)
|
||||
done()
|
||||
}, 50)
|
||||
})
|
||||
|
||||
it('should not set saverSleep when more than 1 note save revision', (done) => {
|
||||
mockModels.Revision.saveAllNotesRevision.callsFake((callback) => {
|
||||
callback(null, [1])
|
||||
})
|
||||
realtime = require('../../lib/realtime')
|
||||
clock.tick(5 * 60 * 1000)
|
||||
clock.restore()
|
||||
setTimeout(() => {
|
||||
assert(mockModels.Revision.saveAllNotesRevision.called)
|
||||
assert(realtime.saveRevisionJob.getSaverSleep() === false)
|
||||
done()
|
||||
}, 50)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,590 @@
|
|||
/* eslint-env node, mocha */
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const mock = require('mock-require')
|
||||
const sinon = require('sinon')
|
||||
|
||||
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
|
||||
let eventFuncMap
|
||||
let configMock
|
||||
let clock
|
||||
|
||||
beforeEach(function (done) {
|
||||
clock = sinon.useFakeTimers({
|
||||
toFake: ['setInterval']
|
||||
})
|
||||
eventFuncMap = new Map()
|
||||
modelsMock = {
|
||||
Note: {
|
||||
parseNoteTitle: (data) => (data),
|
||||
destroy: 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'
|
||||
}
|
||||
mock('../../lib/logger', {
|
||||
error: () => {
|
||||
},
|
||||
info: () => {
|
||||
}
|
||||
})
|
||||
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(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
|
||||
|
||||
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, 'updateUserData'))
|
||||
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)
|
||||
|
||||
setTimeout(() => {
|
||||
wrappedFuncs.forEach((wrappedFunc) => {
|
||||
wrappedFunc.restore()
|
||||
})
|
||||
done()
|
||||
}, 50)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
removeModuleFromRequireCache('../../lib/realtime')
|
||||
removeModuleFromRequireCache('../../lib/realtimeClientConnection')
|
||||
mock.stopAll()
|
||||
sinon.restore()
|
||||
clock.restore()
|
||||
clientSocket = null
|
||||
})
|
||||
|
||||
describe('refresh', function () {
|
||||
it('should call refresh', () => {
|
||||
const refreshFunc = eventFuncMap.get('refresh')
|
||||
const emitRefreshStub = sinon.stub(realtime, 'emitRefresh')
|
||||
refreshFunc()
|
||||
assert(emitRefreshStub.calledOnce)
|
||||
assert.deepStrictEqual(emitRefreshStub.lastCall.args[0], clientSocket)
|
||||
})
|
||||
})
|
||||
|
||||
describe('user status', function () {
|
||||
it('should call emitUserStatus and update user data', () => {
|
||||
const userStatusFunc = eventFuncMap.get('user status')
|
||||
const emitUserStatusStub = sinon.stub(realtime, 'emitUserStatus')
|
||||
realtime.notes[noteId] = {}
|
||||
|
||||
const userData = {
|
||||
idle: true,
|
||||
type: 'xs'
|
||||
}
|
||||
userStatusFunc(userData)
|
||||
assert(emitUserStatusStub.calledOnce)
|
||||
assert.deepStrictEqual(emitUserStatusStub.lastCall.args[0], clientSocket)
|
||||
assert(realtime.users[clientSocket.id].idle === true)
|
||||
assert(realtime.users[clientSocket.id].type === 'xs')
|
||||
})
|
||||
|
||||
it('should call emitUserStatus without userdata', () => {
|
||||
const userStatusFunc = eventFuncMap.get('user status')
|
||||
const emitUserStatusStub = sinon.stub(realtime, 'emitUserStatus')
|
||||
realtime.notes[noteId] = {}
|
||||
userStatusFunc()
|
||||
assert(emitUserStatusStub.calledOnce)
|
||||
assert.deepStrictEqual(emitUserStatusStub.lastCall.args[0], clientSocket)
|
||||
assert(realtime.users[clientSocket.id].idle === false)
|
||||
assert(realtime.users[clientSocket.id].type === null)
|
||||
})
|
||||
|
||||
it('should not call emitUserStatus when user not exists', () => {
|
||||
const userStatusFunc = eventFuncMap.get('user status')
|
||||
const emitUserStatusStub = sinon.stub(realtime, 'emitUserStatus')
|
||||
realtime.notes[noteId] = {}
|
||||
delete realtime.users[clientSocket.id]
|
||||
const userData = {
|
||||
idle: true,
|
||||
type: 'xs'
|
||||
}
|
||||
userStatusFunc(userData)
|
||||
assert(emitUserStatusStub.called === false)
|
||||
})
|
||||
|
||||
it('should not call emitUserStatus when note not exists', () => {
|
||||
const userStatusFunc = eventFuncMap.get('user status')
|
||||
const emitUserStatusStub = sinon.stub(realtime, 'emitUserStatus')
|
||||
realtime.deleteAllNoteFromPool()
|
||||
realtime.users[clientSocket.id] = {}
|
||||
const userData = {
|
||||
idle: true,
|
||||
type: 'xs'
|
||||
}
|
||||
userStatusFunc(userData)
|
||||
assert(emitUserStatusStub.called === false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disconnect', function () {
|
||||
it('should push socket to disconnect queue and call disconnect function', () => {
|
||||
const disconnectFunc = eventFuncMap.get('disconnect')
|
||||
const queueForDisconnectStub = sinon.stub(realtime, 'queueForDisconnect')
|
||||
disconnectFunc()
|
||||
assert(queueForDisconnectStub.calledOnce)
|
||||
})
|
||||
|
||||
it('should quick return when socket is in disconnect queue', () => {
|
||||
const disconnectFunc = eventFuncMap.get('disconnect')
|
||||
const queueForDisconnectStub = sinon.stub(realtime, 'queueForDisconnect')
|
||||
realtime.disconnectProcessQueue.push(clientSocket.id, async () => {})
|
||||
disconnectFunc()
|
||||
assert(queueForDisconnectStub.called === false)
|
||||
})
|
||||
})
|
||||
|
||||
;['cursor focus', 'cursor activity', 'cursor blur'].forEach((event) => {
|
||||
describe(event, function () {
|
||||
let cursorFocusFunc
|
||||
|
||||
const cursorData = {
|
||||
cursor: 10
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cursorFocusFunc = eventFuncMap.get(event)
|
||||
realtime.notes[noteId] = {}
|
||||
})
|
||||
|
||||
it('should broadcast to all client', () => {
|
||||
cursorFocusFunc(cursorData)
|
||||
const broadChannelEmitFake = clientSocket.broadcast.to(noteId).emit
|
||||
assert(broadChannelEmitFake.calledOnce)
|
||||
assert(broadChannelEmitFake.lastCall.args[0] === event)
|
||||
if (event === 'cursor blur') {
|
||||
assert(broadChannelEmitFake.lastCall.args[1].id === clientSocket.id)
|
||||
} else {
|
||||
assert.deepStrictEqual(broadChannelEmitFake.lastCall.args[1].cursor, cursorData)
|
||||
}
|
||||
})
|
||||
|
||||
it('should not broadcast when note not exists', () => {
|
||||
delete realtime.notes[noteId]
|
||||
cursorFocusFunc(cursorData)
|
||||
const broadChannelEmitFake = clientSocket.broadcast.to(noteId).emit
|
||||
assert(broadChannelEmitFake.called === false)
|
||||
})
|
||||
|
||||
it('should not broadcast when user not exists', () => {
|
||||
delete realtime.users[clientSocket.id]
|
||||
cursorFocusFunc(cursorData)
|
||||
const broadChannelEmitFake = clientSocket.broadcast.to(noteId).emit
|
||||
assert(broadChannelEmitFake.called === false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('version', function () {
|
||||
it('should emit server version ', () => {
|
||||
const versionFunc = eventFuncMap.get('version')
|
||||
versionFunc()
|
||||
assert(clientSocket.emit.called)
|
||||
assert(clientSocket.emit.lastCall.args[0], 'version')
|
||||
assert.deepStrictEqual(clientSocket.emit.lastCall.args[1], {
|
||||
version: '1.5.0',
|
||||
minimumCompatibleVersion: '1.0.0'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('online users', function () {
|
||||
it('should return online user list', function () {
|
||||
const onlineUsersFunc = eventFuncMap.get('online users')
|
||||
realtime.notes[noteId] = {
|
||||
users: {
|
||||
10: {
|
||||
id: 10
|
||||
},
|
||||
20: {
|
||||
id: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
onlineUsersFunc()
|
||||
assert(clientSocket.emit.called)
|
||||
assert(clientSocket.emit.lastCall.args[0] === 'online users')
|
||||
let returnUserList = clientSocket.emit.lastCall.args[1].users
|
||||
assert(returnUserList.length === 2)
|
||||
assert(returnUserList[0].id === 10)
|
||||
assert(returnUserList[1].id === 20)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('user changed', function () {
|
||||
it('should call updateUserData', () => {
|
||||
const userChangedFunc = eventFuncMap.get('user changed')
|
||||
realtime.notes[noteId] = {
|
||||
users: {
|
||||
[clientSocket.id]: {}
|
||||
}
|
||||
}
|
||||
const updateUserDataStub = sinon.stub(realtime, 'updateUserData')
|
||||
const emitOnlineUsersStub = sinon.stub(realtime, 'emitOnlineUsers')
|
||||
userChangedFunc()
|
||||
assert(updateUserDataStub.calledOnce)
|
||||
assert(emitOnlineUsersStub.calledOnce)
|
||||
})
|
||||
|
||||
it('should direct return when note not exists', 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\'s users not exists', function () {
|
||||
const userChangedFunc = eventFuncMap.get('user changed')
|
||||
realtime.notes[noteId] = {
|
||||
users: {}
|
||||
}
|
||||
delete realtime.users[clientSocket.id]
|
||||
const updateUserDataStub = sinon.stub(realtime, 'updateUserData')
|
||||
const emitOnlineUsersStub = sinon.stub(realtime, 'emitOnlineUsers')
|
||||
userChangedFunc()
|
||||
assert(updateUserDataStub.called === false)
|
||||
assert(emitOnlineUsersStub.called === false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', function () {
|
||||
it('should delete note when owner request', function (done) {
|
||||
const currentUserId = 'user1_id'
|
||||
const noteOwnerId = 'user1_id'
|
||||
const otherClient = makeMockSocket()
|
||||
clientSocket.request = {
|
||||
user: {
|
||||
logged_in: true,
|
||||
id: currentUserId
|
||||
}
|
||||
}
|
||||
realtime.notes[noteId] = {
|
||||
owner: noteOwnerId,
|
||||
socks: [clientSocket, undefined, otherClient]
|
||||
}
|
||||
const deleteFunc = eventFuncMap.get('delete')
|
||||
deleteFunc()
|
||||
setTimeout(() => {
|
||||
assert(otherClient.disconnect.calledOnce)
|
||||
assert(otherClient.emit.calledOnce)
|
||||
assert(otherClient.emit.lastCall.args[0] === 'delete')
|
||||
assert(clientSocket.disconnect.calledOnce)
|
||||
assert(clientSocket.emit.calledOnce)
|
||||
assert(clientSocket.emit.lastCall.args[0] === 'delete')
|
||||
assert(modelsMock.Note.destroy.calledOnce)
|
||||
done()
|
||||
}, 10)
|
||||
})
|
||||
|
||||
it('should not do anything when user not login', function (done) {
|
||||
const noteOwnerId = 'user1_id'
|
||||
clientSocket.request = {}
|
||||
realtime.notes[noteId] = {
|
||||
owner: noteOwnerId,
|
||||
socks: [clientSocket]
|
||||
}
|
||||
const deleteFunc = eventFuncMap.get('delete')
|
||||
deleteFunc()
|
||||
setTimeout(() => {
|
||||
assert(modelsMock.Note.destroy.called === false)
|
||||
assert(clientSocket.disconnect.called === false)
|
||||
done()
|
||||
}, 10)
|
||||
})
|
||||
|
||||
it('should not do anything when note not exists', function (done) {
|
||||
const currentUserId = 'user1_id'
|
||||
clientSocket.request = {
|
||||
user: {
|
||||
logged_in: true,
|
||||
id: currentUserId
|
||||
}
|
||||
}
|
||||
const deleteFunc = eventFuncMap.get('delete')
|
||||
deleteFunc()
|
||||
setTimeout(() => {
|
||||
assert(modelsMock.Note.destroy.called === false)
|
||||
assert(clientSocket.disconnect.called === false)
|
||||
done()
|
||||
}, 10)
|
||||
})
|
||||
|
||||
it('should not do anything when note owner is not me', function (done) {
|
||||
const currentUserId = 'user1_id'
|
||||
const noteOwnerId = 'user2_id'
|
||||
const otherClient = makeMockSocket()
|
||||
clientSocket.request = {
|
||||
user: {
|
||||
logged_in: true,
|
||||
id: currentUserId
|
||||
}
|
||||
}
|
||||
realtime.notes[noteId] = {
|
||||
owner: noteOwnerId,
|
||||
socks: [clientSocket, otherClient]
|
||||
}
|
||||
const deleteFunc = eventFuncMap.get('delete')
|
||||
deleteFunc()
|
||||
setTimeout(() => {
|
||||
assert(clientSocket.disconnect.called === false)
|
||||
assert(modelsMock.Note.destroy.called === false)
|
||||
done()
|
||||
}, 10)
|
||||
})
|
||||
|
||||
it('should not do anything when note destroy fail', function (done) {
|
||||
const currentUserId = 'user1_id'
|
||||
const noteOwnerId = 'user1_id'
|
||||
modelsMock.Note.destroy.withArgs({
|
||||
where: {
|
||||
id: noteId
|
||||
}
|
||||
}).returns(Promise.resolve(0))
|
||||
|
||||
const otherClient = makeMockSocket()
|
||||
clientSocket.request = {
|
||||
user: {
|
||||
logged_in: true,
|
||||
id: currentUserId
|
||||
}
|
||||
}
|
||||
realtime.notes[noteId] = {
|
||||
id: noteId,
|
||||
owner: noteOwnerId,
|
||||
socks: [clientSocket, otherClient]
|
||||
}
|
||||
const deleteFunc = eventFuncMap.get('delete')
|
||||
deleteFunc()
|
||||
setTimeout(() => {
|
||||
assert(modelsMock.Note.destroy.calledOnce)
|
||||
assert(clientSocket.disconnect.called === false)
|
||||
done()
|
||||
}, 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('permission', function () {
|
||||
let ownerId = 'user1_id'
|
||||
let otherSignInUserId = 'user2_id'
|
||||
let otherClient
|
||||
let checkViewPermissionSpy
|
||||
let permissionFunc
|
||||
|
||||
beforeEach(function () {
|
||||
otherClient = makeMockSocket()
|
||||
clientSocket.request = {
|
||||
user: {
|
||||
id: ownerId,
|
||||
logged_in: true
|
||||
}
|
||||
}
|
||||
|
||||
otherClient.request = {
|
||||
user: {
|
||||
id: otherSignInUserId,
|
||||
logged_in: true
|
||||
}
|
||||
}
|
||||
|
||||
realtime.deleteAllNoteFromPool()
|
||||
realtime.addNote({
|
||||
id: noteId,
|
||||
owner: ownerId
|
||||
})
|
||||
|
||||
checkViewPermissionSpy = sinon.spy(realtime, 'checkViewPermission')
|
||||
permissionFunc = eventFuncMap.get('permission')
|
||||
})
|
||||
|
||||
it('should disconnect when lose view permission', function (done) {
|
||||
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)
|
||||
done()
|
||||
}, 5)
|
||||
})
|
||||
|
||||
it('should not do anything when user not logged in', function (done) {
|
||||
clientSocket.request = {}
|
||||
permissionFunc('private')
|
||||
setTimeout(() => {
|
||||
assert(modelsMock.Note.update.called === false)
|
||||
done()
|
||||
}, 5)
|
||||
})
|
||||
|
||||
it('should not do anything when note not exists', function (done) {
|
||||
delete realtime.notes[noteId]
|
||||
permissionFunc('private')
|
||||
setTimeout(() => {
|
||||
assert(modelsMock.Note.update.called === false)
|
||||
done()
|
||||
}, 5)
|
||||
})
|
||||
|
||||
it('should not do anything when not note owner', function (done) {
|
||||
clientSocket.request.user.id = 'other_user_id'
|
||||
permissionFunc('private')
|
||||
setTimeout(() => {
|
||||
assert(modelsMock.Note.update.called === false)
|
||||
done()
|
||||
}, 5)
|
||||
})
|
||||
|
||||
it('should change permission to freely when config allowAnonymous and allowAnonymousEdits are true', function (done) {
|
||||
configMock.allowAnonymous = true
|
||||
configMock.allowAnonymousEdits = true
|
||||
realtime.notes[noteId].socks = [clientSocket, undefined, otherClient]
|
||||
|
||||
permissionFunc('freely')
|
||||
|
||||
setTimeout(() => {
|
||||
assert(checkViewPermissionSpy.callCount === 2)
|
||||
assert(otherClient.emit.called === false)
|
||||
assert(otherClient.disconnect.called === false)
|
||||
assert(clientSocket.emit.called === false)
|
||||
assert(clientSocket.disconnect.called === false)
|
||||
done()
|
||||
}, 5)
|
||||
})
|
||||
|
||||
it('should not change permission to freely when config allowAnonymous and allowAnonymousEdits are false', function (done) {
|
||||
configMock.allowAnonymous = false
|
||||
configMock.allowAnonymousEdits = false
|
||||
realtime.notes[noteId].socks = [clientSocket, undefined, otherClient]
|
||||
|
||||
permissionFunc('freely')
|
||||
|
||||
setTimeout(() => {
|
||||
assert(modelsMock.Note.update.called === false)
|
||||
assert(checkViewPermissionSpy.called === false)
|
||||
done()
|
||||
}, 5)
|
||||
})
|
||||
|
||||
it('should change permission to freely when config allowAnonymous is true', function (done) {
|
||||
configMock.allowAnonymous = true
|
||||
configMock.allowAnonymousEdits = false
|
||||
realtime.notes[noteId].socks = [clientSocket, undefined, otherClient]
|
||||
|
||||
permissionFunc('freely')
|
||||
|
||||
setTimeout(() => {
|
||||
assert(checkViewPermissionSpy.callCount === 2)
|
||||
assert(otherClient.emit.called === false)
|
||||
assert(otherClient.disconnect.called === false)
|
||||
assert(clientSocket.emit.called === false)
|
||||
assert(clientSocket.disconnect.called === false)
|
||||
done()
|
||||
}, 5)
|
||||
})
|
||||
|
||||
it('should change permission to freely when config allowAnonymousEdits is true', function (done) {
|
||||
configMock.allowAnonymous = false
|
||||
configMock.allowAnonymousEdits = true
|
||||
realtime.notes[noteId].socks = [clientSocket, undefined, otherClient]
|
||||
|
||||
permissionFunc('freely')
|
||||
|
||||
setTimeout(() => {
|
||||
assert(checkViewPermissionSpy.callCount === 2)
|
||||
assert(otherClient.emit.called === false)
|
||||
assert(otherClient.disconnect.called === false)
|
||||
assert(clientSocket.emit.called === false)
|
||||
assert(clientSocket.disconnect.called === false)
|
||||
done()
|
||||
}, 5)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,286 @@
|
|||
/* eslint-env node, mocha */
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const mock = require('mock-require')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const { removeLibModuleCache } = require('./utils')
|
||||
const { createFakeLogger } = require('../testDoubles/loggerFake')
|
||||
const realtimeJobStub = require('../testDoubles/realtimeJobStub')
|
||||
|
||||
describe('realtime#updateNote', function () {
|
||||
let modelsStub
|
||||
let realtime
|
||||
const now = 1546300800000
|
||||
let clock
|
||||
|
||||
beforeEach(() => {
|
||||
removeLibModuleCache()
|
||||
clock = sinon.useFakeTimers({
|
||||
now,
|
||||
toFake: ['Date']
|
||||
})
|
||||
modelsStub = {
|
||||
Note: {
|
||||
findOne: sinon.stub()
|
||||
},
|
||||
User: {
|
||||
findOne: sinon.stub()
|
||||
}
|
||||
}
|
||||
|
||||
mock('../../lib/config', {})
|
||||
mock('../../lib/logger', createFakeLogger())
|
||||
mock('../../lib/models', modelsStub)
|
||||
mock('../../lib/realtimeUpdateDirtyNoteJob', realtimeJobStub)
|
||||
mock('../../lib/realtimeCleanDanglingUserJob', realtimeJobStub)
|
||||
// mock('../../lib/realtimeSaveRevisionJob', realtimeJobStub)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.stopAll()
|
||||
clock.restore()
|
||||
sinon.restore()
|
||||
removeLibModuleCache()
|
||||
})
|
||||
|
||||
it('should save history to each edited user', function (done) {
|
||||
modelsStub.Note.findOne.returns(Promise.resolve({}))
|
||||
realtime = require('../../lib/realtime')
|
||||
const updateHistoryStub = sinon.stub(realtime, 'updateHistory')
|
||||
|
||||
const callback = sinon.stub()
|
||||
const note = {
|
||||
tempUsers: {
|
||||
'user1': Date.now()
|
||||
}
|
||||
}
|
||||
realtime.updateNote(note, callback)
|
||||
clock.restore()
|
||||
setTimeout(() => {
|
||||
assert(updateHistoryStub.calledOnce)
|
||||
assert(updateHistoryStub.lastCall.calledWith('user1', note, now))
|
||||
done()
|
||||
}, 50)
|
||||
})
|
||||
|
||||
it('should set lastchangeprofile when lastchangeuser is set', function (done) {
|
||||
const callback = sinon.stub()
|
||||
|
||||
const note = {
|
||||
lastchangeuser: 'user1'
|
||||
}
|
||||
|
||||
modelsStub.Note.findOne.returns(Promise.resolve({}))
|
||||
|
||||
modelsStub.User.findOne.withArgs({
|
||||
where: {
|
||||
id: 'user1'
|
||||
}
|
||||
}).returns(Promise.resolve({
|
||||
id: 'user1',
|
||||
profile: '{ "displayName": "User 01" }'
|
||||
}))
|
||||
modelsStub.User.getProfile = sinon.stub().returns({
|
||||
name: 'User 01'
|
||||
})
|
||||
|
||||
realtime = require('../../lib/realtime')
|
||||
|
||||
realtime.updateNote(note, callback)
|
||||
clock.restore()
|
||||
setTimeout(() => {
|
||||
assert(note.lastchangeuserprofile.name === 'User 01')
|
||||
done()
|
||||
}, 50)
|
||||
})
|
||||
|
||||
it('should save note with new data', function (done) {
|
||||
const callback = sinon.stub()
|
||||
const note = {
|
||||
lastchangeuser: 'user1',
|
||||
server: {
|
||||
document: '# title\n\n## test2'
|
||||
},
|
||||
authorship: []
|
||||
}
|
||||
|
||||
modelsStub.Note.parseNoteTitle = sinon.stub().returns('title')
|
||||
const updateNoteStub = sinon.stub().returns(Promise.resolve({}))
|
||||
modelsStub.Note.findOne.returns(Promise.resolve({
|
||||
update: updateNoteStub
|
||||
}))
|
||||
|
||||
modelsStub.User.findOne.withArgs({
|
||||
where: {
|
||||
id: 'user1'
|
||||
}
|
||||
}).returns(Promise.resolve({
|
||||
id: 'user1',
|
||||
profile: '{ "displayName": "User 01" }'
|
||||
}))
|
||||
modelsStub.User.getProfile = sinon.stub().returns({
|
||||
name: 'User 01'
|
||||
})
|
||||
clock.tick(1000)
|
||||
|
||||
realtime = require('../../lib/realtime')
|
||||
realtime.updateNote(note, callback)
|
||||
setTimeout(() => {
|
||||
assert(note.lastchangeuserprofile.name === 'User 01')
|
||||
assert(callback.calledOnce)
|
||||
assert(callback.lastCall.args[0] === null)
|
||||
assert(updateNoteStub.calledOnce)
|
||||
assert(updateNoteStub.lastCall.args[0].lastchangeAt === now + 1000)
|
||||
assert(updateNoteStub.lastCall.args[0].title === 'title')
|
||||
assert(updateNoteStub.lastCall.args[0].content === '# title\n\n## test2')
|
||||
done()
|
||||
}, 50)
|
||||
})
|
||||
|
||||
it('should save note when lsatChangeUser is guest', function (done) {
|
||||
const callback = sinon.stub()
|
||||
const note = {
|
||||
server: {
|
||||
document: '# title\n\n## test2'
|
||||
},
|
||||
authorship: []
|
||||
}
|
||||
|
||||
modelsStub.Note.parseNoteTitle = sinon.stub().returns('title')
|
||||
const updateNoteStub = sinon.stub().returns(Promise.resolve({}))
|
||||
modelsStub.Note.findOne.returns(Promise.resolve({
|
||||
update: updateNoteStub
|
||||
}))
|
||||
|
||||
modelsStub.User.getProfile = sinon.stub().returns({
|
||||
name: 'User 01'
|
||||
})
|
||||
clock.tick(1000)
|
||||
|
||||
realtime = require('../../lib/realtime')
|
||||
realtime.updateNote(note, callback)
|
||||
setTimeout(() => {
|
||||
assert(modelsStub.User.findOne.callCount === 0)
|
||||
assert(note.lastchangeuserprofile === null)
|
||||
assert(callback.calledOnce)
|
||||
assert(callback.lastCall.args[0] === null)
|
||||
assert(updateNoteStub.calledOnce)
|
||||
assert(updateNoteStub.lastCall.args[0].lastchangeAt === now + 1000)
|
||||
assert(updateNoteStub.lastCall.args[0].title === 'title')
|
||||
assert(updateNoteStub.lastCall.args[0].content === '# title\n\n## test2')
|
||||
done()
|
||||
}, 50)
|
||||
})
|
||||
|
||||
it('should save note when lastChangeUser as same as database', function (done) {
|
||||
const callback = sinon.stub()
|
||||
const note = {
|
||||
lastchangeuser: 'user1',
|
||||
server: {
|
||||
document: '# title\n\n## test2'
|
||||
},
|
||||
authorship: []
|
||||
}
|
||||
|
||||
modelsStub.Note.parseNoteTitle = sinon.stub().returns('title')
|
||||
const updateNoteStub = sinon.stub().returns(Promise.resolve({}))
|
||||
modelsStub.Note.findOne.returns(Promise.resolve({
|
||||
update: updateNoteStub,
|
||||
lastchangeuserId: 'user1'
|
||||
}))
|
||||
|
||||
modelsStub.User.getProfile = sinon.stub().returns({
|
||||
name: 'User 01'
|
||||
})
|
||||
clock.tick(1000)
|
||||
|
||||
realtime = require('../../lib/realtime')
|
||||
realtime.updateNote(note, callback)
|
||||
setTimeout(() => {
|
||||
assert(modelsStub.User.findOne.callCount === 0)
|
||||
assert(modelsStub.User.getProfile.callCount === 0)
|
||||
assert(callback.calledOnce)
|
||||
assert(callback.lastCall.args[0] === null)
|
||||
assert(updateNoteStub.calledOnce)
|
||||
assert(updateNoteStub.lastCall.args[0].lastchangeAt === now + 1000)
|
||||
assert(updateNoteStub.lastCall.args[0].lastchangeuserId === 'user1')
|
||||
assert(updateNoteStub.lastCall.args[0].title === 'title')
|
||||
assert(updateNoteStub.lastCall.args[0].content === '# title\n\n## test2')
|
||||
done()
|
||||
}, 50)
|
||||
})
|
||||
|
||||
it('should not save note when lastChangeUser not found in database', function (done) {
|
||||
const callback = sinon.stub()
|
||||
const note = {
|
||||
lastchangeuser: 'user1',
|
||||
server: {
|
||||
document: '# title\n\n## test2'
|
||||
},
|
||||
authorship: []
|
||||
}
|
||||
|
||||
modelsStub.Note.parseNoteTitle = sinon.stub().returns('title')
|
||||
const updateNoteStub = sinon.stub().returns(Promise.resolve({}))
|
||||
modelsStub.Note.findOne.returns(Promise.resolve({
|
||||
update: updateNoteStub
|
||||
}))
|
||||
modelsStub.User.findOne.returns(Promise.resolve(null))
|
||||
modelsStub.User.getProfile = sinon.stub().returns({
|
||||
name: 'User 01'
|
||||
})
|
||||
clock.tick(1000)
|
||||
|
||||
realtime = require('../../lib/realtime')
|
||||
realtime.updateNote(note, callback)
|
||||
setTimeout(() => {
|
||||
assert(modelsStub.User.findOne.called)
|
||||
assert(modelsStub.User.getProfile.callCount === 0)
|
||||
assert(callback.calledOnce)
|
||||
assert(callback.lastCall.args[0] === null)
|
||||
assert(callback.lastCall.args[1] === null)
|
||||
assert(updateNoteStub.callCount === 0)
|
||||
done()
|
||||
}, 50)
|
||||
})
|
||||
|
||||
it('should not save note when note.server not exists', function (done) {
|
||||
const callback = sinon.stub()
|
||||
const note = {
|
||||
lastchangeuser: 'user1',
|
||||
authorship: []
|
||||
}
|
||||
|
||||
modelsStub.Note.parseNoteTitle = sinon.stub().returns('title')
|
||||
const updateNoteStub = sinon.stub().returns(Promise.resolve({}))
|
||||
modelsStub.Note.findOne.returns(Promise.resolve({
|
||||
update: updateNoteStub
|
||||
}))
|
||||
|
||||
modelsStub.User.findOne.withArgs({
|
||||
where: {
|
||||
id: 'user1'
|
||||
}
|
||||
}).returns(Promise.resolve({
|
||||
id: 'user1',
|
||||
profile: '{ "displayName": "User 01" }'
|
||||
}))
|
||||
modelsStub.User.getProfile = sinon.stub().returns({
|
||||
name: 'User 01'
|
||||
})
|
||||
clock.tick(1000)
|
||||
|
||||
realtime = require('../../lib/realtime')
|
||||
realtime.updateNote(note, callback)
|
||||
setTimeout(() => {
|
||||
assert(note.lastchangeuserprofile.name === 'User 01')
|
||||
assert(callback.calledOnce)
|
||||
assert(callback.lastCall.args[0] === null)
|
||||
assert(callback.lastCall.args[1] === null)
|
||||
assert(updateNoteStub.callCount === 0)
|
||||
done()
|
||||
}, 50)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,50 @@
|
|||
'use strict'
|
||||
|
||||
const sinon = require('sinon')
|
||||
const path = require('path')
|
||||
|
||||
function makeMockSocket (headers, query) {
|
||||
const broadCastChannelCache = {}
|
||||
return {
|
||||
id: Math.round(Math.random() * 10000),
|
||||
request: {
|
||||
user: {}
|
||||
},
|
||||
handshake: {
|
||||
headers: Object.assign({}, headers),
|
||||
query: Object.assign({}, query)
|
||||
},
|
||||
on: sinon.fake(),
|
||||
emit: sinon.fake(),
|
||||
broadCastChannelCache: {},
|
||||
broadcast: {
|
||||
to: (channel) => {
|
||||
if (!broadCastChannelCache[channel]) {
|
||||
broadCastChannelCache[channel] = {
|
||||
channel: channel,
|
||||
emit: sinon.fake()
|
||||
}
|
||||
}
|
||||
return broadCastChannelCache[channel]
|
||||
}
|
||||
},
|
||||
disconnect: sinon.fake(),
|
||||
rooms: []
|
||||
}
|
||||
}
|
||||
|
||||
function removeModuleFromRequireCache (modulePath) {
|
||||
delete require.cache[require.resolve(modulePath)]
|
||||
}
|
||||
function removeLibModuleCache () {
|
||||
const libPath = path.resolve(path.join(__dirname, '../../lib'))
|
||||
Object.keys(require.cache).forEach(key => {
|
||||
if (key.startsWith(libPath)) {
|
||||
delete require.cache[require.resolve(key)]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
exports.makeMockSocket = makeMockSocket
|
||||
exports.removeModuleFromRequireCache = removeModuleFromRequireCache
|
||||
exports.removeLibModuleCache = removeLibModuleCache
|
|
@ -0,0 +1,35 @@
|
|||
'use strict'
|
||||
|
||||
class ProcessQueueFake {
|
||||
constructor () {
|
||||
this.taskMap = new Map()
|
||||
this.queue = []
|
||||
}
|
||||
|
||||
start () {
|
||||
|
||||
}
|
||||
|
||||
stop () {
|
||||
|
||||
}
|
||||
|
||||
checkTaskIsInQueue (id) {
|
||||
return this.taskMap.has(id)
|
||||
}
|
||||
|
||||
push (id, processFunc) {
|
||||
this.queue.push({
|
||||
id: id,
|
||||
processFunc: processFunc
|
||||
})
|
||||
this.taskMap.set(id, true)
|
||||
}
|
||||
|
||||
process () {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
exports.ProcessQueueFake = ProcessQueueFake
|
||||
exports.ProcessQueue = ProcessQueueFake
|
|
@ -0,0 +1,15 @@
|
|||
'use strict'
|
||||
|
||||
const sinon = require('sinon')
|
||||
|
||||
function createFakeLogger () {
|
||||
return {
|
||||
error: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
info: sinon.stub(),
|
||||
debug: sinon.stub(),
|
||||
log: sinon.stub()
|
||||
}
|
||||
}
|
||||
|
||||
exports.createFakeLogger = createFakeLogger
|
|
@ -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
|
|
@ -0,0 +1,14 @@
|
|||
'use strict'
|
||||
|
||||
class realtimeJobStub {
|
||||
start () {
|
||||
}
|
||||
|
||||
stop () {
|
||||
}
|
||||
}
|
||||
|
||||
exports.realtimeJobStub = realtimeJobStub
|
||||
exports.UpdateDirtyNoteJob = realtimeJobStub
|
||||
exports.CleanDanglingUserJob = realtimeJobStub
|
||||
exports.SaveRevisionJob = realtimeJobStub
|
Loading…
Reference in New Issue