├── .gitignore ├── data ├── import.js ├── users.json └── import.sh ├── config.js ├── app.js ├── common ├── response.js ├── middleware.js └── crud.js ├── package.json ├── models ├── User.js └── Restaurant.js ├── .github └── workflows │ └── ci.yml ├── README.md ├── api ├── index.js ├── restaurant.js ├── user.js └── auth.js ├── postman ├── dev.postman_environment.json └── restaurant.postman_collection.json ├── test ├── middleware.test.js ├── response.test.js └── crud.test.js └── server.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | # config.js 3 | 4 | PUBLISH.md 5 | PUBLISH2.md 6 | -------------------------------------------------------------------------------- /data/import.js: -------------------------------------------------------------------------------- 1 | use kane 2 | 3 | db.restaurants.createIndex( { location : "2dsphere" } ) 4 | db.users.createIndex( { email: 1 }, { unique: true } ) 5 | -------------------------------------------------------------------------------- /data/users.json: -------------------------------------------------------------------------------- 1 | {"_id":{"$oid":"5d5038dae892884fd6d68316"},"name":"Kane Ong","email":"kane@here.com","type":"admin","password":"$2b$10$5mXKEiNgDzHq7N2ZD4VIwO2VSCOygg2tbaatLZaWqeObLHZbx68bW","__v":0} 2 | -------------------------------------------------------------------------------- /data/import.sh: -------------------------------------------------------------------------------- 1 | set -eo pipefail 2 | 3 | cd ./data 4 | mongo < import.js 5 | 6 | mongoimport --db kane --collection restaurants --file restaurants.json 7 | mongoimport --db kane --collection users --file users.json 8 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | saltRounds: Math.floor(Math.random()*10), 3 | jwtSecretSalt: [...Array(9)].map(() => Math.random().toString(36)[2]).join(''), 4 | devMongoUrl: 'mongodb://localhost/kane', 5 | prodMongoUrl: 'mongodb://localhost/kane', 6 | testMongoUrl: 'mongodb://localhost/test', 7 | } 8 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const logger = require('morgan'); 4 | const api = require('./api'); 5 | const { notFound } = require('./common/middleware') 6 | const cors = require('cors') 7 | const app = express(); 8 | 9 | app 10 | .use(cors()) 11 | 12 | .use(logger('dev')) 13 | .use(express.json()) 14 | 15 | .use('/api', api) 16 | 17 | .use(notFound) 18 | 19 | module.exports = app; 20 | -------------------------------------------------------------------------------- /common/response.js: -------------------------------------------------------------------------------- 1 | function errorRes (res, err, errMsg="failed operation", statusCode=500) { 2 | console.error("ERROR:", err) 3 | return res.status(statusCode).json({ success: false, error: errMsg }) 4 | } 5 | 6 | function successRes (res, data={}, statusCode=200) { 7 | return res.status(statusCode).json({ success: true, data }) 8 | } 9 | 10 | function errData (res, errMsg="failed operation") { 11 | return (err, data) => { 12 | if (err) return errorRes(res, err, errMsg) 13 | return successRes(res, data) 14 | } 15 | } 16 | 17 | module.exports = { errorRes, successRes, errData } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ko-architecture-expressjs", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node server.js", 7 | "dev": "nodemon server.js", 8 | "test": "./node_modules/mocha/bin/mocha" 9 | }, 10 | "dependencies": { 11 | "bcrypt": "^5.0.0", 12 | "cors": "^2.8.5", 13 | "debug": "^4.1.1", 14 | "express": "^4.16.4", 15 | "express-jwt": "^6.0.0", 16 | "jsonwebtoken": "^8.5.1", 17 | "mongoose": "^5.13.15", 18 | "morgan": "^1.10.0", 19 | "validator": "^13.7.0" 20 | }, 21 | "devDependencies": { 22 | "chai": "^4.2.0", 23 | "mocha": "^6.2.3", 24 | "nodemon": "^2.0.20", 25 | "supertest": "^4.0.2" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /models/User.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | const Schema = mongoose.Schema 3 | const ObjectId = Schema.ObjectId 4 | const validator = require('validator') 5 | 6 | 7 | const userSchema = new Schema({ 8 | _id: ObjectId, 9 | name: { type: String, required: true }, 10 | email: { 11 | type: String, 12 | required: true, 13 | unique: true, 14 | validate: [ validator.isEmail, 'invalid email' ] 15 | }, 16 | type: { 17 | type: String, 18 | enum: ['member', 'owner', 'admin'], 19 | required: true 20 | }, 21 | password: { type: String, required: true, select: false }, 22 | 23 | updated_at: Date, 24 | }); 25 | 26 | module.exports = mongoose.model('User', userSchema, 'users'); 27 | -------------------------------------------------------------------------------- /models/Restaurant.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | const Schema = mongoose.Schema 3 | const ObjectId = Schema.Types.ObjectId 4 | const validator = require('validator') 5 | 6 | 7 | const restaurantSchema = new Schema({ 8 | _id: ObjectId, 9 | name: { type: String, required: true }, 10 | location: { 11 | type: { 12 | type: String, 13 | enum: [ 'Point' ], 14 | required: true 15 | }, 16 | coordinates: { 17 | type: [ Number ], 18 | required: true 19 | } 20 | }, 21 | owner: { type: ObjectId, ref: 'User', required: true }, 22 | available: { 23 | type: Boolean, 24 | required: true, 25 | }, 26 | 27 | updated_at: Date, 28 | }); 29 | 30 | module.exports = mongoose.model('Restaurant', restaurantSchema, 'restaurants'); 31 | -------------------------------------------------------------------------------- /common/middleware.js: -------------------------------------------------------------------------------- 1 | const { errorRes } = require('./response') 2 | 3 | 4 | function notFound (req, res, _) { 5 | return errorRes(res, 'no routes', 'you are lost.', 404) 6 | } 7 | 8 | function onlyAdmin (req, res, next) { 9 | if (req.user.type === 'admin') 10 | return next() 11 | return invalidToken(req, res) 12 | } 13 | 14 | function notOnlyMember (req, res, next) { 15 | if (req.user.type === 'member') 16 | return invalidToken(req, res) 17 | return next() 18 | } 19 | 20 | function invalidToken (req, res) { 21 | const errMsg = 'INVALID TOKEN' 22 | const userText = JSON.stringify(req.user) 23 | const err = `${errMsg} ERROR - user: ${userText}, IP: ${req.ip}` 24 | return errorRes(res, err, errMsg, 401) 25 | } 26 | 27 | module.exports = { notFound, onlyAdmin, notOnlyMember } 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | push: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [8.x, 10.x, 12.x, 13.x] 15 | mongodb-version: [4.0, 4.2] 16 | 17 | steps: 18 | - name: Git checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - name: Start MongoDB 27 | uses: supercharge/mongodb-github-action@1.2.0 28 | with: 29 | mongodb-version: ${{ matrix.mongodb-version }} 30 | 31 | - run: npm install 32 | 33 | - run: npm test 34 | env: 35 | CI: true 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://github.com/dividedbynil/ko-architecture/workflows/CI/badge.svg) 2 | 3 | # K.O Architecture Demo 4 | - Framework: ExpressJS 5 | - Database: MongoDB 6 | - Authentication: JSON Web Token 7 | 8 | ## Experiment data 9 | - origin: [restaurants.json](https://raw.githubusercontent.com/mongodb/docs-assets/geospatial/restaurants.json) 10 | 11 | ## APIs document 12 | Postman APIs collection and environment can be imported from `./postman/` 13 | 14 | ## Pre-running 15 | Update the `./config.js` file 16 | ```js 17 | module.exports = { 18 | saltRounds: 10, 19 | jwtSecretSalt: '87908798', 20 | devMongoUrl: 'mongodb://localhost/kane', 21 | prodMongoUrl: 'mongodb://localhost/kane', 22 | testMongoUrl: 'mongodb://localhost/test', 23 | } 24 | ``` 25 | 26 | ## Import experiment data 27 | 28 | ### Open a terminal and run: 29 | ``` 30 | mongod 31 | ``` 32 | 33 | ### Open another terminal in this directory: 34 | ``` 35 | bash ./data/import.sh 36 | ``` 37 | 38 | ## Start the server with 39 | ``` 40 | npm start 41 | ``` 42 | 43 | ## Start development with 44 | ``` 45 | npm run dev 46 | ``` 47 | -------------------------------------------------------------------------------- /common/crud.js: -------------------------------------------------------------------------------- 1 | const { errData, errorRes, successRes } = require('../common/response') 2 | const mongoose = require('mongoose') 3 | 4 | 5 | function create (model, populate=[]) { 6 | return (req, res) => { 7 | const newData = new model({ 8 | _id: new mongoose.Types.ObjectId(), 9 | ...req.body 10 | }) 11 | return newData.save() 12 | .then(t => t.populate(...populate, errData(res))) 13 | .catch(err => errorRes(res, err)) 14 | } 15 | } 16 | 17 | function read (model, populate=[]) { 18 | return (req, res) => ( 19 | model.find(...req.body, errData(res)).populate(...populate) 20 | ) 21 | } 22 | 23 | function update (model, populate=[]) { 24 | return (req, res) => { 25 | req.body.updated_at = new Date() 26 | return model.findByIdAndUpdate( 27 | req.params._id, 28 | req.body, 29 | { new: true }, 30 | errData(res) 31 | ).populate(...populate) 32 | } 33 | } 34 | 35 | function remove (model) { 36 | return (req, res) => ( 37 | model.deleteOne({ _id: req.params._id }, errData(res)) 38 | ) 39 | } 40 | 41 | module.exports = { read, create, update, remove } 42 | -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const router = express.Router() 3 | const auth = require ('./auth') 4 | const restaurant = require ('./restaurant') 5 | const user = require ('./user') 6 | const { errorRes, successRes } = require('../common/response') 7 | const { notFound } = require('../common/middleware') 8 | const mongoose = require('mongoose') 9 | const { devMongoUrl, prodMongoUrl } = require('../config') 10 | const expressJwt = require('express-jwt') 11 | const { jwtSecretSalt } = require('../config') 12 | 13 | const mongoUrl = process.platform === 'darwin' ? devMongoUrl : prodMongoUrl 14 | mongoose.connect(mongoUrl, { 15 | useNewUrlParser: true, 16 | autoIndex: false, 17 | useFindAndModify: false, 18 | useUnifiedTopology: true, 19 | }); 20 | 21 | 22 | router 23 | .get('/ping', (req, res) => res.json('pong')) 24 | 25 | .use('/auth', auth) 26 | 27 | .use(expressJwt({ secret: jwtSecretSalt }), 28 | (err, req, res, next) => { 29 | if (err.name === 'UnauthorizedError') { 30 | console.error(req.user, req.ip, 'invalid token'); 31 | return errorRes(res, err, 'Login to proceed', 401) 32 | } 33 | } 34 | ) 35 | 36 | .use('/restaurant', restaurant) 37 | .use('/user', user) 38 | 39 | .use(notFound) 40 | 41 | 42 | module.exports = router; 43 | -------------------------------------------------------------------------------- /api/restaurant.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const router = express.Router() 3 | const { create, read, update, remove } = require('../common/crud') 4 | const Restaurant = require('../models/Restaurant') 5 | const { notOnlyMember, notFound } = require('../common/middleware') 6 | 7 | 8 | router 9 | .get('/available/:lng/:lat/:page', 10 | nearBy({ available: true }), 11 | read(Restaurant, ['owner']) 12 | ) 13 | 14 | .use(notOnlyMember) 15 | 16 | .get('/all/:lng/:lat/:page', nearBy(), read(Restaurant, ['owner'])) 17 | .post('/', create(Restaurant, ['owner'])) 18 | .put('/:_id', update(Restaurant, ['owner'])) 19 | .delete('/:_id', remove(Restaurant)) 20 | 21 | .use(notFound) 22 | 23 | function nearBy (query={}) { 24 | return (req, res, next) => { 25 | const { lng, lat, page } = req.params 26 | req.body = geoQuery(lng, lat, query, page) 27 | next() 28 | } 29 | } 30 | 31 | function geoQuery (lng, lat, query, page) { 32 | return( 33 | [ 34 | { 35 | ...query, 36 | location: { 37 | $near: { 38 | $geometry: { 39 | type: "Point", 40 | coordinates: [lng, lat] 41 | } 42 | } 43 | } 44 | }, 45 | null 46 | , { limit: 25, skip: (page-1) * 25 } 47 | ] 48 | ) 49 | } 50 | 51 | module.exports = router 52 | -------------------------------------------------------------------------------- /postman/dev.postman_environment.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "c5967ce1-162a-4e94-8e57-ac22149f7e81", 3 | "name": "dev", 4 | "values": [ 5 | { 6 | "key": "url", 7 | "value": "localhost:3001", 8 | "enabled": true 9 | }, 10 | { 11 | "key": "userId", 12 | "value": "5d5038dae892884fd6d68316", 13 | "enabled": true 14 | }, 15 | { 16 | "key": "signUpUserId", 17 | "value": "", 18 | "enabled": true 19 | }, 20 | { 21 | "key": "token", 22 | "value": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI1ZDdkODgxZWNmZDI3ODBhMTg0NTVkODIiLCJuYW1lIjoibWVtYmVyIiwiZW1haWwiOiJuYW1lQG1lbWJlci5jb20iLCJ0eXBlIjoibWVtYmVyIiwiX192IjowLCJpYXQiOjE1NzI5NDYzMTEsImV4cCI6MTU3NTYyNDcxMX0.BjbbI3gJJyCIqN5JmP52OjAbJWEdiKBi5nW4v5EjpCoIg_7hsAy4wwVUkoBlX6E-0wUXA1EGi52tEhUeq42v1g", 23 | "enabled": true 24 | }, 25 | { 26 | "key": "type", 27 | "value": "", 28 | "enabled": true 29 | }, 30 | { 31 | "key": "lng", 32 | "value": "-73.9983", 33 | "enabled": true 34 | }, 35 | { 36 | "key": "lat", 37 | "value": "40.715051", 38 | "enabled": true 39 | }, 40 | { 41 | "key": "id", 42 | "value": "5d7c85ae0d6ca03f3c234bac", 43 | "enabled": true 44 | } 45 | ], 46 | "_postman_variable_scope": "environment", 47 | "_postman_exported_at": "2020-02-23T11:37:06.753Z", 48 | "_postman_exported_using": "Postman/7.10.0" 49 | } -------------------------------------------------------------------------------- /api/user.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const router = express.Router() 3 | const { create, read, update, remove } = require('../common/crud') 4 | const User = require('../models/User') 5 | const { errorRes } = require('../common/response') 6 | const { onlyAdmin, notFound } = require('../common/middleware') 7 | const bcrypt = require('bcrypt') 8 | const { saltRounds, jwtSecretSalt } = require('../config') 9 | 10 | 11 | router 12 | .use(onlyAdmin) 13 | 14 | .post('/', create(User)) 15 | .get('/all/:page', usersAtPage, read(User)) 16 | .put('/:_id', handlePassword, update(User)) 17 | .delete('/:_id', remove(User)) 18 | 19 | .use(notFound) 20 | 21 | function usersAtPage (req, res, next) { 22 | req.body = [ {}, null, { limit: 25, skip: (req.params.page-1) * 25 } ] 23 | return next() 24 | } 25 | 26 | function handlePassword (req, res, next) { 27 | const { password, ...body } = req.body 28 | if (!password || password.length < 1) { 29 | req.body = body 30 | return next() 31 | } 32 | if (password.length < 6) 33 | return errorRes(res, 'invalid password', 'password is too short') 34 | 35 | bcrypt.hash(password, saltRounds, (err, hash) => { 36 | if (err) 37 | return errorRes(res, err, 'password error') 38 | 39 | const data = {...body, password: hash} 40 | req.body = data 41 | return next() 42 | }) 43 | } 44 | 45 | module.exports = router; 46 | -------------------------------------------------------------------------------- /test/middleware.test.js: -------------------------------------------------------------------------------- 1 | const { notFound, onlyAdmin, notOnlyMember } = require('../common/middleware') 2 | const request = require('supertest'); 3 | const bodyParser = require('body-parser'); 4 | const express = require('express'); 5 | const app = express(); 6 | 7 | app 8 | .use(bodyParser.json()) 9 | .post('/notFound', notFound) 10 | .post('/onlyAdmin', addUserType, onlyAdmin, notFound) 11 | .post('/notOnlyMember', addUserType, notOnlyMember, notFound) 12 | 13 | function addUserType (req, res, next) { 14 | req.user = { type: req.body.type } 15 | next() 16 | } 17 | 18 | function test (funcStr, args) { 19 | return request(app) 20 | .post('/'+funcStr) 21 | .send({ type: args }) 22 | } 23 | 24 | describe('middleware', function () { 25 | describe('notFound', function () { 26 | it('should return not found', function (done) { 27 | test('notFound') 28 | .expect(404, done) 29 | }) 30 | }) 31 | 32 | describe('onlyAdmin', function () { 33 | it('should access', function (done) { 34 | test('onlyAdmin', 'admin') 35 | .expect(404, done) 36 | }) 37 | it('should not access', function (done) { 38 | test('onlyAdmin', 'member') 39 | .expect(401, done) 40 | }) 41 | }) 42 | 43 | describe('notOnlyMember', function () { 44 | it('should access', function (done) { 45 | test('notOnlyMember', 'admin') 46 | .expect(404, done) 47 | }) 48 | it('should access', function (done) { 49 | test('notOnlyMember', 'owner') 50 | .expect(404, done) 51 | }) 52 | it('should not access', function (done) { 53 | test('notOnlyMember', 'member') 54 | .expect(401, done) 55 | }) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var app = require('./app'); 8 | var debug = require('debug')('express-react:server'); 9 | var http = require('http'); 10 | 11 | /** 12 | * Get port from environment and store in Express. 13 | */ 14 | 15 | var port = normalizePort(process.env.PORT || '3001'); 16 | app.set('port', port); 17 | 18 | /** 19 | * Create HTTP server. 20 | */ 21 | 22 | var server = http.createServer(app); 23 | 24 | /** 25 | * Listen on provided port, on all network interfaces. 26 | */ 27 | 28 | server.listen(port); 29 | server.on('error', onError); 30 | server.on('listening', onListening); 31 | 32 | /** 33 | * Normalize a port into a number, string, or false. 34 | */ 35 | 36 | function normalizePort(val) { 37 | var port = parseInt(val, 10); 38 | 39 | if (isNaN(port)) { 40 | // named pipe 41 | return val; 42 | } 43 | 44 | if (port >= 0) { 45 | // port number 46 | return port; 47 | } 48 | 49 | return false; 50 | } 51 | 52 | /** 53 | * Event listener for HTTP server "error" event. 54 | */ 55 | 56 | function onError(error) { 57 | if (error.syscall !== 'listen') { 58 | throw error; 59 | } 60 | 61 | var bind = typeof port === 'string' 62 | ? 'Pipe ' + port 63 | : 'Port ' + port; 64 | 65 | // handle specific listen errors with friendly messages 66 | switch (error.code) { 67 | case 'EACCES': 68 | console.error(bind + ' requires elevated privileges'); 69 | process.exit(1); 70 | break; 71 | case 'EADDRINUSE': 72 | console.error(bind + ' is already in use'); 73 | process.exit(1); 74 | break; 75 | default: 76 | throw error; 77 | } 78 | } 79 | 80 | /** 81 | * Event listener for HTTP server "listening" event. 82 | */ 83 | 84 | function onListening() { 85 | var addr = server.address(); 86 | var bind = typeof addr === 'string' 87 | ? 'pipe ' + addr 88 | : 'port ' + addr.port; 89 | console.log('Listening on ' + bind); 90 | } 91 | -------------------------------------------------------------------------------- /api/auth.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const router = express.Router() 3 | const User = require('../models/User') 4 | const mongoose = require('mongoose') 5 | const expressJwt = require('express-jwt') 6 | const jwt = require('jsonwebtoken') 7 | const { errorRes, successRes, errData } = require('../common/response') 8 | const { notFound } = require('../common/middleware') 9 | const bcrypt = require('bcrypt') 10 | const { saltRounds, jwtSecretSalt } = require('../config') 11 | 12 | 13 | router 14 | .use(isValidPassword) 15 | .post('/signup', hashPassword, signUp) 16 | .post('/login', findByEmail, verifyPassword, login) 17 | 18 | .use(notFound) 19 | 20 | function isValidPassword (req, res, next) { 21 | const { password } = req.body 22 | if (!password || password.length < 6) { 23 | const err = `invalid password: ${password}` 24 | const errMsg = 'password is too short' 25 | return errorRes(res, err, errMsg) 26 | } 27 | return next() 28 | } 29 | 30 | function hashPassword (req, res, next) { 31 | const { password } = req.body 32 | bcrypt.hash(password, saltRounds, (err, hashed) => { 33 | if (err) 34 | return errorRes(res, err, 'unable to sign up, try again') 35 | req.body.password = hashed 36 | return next() 37 | }) 38 | } 39 | 40 | function signUp (req, res) { 41 | const newUser = new User({ 42 | _id: new mongoose.Types.ObjectId(), 43 | ...req.body 44 | }) 45 | return newUser.save((err, data) => { 46 | if (err) 47 | return errorRes(res, err, 'unable to create user') 48 | 49 | const { _id, name, email, type } = data 50 | return successRes(res, { _id, name, email, type }) 51 | }) 52 | } 53 | 54 | function findByEmail (req, res, next) { 55 | const { email, password } = req.body 56 | User.findOne({ email }, '+password', { lean: true } , (err, data) => { 57 | if (err || !data) 58 | return errorRes(res, 'invalid login', 'invalid password or email') 59 | req.body = { unhashedPassword: password , ...data } 60 | return next() 61 | }) 62 | } 63 | 64 | function verifyPassword (req, res, next) { 65 | const { unhashedPassword, password, ...userData } = req.body 66 | bcrypt.compare(unhashedPassword, password, (err, same) => { 67 | if (same) { 68 | req.body = userData 69 | return next() 70 | } else 71 | return errorRes(res, err, 'password error, try again') 72 | }) 73 | } 74 | 75 | function login (req, res) { 76 | jwt.sign(req.body, jwtSecretSalt, 77 | {algorithm: 'HS512', expiresIn: '31d'}, 78 | errData(res, 'token error') 79 | ) 80 | } 81 | 82 | module.exports = router; 83 | -------------------------------------------------------------------------------- /test/response.test.js: -------------------------------------------------------------------------------- 1 | const { errData, errorRes, successRes } = require('../common/response') 2 | const request = require('supertest'); 3 | const bodyParser = require('body-parser'); 4 | const express = require('express'); 5 | const app = express(); 6 | 7 | app 8 | .use(bodyParser.json()) 9 | .post('/errorRes', reqRes(errorRes)) 10 | .post('/successRes', reqRes(successRes)) 11 | .post('/errData', (req, res) => { 12 | const { errMsg, err } = req.body 13 | errData(res, errMsg) (err, { custom: true }) 14 | }) 15 | 16 | function reqRes (func) { 17 | return function (req, res) { 18 | return func(res, ...req.body) 19 | } 20 | } 21 | 22 | function test (funcStr, args) { 23 | return request(app) 24 | .post('/'+funcStr) 25 | .send(args) 26 | } 27 | 28 | describe ('response', function () { 29 | describe('errorRes', function () { 30 | it('should return default status code & message', function (done) { 31 | test('errorRes', ['error']) 32 | .expect(500, { 33 | success: false, 34 | error: 'failed operation' 35 | }, done) 36 | }) 37 | 38 | it('should return custom errMsg', function (done) { 39 | test('errorRes', ['error', 'test']) 40 | .expect(500, { 41 | success: false, 42 | error: 'test' 43 | }, done) 44 | }) 45 | 46 | it('should return custom errMsg & status code', function (done) { 47 | test('errorRes', ['error', 'test', 401]) 48 | .expect(401, { 49 | success: false, 50 | error: 'test' 51 | }, done) 52 | }) 53 | }) 54 | 55 | describe('successRes', function () { 56 | it('should return default status code & data', function (done) { 57 | test('successRes', []) 58 | .expect(200, { 59 | success: true, 60 | data: {} 61 | }, done) 62 | }) 63 | 64 | it('should return custom data', function (done) { 65 | test('successRes', [{ custom: true }]) 66 | .expect(200, { 67 | success: true, 68 | data: { custom: true } 69 | }, done) 70 | }) 71 | 72 | it('should return custom data & status code', function (done) { 73 | test('successRes', [{ custom: true }, 201]) 74 | .expect(201, { 75 | success: true, 76 | data: { custom: true } 77 | }, done) 78 | }) 79 | }) 80 | 81 | describe('errData', function () { 82 | it('should return default status code & error', function (done) { 83 | test('errData', { err: true }) 84 | .expect(500, { 85 | success: false, 86 | error: 'failed operation' 87 | }, done) 88 | }) 89 | 90 | it('should return default status code & data', function (done) { 91 | test('errData', { err: false }) 92 | .expect(200, { 93 | success: true, 94 | data: { custom: true } 95 | }, done) 96 | }) 97 | 98 | it('should return custom error message', function (done) { 99 | test('errData', { err: true, errMsg: 'custom' }) 100 | .expect(500, { 101 | success: false, 102 | error: 'custom' 103 | }, done) 104 | }) 105 | }) 106 | }) 107 | -------------------------------------------------------------------------------- /test/crud.test.js: -------------------------------------------------------------------------------- 1 | const { create, read, update, remove } = require('../common/crud') 2 | const Restaurant = require('../models/Restaurant') 3 | const User = require('../models/User') 4 | const request = require('supertest') 5 | const expect = require('chai').expect 6 | const bodyParser = require('body-parser') 7 | const express = require('express') 8 | const mongoose = require('mongoose') 9 | const { testMongoUrl } = require('../config') 10 | const app = express() 11 | 12 | mongoose.connect(testMongoUrl, { 13 | useNewUrlParser: true, 14 | autoIndex: false, 15 | useFindAndModify: false, 16 | useUnifiedTopology: true, 17 | }) 18 | 19 | app 20 | .use(bodyParser.json()) 21 | .post('/create', create(Restaurant, ['owner'])) 22 | .post('/read', read(Restaurant, ['owner'])) 23 | .post('/update/:_id', update(Restaurant, ['owner'])) 24 | .post('/remove/:_id', remove(User)) 25 | 26 | function test (funcStr, args) { 27 | return request(app) 28 | .post('/'+funcStr) 29 | .send(args) 30 | } 31 | 32 | describe('crud', function () { 33 | const user = { 34 | "name": "Test", 35 | "email": "test@test.com", 36 | "type": "admin", 37 | "password": "12345" 38 | } 39 | 40 | const restaurant = { 41 | "name": "Brand New Restaurant", 42 | "location": { 43 | "type": "Point", 44 | "coordinates": [-73.9983, 40.715051] 45 | }, 46 | "available": true 47 | } 48 | 49 | const testUser = new User({ 50 | _id: new mongoose.Types.ObjectId(), 51 | ...user 52 | }) 53 | 54 | before(function (done) { 55 | testUser.save(done) 56 | }) 57 | 58 | after(function (done) { 59 | mongoose.connection.db.dropDatabase(function(){ 60 | mongoose.connection.close(done) 61 | }) 62 | }) 63 | 64 | describe('create', function () { 65 | it('should not create new restaurant without owner', function (done) { 66 | const data = restaurant 67 | test('create', data) 68 | .expect(500, done) 69 | }) 70 | it('should return new restaurant and populate', function (done) { 71 | const data = { owner: testUser._id, ...restaurant } 72 | test('create', data) 73 | .expect(200) 74 | .expect(function (res) { 75 | restaurant._id = res.body.data._id 76 | expect(res.body.data).to.be.a('object') 77 | expect(res.body.data).to.deep.include(restaurant) 78 | expect(res.body.data.owner.name).to.equal('Test') 79 | expect(res.body.data.owner.password).to.equal(undefined) 80 | }) 81 | .end(done) 82 | }) 83 | }) 84 | 85 | describe('read', function () { 86 | it('should return restaurant and populate', function (done) { 87 | const data = [{ "available": true, _id: restaurant._id }] 88 | test('read', data) 89 | .expect(200) 90 | .expect(function (res) { 91 | expect(res.body.data).to.be.a('array') 92 | expect(res.body.data[0]).to.deep.include(restaurant) 93 | expect(res.body.data[0].owner.name).to.equal('Test') 94 | expect(res.body.data[0].owner.password).to.equal(undefined) 95 | }) 96 | .end(done) 97 | }) 98 | it('should return no restaurant', function (done) { 99 | const data = [{ "available": false }] 100 | test('read', data) 101 | .expect(200) 102 | .expect(function (res) { 103 | expect(res.body.data.length).to.equal(0) 104 | }) 105 | .end(done) 106 | }) 107 | }) 108 | 109 | describe('update', function () { 110 | it('should return updated restaurant and populate', function (done) { 111 | const { _id } = restaurant 112 | const data = { name: 'New name' } 113 | test('update/'+_id, data) 114 | .expect(200) 115 | .expect(function (res) { 116 | expect(res.body.data).to.be.a('object') 117 | expect(res.body.data).to.deep.include({...restaurant, ...data}) 118 | expect(res.body.data.name).to.equal('New name') 119 | expect(res.body.data.owner.name).to.equal('Test') 120 | expect(res.body.data.owner.password).to.equal(undefined) 121 | }) 122 | .end(done) 123 | }) 124 | }) 125 | 126 | describe('remove', function () { 127 | it('should remove user', function (done) { 128 | const { _id } = testUser 129 | test('remove/'+_id) 130 | .expect(200) 131 | .expect(function (res) { 132 | expect(res.body.data).to.be.a('object') 133 | expect(res.body.data).to.have.property('ok') 134 | expect(res.body.data.ok).to.equal(1) 135 | }) 136 | .end(done) 137 | }) 138 | }) 139 | 140 | }) 141 | -------------------------------------------------------------------------------- /postman/restaurant.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "ffa13764-6137-4b20-ad9f-0eab9efd5f04", 4 | "name": "restaurant", 5 | "description": "restaurant app", 6 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 7 | }, 8 | "item": [ 9 | { 10 | "name": "auth", 11 | "item": [ 12 | { 13 | "name": "sign up", 14 | "event": [ 15 | { 16 | "listen": "test", 17 | "script": { 18 | "id": "e9c9cb94-86e2-47cb-acac-8c53719f2a9b", 19 | "exec": [ 20 | "pm.test(\"Status code is 200\", function () {", 21 | " pm.response.to.have.status(200);", 22 | "});", 23 | "", 24 | "pm.test(\"update sign up user id\", function () {", 25 | " var jsonData = pm.response.json();", 26 | " ", 27 | " pm.environment.set(\"signUpUserId\", jsonData.data._id);", 28 | "});", 29 | "" 30 | ], 31 | "type": "text/javascript" 32 | } 33 | } 34 | ], 35 | "request": { 36 | "method": "POST", 37 | "header": [ 38 | { 39 | "key": "Content-Type", 40 | "name": "Content-Type", 41 | "type": "text", 42 | "value": "application/json" 43 | } 44 | ], 45 | "body": { 46 | "mode": "raw", 47 | "raw": "{\n\t\"name\": \"Your name\",\n\t\"email\": \"name@here.com\",\n\t\"type\": \"admin\",\n\t\"password\": \"000000\"\n}" 48 | }, 49 | "url": { 50 | "raw": "{{url}}/api/auth/signup", 51 | "host": [ 52 | "{{url}}" 53 | ], 54 | "path": [ 55 | "api", 56 | "auth", 57 | "signup" 58 | ] 59 | }, 60 | "description": "Sign up with 3 enum types:\nclient\nrealtor\nadmin" 61 | }, 62 | "response": [] 63 | }, 64 | { 65 | "name": "login", 66 | "event": [ 67 | { 68 | "listen": "test", 69 | "script": { 70 | "id": "ce73614d-a3da-4153-819a-b624086a2cfc", 71 | "exec": [ 72 | "pm.test(\"Status code is 200\", function () {", 73 | " pm.response.to.have.status(200);", 74 | "});", 75 | "", 76 | "pm.test(\"update token\", function () {", 77 | " var jsonData = pm.response.json();", 78 | " ", 79 | " pm.environment.set(\"token\", jsonData.data);", 80 | "});", 81 | "" 82 | ], 83 | "type": "text/javascript" 84 | } 85 | } 86 | ], 87 | "request": { 88 | "method": "POST", 89 | "header": [ 90 | { 91 | "key": "Content-Type", 92 | "name": "Content-Type", 93 | "type": "text", 94 | "value": "application/json" 95 | } 96 | ], 97 | "body": { 98 | "mode": "raw", 99 | "raw": "{\n\t\"email\": \"kane@here.com\",\n\t\"type\": \"admin\",\n\t\"password\": \"000000\"\n}" 100 | }, 101 | "url": { 102 | "raw": "{{url}}/api/auth/login", 103 | "host": [ 104 | "{{url}}" 105 | ], 106 | "path": [ 107 | "api", 108 | "auth", 109 | "login" 110 | ] 111 | }, 112 | "description": "login with different types" 113 | }, 114 | "response": [] 115 | } 116 | ] 117 | }, 118 | { 119 | "name": "restaurant", 120 | "item": [ 121 | { 122 | "name": "all", 123 | "event": [ 124 | { 125 | "listen": "test", 126 | "script": { 127 | "id": "c858845b-828c-43c9-b685-c6fc842f54a7", 128 | "exec": [ 129 | "pm.test(\"Status code is 200\", function () {", 130 | " pm.response.to.have.status(200);", 131 | "});", 132 | "", 133 | "var schema = {", 134 | " \"data\": {", 135 | " \"type\": \"array\"", 136 | " }", 137 | "};", 138 | "", 139 | "pm.test('Schema is valid', function() {", 140 | " var jsonData = pm.response.json();", 141 | " pm.expect(tv4.validate(jsonData.data, schema)).to.be.true;", 142 | "});" 143 | ], 144 | "type": "text/javascript" 145 | } 146 | } 147 | ], 148 | "request": { 149 | "method": "GET", 150 | "header": [], 151 | "url": { 152 | "raw": "{{url}}/api/restaurant/all/{{lng}}/{{lat}}/1", 153 | "host": [ 154 | "{{url}}" 155 | ], 156 | "path": [ 157 | "api", 158 | "restaurant", 159 | "all", 160 | "{{lng}}", 161 | "{{lat}}", 162 | "1" 163 | ] 164 | } 165 | }, 166 | "response": [] 167 | }, 168 | { 169 | "name": "available", 170 | "event": [ 171 | { 172 | "listen": "test", 173 | "script": { 174 | "id": "7147572c-5ba3-4dfe-9fd6-99065ab69e92", 175 | "exec": [ 176 | "pm.test(\"Status code is 200\", function () {", 177 | " pm.response.to.have.status(200);", 178 | "});", 179 | "", 180 | "var schema = {", 181 | " \"data\": {", 182 | " \"type\": \"array\"", 183 | " }", 184 | "};", 185 | "", 186 | "pm.test('Schema is valid', function() {", 187 | " var jsonData = pm.response.json();", 188 | " pm.expect(tv4.validate(jsonData.data, schema)).to.be.true;", 189 | "});" 190 | ], 191 | "type": "text/javascript" 192 | } 193 | } 194 | ], 195 | "request": { 196 | "method": "GET", 197 | "header": [], 198 | "url": { 199 | "raw": "{{url}}/api/restaurant/available/{{lng}}/{{lat}}/1", 200 | "host": [ 201 | "{{url}}" 202 | ], 203 | "path": [ 204 | "api", 205 | "restaurant", 206 | "available", 207 | "{{lng}}", 208 | "{{lat}}", 209 | "1" 210 | ] 211 | } 212 | }, 213 | "response": [] 214 | }, 215 | { 216 | "name": "create", 217 | "event": [ 218 | { 219 | "listen": "test", 220 | "script": { 221 | "id": "d122c8e3-1742-4550-be3d-9cbfaf9c1f69", 222 | "exec": [ 223 | "pm.test(\"Status code is 200\", function () {", 224 | " pm.response.to.have.status(200);", 225 | "});", 226 | "", 227 | "pm.test(\"Update id\", function () {", 228 | " var jsonData = pm.response.json();", 229 | " ", 230 | " pm.environment.set(\"id\", jsonData.data._id);", 231 | "});" 232 | ], 233 | "type": "text/javascript" 234 | } 235 | } 236 | ], 237 | "request": { 238 | "method": "POST", 239 | "header": [ 240 | { 241 | "key": "Content-Type", 242 | "name": "Content-Type", 243 | "value": "application/json", 244 | "type": "text" 245 | } 246 | ], 247 | "body": { 248 | "mode": "raw", 249 | "raw": "{\n\t\"name\": \"Brand New Restaurant\",\n\t\"owner\": \"5d5038dae892884fd6d68316\",\n\t\"location\": {\n\t\t\"type\": \"Point\",\n\t\t\"coordinates\": [-73.9983, 40.715051]\n\t},\n\t\"available\": true\n}" 250 | }, 251 | "url": { 252 | "raw": "{{url}}/api/restaurant", 253 | "host": [ 254 | "{{url}}" 255 | ], 256 | "path": [ 257 | "api", 258 | "restaurant" 259 | ] 260 | } 261 | }, 262 | "response": [] 263 | }, 264 | { 265 | "name": "update", 266 | "event": [ 267 | { 268 | "listen": "test", 269 | "script": { 270 | "id": "5140a397-7c72-4a46-ba02-f4ad5a82efae", 271 | "exec": [ 272 | "pm.test(\"Status code is 200\", function () {", 273 | " pm.response.to.have.status(200);", 274 | "});" 275 | ], 276 | "type": "text/javascript" 277 | } 278 | } 279 | ], 280 | "request": { 281 | "method": "PUT", 282 | "header": [ 283 | { 284 | "key": "Content-Type", 285 | "name": "Content-Type", 286 | "value": "application/json", 287 | "type": "text" 288 | } 289 | ], 290 | "body": { 291 | "mode": "raw", 292 | "raw": "{\n\t\"name\": \"Here it is\",\n\t\"available\": false\n}" 293 | }, 294 | "url": { 295 | "raw": "{{url}}/api/restaurant/{{id}}", 296 | "host": [ 297 | "{{url}}" 298 | ], 299 | "path": [ 300 | "api", 301 | "restaurant", 302 | "{{id}}" 303 | ] 304 | } 305 | }, 306 | "response": [] 307 | }, 308 | { 309 | "name": "delete", 310 | "event": [ 311 | { 312 | "listen": "test", 313 | "script": { 314 | "id": "f60bcd9e-fdc2-47a6-9bfa-84d9a2949eb7", 315 | "exec": [ 316 | "pm.test(\"Status code is 200\", function () {", 317 | " pm.response.to.have.status(200);", 318 | "});" 319 | ], 320 | "type": "text/javascript" 321 | } 322 | } 323 | ], 324 | "request": { 325 | "method": "DELETE", 326 | "header": [], 327 | "url": { 328 | "raw": "{{url}}/api/restaurant/{{id}}", 329 | "host": [ 330 | "{{url}}" 331 | ], 332 | "path": [ 333 | "api", 334 | "restaurant", 335 | "{{id}}" 336 | ] 337 | } 338 | }, 339 | "response": [] 340 | } 341 | ], 342 | "event": [ 343 | { 344 | "listen": "prerequest", 345 | "script": { 346 | "id": "3dfec407-a889-45a9-bc56-77a11997da8e", 347 | "type": "text/javascript", 348 | "exec": [ 349 | "" 350 | ] 351 | } 352 | }, 353 | { 354 | "listen": "test", 355 | "script": { 356 | "id": "4697522f-4ffa-4e8a-a4f1-02f3f4debfe3", 357 | "type": "text/javascript", 358 | "exec": [ 359 | "" 360 | ] 361 | } 362 | } 363 | ] 364 | }, 365 | { 366 | "name": "user", 367 | "item": [ 368 | { 369 | "name": "all", 370 | "event": [ 371 | { 372 | "listen": "test", 373 | "script": { 374 | "id": "76d6b3a7-067f-4665-b2c6-eccf93309c01", 375 | "exec": [ 376 | "pm.test(\"Status code is 200\", function () {", 377 | " pm.response.to.have.status(200);", 378 | "});" 379 | ], 380 | "type": "text/javascript" 381 | } 382 | } 383 | ], 384 | "request": { 385 | "method": "GET", 386 | "header": [], 387 | "url": { 388 | "raw": "{{url}}/api/user/all/1", 389 | "host": [ 390 | "{{url}}" 391 | ], 392 | "path": [ 393 | "api", 394 | "user", 395 | "all", 396 | "1" 397 | ] 398 | } 399 | }, 400 | "response": [] 401 | }, 402 | { 403 | "name": "create", 404 | "event": [ 405 | { 406 | "listen": "test", 407 | "script": { 408 | "id": "14afd13b-129f-482a-a215-1590abc24c76", 409 | "exec": [ 410 | "pm.test(\"Status code is 200\", function () {", 411 | " pm.response.to.have.status(200);", 412 | "});", 413 | "", 414 | "pm.test(\"update userId\", function () {", 415 | " var jsonData = pm.response.json();", 416 | " ", 417 | " pm.environment.set(\"userId\", jsonData.data._id);", 418 | "});" 419 | ], 420 | "type": "text/javascript" 421 | } 422 | } 423 | ], 424 | "request": { 425 | "method": "POST", 426 | "header": [ 427 | { 428 | "key": "Content-Type", 429 | "name": "Content-Type", 430 | "value": "application/json", 431 | "type": "text" 432 | } 433 | ], 434 | "body": { 435 | "mode": "raw", 436 | "raw": "{\n\t\"name\": \"Kim\",\n\t\"email\": \"kim@test.com\",\n\t\"type\": \"admin\",\n\t\"password\": \"sdfseifee\"\n}" 437 | }, 438 | "url": { 439 | "raw": "{{url}}/api/auth/signup", 440 | "host": [ 441 | "{{url}}" 442 | ], 443 | "path": [ 444 | "api", 445 | "auth", 446 | "signup" 447 | ] 448 | } 449 | }, 450 | "response": [] 451 | }, 452 | { 453 | "name": "update", 454 | "event": [ 455 | { 456 | "listen": "test", 457 | "script": { 458 | "id": "9ee585eb-d324-42e9-8dd6-df9576c9f38b", 459 | "exec": [ 460 | "pm.test(\"Status code is 200\", function () {", 461 | " pm.response.to.have.status(200);", 462 | "});" 463 | ], 464 | "type": "text/javascript" 465 | } 466 | } 467 | ], 468 | "request": { 469 | "method": "PUT", 470 | "header": [ 471 | { 472 | "key": "Content-Type", 473 | "name": "Content-Type", 474 | "value": "application/json", 475 | "type": "text" 476 | } 477 | ], 478 | "body": { 479 | "mode": "raw", 480 | "raw": "{\n\t\"name\": \"Jacky\"\n}" 481 | }, 482 | "url": { 483 | "raw": "{{url}}/api/user/{{userId}}", 484 | "host": [ 485 | "{{url}}" 486 | ], 487 | "path": [ 488 | "api", 489 | "user", 490 | "{{userId}}" 491 | ] 492 | } 493 | }, 494 | "response": [] 495 | }, 496 | { 497 | "name": "delete", 498 | "event": [ 499 | { 500 | "listen": "test", 501 | "script": { 502 | "id": "7215191a-88da-4a97-9219-62580eab213b", 503 | "exec": [ 504 | "pm.test(\"Status code is 200\", function () {", 505 | " pm.response.to.have.status(200);", 506 | "});" 507 | ], 508 | "type": "text/javascript" 509 | } 510 | } 511 | ], 512 | "request": { 513 | "method": "DELETE", 514 | "header": [], 515 | "url": { 516 | "raw": "{{url}}/api/user/{{userId}}", 517 | "host": [ 518 | "{{url}}" 519 | ], 520 | "path": [ 521 | "api", 522 | "user", 523 | "{{userId}}" 524 | ] 525 | } 526 | }, 527 | "response": [] 528 | }, 529 | { 530 | "name": "delete sign up user", 531 | "event": [ 532 | { 533 | "listen": "test", 534 | "script": { 535 | "id": "7215191a-88da-4a97-9219-62580eab213b", 536 | "exec": [ 537 | "pm.test(\"Status code is 200\", function () {", 538 | " pm.response.to.have.status(200);", 539 | "});" 540 | ], 541 | "type": "text/javascript" 542 | } 543 | } 544 | ], 545 | "request": { 546 | "method": "DELETE", 547 | "header": [], 548 | "url": { 549 | "raw": "{{url}}/api/user/{{signUpUserId}}", 550 | "host": [ 551 | "{{url}}" 552 | ], 553 | "path": [ 554 | "api", 555 | "user", 556 | "{{signUpUserId}}" 557 | ] 558 | } 559 | }, 560 | "response": [] 561 | } 562 | ], 563 | "event": [ 564 | { 565 | "listen": "prerequest", 566 | "script": { 567 | "id": "2febca8c-7a6a-4cec-887d-7d8a8aec2c19", 568 | "type": "text/javascript", 569 | "exec": [ 570 | "" 571 | ] 572 | } 573 | }, 574 | { 575 | "listen": "test", 576 | "script": { 577 | "id": "8ad518e0-3f89-4633-ba8a-86b2cf9fb977", 578 | "type": "text/javascript", 579 | "exec": [ 580 | "" 581 | ] 582 | } 583 | } 584 | ] 585 | }, 586 | { 587 | "name": "{{url}}/api/ping", 588 | "event": [ 589 | { 590 | "listen": "test", 591 | "script": { 592 | "id": "3337ae92-bf8d-479f-b40c-639d941fc768", 593 | "exec": [ 594 | "pm.test(\"Status code is 200\", function () {", 595 | " pm.response.to.have.status(200);", 596 | "});", 597 | "", 598 | "pm.test(\"Body matches string\", function () {", 599 | " pm.expect(pm.response.text()).to.include(\"pong\");", 600 | "});", 601 | "" 602 | ], 603 | "type": "text/javascript" 604 | } 605 | } 606 | ], 607 | "request": { 608 | "auth": { 609 | "type": "oauth2", 610 | "oauth2": [ 611 | { 612 | "key": "addTokenTo", 613 | "value": "header", 614 | "type": "string" 615 | } 616 | ] 617 | }, 618 | "method": "GET", 619 | "header": [], 620 | "url": { 621 | "raw": "{{url}}/api/ping", 622 | "host": [ 623 | "{{url}}" 624 | ], 625 | "path": [ 626 | "api", 627 | "ping" 628 | ] 629 | }, 630 | "description": "ping" 631 | }, 632 | "response": [] 633 | } 634 | ], 635 | "auth": { 636 | "type": "oauth2", 637 | "oauth2": [ 638 | { 639 | "key": "accessToken", 640 | "value": "{{token}}", 641 | "type": "string" 642 | }, 643 | { 644 | "key": "addTokenTo", 645 | "value": "header", 646 | "type": "string" 647 | } 648 | ] 649 | }, 650 | "event": [ 651 | { 652 | "listen": "prerequest", 653 | "script": { 654 | "id": "218fad8d-205c-41d4-b715-0b7a8bc3d02f", 655 | "type": "text/javascript", 656 | "exec": [ 657 | "" 658 | ] 659 | } 660 | }, 661 | { 662 | "listen": "test", 663 | "script": { 664 | "id": "2c229ce4-ad38-4a78-8dd0-816f7cd96036", 665 | "type": "text/javascript", 666 | "exec": [ 667 | "" 668 | ] 669 | } 670 | } 671 | ] 672 | } --------------------------------------------------------------------------------