Merge pull request #1228 from hackmdio/refactor-realtime

Refactor realtime
This commit is contained in:
Raccoon 2019-07-31 22:38:10 +08:00 committed by GitHub
commit ba0e4df6b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 4417 additions and 901 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
.git/
node_modules/
docs/
test/
.sequelizerc.example
config.json.example
public/build

View File

@ -1,3 +0,0 @@
lib/ot
public/vendor
public/build

View File

@ -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"]
}
};

2
.gitignore vendored
View File

@ -27,3 +27,5 @@ public/views/build
public/uploads/*
!public/uploads/.gitkeep
/.nyc_output
/coverage/

View File

@ -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

View File

@ -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
View File

@ -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)

56
deployments/Dockerfile Normal file
View File

@ -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"]

View File

@ -0,0 +1,7 @@
FROM node:8.15.1-jessie
WORKDIR /codimd
EXPOSE 3000
VOLUME ['/codimd/node_modules']

View File

@ -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:

View File

@ -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

View File

@ -0,0 +1,9 @@
#!/bin/bash
set -euo pipefail
pcheck -constr "$CMD_DB_URL"
sequelize db:migrate
node app.js

106
lib/processQueue.js Normal file
View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"
}
}

View File

@ -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'

View File

@ -1,3 +1,4 @@
/* global CodeMirror, $, editor, Cookies */
import * as utils from './utils'
import config from './config'
import statusBarTemplate from './statusbar.html'

View File

@ -1,6 +1,7 @@
/*
* Global UI elements references
*/
/* global $ */
export const getUIElements = () => ({
spinner: $('.ui-spinner'),

View File

@ -1,3 +1,4 @@
/* global CodeMirror, editor */
const wrapSymbols = ['*', '_', '~', '^', '+', '=']
export function wrapTextWith (editor, cm, symbol) {
if (!cm.getSelection()) {

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -0,0 +1,193 @@
/* eslint-env node, mocha */
'use strict'
const assert = require('assert')
const mock = require('mock-require')
const sinon = require('sinon')
const { createFakeLogger } = require('../testDoubles/loggerFake')
const { removeLibModuleCache, makeMockSocket } = require('./utils')
const realtimeJobStub = require('../testDoubles/realtimeJobStub')
describe('realtime#connection', function () {
describe('connection', function () {
let realtime
let modelStub
beforeEach(() => {
removeLibModuleCache()
modelStub = {
Note: {
findOne: sinon.stub()
},
User: {},
Author: {}
}
mock('../../lib/logger', createFakeLogger())
mock('../../lib/history', {})
mock('../../lib/models', modelStub)
mock('../../lib/config', {})
mock('../../lib/realtimeUpdateDirtyNoteJob', realtimeJobStub)
mock('../../lib/realtimeCleanDanglingUserJob', realtimeJobStub)
mock('../../lib/realtimeSaveRevisionJob', realtimeJobStub)
mock('../../lib/ot', require('../testDoubles/otFake'))
realtime = require('../../lib/realtime')
})
afterEach(() => {
mock.stopAll()
sinon.restore()
})
describe('fail', function () {
it('should fast return when server not start', () => {
const mockSocket = makeMockSocket()
realtime.maintenance = true
const spy = sinon.spy(realtime, '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)
})
})
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})
})

View File

@ -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)
})
})

View File

@ -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')
}
})
})
})

View File

@ -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])
})
})
})
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})
})

View File

@ -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)
})
})

50
test/realtime/utils.js Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

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

View File

@ -0,0 +1,14 @@
'use strict'
class realtimeJobStub {
start () {
}
stop () {
}
}
exports.realtimeJobStub = realtimeJobStub
exports.UpdateDirtyNoteJob = realtimeJobStub
exports.CleanDanglingUserJob = realtimeJobStub
exports.SaveRevisionJob = realtimeJobStub

1311
yarn.lock

File diff suppressed because it is too large Load Diff