├── .gitignore
├── .gitlab-ci.yml
├── README.md
├── client
├── .babelrc
├── .dockerignore
├── .eslintrc
├── Dockerfile
├── index.html
├── package.json
├── serve.js
├── src
│ ├── app
│ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ └── app.test.js.snap
│ │ │ └── app.test.js
│ │ └── index.js
│ ├── components
│ │ ├── footer
│ │ │ ├── __tests__
│ │ │ │ ├── __snapshots__
│ │ │ │ │ └── footer.test.js.snap
│ │ │ │ └── footer.test.js
│ │ │ └── index.js
│ │ ├── navbar
│ │ │ ├── __tests__
│ │ │ │ ├── __snapshots__
│ │ │ │ │ └── navbar.test.js.snap
│ │ │ │ └── navbar.test.js
│ │ │ └── index.js
│ │ ├── notifications
│ │ │ ├── __tests__
│ │ │ │ ├── __snapshots__
│ │ │ │ │ ├── notification.test.js.snap
│ │ │ │ │ └── notifications.test.js.snap
│ │ │ │ ├── notification.test.js
│ │ │ │ └── notifications.test.js
│ │ │ ├── index.js
│ │ │ ├── notification.js
│ │ │ ├── notifications.js
│ │ │ └── transitions.css
│ │ ├── question
│ │ │ ├── __tests__
│ │ │ │ ├── __snapshots__
│ │ │ │ │ └── question.test.js.snap
│ │ │ │ └── question.test.js
│ │ │ └── index.js
│ │ └── user
│ │ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ └── user.test.js.snap
│ │ │ └── user.test.js
│ │ │ └── index.js
│ ├── index.js
│ ├── pages
│ │ ├── create
│ │ │ ├── __tests__
│ │ │ │ ├── __snapshots__
│ │ │ │ │ └── create.test.js.snap
│ │ │ │ └── create.test.js
│ │ │ └── index.js
│ │ ├── home
│ │ │ ├── __tests__
│ │ │ │ ├── __snapshots__
│ │ │ │ │ └── home.test.js.snap
│ │ │ │ └── home.test.js
│ │ │ └── index.js
│ │ ├── login
│ │ │ ├── __tests__
│ │ │ │ ├── __snapshots__
│ │ │ │ │ └── login.test.js.snap
│ │ │ │ └── login.test.js
│ │ │ └── index.js
│ │ ├── notfound
│ │ │ ├── __tests__
│ │ │ │ ├── __snapshots__
│ │ │ │ │ └── notfound.test.js.snap
│ │ │ │ └── notfound.test.js
│ │ │ └── index.js
│ │ ├── profile
│ │ │ ├── __tests__
│ │ │ │ ├── __snapshots__
│ │ │ │ │ └── profile.test.js.snap
│ │ │ │ └── profile.test.js
│ │ │ └── index.js
│ │ └── register
│ │ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ └── register.test.js.snap
│ │ │ └── register.test.js
│ │ │ └── index.js
│ ├── store
│ │ ├── actionTypes.js
│ │ ├── actions
│ │ │ └── index.js
│ │ ├── epics
│ │ │ ├── __tests__
│ │ │ │ ├── auth.test.js
│ │ │ │ ├── notifications.test.js
│ │ │ │ ├── questions.test.js
│ │ │ │ └── users.test.js
│ │ │ ├── auth.js
│ │ │ ├── helloworld.js
│ │ │ ├── index.js
│ │ │ ├── notifications.js
│ │ │ ├── questions.js
│ │ │ └── users.js
│ │ ├── index.js
│ │ ├── reducers
│ │ │ ├── __tests__
│ │ │ │ ├── auth.test.js
│ │ │ │ ├── notifications.test.js
│ │ │ │ ├── questions.test.js
│ │ │ │ └── users.test.js
│ │ │ ├── auth.js
│ │ │ ├── helloworld.js
│ │ │ ├── index.js
│ │ │ ├── notifications.js
│ │ │ ├── questions.js
│ │ │ └── users.js
│ │ ├── rootEpic.js
│ │ └── rootReducer.js
│ └── util
│ │ ├── __tests__
│ │ ├── errorToMessage.test.js
│ │ ├── requireAuth.test.js
│ │ └── signRequest.test.js
│ │ ├── errorToMessage.js
│ │ ├── index.js
│ │ ├── requireAuth.js
│ │ └── signRequest.js
├── test
│ └── setup.js
├── webpack.config.js
└── yarn.lock
├── deploy
├── Makefile
├── docker-compose-img.yml
└── docker-compose.yml
└── server
├── .dockerignore
├── .eslintrc
├── CHANGELOG.md
├── Dockerfile
├── README.md
├── config.js
├── index.js
├── package.json
├── src
├── app.js
├── auth
│ ├── index.js
│ ├── login.js
│ ├── passport.js
│ └── register.js
├── db
│ ├── index.js
│ ├── question.js
│ ├── thinky.js
│ └── user.js
├── index.js
├── question
│ ├── answer.js
│ ├── create.js
│ ├── delete.js
│ ├── get.js
│ ├── index.js
│ └── update.js
├── user
│ ├── get.js
│ ├── index.js
│ └── update.js
└── util
│ ├── asyncRequest.js
│ ├── hash.js
│ ├── index.js
│ └── logger.js
├── test
├── core.js
├── index.js
├── login.js
├── main.js
├── question.js
├── register.js
└── user.js
├── util
└── db
│ └── create.js
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | server/db/
3 | server/lib/
4 | npm-debug.log
5 | client/dist/
6 | coverage
7 | .vscode
8 | deploy/db
9 |
--------------------------------------------------------------------------------
/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | image: docker:latest
2 | services:
3 | - docker:dind
4 |
5 | stages:
6 | - build
7 | - test
8 | - release
9 | - deploy
10 |
11 | variables:
12 | SERVER_TEST_IMAGE: yamalight/bpwjs-server:$CI_BUILD_REF_NAME
13 | CLIENT_TEST_IMAGE: yamalight/bpwjs-client:$CI_BUILD_REF_NAME
14 | SERVER_RELEASE_IMAGE: yamalight/bpwjs-server:latest
15 | CLIENT_RELEASE_IMAGE: yamalight/bpwjs-client:latest
16 |
17 | before_script:
18 | - docker login -u yamalight -p $CI_DOCKERHUB_PASSWORD
19 |
20 | build-server:
21 | stage: build
22 | script:
23 | - docker build --pull -t $SERVER_TEST_IMAGE ./server
24 | - docker push $SERVER_TEST_IMAGE
25 |
26 | build-client:
27 | stage: build
28 | script:
29 | - docker build --pull -t $CLIENT_TEST_IMAGE ./client
30 | - docker push $CLIENT_TEST_IMAGE
31 |
32 | test-server:
33 | stage: test
34 | script:
35 | - docker run -d --name expertsdb rethinkdb
36 | - docker pull $SERVER_TEST_IMAGE
37 | - docker run --link expertsdb:expertsdb -e EXPERTS_DB_URL=expertsdb $SERVER_TEST_IMAGE npm test
38 |
39 | test-client:
40 | stage: test
41 | script:
42 | - docker pull $CLIENT_TEST_IMAGE
43 | - docker run -e TZ=Europe/Berlin $CLIENT_TEST_IMAGE npm test
44 |
45 | release-server:
46 | stage: release
47 | script:
48 | - docker pull $SERVER_TEST_IMAGE
49 | - docker tag $SERVER_TEST_IMAGE $SERVER_RELEASE_IMAGE
50 | - docker push $SERVER_RELEASE_IMAGE
51 | only:
52 | - master
53 |
54 | release-client:
55 | stage: release
56 | script:
57 | - docker pull $CLIENT_TEST_IMAGE
58 | - docker tag $CLIENT_TEST_IMAGE $CLIENT_RELEASE_IMAGE
59 | - docker push $CLIENT_RELEASE_IMAGE
60 | only:
61 | - master
62 |
63 | deploy:
64 | stage: deploy
65 | only:
66 | - master
67 | when: manual
68 | before_script:
69 | # Install ssh-agent, ldap if not already installed
70 | - apk add --update openssh
71 | # Run ssh-agent (inside the build environment)
72 | - eval $(ssh-agent -s)
73 | # Add the SSH key stored in SSH_PRIVATE_KEY variable to the agent store
74 | - echo "$SSH_PRIVATE_KEY" > ~/id_rsa && chmod 600 ~/id_rsa && ssh-add ~/id_rsa
75 | # For Docker builds disable host key checking. Be aware that by adding that
76 | # you are suspectible to man-in-the-middle attacks.
77 | # WARNING: Use this only with the Docker executor, if you use it with shell
78 | # you will overwrite your user's SSH config.
79 | - mkdir -p ~/.ssh
80 | - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
81 | script:
82 | - ssh root@codezen.net "cd /root/bpwjs.deploy; make deploy"
83 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Building products with javascript
2 |
3 | > Free open-source course
4 |
5 | [](https://gitlab.com/yamalight/building-products-with-js/pipelines)
6 |
7 | This repository contains code and related materials for [Building products with javascript](https://www.youtube.com/playlist?list=PL_gX69xPLi-ljVdNhspjZUlPmBNjRgD2X) course.
8 |
9 | ## Project description
10 |
11 | This is a simple client-server CRUD application that allows users to ask and answer questions.
12 | It uses [express.js](https://expressjs.com/), [passport.js](http://passportjs.org/) with [JWT](https://jwt.io/) and [thinky](https://github.com/neumino/thinky) along with [RethinkDB](https://www.rethinkdb.com/) on a backend; [React](https://facebook.github.io/react/), [Redux](http://redux.js.org/), [RxJS](https://github.com/Reactive-Extensions/RxJS) on front-end.
13 | Backend is tested using [tape](https://github.com/substack/tape) and [supertest](https://github.com/visionmedia/supertest), while front-end uses [jest](https://facebook.github.io/jest/) and [enzyme](https://github.com/airbnb/enzyme).
14 | [Docker](https://www.docker.com/) and [docker-compose](https://docs.docker.com/compose/) are used for deployment.
15 |
16 | ## CI/CD
17 |
18 | CI and CD for all the subprojects is done using [Gitlab-CI](https://gitlab.com/yamalight/building-products-with-js).
19 |
20 | ## Useful links
21 |
22 | - [YouTube channel](https://www.youtube.com/c/TimErmilov) with videos covering code
23 | - [Discord chat](https://discord.gg/hnKCXqQ) for questions and live discussions
24 | - [Subreddit](https://www.reddit.com/r/BuildingWithJS/) for discussions
25 | - [Facebook page](https://www.facebook.com/buildingproductswithjs/) with updates on progress
26 | - [My twitter](https://twitter.com/yamalight) with updates on progress (and other stuff)
27 |
28 | ## Course 2: Building Electron.js apps
29 |
30 | My second course on building Electron.js apps can be found [here](https://github.com/yamalight/bpjs-electron) and [here](https://www.youtube.com/playlist?list=PL_gX69xPLi-lBH8I52J-3nEhEQD6_nDs6).
31 |
32 | ## Course 3: Building Data Science apps
33 |
34 | My third course on building data science apps can be found [here](https://github.com/BuildingXwithJS/building-data-science-with-js) and [here](https://www.youtube.com/playlist?list=PL_gX69xPLi-lGe7iRt6DqTZ7PpIrNq8ep).
35 |
36 | ## License
37 |
38 | [MIT](https://opensource.org/licenses/mit-license)
39 |
--------------------------------------------------------------------------------
/client/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015", "react", "stage-0"],
3 | "plugins": ["transform-runtime"],
4 | "env": {
5 | "development": {
6 | "presets": ["react-hmre"]
7 | },
8 | "production": {
9 | "presets": ["react-optimize"]
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/client/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 | .vscode
3 | coverage
4 | dist
5 | node_modules
6 |
--------------------------------------------------------------------------------
/client/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": "airbnb",
4 | "env": {
5 | "browser": true,
6 | "jest": true
7 | },
8 | "globals": {
9 | "shallow": true,
10 | "render": true,
11 | "mount": true,
12 | "API_HOST": true
13 | },
14 | "rules": {
15 | "object-curly-spacing": ["warn", "never"],
16 | "func-names": "off",
17 | "space-before-function-paren": ["error", "never"],
18 | "max-len": ["error", 120, 4],
19 | "no-unused-vars": ["error", {"argsIgnorePattern": "next"}],
20 | "import/prefer-default-export": "off",
21 | "react/jsx-filename-extension": "off",
22 | "import/no-extraneous-dependencies": ["error", {"devDependencies": true}],
23 | "jsx-a11y/anchor-has-content": ["error"]
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/client/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM kkarczmarczyk/node-yarn:latest
2 |
3 | # Create app folder
4 | RUN mkdir -p /app
5 | WORKDIR /app
6 |
7 | # Cache npm dependencies
8 | COPY package.json /app/
9 | COPY yarn.lock /app/
10 | RUN yarn
11 |
12 | # Copy application files
13 | COPY . /app
14 |
15 | EXPOSE 3000
16 |
17 | CMD ["npm", "start"]
18 |
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Experts client
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "experts-client",
3 | "version": "0.1.0",
4 | "description": "Experts portal client",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "node serve.js",
8 | "test": "jest",
9 | "test:watch": "jest --watch",
10 | "cover": "jest --coverage"
11 | },
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/yamalight/building-products-with-js.git"
15 | },
16 | "keywords": [
17 | "react",
18 | "react-router",
19 | "webpack",
20 | "tutorial",
21 | "front-end"
22 | ],
23 | "author": "Tim Ermilov (http://codezen.net)",
24 | "license": "MIT",
25 | "bugs": {
26 | "url": "https://github.com/yamalight/building-products-with-js/issues"
27 | },
28 | "homepage": "https://github.com/yamalight/building-products-with-js#readme",
29 | "jest": {
30 | "setupFiles": [
31 | "./test/setup.js"
32 | ],
33 | "snapshotSerializers": [
34 | "/node_modules/enzyme-to-json/serializer"
35 | ],
36 | "moduleNameMapper": {
37 | "^.+\\.(css|scss)$": "identity-obj-proxy"
38 | }
39 | },
40 | "devDependencies": {
41 | "babel-core": "^6.23.1",
42 | "babel-eslint": "^7.1.1",
43 | "babel-jest": "^19.0.0",
44 | "babel-loader": "^6.3.2",
45 | "babel-plugin-lodash": "^3.2.11",
46 | "babel-plugin-transform-runtime": "^6.23.0",
47 | "babel-preset-es2015": "^6.22.0",
48 | "babel-preset-react": "^6.23.0",
49 | "babel-preset-react-hmre": "^1.1.1",
50 | "babel-preset-react-optimize": "^1.0.1",
51 | "babel-preset-stage-0": "^6.22.0",
52 | "css-loader": "^0.26.2",
53 | "enzyme": "^2.7.1",
54 | "enzyme-to-json": "^1.5.0",
55 | "eslint": "^3.16.1",
56 | "eslint-config-airbnb": "^14.1.0",
57 | "eslint-plugin-import": "^2.2.0",
58 | "eslint-plugin-jsx-a11y": "^4.0.0",
59 | "eslint-plugin-react": "^6.10.0",
60 | "express": "^4.14.1",
61 | "extract-text-webpack-plugin": "^2.0.0",
62 | "file-loader": "^0.10.1",
63 | "identity-obj-proxy": "^3.0.0",
64 | "jest": "^19.0.2",
65 | "json-loader": "^0.5.4",
66 | "lodash-webpack-plugin": "^0.11.2",
67 | "react-addons-test-utils": "^15.4.2",
68 | "redux-mock-store": "^1.2.2",
69 | "style-loader": "^0.13.2",
70 | "url-loader": "^0.5.8",
71 | "webpack": "^2.2.1",
72 | "webpack-dev-middleware": "^1.10.1",
73 | "webpack-hot-middleware": "^2.17.1"
74 | },
75 | "dependencies": {
76 | "bootstrap": "^3.3.7",
77 | "history": "^4.5.1",
78 | "lodash": "^4.17.4",
79 | "moment": "^2.17.1",
80 | "react": "^15.4.2",
81 | "react-addons-css-transition-group": "^15.4.1",
82 | "react-dom": "^15.4.2",
83 | "react-redux": "^5.0.3",
84 | "react-router": "^3.0.2",
85 | "react-router-redux": "^4.0.8",
86 | "redux": "^3.6.0",
87 | "redux-observable": "^0.13.0",
88 | "rxjs": "^5.2.0"
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/client/serve.js:
--------------------------------------------------------------------------------
1 | /* eslint no-console: 0 */
2 | // start webpack
3 | const path = require('path');
4 | const express = require('express');
5 | const webpack = require('webpack');
6 | const webpackMiddleware = require('webpack-dev-middleware');
7 | const webpackHotMiddleware = require('webpack-hot-middleware');
8 | const ExtractTextPlugin = require('extract-text-webpack-plugin');
9 | const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
10 | const config = require('./webpack.config');
11 |
12 | // create express
13 | const app = express();
14 |
15 | // get the environment
16 | const isProduction = process.env.NODE_ENV === 'production';
17 |
18 | // setup plugins
19 | config.plugins = [
20 | // define plugin for node env
21 | new webpack.DefinePlugin({
22 | 'process.env': {NODE_ENV: JSON.stringify(process.env.NODE_ENV)},
23 | API_HOST: JSON.stringify(process.env.API_HOST || 'http://localhost:8080'),
24 | }),
25 | ];
26 | // if not in prod - setup hot reload
27 | if (!isProduction) {
28 | // hot reload plugin
29 | config.plugins.push(new webpack.HotModuleReplacementPlugin());
30 | // setup no errors plugin
31 | config.plugins.push(new webpack.NoEmitOnErrorsPlugin());
32 | }
33 |
34 | // override entry for hot reload
35 | if (!isProduction) {
36 | config.entry = [
37 | 'webpack-hot-middleware/client',
38 | config.entry,
39 | ];
40 | }
41 |
42 | // tweak config for production
43 | if (isProduction) {
44 | // set devtool to cheap source map
45 | config.devtool = 'source-map';
46 |
47 | // extract styles into file
48 | const extractCSS = new ExtractTextPlugin('main.css');
49 | config.plugins.push(extractCSS);
50 | config.module.rules[0].use = ExtractTextPlugin.extract({ // eslint-disable-line
51 | fallback: 'style-loader',
52 | use: [{
53 | loader: 'css-loader',
54 | options: {
55 | modules: true,
56 | minimize: true,
57 | },
58 | }],
59 | });
60 | config.module.rules[1].use = ExtractTextPlugin.extract({ // eslint-disable-line
61 | fallback: 'style-loader',
62 | use: [{
63 | loader: 'css-loader',
64 | options: {
65 | minimize: true,
66 | },
67 | }],
68 | });
69 |
70 | // add js optimization plugins
71 | config.plugins.push(new webpack.LoaderOptionsPlugin({minimize: true}));
72 | config.plugins.push(new webpack.optimize.UglifyJsPlugin());
73 | config.plugins.push(new LodashModuleReplacementPlugin());
74 | }
75 |
76 | // returns a Compiler instance
77 | const compiler = webpack(config);
78 | // stats output config
79 | const statsConf = {
80 | colors: true,
81 | hash: false,
82 | timings: true,
83 | chunks: false,
84 | chunkModules: false,
85 | modules: false,
86 | };
87 |
88 | // add hot reload middleware if not in production
89 | if (!isProduction) {
90 | app.use(webpackMiddleware(compiler, {
91 | publicPath: config.output.publicPath,
92 | contentBase: 'src',
93 | stats: statsConf,
94 | }));
95 | app.use(webpackHotMiddleware(compiler));
96 | } else {
97 | compiler.run((err, stats) => {
98 | if (err) {
99 | console.error('Error compiling with webpack:', err);
100 | process.exit(1);
101 | }
102 |
103 | console.log(stats.toString(statsConf));
104 | });
105 | }
106 |
107 | // serve statics
108 | app.use(express.static(__dirname));
109 | // serve index
110 | app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'index.html')));
111 | // start server
112 | app.listen(3000, (err) => {
113 | if (err) {
114 | console.log(err);
115 | }
116 | console.info('==> Listening on port 3000');
117 | });
118 |
--------------------------------------------------------------------------------
/client/src/app/__tests__/__snapshots__/app.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`# App 1`] = `
4 |
7 | test
8 |
9 |
10 | `;
11 |
--------------------------------------------------------------------------------
/client/src/app/__tests__/app.test.js:
--------------------------------------------------------------------------------
1 | import App from '../index';
2 |
3 | test('# App', () => {
4 | const wrapper = shallow(
5 | test
6 | );
7 | expect(wrapper).toMatchSnapshot();
8 | });
9 |
--------------------------------------------------------------------------------
/client/src/app/index.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import React from 'react';
3 |
4 | import Footer from '../components/footer';
5 |
6 | export default ({children}) => (
7 |
8 | {children}
9 |
10 |
11 | );
12 |
--------------------------------------------------------------------------------
/client/src/components/footer/__tests__/__snapshots__/footer.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`# Footer 1`] = `
4 |
23 | `;
24 |
--------------------------------------------------------------------------------
/client/src/components/footer/__tests__/footer.test.js:
--------------------------------------------------------------------------------
1 | import Footer from '../index';
2 |
3 | test('# Footer', () => {
4 | const wrapper = shallow();
5 | expect(wrapper).toMatchSnapshot();
6 | });
7 |
--------------------------------------------------------------------------------
/client/src/components/footer/index.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import React from 'react';
3 |
4 | import {Notifications} from '../notifications';
5 |
6 | const style = {
7 | footer: {
8 | position: 'absolute',
9 | bottom: '0',
10 | width: '100%',
11 | },
12 | };
13 |
14 | export default () => (
15 |
22 | );
23 |
--------------------------------------------------------------------------------
/client/src/components/navbar/__tests__/__snapshots__/navbar.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`# Navbar 1`] = `
4 |
44 | `;
45 |
46 | exports[`# Navbar 2`] = `
47 |
87 | `;
88 |
89 | exports[`# Navbar 3`] = `
90 |
143 | `;
144 |
--------------------------------------------------------------------------------
/client/src/components/navbar/__tests__/navbar.test.js:
--------------------------------------------------------------------------------
1 | import Navbar from '../index';
2 |
3 | test('# Navbar', () => {
4 | const wrapperRoot = shallow();
5 | expect(wrapperRoot).toMatchSnapshot();
6 |
7 | const wrapperCreate = shallow();
8 | expect(wrapperCreate).toMatchSnapshot();
9 |
10 | const wrapperUser = shallow();
11 | expect(wrapperUser).toMatchSnapshot();
12 | });
13 |
--------------------------------------------------------------------------------
/client/src/components/navbar/index.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import React from 'react';
3 | import {Link} from 'react-router';
4 |
5 | const createLink = ({label, link, isText}) => isText ? (
6 | {label}
7 | ) : (
8 | {label}
9 | );
10 |
11 | export default ({user, current}) => (
12 |
36 | );
37 |
--------------------------------------------------------------------------------
/client/src/components/notifications/__tests__/__snapshots__/notification.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`# Notification 1`] = `
4 |
8 |
21 | Test notification
22 |
23 | `;
24 |
25 | exports[`# NotificationWrapper 1`] = `
26 |
46 | `;
47 |
--------------------------------------------------------------------------------
/client/src/components/notifications/__tests__/__snapshots__/notifications.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`# Notifications 1`] = `
4 |
36 | `;
37 |
--------------------------------------------------------------------------------
/client/src/components/notifications/__tests__/notification.test.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import configureMockStore from 'redux-mock-store';
3 |
4 | // our packages
5 | import NotificationWrapper, {Notification} from '../notification';
6 |
7 | // create mock store
8 | const mockStore = configureMockStore();
9 |
10 | const notification = {
11 | id: '0',
12 | text: 'Test notification',
13 | alertType: 'test',
14 | };
15 |
16 | test('# NotificationWrapper', () => {
17 | const store = mockStore({});
18 | const wrapper = shallow();
19 | expect(wrapper).toMatchSnapshot();
20 | });
21 |
22 | test('# Notification', () => {
23 | const onClick = (id) => expect(id).toBe(notification.id);
24 | const component = ;
25 | const wrapper = shallow(component);
26 | expect(wrapper).toMatchSnapshot();
27 | // test interaction
28 | const app = mount(component);
29 | const item = app.find('button');
30 | item.simulate('click');
31 | });
32 |
33 |
--------------------------------------------------------------------------------
/client/src/components/notifications/__tests__/notifications.test.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import configureMockStore from 'redux-mock-store';
3 |
4 | // our packages
5 | import Notifications from '../notifications';
6 |
7 | // create mock store
8 | const mockStore = configureMockStore();
9 |
10 | test('# Notifications', () => {
11 | const store = mockStore({notifications: []});
12 | const wrapper = shallow();
13 | expect(wrapper).toMatchSnapshot();
14 | });
15 |
--------------------------------------------------------------------------------
/client/src/components/notifications/index.js:
--------------------------------------------------------------------------------
1 | import Notifications from './notifications';
2 |
3 | export {
4 | Notifications,
5 | };
6 |
--------------------------------------------------------------------------------
/client/src/components/notifications/notification.js:
--------------------------------------------------------------------------------
1 | import React, {PropTypes} from 'react';
2 | import {connect} from 'react-redux';
3 |
4 | import {removeNotificationAction} from '../../store/actions';
5 |
6 | const mapDispatchToProps = dispatch => ({
7 | onRemoveNotificationClick: notificationId => dispatch(removeNotificationAction(notificationId)),
8 | });
9 |
10 | export const Notification = ({onRemoveNotificationClick, notification}) => (
11 |
12 |
20 | {notification.text}
21 |
22 | );
23 |
24 | Notification.propTypes = {
25 | onRemoveNotificationClick: PropTypes.func.isRequired,
26 | notification: PropTypes.object.isRequired,
27 | };
28 |
29 | export default connect(null, mapDispatchToProps)(Notification);
30 |
--------------------------------------------------------------------------------
/client/src/components/notifications/notifications.js:
--------------------------------------------------------------------------------
1 | import React, {PropTypes} from 'react';
2 | import {connect} from 'react-redux';
3 | import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
4 |
5 | import Notification from './notification';
6 | import transitions from './transitions.css';
7 |
8 | const mapStateToProps = state => ({
9 | notifications: state.notifications,
10 | });
11 |
12 | const Notifications = ({notifications}) => (
13 |
14 |
18 | {
19 | notifications.map(notification => (
20 |
21 | ))
22 | }
23 |
24 |
25 | );
26 |
27 | Notifications.propTypes = {
28 | notifications: PropTypes.array.isRequired,
29 | };
30 |
31 | export default connect(mapStateToProps)(Notifications);
32 |
--------------------------------------------------------------------------------
/client/src/components/notifications/transitions.css:
--------------------------------------------------------------------------------
1 | .enter {
2 | opacity: 0.01;
3 | height: 0px;
4 | }
5 |
6 | .enter.enterActive {
7 | opacity: 1;
8 | height: 52px;
9 | transition: 700ms;
10 | }
11 |
12 | .leave {
13 | opacity: 1;
14 | height: 52px;
15 | }
16 |
17 | .leave.leaveActive {
18 | opacity: 0.01;
19 | height: 0px;
20 | transition: 700ms;
21 | }
22 |
--------------------------------------------------------------------------------
/client/src/components/question/__tests__/__snapshots__/question.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`# Question 1`] = `
4 |
7 |
10 |
11 |
20 |
29 |
30 | Question text
31 |
34 |
39 | test
40 |
41 |
42 |
43 |
46 |
49 | -
52 | Test answer
53 |
54 |
55 |
56 |
82 |
83 | `;
84 |
85 | exports[`# QuestionWrapper 1`] = `
86 |
138 | `;
139 |
--------------------------------------------------------------------------------
/client/src/components/question/__tests__/question.test.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import configureMockStore from 'redux-mock-store';
3 |
4 | // our packages
5 | import QuestionWrapper, {Question} from '../index';
6 |
7 | // create mock store
8 | const mockStore = configureMockStore();
9 |
10 | const user = {
11 | id: '0',
12 | login: 'test',
13 | };
14 | const question = {
15 | owner: user,
16 | text: 'Question text',
17 | answers: [{answer: 'Test answer'}],
18 | };
19 |
20 | test('# QuestionWrapper', () => {
21 | const store = mockStore({auth: {user}});
22 | const wrapper = shallow();
23 | expect(wrapper).toMatchSnapshot();
24 | });
25 |
26 | test('# Question', () => {
27 | const answer = 'Test answer';
28 | const updatedText = 'Updated text';
29 | const onAnswer = ({question: q, answer: a}) => {
30 | expect(q).toEqual(question);
31 | expect(a).toBe(answer);
32 | };
33 | const deleteQuestion = q => expect(q).toEqual(question);
34 | const updateQuestion = ({text}) => expect(text).toBe(updatedText);
35 | const component = (
36 |
43 | );
44 | // test rendering
45 | const wrapper = shallow(component);
46 | expect(wrapper).toMatchSnapshot();
47 | // test interaction
48 | const app = mount(component);
49 |
50 | // test answer
51 | // set answer
52 | app.find('#answerInput').getDOMNode().value = answer;
53 | // click answer button
54 | app.find('#answerBtn').simulate('click');
55 |
56 | // test delete
57 | app.find('#deleteBtn').simulate('click');
58 |
59 | // test update
60 | // enable editing
61 | app.find('#editBtn').simulate('click');
62 | // change question text
63 | app.find('#questionText').getDOMNode().value = updatedText;
64 | // trigger update
65 | app.find('#updateBtn').simulate('click');
66 | });
67 |
--------------------------------------------------------------------------------
/client/src/components/question/index.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import _ from 'lodash';
3 | import React from 'react';
4 | import {Link} from 'react-router';
5 | import {connect} from 'react-redux';
6 |
7 | // our packages
8 | import {deleteQuestion, updateQuestion} from '../../store/actions';
9 |
10 | const mapStateToProps = (state) => ({
11 | user: state.auth.user,
12 | });
13 | const mapDispatchToProps = (dispatch) => ({
14 | deleteQuestion: payload => dispatch(deleteQuestion(payload)),
15 | updateQuestion: payload => dispatch(updateQuestion(payload)),
16 | });
17 |
18 | export class Question extends React.Component {
19 | constructor() {
20 | super();
21 |
22 | this.state = {editing: false};
23 |
24 | this.answerInput = null;
25 | this.questionInput = null;
26 | }
27 |
28 | handleAnswerClick(e) {
29 | e.preventDefault();
30 | this.props.onAnswer({question: this.props.question, answer: this.answerInput.value});
31 | this.answerInput.value = '';
32 | return false;
33 | }
34 |
35 | handleDeleteQuestionClick(e) {
36 | e.preventDefault();
37 | this.props.deleteQuestion(this.props.question);
38 | return false;
39 | }
40 |
41 | handleUpdateQuestionClick(e) {
42 | e.preventDefault();
43 | const newQuestion = _.omit(this.props.question, ['owner', 'answers']);
44 | newQuestion.text = this.questionInput.value;
45 | this.props.updateQuestion(newQuestion);
46 | this.setState({editing: !this.state.editing});
47 | return false;
48 | }
49 |
50 | toggleEdit(e) {
51 | e.preventDefault();
52 | this.setState({editing: !this.state.editing});
53 | return false;
54 | }
55 |
56 | render() {
57 | const {question, user, onAnswer, deleteQuestion, updateQuestion} = this.props;
58 | const {editing} = this.state;
59 |
60 | return (
61 |
62 |
63 | {user.id === question.owner.id && (
64 |
65 |
68 | {editing ? '' : (
69 |
72 | )}
73 |
74 | )}
75 | {editing ? (
76 |
77 | { this.questionInput = i; }} defaultValue={question.text} />
78 |
81 |
84 |
85 | ) : question.text}
86 |
87 |
88 | {question.owner.login}
89 |
90 |
91 |
92 | {question.answers.length > 0 ? (
93 |
94 | {question.answers.map((answer, i) => (
95 | - {answer.answer}
96 | ))}
97 |
98 | ) : 'No answers yet'}
99 |
100 |
116 |
117 | );
118 | };
119 | }
120 |
121 | export default connect(mapStateToProps, mapDispatchToProps)(Question);
122 |
--------------------------------------------------------------------------------
/client/src/components/user/__tests__/__snapshots__/user.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`# User 1`] = `
4 |
7 |
10 | User:
11 |
16 |
19 |
25 |
26 |
27 |
30 | Registration date:
31 | Mon Feb 01 2016 01:01:01 GMT+0100
32 |
33 |
34 | `;
35 |
36 | exports[`# UserWrapper 1`] = `
37 |
75 | `;
76 |
--------------------------------------------------------------------------------
/client/src/components/user/__tests__/user.test.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import configureMockStore from 'redux-mock-store';
3 |
4 | // our packages
5 | import UserWrapper, {User} from '../index';
6 |
7 | // create mock store
8 | const mockStore = configureMockStore();
9 |
10 | const user = {
11 | id: '0',
12 | login: 'test',
13 | registrationDate: new Date(2016, 1, 1, 1, 1, 1, 1),
14 | };
15 |
16 | test('# UserWrapper', () => {
17 | const store = mockStore({});
18 | const wrapper = shallow();
19 | expect(wrapper).toMatchSnapshot();
20 | });
21 |
22 | test('# User', () => {
23 | const newLogin = 'newLogin';
24 | const updateUser = ({login}) => expect(login).toBe(newLogin);
25 |
26 | const component = (
27 |
32 | );
33 |
34 | // test rendering
35 | const wrapper = shallow(component);
36 | expect(wrapper).toMatchSnapshot();
37 |
38 | // test user update
39 | const app = mount(component);
40 | // set new username
41 | app.find('#loginInput').getDOMNode().value = newLogin;
42 | // click answer button
43 | app.find('button').simulate('click');
44 | });
45 |
--------------------------------------------------------------------------------
/client/src/components/user/index.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import React from 'react';
3 | import moment from 'moment';
4 | import {connect} from 'react-redux';
5 |
6 | // our packages
7 | import {updateUser} from '../../store/actions';
8 |
9 | const mapStateToProps = () => ({});
10 | const mapDispatchToProps = (dispatch) => ({
11 | updateUser: payload => dispatch(updateUser(payload)),
12 | });
13 |
14 | export const User = ({user, edit, updateUser}) => {
15 | let userInput;
16 |
17 | const saveUser = () => {
18 | updateUser({
19 | ...user,
20 | login: userInput.value,
21 | });
22 | };
23 |
24 | return user ? (
25 |
26 |
27 | User: {edit ? (
28 |
{ userInput = i; } } defaultValue={user.login} />
29 | ) : user.login }
30 |
31 | {edit && (
32 |
33 |
36 |
37 | )}
38 |
39 |
40 | Registration date: {moment(user.registrationDate).toString()}
41 |
42 |
43 | ) : null;
44 | };
45 |
46 | export default connect(mapStateToProps, mapDispatchToProps)(User);
47 |
--------------------------------------------------------------------------------
/client/src/index.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import 'rxjs';
3 | import React from 'react';
4 | import ReactDOM from 'react-dom';
5 | import {Router, Route, IndexRoute, browserHistory} from 'react-router';
6 | import {syncHistoryWithStore} from 'react-router-redux';
7 | import {Provider} from 'react-redux';
8 |
9 | // styles
10 | import 'bootstrap/dist/css/bootstrap.min.css';
11 |
12 | // our packages
13 | import App from './app';
14 | import store from './store';
15 | import {requireAuth} from './util';
16 |
17 | // our pages
18 | import Home from './pages/home';
19 | import Create from './pages/create';
20 | import Login from './pages/login';
21 | import Register from './pages/register';
22 | import Profile from './pages/profile';
23 | import NotFound from './pages/notfound';
24 |
25 | // Create an enhanced history that syncs navigation events with the store
26 | const history = syncHistoryWithStore(browserHistory, store);
27 |
28 | // render on page
29 | ReactDOM.render((
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | ), document.getElementById('app'));
43 |
--------------------------------------------------------------------------------
/client/src/pages/create/__tests__/__snapshots__/create.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`# Create page 1`] = `
4 |
57 | `;
58 |
59 | exports[`# Create page wrapper 1`] = `
60 |
98 | `;
99 |
--------------------------------------------------------------------------------
/client/src/pages/create/__tests__/create.test.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import configureMockStore from 'redux-mock-store';
3 |
4 | // our packages
5 | import CreatePage, {Create} from '../index';
6 |
7 | // create mock store
8 | const mockStore = configureMockStore();
9 |
10 | const user = {
11 | id: '0',
12 | login: 'test',
13 | registrationDate: new Date(2016, 1, 1, 1, 1, 1, 1),
14 | };
15 |
16 | test('# Create page wrapper', () => {
17 | const store = mockStore({auth: {user}});
18 | const wrapper = shallow();
19 | expect(wrapper).toMatchSnapshot();
20 | });
21 |
22 | test('# Create page', () => {
23 | const newText = 'newText';
24 | const newExpirationDate = new Date(2016, 1, 1, 1, 1, 1, 1);
25 | const doCreateQuestion = ({text, expirationDate}) => {
26 | expect(text).toBe(newText);
27 | expect(expirationDate).toBe(newExpirationDate.toISOString());
28 | };
29 |
30 | const component = (
31 |
35 | );
36 |
37 | // test rendering
38 | const wrapper = shallow(component);
39 | expect(wrapper).toMatchSnapshot();
40 |
41 | // test user update
42 | const app = mount(component);
43 | // set new question text
44 | app.find('#questionText').getDOMNode().value = newText;
45 | // set new question expiration date
46 | app.find('#expirationDate').getDOMNode().value = newExpirationDate.toISOString();
47 | // click answer button
48 | app.find('button').simulate('click');
49 | });
50 |
--------------------------------------------------------------------------------
/client/src/pages/create/index.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import React from 'react';
3 | import {connect} from 'react-redux';
4 | import moment from 'moment';
5 |
6 | // our packages
7 | import {createQuestion} from '../../store/actions';
8 | import Navbar from '../../components/navbar';
9 |
10 | const mapStateToProps = (state) => ({
11 | user: state.auth.user,
12 | });
13 |
14 | const mapDispatchToProps = (dispatch) => ({
15 | doCreateQuestion: payload => dispatch(createQuestion(payload)),
16 | });
17 |
18 |
19 | export const Create = ({doCreateQuestion, user}) => {
20 | let questionText;
21 | let questionDate;
22 |
23 | const handleCreate = (e) => {
24 | e.preventDefault();
25 |
26 | const text = questionText.value;
27 | const expirationDate = moment(questionDate.value).toISOString();
28 |
29 | doCreateQuestion({text, expirationDate});
30 |
31 | return false;
32 | };
33 |
34 | return (
35 |
66 | );
67 | };
68 |
69 | export default connect(mapStateToProps, mapDispatchToProps)(Create);
70 |
--------------------------------------------------------------------------------
/client/src/pages/home/__tests__/__snapshots__/home.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`# Home page 1`] = `
4 |
37 | `;
38 |
39 | exports[`# Home page wrapper 1`] = `
40 |
80 | `;
81 |
--------------------------------------------------------------------------------
/client/src/pages/home/__tests__/home.test.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import configureMockStore from 'redux-mock-store';
3 |
4 | // our packages
5 | import HomePage, {Home} from '../index';
6 |
7 | // create mock store
8 | const mockStore = configureMockStore();
9 |
10 | const user = {
11 | id: '0',
12 | login: 'test',
13 | registrationDate: new Date(2016, 1, 1, 1, 1, 1, 1),
14 | };
15 |
16 | test('# Home page wrapper', () => {
17 | const store = mockStore({auth: {user}, questions: {questions: []}});
18 | const wrapper = shallow();
19 | expect(wrapper).toMatchSnapshot();
20 | });
21 |
22 | test('# Home page', () => {
23 | const questions = [];
24 | const fetchQuestions = () => {
25 | questions.push({
26 | id: 0,
27 | owner: user,
28 | text: 'Question text',
29 | answers: [{answer: 'Test answer'}],
30 | });
31 | };
32 |
33 | const component = (
34 | {}}
39 | />
40 | );
41 |
42 | // test rendering
43 | const wrapper = shallow(component);
44 | expect(wrapper).toMatchSnapshot();
45 | });
46 |
--------------------------------------------------------------------------------
/client/src/pages/home/index.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import React from 'react';
3 | import _ from 'lodash';
4 | import {connect} from 'react-redux';
5 |
6 | // our packages
7 | import {getAllQuestions, answerQuestion} from '../../store/actions';
8 | import Question from '../../components/question';
9 | import Navbar from '../../components/navbar';
10 |
11 | const mapStateToProps = (state) => ({
12 | questions: state.questions.questions,
13 | user: state.auth.user,
14 | });
15 |
16 | const mapDispatchToProps = (dispatch) => ({
17 | fetchQuestions: _.once(() => dispatch(getAllQuestions())),
18 | doAnswer: payload => dispatch(answerQuestion(payload)),
19 | });
20 |
21 |
22 | export const Home = ({fetchQuestions, doAnswer, questions, user}) => {
23 | fetchQuestions();
24 |
25 | return (
26 |
27 |
28 |
29 |
30 | {questions.map(question => (
31 |
32 | ))}
33 |
34 |
35 | );
36 | };
37 |
38 | export default connect(mapStateToProps, mapDispatchToProps)(Home);
39 |
--------------------------------------------------------------------------------
/client/src/pages/login/__tests__/__snapshots__/login.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`# Login page 1`] = `
4 |
7 |
8 | Experts portal:
9 |
10 |
11 | Please log in. Or
12 |
17 | register
18 |
19 |
20 |
72 |
73 | `;
74 |
75 | exports[`# Login page wrapper 1`] = `
76 |
108 | `;
109 |
--------------------------------------------------------------------------------
/client/src/pages/login/__tests__/login.test.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import configureMockStore from 'redux-mock-store';
3 |
4 | // our packages
5 | import LoginPage, {Login} from '../index';
6 |
7 | // create mock store
8 | const mockStore = configureMockStore();
9 |
10 | const user = {
11 | id: '0',
12 | login: 'test',
13 | registrationDate: new Date(2016, 1, 1, 1, 1, 1, 1),
14 | };
15 |
16 | test('# Login page wrapper', () => {
17 | const store = mockStore({auth: {user}});
18 | const wrapper = shallow();
19 | expect(wrapper).toMatchSnapshot();
20 | });
21 |
22 | test('# Login page', () => {
23 | const newLogin = 'test';
24 | const newPassword = '123';
25 | const token = 'asd';
26 | const navToHome = () => expect(true).toBeTruthy();
27 | const onLoginClick = ({login, password, remember}) => {
28 | expect(login).toBe(newLogin);
29 | expect(password).toBe(newPassword);
30 | expect(remember).toBeTruthy();
31 | };
32 |
33 | const component = (
34 |
39 | );
40 |
41 | // test rendering
42 | const wrapper = shallow(component);
43 | expect(wrapper).toMatchSnapshot();
44 |
45 | // mount for testing
46 | const app = mount(component);
47 | // set new login, pass and remember
48 | app.find('#inputUsername').getDOMNode().value = newLogin;
49 | app.find('#inputPassword').getDOMNode().value = newPassword;
50 | app.find('#inputRemember').getDOMNode().checked = true;
51 | // click login button
52 | app.find('button').simulate('click');
53 | });
54 |
--------------------------------------------------------------------------------
/client/src/pages/login/index.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import React from 'react';
3 | import {Link} from 'react-router';
4 | import {connect} from 'react-redux';
5 | import {push} from 'react-router-redux';
6 |
7 | // our packages
8 | import {loginAction} from '../../store/actions';
9 |
10 | const mapStateToProps = state => ({
11 | token: state.auth.token,
12 | });
13 |
14 | const mapDispatchToProps = dispatch => ({
15 | onLoginClick: params => dispatch(loginAction(params)),
16 | navToHome: () => dispatch(push('/')),
17 | });
18 |
19 | export const Login = ({onLoginClick, navToHome, token}) => {
20 | let usernameInput;
21 | let passwordInput;
22 | let rememberInput;
23 |
24 | const handleClick = (e) => {
25 | e.preventDefault();
26 |
27 | onLoginClick({
28 | login: usernameInput.value,
29 | password: passwordInput.value,
30 | remember: rememberInput.checked,
31 | });
32 | };
33 |
34 | if (token) {
35 | // TODO: figure out a better way to do nav
36 | setImmediate(() => navToHome());
37 | }
38 |
39 | return (
40 |
41 |
Experts portal:
42 |
Please log in. Or register
43 |
44 |
76 |
77 | );
78 | };
79 |
80 | export default connect(mapStateToProps, mapDispatchToProps)(Login);
81 |
--------------------------------------------------------------------------------
/client/src/pages/notfound/__tests__/__snapshots__/notfound.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`# Not found page 1`] = `
4 |
5 | Page not found
6 |
7 | `;
8 |
--------------------------------------------------------------------------------
/client/src/pages/notfound/__tests__/notfound.test.js:
--------------------------------------------------------------------------------
1 | // our packages
2 | import NotFoundPage from '../index';
3 |
4 | test('# Not found page', () => {
5 | const wrapper = shallow();
6 | expect(wrapper).toMatchSnapshot();
7 | });
8 |
--------------------------------------------------------------------------------
/client/src/pages/notfound/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default () => (
4 | Page not found
5 | );
6 |
--------------------------------------------------------------------------------
/client/src/pages/profile/__tests__/__snapshots__/profile.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`# Profile page 1`] = `
4 |
5 |
15 |
25 |
26 | `;
27 |
28 | exports[`# Profile page wrapper 1`] = `
29 |
74 | `;
75 |
--------------------------------------------------------------------------------
/client/src/pages/profile/__tests__/profile.test.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import configureMockStore from 'redux-mock-store';
3 |
4 | // our packages
5 | import ProfilePage, {Profile} from '../index';
6 |
7 | // create mock store
8 | const mockStore = configureMockStore();
9 |
10 | const user = {
11 | id: '0',
12 | login: 'test',
13 | registrationDate: new Date(2016, 1, 1, 1, 1, 1, 1),
14 | };
15 |
16 | test('# Profile page wrapper', () => {
17 | const store = mockStore({auth: {user}, users: {user}});
18 | const wrapper = shallow();
19 | expect(wrapper).toMatchSnapshot();
20 | });
21 |
22 | test('# Profile page', () => {
23 | const getUser = (u) => {
24 | expect(u).toEqual(user);
25 | };
26 |
27 | const component = (
28 |
34 | );
35 |
36 | // test rendering
37 | const wrapper = shallow(component);
38 | expect(wrapper).toMatchSnapshot();
39 | });
40 |
--------------------------------------------------------------------------------
/client/src/pages/profile/index.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import React from 'react';
3 | import {connect} from 'react-redux';
4 |
5 | // our packages
6 | import {getUser} from '../../store/actions';
7 | import Navbar from '../../components/navbar';
8 | import User from '../../components/user';
9 |
10 | const mapStateToProps = (state) => ({
11 | user: state.auth.user,
12 | loadedUser: state.users.user,
13 | });
14 |
15 | const mapDispatchToProps = (dispatch) => ({
16 | getUser: payload => dispatch(getUser(payload)),
17 | });
18 |
19 | export class Profile extends React.Component {
20 | constructor() {
21 | super();
22 |
23 | this.state = {};
24 | }
25 |
26 | componentWillMount() {
27 | this.props.getUser(this.props.params);
28 | }
29 |
30 | render() {
31 | const {user, loadedUser, params, getUser} = this.props;
32 | const allowEdit = user && loadedUser && user.id === loadedUser.id;
33 |
34 | return (
35 |
36 |
37 |
38 |
39 |
40 | );
41 | }
42 | }
43 |
44 | export default connect(mapStateToProps, mapDispatchToProps)(Profile);
45 |
--------------------------------------------------------------------------------
/client/src/pages/register/__tests__/__snapshots__/register.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`# Register page 1`] = `
4 |
7 |
8 | Experts portal:
9 |
10 |
11 | Please register. Or
12 |
17 | login
18 |
19 |
20 |
74 |
75 | `;
76 |
77 | exports[`# Register page wrapper 1`] = `
78 |
111 | `;
112 |
--------------------------------------------------------------------------------
/client/src/pages/register/__tests__/register.test.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import configureMockStore from 'redux-mock-store';
3 |
4 | // our packages
5 | import RegisterPage, {Register} from '../index';
6 |
7 | // create mock store
8 | const mockStore = configureMockStore();
9 |
10 | test('# Register page wrapper', () => {
11 | const store = mockStore({auth: {redirectToLogin() {}}});
12 | const wrapper = shallow();
13 | expect(wrapper).toMatchSnapshot();
14 | });
15 |
16 | test('# Register page', () => {
17 | const newLogin = 'login123';
18 | const newPass = 'pwd123';
19 | const navToLogin = () => expect(true).toBeTruthy();
20 | const redirectToLogin = 'true';
21 | const onRegisterClick = ({login, password, passwordRepeat}) => {
22 | expect(login).toBe(newLogin);
23 | expect(password).toBe(newPass);
24 | expect(passwordRepeat).toBe(newPass);
25 | };
26 |
27 | const component = (
28 |
33 | );
34 |
35 | // test rendering
36 | const wrapper = shallow(component);
37 | expect(wrapper).toMatchSnapshot();
38 |
39 | // mount for testing
40 | const app = mount(component);
41 | // set new login, pass and remember
42 | app.find('#inputUsername').getDOMNode().value = newLogin;
43 | app.find('#inputPassword').getDOMNode().value = newPass;
44 | app.find('#inputPasswordRepeat').getDOMNode().value = newPass;
45 | // click login button
46 | app.find('button').simulate('click');
47 | });
48 |
--------------------------------------------------------------------------------
/client/src/pages/register/index.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import React from 'react';
3 | import {Link} from 'react-router';
4 | import {connect} from 'react-redux';
5 | import {push} from 'react-router-redux';
6 |
7 | // our packages
8 | import {registerAction} from '../../store/actions';
9 |
10 | const mapStateToProps = state => ({
11 | redirectToLogin: state.auth.redirectToLogin,
12 | });
13 |
14 | const mapDispatchToProps = dispatch => ({
15 | navToLogin: () => dispatch(push('/login')),
16 | onRegisterClick: params => dispatch(registerAction(params)),
17 | });
18 |
19 | export const Register = ({onRegisterClick, navToLogin, redirectToLogin}) => {
20 | let usernameInput;
21 | let passwordInput;
22 | let passwordInputRepeat;
23 |
24 | const handleClick = (e) => {
25 | e.preventDefault();
26 |
27 | onRegisterClick({
28 | login: usernameInput.value,
29 | password: passwordInput.value,
30 | passwordRepeat: passwordInputRepeat.value,
31 | });
32 | };
33 |
34 | if (redirectToLogin) {
35 | // TODO: figure out a better way to do nav
36 | setImmediate(() => navToLogin());
37 | }
38 |
39 | return (
40 |
41 |
Experts portal:
42 |
Please register. Or login
43 |
44 |
77 |
78 | );
79 | };
80 |
81 | export default connect(mapStateToProps, mapDispatchToProps)(Register);
82 |
--------------------------------------------------------------------------------
/client/src/store/actionTypes.js:
--------------------------------------------------------------------------------
1 | // hello world actions
2 | export const HELLO_WORLD = 'HELLO_WORLD';
3 | export const HELLO_WORLD_END = 'HELLO_WORLD_END';
4 | // auth actions
5 | export const DO_LOGIN = 'DO_LOGIN';
6 | export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
7 | export const LOGIN_ERROR = 'LOGIN_ERROR';
8 | export const DO_REGISTER = 'DO_REGISTER';
9 | export const REGISTER_SUCCESS = 'REGISTER_SUCCESS';
10 | export const REGISTER_ERROR = 'REGISTER_ERROR';
11 | // notifications actions
12 | export const ADD_NOTIFICATION = 'ADD_NOTIFICATION';
13 | export const REMOVE_NOTIFICATION = 'REMOVE_NOTIFICATION';
14 | // questions actions
15 | export const GET_ALL_QUESTIONS = 'GET_ALL_QUESTIONS';
16 | export const GET_ALL_QUESTIONS_SUCCESS = 'GET_ALL_QUESTIONS_SUCCESS';
17 | export const GET_ALL_QUESTIONS_ERROR = 'GET_ALL_QUESTIONS_ERROR';
18 | export const ANSWER_QUESTION = 'ANSWER_QUESTION';
19 | export const ANSWER_QUESTION_SUCCESS = 'ANSWER_QUESTION_SUCCESS';
20 | export const ANSWER_QUESTION_ERROR = 'ANSWER_QUESTION_ERROR';
21 | export const CREATE_QUESTION = 'CREATE_QUESTION';
22 | export const CREATE_QUESTION_SUCCESS = 'CREATE_QUESTION_SUCCESS';
23 | export const CREATE_QUESTION_ERROR = 'CREATE_QUESTION_ERROR';
24 | export const DELETE_QUESTION = 'DELETE_QUESTION';
25 | export const DELETE_QUESTION_SUCCESS = 'DELETE_QUESTION_SUCCESS';
26 | export const DELETE_QUESTION_ERROR = 'DELETE_QUESTION_ERROR';
27 | export const UPDATE_QUESTION = 'UPDATE_QUESTION';
28 | export const UPDATE_QUESTION_SUCCESS = 'UPDATE_QUESTION_SUCCESS';
29 | export const UPDATE_QUESTION_ERROR = 'UPDATE_QUESTION_ERROR';
30 | // user actions
31 | export const GET_USER = 'GET_USER';
32 | export const GET_USER_SUCCESS = 'GET_USER_SUCCESS';
33 | export const GET_USER_ERROR = 'GET_USER_ERROR';
34 | export const UPDATE_USER = 'UPDATE_USER';
35 | export const UPDATE_USER_SUCCESS = 'UPDATE_USER_SUCCESS';
36 | export const UPDATE_USER_ERROR = 'UPDATE_USER_ERROR';
37 |
--------------------------------------------------------------------------------
/client/src/store/actions/index.js:
--------------------------------------------------------------------------------
1 | import * as ActionTypes from '../actionTypes';
2 |
3 | let nextNotificationId = 0;
4 |
5 | export const helloWorldAction = () => ({
6 | type: ActionTypes.HELLO_WORLD,
7 | });
8 |
9 | export const loginAction = payload => ({
10 | type: ActionTypes.DO_LOGIN,
11 | payload,
12 | });
13 |
14 | export const registerAction = payload => ({
15 | type: ActionTypes.DO_REGISTER,
16 | payload,
17 | });
18 |
19 | /**
20 | * Add a notification to the store.
21 | * @param {String} text - text to display
22 | * @param {String} alertType - Bootstrap alert style: success | info | warning | danger
23 | */
24 | export const addNotificationAction = ({text, alertType}) => ({
25 | type: ActionTypes.ADD_NOTIFICATION,
26 | payload: {
27 | id: nextNotificationId++,
28 | text,
29 | alertType,
30 | },
31 | });
32 |
33 | /**
34 | * Remove a notification from the store.
35 | * @param {String} notificationId
36 | */
37 |
38 | export const removeNotificationAction = notificationId => ({
39 | type: ActionTypes.REMOVE_NOTIFICATION,
40 | payload: {notificationId},
41 | });
42 |
43 | export const getAllQuestions = () => ({
44 | type: ActionTypes.GET_ALL_QUESTIONS,
45 | });
46 |
47 | export const answerQuestion = payload => ({
48 | type: ActionTypes.ANSWER_QUESTION,
49 | payload,
50 | });
51 |
52 | export const createQuestion = payload => ({
53 | type: ActionTypes.CREATE_QUESTION,
54 | payload,
55 | });
56 |
57 | export const deleteQuestion = payload => ({
58 | type: ActionTypes.DELETE_QUESTION,
59 | payload,
60 | });
61 |
62 | export const updateQuestion = payload => ({
63 | type: ActionTypes.UPDATE_QUESTION,
64 | payload,
65 | });
66 |
67 | // users
68 |
69 | export const getUser = (payload) => ({
70 | type: ActionTypes.GET_USER,
71 | payload,
72 | });
73 |
74 | export const updateUser = (payload) => ({
75 | type: ActionTypes.UPDATE_USER,
76 | payload,
77 | });
78 |
--------------------------------------------------------------------------------
/client/src/store/epics/__tests__/auth.test.js:
--------------------------------------------------------------------------------
1 | import Rx from 'rxjs';
2 | import {ActionsObservable} from 'redux-observable';
3 | import * as ActionTypes from '../../actionTypes';
4 | import {login, register} from '../auth';
5 |
6 | let oldPost;
7 |
8 | beforeEach(() => {
9 | oldPost = Rx.Observable.ajax.post;
10 | });
11 | afterEach(() => {
12 | Rx.Observable.ajax.post = oldPost;
13 | });
14 |
15 | test('# login epic - success', () => {
16 | const payload = {test: true};
17 | const response = {data: true};
18 | const input = {type: ActionTypes.DO_LOGIN, payload};
19 | const input$ = ActionsObservable.from([input]);
20 | const post = jest.fn().mockReturnValueOnce(Rx.Observable.from([{response}]));
21 | Rx.Observable.ajax.post = post;
22 |
23 | let responseCount = 0;
24 | login(input$)
25 | .subscribe((res) => {
26 | if (responseCount === 0) {
27 | expect(post.mock.calls.length).toBe(1);
28 | expect(post.mock.calls[0][0]).toBe('http://localhost:8080/api/login');
29 | expect(post.mock.calls[0][1]).toEqual(payload);
30 | expect(res).toEqual({type: 'LOGIN_SUCCESS', payload: response});
31 | responseCount += 1;
32 | } else {
33 | expect(res).toEqual({type: 'ADD_NOTIFICATION', payload: {id: 0, text: 'Login success', alertType: 'info'}});
34 | }
35 | });
36 | });
37 |
38 | test('# login epic - error', () => {
39 | const input = {type: ActionTypes.DO_LOGIN, payload: {}};
40 | const input$ = ActionsObservable.from([input]);
41 |
42 | let responseCount = 0;
43 | login(input$)
44 | .subscribe((res) => {
45 | if (responseCount === 0) {
46 | expect(res.type).toBe('LOGIN_ERROR');
47 | expect(res.payload.error.AjaxError.message).toBe('ajax error');
48 | responseCount += 1;
49 | } else {
50 | // TODO: figure out why notification is not dispatched
51 | expect(res).toEqual({type: 'ADD_NOTIFICATION', payload: {id: 1, text: 'ajax error', alertType: 'danger'}});
52 | }
53 | });
54 | });
55 |
56 | test('# register epic - success', () => {
57 | const payload = {test: true};
58 | const response = {data: true};
59 | const input = {type: ActionTypes.DO_REGISTER, payload};
60 | const input$ = ActionsObservable.from([input]);
61 | const post = jest.fn().mockReturnValueOnce(Rx.Observable.from([{response}]));
62 | Rx.Observable.ajax.post = post;
63 |
64 | let responseCount = 0;
65 | register(input$)
66 | .subscribe((res) => {
67 | if (responseCount === 0) {
68 | expect(post.mock.calls.length).toBe(1);
69 | expect(post.mock.calls[0][0]).toBe('http://localhost:8080/api/register');
70 | expect(post.mock.calls[0][1]).toEqual(payload);
71 | expect(res).toEqual({type: 'REGISTER_SUCCESS', payload: response});
72 | responseCount += 1;
73 | } else {
74 | expect(res).toEqual({type: 'ADD_NOTIFICATION', payload: {id: 1, text: 'Register success', alertType: 'info'}});
75 | }
76 | });
77 | });
78 |
79 | test('# register epic - error', () => {
80 | const input = {type: ActionTypes.DO_REGISTER, payload: {}};
81 | const input$ = ActionsObservable.from([input]);
82 |
83 | let responseCount = 0;
84 | login(input$)
85 | .subscribe((res) => {
86 | if (responseCount === 0) {
87 | expect(res.type).toBe('REGISTER_ERROR');
88 | expect(res.payload.error.AjaxError.message).toBe('ajax error');
89 | responseCount += 1;
90 | } else {
91 | expect(res).toEqual({type: 'ADD_NOTIFICATION', payload: {id: 2, text: 'ajax error', alertType: 'danger'}});
92 | }
93 | });
94 | });
95 |
--------------------------------------------------------------------------------
/client/src/store/epics/__tests__/notifications.test.js:
--------------------------------------------------------------------------------
1 | import Rx from 'rxjs';
2 | import {ActionsObservable} from 'redux-observable';
3 | import * as ActionTypes from '../../actionTypes';
4 | import {addNotification} from '../notifications';
5 |
6 | // increase test timeout to 6s
7 | jasmine.DEFAULT_TIMEOUT_INTERVAL = 6000;
8 |
9 | test('# notifications epic', (done) => {
10 | const payload = {id: 0};
11 | const input = {type: ActionTypes.ADD_NOTIFICATION, payload};
12 | const input$ = ActionsObservable.from([input]);
13 |
14 | addNotification(input$)
15 | .subscribe((res) => {
16 | expect(res).toEqual({type: 'REMOVE_NOTIFICATION', payload: {notificationId: 0}});
17 | done();
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/client/src/store/epics/__tests__/questions.test.js:
--------------------------------------------------------------------------------
1 | import Rx from 'rxjs';
2 | import {ActionsObservable} from 'redux-observable';
3 | import * as ActionTypes from '../../actionTypes';
4 | import {getAllQuestions, answerQuestion, createQuestion, deleteQuestion, updateQuestion} from '../questions';
5 |
6 | let oldPost;
7 | let oldGet;
8 | let oldDelete;
9 | beforeEach(() => {
10 | oldPost = Rx.Observable.ajax.post;
11 | oldGet = Rx.Observable.ajax.get;
12 | oldDelete = Rx.Observable.ajax.delete;
13 | });
14 | afterEach(() => {
15 | Rx.Observable.ajax.post = oldPost;
16 | Rx.Observable.ajax.get = oldGet;
17 | Rx.Observable.ajax.delete = oldDelete;
18 | });
19 |
20 | test('# questions epic - getAllQuestions success', () => {
21 | const payload = {id: 0};
22 | const headers = {'x-access-token': undefined};
23 | const response = {user: 'test'};
24 | const input = {type: ActionTypes.GET_ALL_QUESTIONS, payload};
25 | const input$ = ActionsObservable.from([input]);
26 | const get = jest.fn().mockReturnValueOnce(Rx.Observable.from([{response}]));
27 | Rx.Observable.ajax.get = get;
28 |
29 | getAllQuestions(input$)
30 | .subscribe((res) => {
31 | expect(get.mock.calls.length).toBe(1);
32 | expect(get.mock.calls[0][0]).toBe('http://localhost:8080/api/question');
33 | expect(get.mock.calls[0][1]).toEqual(headers);
34 | expect(res).toEqual({type: ActionTypes.GET_ALL_QUESTIONS_SUCCESS, payload: {questions: response}});
35 | });
36 | });
37 |
38 | test('# questions epic - getAllQuestions error', () => {
39 | const input = {type: ActionTypes.GET_ALL_QUESTIONS, payload: {}};
40 | const input$ = ActionsObservable.from([input]);
41 |
42 | getAllQuestions(input$)
43 | .subscribe((res) => {
44 | expect(res.type).toBe(ActionTypes.GET_ALL_QUESTIONS_ERROR);
45 | expect(res.payload.error.AjaxError.message).toBe('ajax error');
46 | });
47 | });
48 |
49 | test('# questions epic - answerQuestion success', () => {
50 | const payload = {question: {id: 0}, answer: 'test'};
51 | const headers = {'x-access-token': undefined};
52 | const response = {user: 'test'};
53 | const input = {type: ActionTypes.ANSWER_QUESTION, payload};
54 | const input$ = ActionsObservable.from([input]);
55 | const post = jest.fn().mockReturnValueOnce(Rx.Observable.from([{response}]));
56 | Rx.Observable.ajax.post = post;
57 |
58 | answerQuestion(input$)
59 | .subscribe((res) => {
60 | expect(post.mock.calls.length).toBe(1);
61 | expect(post.mock.calls[0][0]).toBe('http://localhost:8080/api/question/0/answer');
62 | expect(post.mock.calls[0][1]).toEqual({answer: payload.answer});
63 | expect(post.mock.calls[0][2]).toEqual(headers);
64 | expect(res).toEqual({type: ActionTypes.ANSWER_QUESTION_SUCCESS, payload: response});
65 | });
66 | });
67 |
68 | test('# questions epic - answerQuestion error', () => {
69 | const input = {type: ActionTypes.ANSWER_QUESTION, payload: {question: {id: 0}}};
70 | const input$ = ActionsObservable.from([input]);
71 |
72 | answerQuestion(input$)
73 | .subscribe((res) => {
74 | expect(res.type).toBe(ActionTypes.ANSWER_QUESTION_ERROR);
75 | expect(res.payload.error.AjaxError.message).toBe('ajax error');
76 | });
77 | });
78 |
79 | test('# questions epic - createQuestion success', () => {
80 | const payload = {question: {id: 0}, answer: 'test'};
81 | const headers = {'x-access-token': undefined};
82 | const response = {user: 'test'};
83 | const input = {type: ActionTypes.ANSWER_QUESTION, payload};
84 | const input$ = ActionsObservable.from([input]);
85 | const post = jest.fn().mockReturnValueOnce(Rx.Observable.from([{response}]));
86 | Rx.Observable.ajax.post = post;
87 |
88 | createQuestion(input$)
89 | .subscribe((res) => {
90 | expect(post.mock.calls.length).toBe(1);
91 | expect(post.mock.calls[0][0]).toBe('http://localhost:8080/api/question');
92 | expect(post.mock.calls[0][1]).toEqual(payload);
93 | expect(post.mock.calls[0][2]).toEqual(headers);
94 | expect(res).toEqual({type: ActionTypes.CREATE_QUESTION_SUCCESS, payload: response});
95 | });
96 | });
97 |
98 | test('# questions epic - createQuestion error', () => {
99 | const input = {type: ActionTypes.CREATE_QUESTION, payload: {}};
100 | const input$ = ActionsObservable.from([input]);
101 |
102 | createQuestion(input$)
103 | .subscribe((res) => {
104 | expect(res.type).toBe(ActionTypes.CREATE_QUESTION_ERROR);
105 | expect(res.payload.error.AjaxError.message).toBe('ajax error');
106 | });
107 | });
108 |
109 | test('# questions epic - deleteQuestion success', () => {
110 | const payload = {id: 0};
111 | const headers = {'x-access-token': undefined};
112 | const input = {type: ActionTypes.DELETE_QUESTION, payload};
113 | const input$ = ActionsObservable.from([input]);
114 | const del = jest.fn().mockReturnValueOnce(Rx.Observable.from([{}]));
115 | Rx.Observable.ajax.delete = del;
116 |
117 | deleteQuestion(input$)
118 | .subscribe((res) => {
119 | expect(del.mock.calls.length).toBe(1);
120 | expect(del.mock.calls[0][0]).toBe('http://localhost:8080/api/question/0');
121 | expect(del.mock.calls[0][1]).toEqual(headers);
122 | expect(res).toEqual({type: ActionTypes.DELETE_QUESTION_SUCCESS, payload});
123 | });
124 | });
125 |
126 | test('# questions epic - deleteQuestion error', () => {
127 | const input = {type: ActionTypes.DELETE_QUESTION, payload: {id: 0}};
128 | const input$ = ActionsObservable.from([input]);
129 |
130 | deleteQuestion(input$)
131 | .subscribe((res) => {
132 | expect(res.type).toBe(ActionTypes.DELETE_QUESTION_ERROR);
133 | expect(res.payload.error.AjaxError.message).toBe('ajax error');
134 | });
135 | });
136 |
137 |
138 |
139 |
140 | test('# questions epic - updateQuestion success', () => {
141 | const payload = {id: 0};
142 | const headers = {'x-access-token': undefined};
143 | const response = {data: true};
144 | const input = {type: ActionTypes.UPDATE_QUESTION, payload};
145 | const input$ = ActionsObservable.from([input]);
146 | const post = jest.fn().mockReturnValueOnce(Rx.Observable.from([{response}]));
147 | Rx.Observable.ajax.post = post;
148 |
149 | updateQuestion(input$)
150 | .subscribe((res) => {
151 | expect(post.mock.calls.length).toBe(1);
152 | expect(post.mock.calls[0][0]).toBe('http://localhost:8080/api/question/0');
153 | expect(post.mock.calls[0][1]).toEqual(payload);
154 | expect(post.mock.calls[0][2]).toEqual(headers);
155 | expect(res).toEqual({type: ActionTypes.UPDATE_QUESTION_SUCCESS, payload: response});
156 | });
157 | });
158 |
159 | test('# questions epic - updateQuestion error', () => {
160 | const input = {type: ActionTypes.UPDATE_QUESTION, payload: {id: 0}};
161 | const input$ = ActionsObservable.from([input]);
162 |
163 | updateQuestion(input$)
164 | .subscribe((res) => {
165 | expect(res.type).toBe(ActionTypes.UPDATE_QUESTION_ERROR);
166 | expect(res.payload.error.AjaxError.message).toBe('ajax error');
167 | });
168 | });
169 |
170 |
--------------------------------------------------------------------------------
/client/src/store/epics/__tests__/users.test.js:
--------------------------------------------------------------------------------
1 | import Rx from 'rxjs';
2 | import {ActionsObservable} from 'redux-observable';
3 | import * as ActionTypes from '../../actionTypes';
4 | import {getUser, updateUser} from '../users';
5 |
6 | let oldPost;
7 | let oldGet;
8 |
9 | beforeEach(() => {
10 | oldPost = Rx.Observable.ajax.post;
11 | oldGet = Rx.Observable.ajax.get;
12 | });
13 | afterEach(() => {
14 | Rx.Observable.ajax.post = oldPost;
15 | Rx.Observable.ajax.get = oldGet;
16 | });
17 |
18 | test('# users epic - getUser success', () => {
19 | const payload = {id: 0};
20 | const headers = {'x-access-token': undefined};
21 | const response = {user: 'test'};
22 | const input = {type: ActionTypes.GET_USER, payload};
23 | const input$ = ActionsObservable.from([input]);
24 | const get = jest.fn().mockReturnValueOnce(Rx.Observable.from([{response}]));
25 | Rx.Observable.ajax.get = get;
26 |
27 | getUser(input$)
28 | .subscribe((res) => {
29 | expect(get.mock.calls.length).toBe(1);
30 | expect(get.mock.calls[0][0]).toBe('http://localhost:8080/api/user/0');
31 | expect(get.mock.calls[0][1]).toEqual(headers);
32 | expect(res).toEqual({type: ActionTypes.GET_USER_SUCCESS, payload: {user: response}});
33 | });
34 | });
35 |
36 | test('# users epic - getUser error', () => {
37 | const input = {type: ActionTypes.GET_USER, payload: {}};
38 | const input$ = ActionsObservable.from([input]);
39 |
40 | getUser(input$)
41 | .subscribe((res) => {
42 | expect(res.type).toBe(ActionTypes.GET_USER_ERROR);
43 | expect(res.payload.error.AjaxError.message).toBe('ajax error');
44 | });
45 | });
46 |
47 | test('# users epic - updateUser success', () => {
48 | const payload = {id: 0, test: '123'};
49 | const headers = {'x-access-token': undefined};
50 | const response = {user: 'test'};
51 | const input = {type: ActionTypes.UPDATE_USER, payload};
52 | const input$ = ActionsObservable.from([input]);
53 | const get = jest.fn().mockReturnValueOnce(Rx.Observable.from([{response}]));
54 | Rx.Observable.ajax.get = get;
55 |
56 | getUser(input$)
57 | .subscribe((res) => {
58 | expect(get.mock.calls.length).toBe(1);
59 | expect(get.mock.calls[0][0]).toBe('http://localhost:8080/api/user/0');
60 | expect(get.mock.calls[0][1]).toEqual(payload);
61 | expect(get.mock.calls[0][2]).toEqual(headers);
62 | expect(res).toEqual({type: ActionTypes.UPDATE_USER_SUCCESS, payload: {user: response}});
63 | });
64 | });
65 |
66 | test('# users epic - getUser error', () => {
67 | const input = {type: ActionTypes.UPDATE_USER, payload: {}};
68 | const input$ = ActionsObservable.from([input]);
69 |
70 | getUser(input$)
71 | .subscribe((res) => {
72 | expect(res.type).toBe(ActionTypes.UPDATE_USER_ERROR);
73 | expect(res.payload.error.AjaxError.message).toBe('ajax error');
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/client/src/store/epics/auth.js:
--------------------------------------------------------------------------------
1 | import {Observable} from 'rxjs/Observable';
2 | import * as ActionTypes from '../actionTypes';
3 | import * as Actions from '../actions';
4 | import {loginErrorToMessage, registerErrorToMessage} from '../../util/errorToMessage';
5 |
6 |
7 | // ASCII diagram for Rx Streams (see: https://gist.github.com/staltz/868e7e9bc2a7b8c1f754)
8 |
9 | // Success login:
10 | // --(DO_LOGIN|)
11 | // switchMap(credentials => ajax)
12 | // -------------(token|)
13 | // mergeMap
14 | // -------------(LOGIN_SUCCESS with token|)
15 | // -------------(ADD_NOTIFICATION with login success|)
16 |
17 | // Failed login:
18 | // --(DO_LOGIN|)
19 | // switchMap(credentials => ajax)
20 | // -------------(X|)
21 | // catch
22 | // -------------(LOGIN_ERROR, ADD_NOTIFICATION with login error|)
23 | export const login = action$ => action$
24 | .ofType(ActionTypes.DO_LOGIN)
25 | .switchMap(({payload}) => Observable
26 | .ajax.post(`${API_HOST}/api/login`, payload)
27 | .map(res => res.response)
28 | .mergeMap(response => Observable.of(
29 | {
30 | type: ActionTypes.LOGIN_SUCCESS,
31 | payload: response,
32 | },
33 | Actions.addNotificationAction(
34 | {text: 'Login success', alertType: 'info'}),
35 | ))
36 | .catch(error => Observable.of(
37 | {
38 | type: ActionTypes.LOGIN_ERROR,
39 | payload: {
40 | error,
41 | },
42 | },
43 | Actions.addNotificationAction({text: loginErrorToMessage(error), alertType: 'danger'}),
44 | )),
45 | );
46 |
47 | // Similar to login
48 | export const register = action$ => action$
49 | .ofType(ActionTypes.DO_REGISTER)
50 | .switchMap(({payload}) => Observable
51 | .ajax.post(`${API_HOST}/api/register`, payload)
52 | .map(res => res.response)
53 | .mergeMap(response => Observable.of(
54 | {
55 | type: ActionTypes.REGISTER_SUCCESS,
56 | payload: response,
57 | },
58 | Actions.addNotificationAction(
59 | {text: 'Register success', alertType: 'info'},
60 | ),
61 | ))
62 | .catch(error => Observable.of(
63 | {
64 | type: ActionTypes.REGISTER_ERROR,
65 | payload: {
66 | error,
67 | },
68 | },
69 | Actions.addNotificationAction({text: registerErrorToMessage(error), alertType: 'danger'}),
70 | )),
71 | );
72 |
--------------------------------------------------------------------------------
/client/src/store/epics/helloworld.js:
--------------------------------------------------------------------------------
1 | import {Observable} from 'rxjs/Observable';
2 | import * as ActionTypes from '../actionTypes';
3 |
4 | export const helloWorld = action$ => action$
5 | .ofType(ActionTypes.HELLO_WORLD)
6 | .switchMap(() => Observable
7 | .timer(500)
8 | .map(() => ({
9 | type: ActionTypes.HELLO_WORLD_END,
10 | payload: {
11 | world: 'World',
12 | },
13 | }))
14 | );
15 |
--------------------------------------------------------------------------------
/client/src/store/epics/index.js:
--------------------------------------------------------------------------------
1 | import {login, register} from './auth';
2 | import {addNotification} from './notifications';
3 | import {helloWorld} from './helloworld';
4 | import {getAllQuestions, answerQuestion, createQuestion, deleteQuestion, updateQuestion} from './questions';
5 | import {getUser, updateUser} from './users';
6 |
7 | export default [
8 | // auth
9 | login,
10 | register,
11 | addNotification,
12 | // hello world
13 | helloWorld,
14 | // questions
15 | getAllQuestions,
16 | answerQuestion,
17 | createQuestion,
18 | deleteQuestion,
19 | updateQuestion,
20 | // users
21 | getUser,
22 | updateUser,
23 | ];
24 |
--------------------------------------------------------------------------------
/client/src/store/epics/notifications.js:
--------------------------------------------------------------------------------
1 | import {Observable} from 'rxjs/Observable';
2 | import * as ActionTypes from '../actionTypes';
3 | import * as Actions from '../actions';
4 |
5 | export const addNotification = action$ => action$
6 | .ofType(ActionTypes.ADD_NOTIFICATION)
7 | .mergeMap(({payload: notification}) =>
8 | Observable.of(Actions.removeNotificationAction(notification.id))
9 | .delay(5000)
10 | .takeUntil(
11 | action$.ofType(ActionTypes.REMOVE_NOTIFICATION)
12 | .filter(({payload: {notificationId}}) => notification.id === notificationId),
13 | ),
14 | );
15 |
--------------------------------------------------------------------------------
/client/src/store/epics/questions.js:
--------------------------------------------------------------------------------
1 | import {Observable} from 'rxjs/Observable';
2 | import * as ActionTypes from '../actionTypes';
3 | import {signRequest} from '../../util/signRequest';
4 |
5 | export const getAllQuestions = action$ => action$
6 | .ofType(ActionTypes.GET_ALL_QUESTIONS)
7 | .map(signRequest)
8 | .switchMap(({headers}) => Observable
9 | .ajax.get(`${API_HOST}/api/question`, headers)
10 | .map(res => res.response)
11 | .map(questions => ({
12 | type: ActionTypes.GET_ALL_QUESTIONS_SUCCESS,
13 | payload: {questions},
14 | }))
15 | .catch(error => Observable.of({
16 | type: ActionTypes.GET_ALL_QUESTIONS_ERROR,
17 | payload: {error},
18 | })),
19 | );
20 |
21 | export const answerQuestion = action$ => action$
22 | .ofType(ActionTypes.ANSWER_QUESTION)
23 | .map(signRequest)
24 | .switchMap(({headers, payload}) => Observable
25 | .ajax.post(`${API_HOST}/api/question/${payload.question.id}/answer`, {answer: payload.answer}, headers)
26 | .map(res => res.response)
27 | .map(question => ({
28 | type: ActionTypes.ANSWER_QUESTION_SUCCESS,
29 | payload: question,
30 | }))
31 | .catch(error => Observable.of({
32 | type: ActionTypes.ANSWER_QUESTION_ERROR,
33 | payload: {error},
34 | })),
35 | );
36 |
37 | export const createQuestion = action$ => action$
38 | .ofType(ActionTypes.CREATE_QUESTION)
39 | .map(signRequest)
40 | .switchMap(({headers, payload}) => Observable
41 | .ajax.post(`${API_HOST}/api/question`, payload, headers)
42 | .map(res => res.response)
43 | .map(question => ({
44 | type: ActionTypes.CREATE_QUESTION_SUCCESS,
45 | payload: question,
46 | }))
47 | .catch(error => Observable.of({
48 | type: ActionTypes.CREATE_QUESTION_ERROR,
49 | payload: {error},
50 | })),
51 | );
52 |
53 | export const deleteQuestion = action$ => action$
54 | .ofType(ActionTypes.DELETE_QUESTION)
55 | .map(signRequest)
56 | .switchMap(({headers, payload}) => Observable
57 | .ajax.delete(`${API_HOST}/api/question/${payload.id}`, headers)
58 | .map(res => res.response)
59 | .map(() => ({
60 | type: ActionTypes.DELETE_QUESTION_SUCCESS,
61 | payload,
62 | }))
63 | .catch(error => Observable.of({
64 | type: ActionTypes.DELETE_QUESTION_ERROR,
65 | payload: {error},
66 | })),
67 | );
68 |
69 | export const updateQuestion = action$ => action$
70 | .ofType(ActionTypes.UPDATE_QUESTION)
71 | .map(signRequest)
72 | .switchMap(({headers, payload}) => Observable
73 | .ajax.post(`${API_HOST}/api/question/${payload.id}`, payload, headers)
74 | .map(res => res.response)
75 | .map(question => ({
76 | type: ActionTypes.UPDATE_QUESTION_SUCCESS,
77 | payload: question,
78 | }))
79 | .catch(error => Observable.of({
80 | type: ActionTypes.UPDATE_QUESTION_ERROR,
81 | payload: {error},
82 | })),
83 | );
84 |
--------------------------------------------------------------------------------
/client/src/store/epics/users.js:
--------------------------------------------------------------------------------
1 | import {Observable} from 'rxjs/Observable';
2 | import * as ActionTypes from '../actionTypes';
3 | import {signRequest} from '../../util/signRequest';
4 |
5 | export const getUser = action$ => action$
6 | .ofType(ActionTypes.GET_USER)
7 | .map(signRequest)
8 | .switchMap(({payload, headers}) => Observable
9 | .ajax.get(`${API_HOST}/api/user/${payload.id}`, headers)
10 | .map(res => res.response)
11 | .map(user => ({
12 | type: ActionTypes.GET_USER_SUCCESS,
13 | payload: {user},
14 | }))
15 | .catch(error => Observable.of({
16 | type: ActionTypes.GET_USER_ERROR,
17 | payload: {error},
18 | })),
19 | );
20 |
21 | export const updateUser = action$ => action$
22 | .ofType(ActionTypes.UPDATE_USER)
23 | .map(signRequest)
24 | .switchMap(({payload, headers}) => Observable
25 | .ajax.post(`${API_HOST}/api/user/${payload.id}`, payload, headers)
26 | .map(res => res.response)
27 | .map(user => ({
28 | type: ActionTypes.UPDATE_USER_SUCCESS,
29 | payload: {user},
30 | }))
31 | .catch(error => Observable.of({
32 | type: ActionTypes.UPDATE_USER_ERROR,
33 | payload: {error},
34 | })),
35 | );
36 |
--------------------------------------------------------------------------------
/client/src/store/index.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import {createStore, applyMiddleware, compose} from 'redux';
3 | import {createEpicMiddleware} from 'redux-observable';
4 | import {browserHistory} from 'react-router';
5 | import {routerMiddleware} from 'react-router-redux';
6 |
7 | // our packages
8 | import rootReducer from './rootReducer';
9 | import rootEpic from './rootEpic';
10 |
11 | // instantiate epic middleware
12 | const epicMiddleware = createEpicMiddleware(rootEpic);
13 |
14 | // pick debug or dummy enhancer
15 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
16 | const preparedRouterMiddleware = routerMiddleware(browserHistory);
17 | const middlewares = composeEnhancers(
18 | applyMiddleware(epicMiddleware),
19 | applyMiddleware(preparedRouterMiddleware),
20 | );
21 |
22 | // create store
23 | const store = createStore(rootReducer, middlewares);
24 |
25 | export default store;
26 |
--------------------------------------------------------------------------------
/client/src/store/reducers/__tests__/auth.test.js:
--------------------------------------------------------------------------------
1 | /* global test, expect */
2 | import {auth} from '../auth';
3 | import * as ActionTypes from '../../actionTypes';
4 |
5 | const user = {
6 | id: '0',
7 | login: 'test',
8 | registrationDate: new Date(2016, 1, 1, 1, 1, 1, 1),
9 | };
10 |
11 | test('# auth reducer - register success', () => {
12 | const testState = {
13 | token: '123',
14 | user,
15 | };
16 | const action = {type: ActionTypes.REGISTER_SUCCESS};
17 |
18 | expect(auth(testState, action).redirectToLogin).toBeTruthy();
19 | });
20 |
21 | test('# auth reducer - login success', () => {
22 | const testState = {
23 | token: '123',
24 | user,
25 | };
26 | const action = {
27 | type: ActionTypes.LOGIN_SUCCESS,
28 | payload: {token: '123', user, test: true},
29 | };
30 |
31 | // TODO: check localStorage
32 | expect(auth(testState, action).test).toBeTruthy();
33 | expect(auth(testState, action).user).toEqual(user);
34 | expect(auth(testState, action).token).toBe('123');
35 | });
36 |
37 | test('# auth reducer - errors', () => {
38 | const testState = {
39 | token: '123',
40 | user,
41 | };
42 | expect(auth(testState, {type: ActionTypes.LOGIN_ERROR})).toEqual(testState);
43 | expect(auth(testState, {type: ActionTypes.REGISTER_ERROR})).toEqual(testState);
44 | expect(auth(testState, {type: '-1'})).toEqual(testState);
45 | });
46 |
--------------------------------------------------------------------------------
/client/src/store/reducers/__tests__/notifications.test.js:
--------------------------------------------------------------------------------
1 | /* global test, expect */
2 | import {notifications} from '../notifications';
3 | import * as ActionTypes from '../../actionTypes';
4 |
5 | test('# notifications reducer - add notification', () => {
6 | const testState = [];
7 | const action = {
8 | type: ActionTypes.ADD_NOTIFICATION,
9 | payload: 'test',
10 | };
11 |
12 | expect(notifications(testState, action).length).toBe(1);
13 | expect(notifications(testState, action)[0]).toBe('test');
14 | });
15 |
16 | test('# notifications reducer - remove notification', () => {
17 | const testState = ['test'];
18 | const action = {
19 | type: ActionTypes.REMOVE_NOTIFICATION,
20 | payload: 'test',
21 | };
22 |
23 | expect(notifications(testState, action).length).toBe(0);
24 | });
25 |
26 | test('# notifications reducer - errors', () => {
27 | const testState = [];
28 | expect(notifications(testState, {type: '-1'})).toEqual(testState);
29 | });
30 |
--------------------------------------------------------------------------------
/client/src/store/reducers/__tests__/questions.test.js:
--------------------------------------------------------------------------------
1 | /* global test, expect */
2 | import {questions} from '../questions';
3 | import * as ActionTypes from '../../actionTypes';
4 |
5 | test('# questions reducer - get all question', () => {
6 | const testState = {};
7 | const action = {type: ActionTypes.GET_ALL_QUESTIONS};
8 | const res = questions(testState, action);
9 | expect(res.questions).toEqual([]);
10 | expect(res.status).toBe('loading...');
11 | });
12 |
13 | test('# questions reducer - get all questions success', () => {
14 | const testState = {};
15 | const action = {
16 | type: ActionTypes.GET_ALL_QUESTIONS_SUCCESS,
17 | payload: {questions: 'test'},
18 | };
19 | const res = questions(testState, action);
20 | expect(res.questions).toBe('test');
21 | expect(res.status).toBe('done');
22 | });
23 |
24 | test('# questions reducer - answer question success', () => {
25 | const q = {id: 1, data: 'old'};
26 | const testState = {questions: [q]};
27 | const action = {
28 | type: ActionTypes.ANSWER_QUESTION_SUCCESS,
29 | payload: {id: q.id, data: 'update'},
30 | };
31 | const res = questions(testState, action);
32 | expect(res.questions[0].data).toBe('update');
33 | });
34 |
35 | test('# questions reducer - create question success', () => {
36 | const q = {id: 1, data: 'test'};
37 | const testState = {questions: []};
38 | const action = {
39 | type: ActionTypes.CREATE_QUESTION_SUCCESS,
40 | payload: q,
41 | };
42 | const res = questions(testState, action);
43 | expect(res.questions.length).toBe(1);
44 | expect(res.questions[0]).toEqual(q);
45 | });
46 |
47 | test('# questions reducer - delete question success', () => {
48 | const q = {id: 1, data: 'test'};
49 | const testState = {questions: [q]};
50 | const action = {
51 | type: ActionTypes.DELETE_QUESTION_SUCCESS,
52 | payload: q,
53 | };
54 | const res = questions(testState, action);
55 | expect(res.questions.length).toBe(0);
56 | });
57 |
58 | test('# questions reducer - update question success', () => {
59 | const q = {id: 1, data: 'test'};
60 | const testState = {questions: [q]};
61 | const action = {
62 | type: ActionTypes.UPDATE_QUESTION_SUCCESS,
63 | payload: {id: q.id, update: true},
64 | };
65 | const res = questions(testState, action);
66 | expect(res.questions.length).toBe(1);
67 | expect(res.questions[0].update).toBeTruthy();
68 | });
69 |
70 | test('# questions reducer - errors', () => {
71 | const testState = {};
72 |
73 | [
74 | ActionTypes.ANSWER_QUESTION_ERROR,
75 | ActionTypes.CREATE_QUESTION_ERROR,
76 | ActionTypes.GET_ALL_QUESTIONS_ERROR,
77 | ActionTypes.DELETE_QUESTION_ERROR,
78 | ActionTypes.UPDATE_QUESTION_ERROR,
79 | ].forEach((type) => {
80 | const res = questions(testState, {type, payload: {error: 'error'}});
81 | expect(res.status).toBe('error');
82 | expect(res.error).toBe('error');
83 | });
84 |
85 | expect(questions(testState, {type: '-1'})).toEqual(testState);
86 | });
87 |
--------------------------------------------------------------------------------
/client/src/store/reducers/__tests__/users.test.js:
--------------------------------------------------------------------------------
1 | /* global test, expect */
2 | import {users} from '../users';
3 | import * as ActionTypes from '../../actionTypes';
4 |
5 | test('# users reducer - update/get user', () => {
6 | const res = users({}, {type: ActionTypes.UPDATE_USER});
7 | expect(res.user).toBe(null);
8 | expect(res.status).toBe('loading...');
9 | const res1 = users({}, {type: ActionTypes.GET_USER});
10 | expect(res1.user).toBe(null);
11 | expect(res1.status).toBe('loading...');
12 | });
13 |
14 | test('# users reducer - update/get user success', () => {
15 | const action = {type: ActionTypes.UPDATE_USER_SUCCESS, payload: {user: 'test'}};
16 | const res = users({}, action);
17 | expect(res.user).toBe('test');
18 | expect(res.status).toBe('done');
19 | const action1 = {type: ActionTypes.GET_USER_SUCCESS, payload: {user: 'test'}};
20 | const res1 = users({}, action1);
21 | expect(res1.user).toBe('test');
22 | expect(res1.status).toBe('done');
23 | });
24 |
25 | test('# users reducer - update/get user error', () => {
26 | const action = {type: ActionTypes.UPDATE_USER_ERROR, payload: {error: 'test'}};
27 | const res = users({}, action);
28 | expect(res.error).toBe('test');
29 | expect(res.status).toBe('error');
30 | const action1 = {type: ActionTypes.GET_USER_ERROR, payload: {error: 'test'}};
31 | const res1 = users({}, action1);
32 | expect(res1.error).toBe('test');
33 | expect(res1.status).toBe('error');
34 | });
35 |
36 | test('# users reducer - errors', () => {
37 | const testState = {};
38 | expect(users(testState, {type: '-1'})).toEqual(testState);
39 | });
40 |
--------------------------------------------------------------------------------
/client/src/store/reducers/auth.js:
--------------------------------------------------------------------------------
1 | // our packages
2 | import * as ActionTypes from '../actionTypes';
3 |
4 | const storedUser = localStorage.getItem('user.data');
5 | // parse use from stored string
6 | let user;
7 | try {
8 | user = JSON.parse(storedUser);
9 | } catch (e) {
10 | // console.error('Error parsing user data', e);
11 | }
12 |
13 | const initialState = {
14 | token: localStorage.getItem('user.token'),
15 | user,
16 | };
17 |
18 | export const auth = (state = initialState, action) => {
19 | switch (action.type) {
20 | case ActionTypes.REGISTER_SUCCESS:
21 | return {
22 | redirectToLogin: true,
23 | };
24 | case ActionTypes.LOGIN_SUCCESS:
25 | localStorage.setItem('user.token', action.payload.token);
26 | localStorage.setItem('user.data', JSON.stringify(action.payload.user));
27 | return {
28 | ...action.payload,
29 | };
30 | case ActionTypes.UPDATE_USER_SUCCESS:
31 | localStorage.setItem('user.data', JSON.stringify(action.payload.user));
32 | return {
33 | user: {
34 | ...user,
35 | login: action.payload.user.login,
36 | },
37 | token: state.token,
38 | };
39 | case ActionTypes.LOGIN_ERROR:
40 | case ActionTypes.REGISTER_ERROR:
41 | // TODO: probably necessary in the future
42 | return state;
43 | default:
44 | return state;
45 | }
46 | };
47 |
--------------------------------------------------------------------------------
/client/src/store/reducers/helloworld.js:
--------------------------------------------------------------------------------
1 | import * as ActionTypes from '../actionTypes';
2 |
3 | const initialState = {world: 'click me'};
4 |
5 | export const helloWorld = (state = initialState, action) => {
6 | switch (action.type) {
7 | case ActionTypes.HELLO_WORLD:
8 | return {
9 | world: 'loading...',
10 | };
11 | case ActionTypes.HELLO_WORLD_END:
12 | return {
13 | world: action.payload.world,
14 | };
15 | default:
16 | return state;
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/client/src/store/reducers/index.js:
--------------------------------------------------------------------------------
1 | import {auth} from './auth';
2 | import {helloWorld} from './helloworld';
3 | import {notifications} from './notifications';
4 | import {questions} from './questions';
5 | import {users} from './users';
6 |
7 | export default {
8 | auth,
9 | helloWorld,
10 | notifications,
11 | questions,
12 | users,
13 | };
14 |
--------------------------------------------------------------------------------
/client/src/store/reducers/notifications.js:
--------------------------------------------------------------------------------
1 | import * as ActionTypes from '../actionTypes';
2 |
3 | const initialState = [];
4 | export const notifications = (state = initialState, action) => {
5 | switch (action.type) {
6 | case ActionTypes.ADD_NOTIFICATION:
7 | return [
8 | ...state,
9 | action.payload,
10 | ];
11 | case ActionTypes.REMOVE_NOTIFICATION: {
12 | const notificationId = action.payload.notificationId;
13 | return state.filter(notification => notification.id !== notificationId);
14 | }
15 | default:
16 | return state;
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/client/src/store/reducers/questions.js:
--------------------------------------------------------------------------------
1 | import * as ActionTypes from '../actionTypes';
2 |
3 | const initialState = {questions: [], status: 'inited'};
4 |
5 | export const questions = (state = initialState, action) => {
6 | switch (action.type) {
7 | // all questions logic
8 | case ActionTypes.GET_ALL_QUESTIONS:
9 | return {
10 | questions: [],
11 | status: 'loading...',
12 | };
13 | case ActionTypes.GET_ALL_QUESTIONS_SUCCESS:
14 | return {
15 | questions: action.payload.questions,
16 | status: 'done',
17 | };
18 | // answer questions logic
19 | case ActionTypes.ANSWER_QUESTION_SUCCESS: {
20 | const newQuestions = state.questions.map((q) => {
21 | if (q.id === action.payload.id) {
22 | return {...action.payload, owner: q.owner};
23 | }
24 | return q;
25 | });
26 | return {...state, questions: newQuestions};
27 | }
28 | case ActionTypes.CREATE_QUESTION_SUCCESS: {
29 | const newQuestions = [...state.questions, action.payload];
30 | return {...state, questions: newQuestions};
31 | }
32 | case ActionTypes.DELETE_QUESTION_SUCCESS: {
33 | const newQuestions = state.questions.filter(q => q.id !== action.payload.id);
34 | return {...state, questions: newQuestions};
35 | }
36 | case ActionTypes.UPDATE_QUESTION_SUCCESS: {
37 | const newQuestions = state.questions.map(q => {
38 | if (q.id === action.payload.id) {
39 | return {
40 | ...action.payload,
41 | owner: q.owner,
42 | };
43 | }
44 | return q;
45 | });
46 | return {...state, questions: newQuestions};
47 | }
48 | case ActionTypes.ANSWER_QUESTION_ERROR:
49 | case ActionTypes.CREATE_QUESTION_ERROR:
50 | case ActionTypes.GET_ALL_QUESTIONS_ERROR:
51 | case ActionTypes.DELETE_QUESTION_ERROR:
52 | case ActionTypes.UPDATE_QUESTION_ERROR:
53 | return {
54 | ...state,
55 | status: 'error',
56 | error: action.payload.error,
57 | };
58 | default:
59 | return state;
60 | }
61 | };
62 |
--------------------------------------------------------------------------------
/client/src/store/reducers/users.js:
--------------------------------------------------------------------------------
1 | import * as ActionTypes from '../actionTypes';
2 |
3 | const initialState = {user: null, status: 'inited'};
4 |
5 | export const users = (state = initialState, action) => {
6 | switch (action.type) {
7 | // all users logic
8 | case ActionTypes.UPDATE_USER:
9 | case ActionTypes.GET_USER:
10 | return {
11 | user: null,
12 | status: 'loading...',
13 | };
14 | case ActionTypes.UPDATE_USER_SUCCESS:
15 | localStorage.setItem('user.data', JSON.stringify(action.payload.user));
16 | return {
17 | user: action.payload.user,
18 | status: 'done',
19 | };
20 | case ActionTypes.GET_USER_SUCCESS:
21 | return {
22 | user: action.payload.user,
23 | status: 'done',
24 | };
25 | case ActionTypes.GET_USER_ERROR:
26 | case ActionTypes.UPDATE_USER_ERROR:
27 | return {
28 | ...state,
29 | status: 'error',
30 | error: action.payload.error,
31 | };
32 | default:
33 | return state;
34 | }
35 | };
36 |
--------------------------------------------------------------------------------
/client/src/store/rootEpic.js:
--------------------------------------------------------------------------------
1 | import {combineEpics} from 'redux-observable';
2 | import epics from './epics';
3 |
4 | export default combineEpics(...epics);
5 |
--------------------------------------------------------------------------------
/client/src/store/rootReducer.js:
--------------------------------------------------------------------------------
1 | import {combineReducers} from 'redux';
2 | import {routerReducer} from 'react-router-redux';
3 |
4 | import reducers from './reducers';
5 |
6 | export default combineReducers({
7 | ...reducers,
8 | routing: routerReducer,
9 | });
10 |
--------------------------------------------------------------------------------
/client/src/util/__tests__/errorToMessage.test.js:
--------------------------------------------------------------------------------
1 | /* global test, expect */
2 |
3 | import {loginErrorToMessage, registerErrorToMessage} from '../errorToMessage';
4 |
5 | const message = 'Test error';
6 |
7 | test('# loginErrorToMessage', () => {
8 | expect(loginErrorToMessage({status: 401})).toBe('Wrong login or password. Please, try again!');
9 | expect(loginErrorToMessage({status: 0, message})).toBe(message);
10 | });
11 |
12 | test('# registerErrorToMessage', () => {
13 | const correctXhr = {
14 | xhr: {
15 | response: {},
16 | },
17 | };
18 | const brokenXhrData = {
19 | xhr: {
20 | response: {
21 | error: 'Test error',
22 | },
23 | },
24 | };
25 | expect(registerErrorToMessage(brokenXhrData)).toBe(brokenXhrData.xhr.response.error);
26 | expect(registerErrorToMessage({...correctXhr, status: 403})).toBe('Oops, something went wrong. Please, try again!');
27 | expect(registerErrorToMessage({...correctXhr, status: 0, message})).toBe(message);
28 | });
29 |
30 |
--------------------------------------------------------------------------------
/client/src/util/__tests__/requireAuth.test.js:
--------------------------------------------------------------------------------
1 | /* global test, expect */
2 |
3 | import {requireAuth} from '../requireAuth';
4 |
5 | test('# requireAuth', () => {
6 | requireAuth({location: {pathname: 'test'}}, (obj) => {
7 | expect(obj.pathname).toBe('/login');
8 | expect(obj.state.nextPathname).toBe('test');
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/client/src/util/__tests__/signRequest.test.js:
--------------------------------------------------------------------------------
1 | /* global test, expect */
2 |
3 | import {signRequest} from '../signRequest';
4 |
5 | test('# signRequest', () => {
6 | localStorage.setItem('user.token', 'test');
7 | const req = {test: 'value'};
8 | const expectedRes = {
9 | ...req,
10 | headers: {
11 | 'x-access-token': 'test',
12 | },
13 | };
14 |
15 | expect(signRequest(req)).toEqual(expectedRes);
16 | });
17 |
--------------------------------------------------------------------------------
/client/src/util/errorToMessage.js:
--------------------------------------------------------------------------------
1 | export const loginErrorToMessage = (error) => {
2 | if (error.status === 401) {
3 | return 'Wrong login or password. Please, try again!';
4 | }
5 |
6 | return error.message;
7 | };
8 |
9 | export const registerErrorToMessage = (error) => {
10 | if (error.xhr.response && error.xhr.response.error) {
11 | return error.xhr.response.error;
12 | }
13 |
14 | if (error.status === 403) {
15 | return 'Oops, something went wrong. Please, try again!';
16 | }
17 |
18 | return error.message;
19 | };
20 |
--------------------------------------------------------------------------------
/client/src/util/index.js:
--------------------------------------------------------------------------------
1 | export {requireAuth} from './requireAuth';
2 | export {loginErrorToMessage, registerErrorToMessage} from './errorToMessage';
3 | export {signRequest} from './signRequest.js';
4 |
--------------------------------------------------------------------------------
/client/src/util/requireAuth.js:
--------------------------------------------------------------------------------
1 | import store from '../store';
2 |
3 | export const requireAuth = (nextState, replace) => {
4 | if (!store.getState().auth.token) {
5 | replace({
6 | pathname: '/login',
7 | state: {
8 | nextPathname: nextState.location.pathname,
9 | },
10 | });
11 | }
12 | };
13 |
--------------------------------------------------------------------------------
/client/src/util/signRequest.js:
--------------------------------------------------------------------------------
1 | export const signRequest = req => ({
2 | ...req,
3 | headers: {
4 | 'x-access-token': localStorage.getItem('user.token'),
5 | },
6 | });
7 |
--------------------------------------------------------------------------------
/client/test/setup.js:
--------------------------------------------------------------------------------
1 | // import enzyme methods
2 | import React from 'react';
3 | import {shallow, render, mount} from 'enzyme';
4 | // import complete rxjs
5 | import 'rxjs';
6 |
7 | // setup localStorage
8 | const localStorageMock = (() => {
9 | let store = {};
10 | return {
11 | getItem(key) {
12 | return store[key];
13 | },
14 | setItem(key, value) {
15 | store[key] = value.toString();
16 | },
17 | clear() {
18 | store = {};
19 | },
20 | };
21 | })();
22 | Object.defineProperty(window, 'localStorage', {value: localStorageMock});
23 |
24 | // setup default API_HOST
25 | global.API_HOST = 'http://localhost:8080';
26 |
27 | // setup enzyme
28 | global.React = React;
29 | global.shallow = shallow;
30 | global.render = render;
31 | global.mount = mount;
32 | // Skip createElement warnings but fail tests on any other warning
33 | console.error = (message) => {
34 | if (!/(React.createElement: type should not be null)/.test(message)) {
35 | throw new Error(message);
36 | }
37 | };
38 |
--------------------------------------------------------------------------------
/client/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | devtool: 'cheap-module-source-map',
5 | context: path.resolve(__dirname, 'src'),
6 | entry: path.join(__dirname, 'src', 'index.js'),
7 | output: {
8 | path: path.resolve(__dirname, 'dist'),
9 | publicPath: '/dist/',
10 | filename: 'app.min.js',
11 | },
12 | resolve: {
13 | modules: [path.resolve(__dirname), 'node_modules'],
14 | },
15 | module: {
16 | rules: [{
17 | test: /\.css$/,
18 | use: ['style-loader', {
19 | loader: 'css-loader',
20 | options: {
21 | modules: true,
22 | },
23 | }],
24 | exclude: /node_modules/,
25 | }, {
26 | test: /node_modules\/.+\.css$/,
27 | use: ['style-loader', 'css-loader'],
28 | }, {
29 | test: /\.js$/,
30 | exclude: /node_modules/,
31 | loader: 'babel-loader',
32 | options: {
33 | cacheDirectory: true,
34 | presets: ['es2015', 'react', 'stage-0'],
35 | plugins: ['transform-runtime', 'lodash'],
36 | env: {
37 | development: {
38 | presets: ['react-hmre'],
39 | },
40 | production: {
41 | presets: ['react-optimize'],
42 | },
43 | },
44 | },
45 | }, {
46 | test: /\.woff\d?(\?.+)?$/,
47 | loader: 'url-loader',
48 | options: {
49 | limit: 10000,
50 | mimetype: 'application/font-woff',
51 | },
52 | }, {
53 | test: /\.ttf(\?.+)?$/,
54 | loader: 'url-loader',
55 | options: {
56 | limit: 10000,
57 | mimetype: 'application/octet-stream',
58 | },
59 | }, {
60 | test: /\.eot(\?.+)?$/,
61 | loader: 'url-loader',
62 | options: {
63 | limit: 10000,
64 | },
65 | }, {
66 | test: /\.svg(\?.+)?$/,
67 | loader: 'url-loader',
68 | options: {
69 | limit: 10000,
70 | mimetype: 'image/svg+xml',
71 | },
72 | }, {
73 | test: /\.png$/,
74 | loader: 'url-loader',
75 | options: {
76 | limit: 10000,
77 | mimetype: 'image/png',
78 | },
79 | }, {
80 | test: /\.gif$/,
81 | loader: 'url-loader',
82 | options: {
83 | limit: 10000,
84 | mimetype: 'image/gif',
85 | },
86 | }],
87 | },
88 | };
89 |
--------------------------------------------------------------------------------
/deploy/Makefile:
--------------------------------------------------------------------------------
1 | deploy: pull clean run
2 |
3 | db:
4 | docker run -d \
5 | -v `pwd`/data:/data \
6 | --name bpwjs-db \
7 | --net codezen_nginx \
8 | rethinkdb
9 |
10 | pull:
11 | docker pull yamalight/bpwjs-server:latest
12 | docker pull yamalight/bpwjs-client:latest
13 |
14 | clean:
15 | docker stop bpwjs-server
16 | docker stop bpwjs-client
17 | docker rm bpwjs-server
18 | docker rm bpwjs-client
19 |
20 | run:
21 | docker run -d \
22 | --name bpwjs-server \
23 | --net codezen_nginx \
24 | --link bpwjs-db:db \
25 | -e VIRTUAL_HOST=bpwjsapi.codezen.net \
26 | -e LETSENCRYPT_HOST=bpwjsapi.codezen.net \
27 | -e LETSENCRYPT_EMAIL=ermilov@codezen.ru \
28 | -e EXPERTS_DB_URL=db \
29 | yamalight/bpwjs-server
30 | docker run -d \
31 | --name bpwjs-client \
32 | --net codezen_nginx \
33 | -e VIRTUAL_HOST=bpwjs.codezen.net \
34 | -e LETSENCRYPT_HOST=bpwjs.codezen.net \
35 | -e LETSENCRYPT_EMAIL=ermilov@codezen.ru \
36 | -e API_HOST=https://bpwjsapi.codezen.net \
37 | yamalight/bpwjs-client
38 |
--------------------------------------------------------------------------------
/deploy/docker-compose-img.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 | services:
3 | db:
4 | image: rethinkdb
5 | volumes:
6 | - ./db:/data
7 |
8 | api:
9 | image: yamalight/bpwjs-server
10 | environment:
11 | - EXPERTS_DB_URL=db
12 | ports:
13 | - 8080:8080
14 |
15 | ui:
16 | image: yamalight/bpwjs-client
17 | environment:
18 | - API_HOST=http://localhost:8080
19 | ports:
20 | - 80:3000
21 |
--------------------------------------------------------------------------------
/deploy/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 | services:
3 | db:
4 | image: rethinkdb
5 | volumes:
6 | - ./db:/data
7 |
8 | api:
9 | build: ../server
10 | environment:
11 | - EXPERTS_DB_URL=db
12 | ports:
13 | - 8080:8080
14 |
15 | ui:
16 | build: ../client
17 | environment:
18 | - API_HOST=http://localhost:8080
19 | ports:
20 | - 80:3000
21 |
--------------------------------------------------------------------------------
/server/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | db/
3 | lib/
4 |
--------------------------------------------------------------------------------
/server/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": "airbnb",
4 | "rules": {
5 | "object-curly-spacing": ["warn", "never"],
6 | "func-names": "off",
7 | "space-before-function-paren": ["error", "never"],
8 | "max-len": ["error", 120, 4],
9 | "no-unused-vars": ["error", {"argsIgnorePattern": "next"}],
10 | "import/prefer-default-export": "off",
11 | "comma-dangle": ["error", {
12 | "arrays": "always-multiline",
13 | "objects": "always-multiline",
14 | "functions": "ignore"
15 | }]
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/server/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | 0.1.0 / 2016-09-04
2 | ==================
3 |
4 | Initial release
5 |
--------------------------------------------------------------------------------
/server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM kkarczmarczyk/node-yarn:latest
2 |
3 | # Create app folder
4 | RUN mkdir -p /app
5 | WORKDIR /app
6 |
7 | # Cache npm dependencies
8 | COPY package.json /app/
9 | COPY yarn.lock /app/
10 | RUN yarn
11 |
12 | # Copy application files
13 | COPY . /app
14 |
15 | # Precompile javascript
16 | RUN ./node_modules/.bin/babel src --out-dir lib
17 |
18 | EXPOSE 8080
19 |
20 | CMD ["node", "lib/index.js"]
21 |
--------------------------------------------------------------------------------
/server/README.md:
--------------------------------------------------------------------------------
1 | # Experts server
2 |
3 | > Server for Building products with javascript course use case
4 |
5 | This is a simple REST API server that provides CRUD capabilities for the experts polling use-case.
6 | The server is build using [Express.js](https://expressjs.com/) as a server and [Passport.js](http://passportjs.org/) as authentication middleware with code compiled from ES2015+ by [babel](http://babeljs.io/)
7 | It relies on [RethinkDB](https://www.rethinkdb.com/) as a database for storing data.
8 |
9 | ## Usage
10 |
11 | Experts server can be launched in two ways - using docker, or using node.
12 |
13 | ### Using Docker
14 |
15 | An assembled docker image is automatically published on Docker Hub after every push.
16 | First, make sure rethinkdb is running in your docker, e.g. by running:
17 |
18 | $ docker run -d --name expertsdb rethinkdb
19 |
20 | After that, all you have to do to run experts server locally is execute the following docker command:
21 |
22 | $ docker run -d --link expertsdb -e EXPERTS_DB_URL=expertsdb -p 8080:8080 yamalight/bpwjs-server
23 |
24 | This will start experts server in daemon mode, link it with your instance of rethinkdb and forward port 8080 to your docker host so you can access it.
25 | You can also replace `-d` flag with `-it` flags to get interactive session and see the output of server in your console.
26 |
27 | ### Using Node
28 |
29 | Running using Node requires you having Node.js v4 or later (v6 is recommended) installed.
30 | To run the server locally, do the following:
31 | 1. Clone this repository
32 | 2. Enter `./server` folder
33 | 3. Install dependencies with `npm install`
34 | 4. Make sure you have local RethinkDB running (or start one using `npm run db:create`, requires docker)
35 | 5. Start the server using `npm start`
36 | 6. Navigate to [http://localhost:8080](http://localhost:8080) in your browser
37 |
38 | ## Development
39 |
40 | Development requires you having Node.js v4 or later installed.
41 | To run server for development just follow instuctions from [Using Node](#using-node) section.
42 | To run test suite simply execute `npm test`.
43 | To see all available commands, see package.json.
44 |
45 | ## Contributing
46 |
47 | For bug fixes, documentation changes, and small features:
48 | 1. Fork this repository
49 | 2. Create your feature branch (`git checkout -b my-new-feature`)
50 | 3. Commit your changes (`git commit -am 'Add some feature'`)
51 | 4. Push to the branch (`git push origin my-new-feature`)
52 | 5. Create a new Pull Request
53 |
54 | For larger new features: Do everything as above, but first also make contact with the project maintainers to be sure your change fits with the project direction and you won't be wasting effort going in the wrong direction.
55 |
56 | ## Contributors
57 |
58 | Thanks to all the people who helped to make this project better:
59 |
60 | 1. [frankhinek](https://github.com/frankhinek)
61 | 2. [EliasJorgensen](https://github.com/EliasJorgensen)
62 |
63 | ## License
64 |
65 | [MIT](https://opensource.org/licenses/mit-license)
66 |
--------------------------------------------------------------------------------
/server/config.js:
--------------------------------------------------------------------------------
1 | exports.db = {
2 | host: process.env.EXPERTS_DB_URL || 'localhost',
3 | port: process.env.EXPERTS_DB_PORT || 28015,
4 | db: 'expertsdb',
5 | };
6 |
7 | exports.auth = {
8 | passwordSalt: process.env.EXPERTS_AUTH_PASSALT ||
9 | 'Gq0twQYeoP6YWZY7iBc!NyhVavauPHB5Q6jPU$LMzCxw@SM&y$udLVnmF0qu!%XR',
10 | sessionSecret: process.env.EXPERTS_AUTH_SESSECRET ||
11 | 'RGP84d%XZ$tck7TPpQ^zn#7Q$i&duxS2K!8ZR!87!9vJ2yZe@ZFqSMIvdvv4EseS',
12 | jwtSecret: process.env.EXPERTS_AUTH_JWTSECRET ||
13 | 'uaeldt!2D9iVrOv1KEH#KRuaiEdJty6rRXJij$FN&D$oYKITos14Utok6W0kt83@',
14 | };
15 |
--------------------------------------------------------------------------------
/server/index.js:
--------------------------------------------------------------------------------
1 | // require babel require hook
2 | require('babel-core/register');
3 | // require server code
4 | require('./src');
5 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "experts-server",
3 | "version": "0.1.0",
4 | "description": "Experts REST backend",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "node index.js",
8 | "db:create": "node util/db/create",
9 | "db:start": "docker start expertsdb",
10 | "db:stop": "docker stop expertsdb",
11 | "db:rm": "docker rm expertsdb",
12 | "test": "eslint src/ && node test/index.js | tap-spec"
13 | },
14 | "keywords": [
15 | "rest",
16 | "javascript",
17 | "experts"
18 | ],
19 | "author": "Tim Ermilov (http://codezen.net)",
20 | "license": "MIT",
21 | "repository": {
22 | "type": "git",
23 | "url": "git+https://github.com/yamalight/building-products-with-js.git"
24 | },
25 | "bugs": {
26 | "url": "https://github.com/yamalight/building-products-with-js/issues"
27 | },
28 | "homepage": "https://github.com/yamalight/building-products-with-js#readme",
29 | "devDependencies": {
30 | "babel-cli": "^6.23.0",
31 | "babel-core": "^6.23.1",
32 | "babel-eslint": "^7.1.1",
33 | "babel-preset-es2015-node": "^6.1.0",
34 | "babel-preset-stage-0": "^6.22.0",
35 | "eslint": "^3.16.1",
36 | "eslint-config-airbnb": "^14.1.0",
37 | "eslint-plugin-import": "^2.2.0",
38 | "eslint-plugin-jsx-a11y": "^4.0.0",
39 | "eslint-plugin-react": "^6.10.0",
40 | "supertest": "^3.0.0",
41 | "tap-spec": "^4.1.1",
42 | "tape": "^4.6.3"
43 | },
44 | "babel": {
45 | "presets": [
46 | "es2015-node",
47 | "stage-0"
48 | ]
49 | },
50 | "dependencies": {
51 | "body-parser": "^1.16.1",
52 | "cookie-parser": "^1.4.3",
53 | "cors": "^2.8.1",
54 | "express": "^4.14.1",
55 | "express-session": "^1.15.1",
56 | "jsonwebtoken": "^7.3.0",
57 | "moment": "^2.17.1",
58 | "morgan": "^1.8.1",
59 | "passport": "^0.3.2",
60 | "passport-jwt": "^2.1.0",
61 | "passport-local": "^1.0.0",
62 | "thinky": "^2.3.8",
63 | "winston": "^2.3.1"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/server/src/app.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import express from 'express';
3 | import bodyParser from 'body-parser';
4 | import cookieParser from 'cookie-parser';
5 | import session from 'express-session';
6 | import passport from 'passport';
7 | import morgan from 'morgan';
8 | import cors from 'cors';
9 |
10 | // our packages
11 | import {logger} from './util';
12 | import {auth as authConfig} from '../config';
13 | import setupAuthRoutes from './auth';
14 | import setupUserRoutes from './user';
15 | import setupQuestionRoutes from './question';
16 |
17 | // init app
18 | const app = express();
19 |
20 | // setup logging
21 | app.use(morgan('combined', {stream: logger.stream}));
22 |
23 | // setup CORS
24 | app.use(cors());
25 |
26 | // add body parsing
27 | app.use(bodyParser.json()); // for parsing application/json
28 | app.use(bodyParser.urlencoded({extended: true})); // for parsing application/x-www-form-urlencoded
29 |
30 | // add cookie parsing
31 | app.use(cookieParser());
32 |
33 | // add session support
34 | app.use(session({
35 | secret: authConfig.sessionSecret,
36 | resave: false,
37 | saveUninitialized: true,
38 | cookie: {secure: true},
39 | }));
40 |
41 | // add passport.js
42 | app.use(passport.initialize());
43 | app.use(passport.session());
44 |
45 | // test method
46 | app.get('/', (req, res) => {
47 | res.send('Hello world!');
48 | });
49 |
50 | // setup authentication routes
51 | setupAuthRoutes(app);
52 | // setup user routes
53 | setupUserRoutes(app);
54 | // setup question routes
55 | setupQuestionRoutes(app);
56 |
57 | // catch all unhandled errors
58 | app.use((err, req, res, next) => {
59 | logger.error('unhandled application error: ', err);
60 | res.status(500).send(err);
61 | });
62 |
63 | // export app
64 | export default app;
65 |
--------------------------------------------------------------------------------
/server/src/auth/index.js:
--------------------------------------------------------------------------------
1 | // our packages
2 | import './passport';
3 | import login from './login';
4 | import register from './register';
5 |
6 | export default (app) => {
7 | login(app);
8 | register(app);
9 | };
10 |
11 | export {loginTaken} from './register';
12 |
--------------------------------------------------------------------------------
/server/src/auth/login.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import passport from 'passport';
3 | import jwt from 'jsonwebtoken';
4 |
5 | // our packages
6 | import {auth as authConfig} from '../../config';
7 |
8 | export default (app) => {
9 | app.post('/api/login', passport.authenticate('local'), (req, res) => {
10 | if (req.user) {
11 | const token = jwt.sign(req.user, authConfig.jwtSecret);
12 | res.send({user: req.user, token});
13 | } else {
14 | res.status(401).send({error: 'Error logging in!'});
15 | }
16 | });
17 | };
18 |
--------------------------------------------------------------------------------
/server/src/auth/passport.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import passport from 'passport';
3 | import {Strategy as LocalStrategy} from 'passport-local';
4 | import {Strategy as JwtStrategy, ExtractJwt} from 'passport-jwt';
5 |
6 | // our packages
7 | import {User} from '../db';
8 | import {hash} from '../util';
9 | import {auth as authConfig} from '../../config';
10 |
11 | // define serialize and deserialize functions
12 | passport.serializeUser((user, done) => done(null, user.id));
13 | passport.deserializeUser(async (id, done) => {
14 | let user = null;
15 | try {
16 | user = await User.get(id)
17 | .without(['password'])
18 | .execute();
19 | } catch (e) {
20 | done(e, false);
21 | return;
22 | }
23 |
24 | done(null, user);
25 | });
26 |
27 | // use LocalStrategy
28 | passport.use(new LocalStrategy({usernameField: 'login'}, async (login, password, done) => {
29 | // find all users with matching login
30 | let users = [];
31 | try {
32 | users = await User.filter({login}).limit(1).run();
33 | } catch (e) {
34 | return done(e, false);
35 | }
36 | // get the first match
37 | const user = users[0];
38 | // check if exists
39 | if (!user) {
40 | return done(null, false);
41 | }
42 | // compare password
43 | if (user.password !== hash(password)) {
44 | return done(null, false);
45 | }
46 | // return user if successful
47 | delete user.password;
48 | return done(null, user);
49 | }));
50 |
51 | // use JWTStrategy
52 | const jwtOpts = {
53 | jwtFromRequest: ExtractJwt.fromHeader('x-access-token'),
54 | secretOrKey: authConfig.jwtSecret,
55 | };
56 | passport.use(new JwtStrategy(jwtOpts, async (payload, done) => {
57 | let user;
58 | try {
59 | user = await User.get(payload.id)
60 | .without(['password'])
61 | .execute();
62 | } catch (e) {
63 | return done(e, false);
64 | }
65 | // check if exists
66 | if (!user) {
67 | return done(null, false);
68 | }
69 | // return user if successful
70 | return done(null, user);
71 | }));
72 |
--------------------------------------------------------------------------------
/server/src/auth/register.js:
--------------------------------------------------------------------------------
1 | // our packages
2 | import {User} from '../db';
3 | import {hash, asyncRequest} from '../util';
4 |
5 | export const loginTaken = async (login) => {
6 | // check if login already taken
7 | const users = await User.filter({login}).run();
8 | return users.length > 0;
9 | };
10 |
11 | export default (app) => {
12 | app.post('/api/register', asyncRequest(async (req, res) => {
13 | // get user input
14 | const {login, password, passwordRepeat} = req.body;
15 |
16 | if (password !== passwordRepeat) {
17 | res.status(400).send({error: 'Passwords do not match!'});
18 | return;
19 | }
20 | // hash password
21 | const hashedPassword = hash(password);
22 |
23 | // check if login already taken
24 | const exists = await loginTaken(login);
25 | if (exists) {
26 | res.status(403).send({error: 'User already exists!'});
27 | return;
28 | }
29 |
30 | // save new user
31 | const user = new User({
32 | login,
33 | password: hashedPassword,
34 | });
35 | await user.save();
36 |
37 | res.sendStatus(201);
38 | }));
39 | };
40 |
--------------------------------------------------------------------------------
/server/src/db/index.js:
--------------------------------------------------------------------------------
1 | export {thinky, r} from './thinky';
2 | export {User} from './user';
3 | export {Question} from './question';
4 |
--------------------------------------------------------------------------------
/server/src/db/question.js:
--------------------------------------------------------------------------------
1 | import {thinky} from './thinky';
2 |
3 | export const Question = thinky.createModel('Question', {
4 | text: thinky.type.string().required(),
5 | creationDate: thinky.type.date().default(thinky.r.now()),
6 | expirationDate: thinky.type.date().required(),
7 | answers: thinky.type.array().schema(
8 | thinky.type.object().schema({
9 | user: thinky.type.string().required(),
10 | answer: thinky.type.string().required(),
11 | })
12 | ).default([]),
13 | owner: thinky.type.string().required(),
14 | });
15 |
--------------------------------------------------------------------------------
/server/src/db/thinky.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import initThinky from 'thinky';
3 |
4 | // our packages
5 | import {db as dbConfig} from '../../config';
6 |
7 | // init thinky
8 | const thinky = initThinky(dbConfig);
9 | const {r} = thinky;
10 |
11 | // export
12 | export {thinky, r};
13 |
--------------------------------------------------------------------------------
/server/src/db/user.js:
--------------------------------------------------------------------------------
1 | import {thinky} from './thinky';
2 |
3 | export const User = thinky.createModel('User', {
4 | login: thinky.type.string().required(),
5 | password: thinky.type.string().required(),
6 | registrationDate: thinky.type.date().default(thinky.r.now()),
7 | });
8 |
--------------------------------------------------------------------------------
/server/src/index.js:
--------------------------------------------------------------------------------
1 | // our packages
2 | import app from './app';
3 | import {logger} from './util';
4 | import {thinky} from './db';
5 |
6 | // wait for DB to initialize
7 | thinky.dbReady().then(() => {
8 | logger.info('Database ready, starting server...');
9 | // start server
10 | app.listen(8080, function() {
11 | const host = this.address().address;
12 | const port = this.address().port;
13 | logger.info(`Experts-server is listening at http://${host}:${port}`);
14 | });
15 | });
16 |
17 | // output all uncaught exceptions
18 | process.on('uncaughtException', err => logger.error('uncaught exception:', err));
19 | process.on('unhandledRejection', error => logger.error('unhandled rejection:', error));
20 |
--------------------------------------------------------------------------------
/server/src/question/answer.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import passport from 'passport';
3 |
4 | // our packages
5 | import {Question} from '../db';
6 | import {asyncRequest} from '../util';
7 |
8 | export default (app) => {
9 | app.post('/api/question/:id/answer', passport.authenticate('jwt', {session: false}),
10 | asyncRequest(async (req, res) => {
11 | const {id} = req.params;
12 | // get user input
13 | const {answer} = req.body;
14 |
15 | // make sure text is not empty
16 | if (answer !== undefined && !answer.length) {
17 | res.status(400).send({error: 'Answer should be not empty!'});
18 | return;
19 | }
20 |
21 | // get the question
22 | const question = await Question.get(id);
23 |
24 | // double-check check if question exists
25 | if (!question) {
26 | res.status(400).send({error: 'Question not found!'});
27 | return;
28 | }
29 |
30 | // append new answer
31 | question.answers.push({answer, user: req.user.id});
32 |
33 | // try saving
34 | await question.save();
35 |
36 | // send created question back
37 | res.send(question);
38 | }));
39 | };
40 |
--------------------------------------------------------------------------------
/server/src/question/create.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import passport from 'passport';
3 | import moment from 'moment';
4 |
5 | // our packages
6 | import {Question} from '../db';
7 | import {asyncRequest} from '../util';
8 |
9 | export default (app) => {
10 | app.post('/api/question', passport.authenticate('jwt', {session: false}), asyncRequest(async (req, res) => {
11 | // get user input
12 | const {text, expirationDate} = req.body;
13 |
14 | // make sure text is not empty
15 | if (!text || !text.length) {
16 | res.status(400).send({error: 'Text should be present!'});
17 | return;
18 | }
19 |
20 | // validate date
21 | if (!moment(expirationDate, moment.ISO_8601).isValid()) {
22 | res.status(400).send({error: 'Date should be valid ISO Date!'});
23 | return;
24 | }
25 |
26 | // save new question
27 | const question = new Question({
28 | text,
29 | expirationDate: moment(expirationDate).toDate(),
30 | owner: req.user.id,
31 | });
32 | await question.save();
33 |
34 | // send created question back
35 | res.send(question);
36 | }));
37 | };
38 |
--------------------------------------------------------------------------------
/server/src/question/delete.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import passport from 'passport';
3 |
4 | // our packages
5 | import {Question} from '../db';
6 | import {asyncRequest} from '../util';
7 |
8 | export default (app) => {
9 | app.delete('/api/question/:id', passport.authenticate('jwt', {session: false}), asyncRequest(async (req, res) => {
10 | // get requested question
11 | const question = await Question.get(req.params.id);
12 |
13 | // check if user is the owner
14 | if (req.user.id !== question.owner) {
15 | res.status(403).send({error: 'Not enough rights to delete the question!'});
16 | return;
17 | }
18 |
19 | // delete
20 | await question.delete();
21 |
22 | // send success status
23 | res.sendStatus(204);
24 | }));
25 | };
26 |
--------------------------------------------------------------------------------
/server/src/question/get.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import passport from 'passport';
3 |
4 | // our packages
5 | import {r, Question} from '../db';
6 | import {asyncRequest} from '../util';
7 |
8 | export default (app) => {
9 | app.get('/api/question/:id', passport.authenticate('jwt', {session: false}), asyncRequest(async (req, res) => {
10 | // get requested question
11 | const question = await Question.get(req.params.id);
12 | // send question back
13 | res.send(question);
14 | }));
15 |
16 | app.get('/api/question', passport.authenticate('jwt', {session: false}), asyncRequest(async (req, res) => {
17 | // get 10 latest questions
18 | const questions = await Question
19 | .merge(q => ({
20 | owner: r.db('expertsdb')
21 | .table('User')
22 | .get(q('owner'))
23 | .without(['password']),
24 | }))
25 | .orderBy(r.desc('creationDate'))
26 | .limit(10)
27 | .execute();
28 | // send question back
29 | res.send(questions);
30 | }));
31 | };
32 |
--------------------------------------------------------------------------------
/server/src/question/index.js:
--------------------------------------------------------------------------------
1 | // our packages
2 | import get from './get';
3 | import create from './create';
4 | import update from './update';
5 | import deleteQuestion from './delete';
6 | import answer from './answer';
7 |
8 | export default (app) => {
9 | get(app);
10 | create(app);
11 | update(app);
12 | deleteQuestion(app);
13 | answer(app);
14 | };
15 |
--------------------------------------------------------------------------------
/server/src/question/update.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import passport from 'passport';
3 | import moment from 'moment';
4 |
5 | // our packages
6 | import {Question} from '../db';
7 | import {asyncRequest} from '../util';
8 |
9 | export default (app) => {
10 | app.post('/api/question/:id', passport.authenticate('jwt', {session: false}), asyncRequest(async (req, res) => {
11 | const {id} = req.params;
12 | // get user input
13 | const {text, expirationDate} = req.body;
14 |
15 | // make sure text is not empty
16 | if (text !== undefined && !text.length) {
17 | res.status(400).send({error: 'Text should be not empty!'});
18 | return;
19 | }
20 |
21 | // validate date
22 | if (expirationDate && !moment(expirationDate, moment.ISO_8601).isValid()) {
23 | res.status(400).send({error: 'Date should be valid ISO Date!'});
24 | return;
25 | }
26 |
27 | // get the question
28 | const question = await Question.get(id);
29 |
30 | // double-check check if question exists
31 | if (!question) {
32 | res.status(400).send({error: 'Question not found!'});
33 | return;
34 | }
35 |
36 | // check if user is the owner
37 | if (req.user.id !== question.owner) {
38 | res.status(403).send({error: 'Not enough rights to change the question!'});
39 | return;
40 | }
41 |
42 | // check if data is actually changed
43 | const textChanged = text && question.text !== text;
44 | const expDateChanged = expirationDate && !moment(expirationDate).isSame(question.expirationDate);
45 | // if not - just send OK
46 | if (!textChanged && !expDateChanged) {
47 | res.send(question);
48 | return;
49 | }
50 |
51 | if (text) {
52 | question.text = text;
53 | }
54 | if (expirationDate) {
55 | question.expirationDate = moment(expirationDate).toDate();
56 | }
57 | // try saving
58 | await question.save();
59 |
60 | // send created question back
61 | res.send(question);
62 | }));
63 | };
64 |
--------------------------------------------------------------------------------
/server/src/user/get.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import passport from 'passport';
3 |
4 | // our packages
5 | import {User} from '../db';
6 | import {asyncRequest} from '../util';
7 |
8 | export default (app) => {
9 | app.get('/api/user/:id', passport.authenticate('jwt', {session: false}), asyncRequest(async (req, res) => {
10 | if (req.params.id === 'me') {
11 | res.send(req.user);
12 | return;
13 | }
14 |
15 | try {
16 | const user = await User.get(req.params.id)
17 | .without(['password'])
18 | .execute();
19 | res.send(user);
20 | } catch (e) {
21 | res.status(400).send({error: 'User does not exist'});
22 | }
23 | }));
24 | };
25 |
--------------------------------------------------------------------------------
/server/src/user/index.js:
--------------------------------------------------------------------------------
1 | // our packages
2 | import get from './get';
3 | import update from './update';
4 |
5 | export default (app) => {
6 | get(app);
7 | update(app);
8 | };
9 |
--------------------------------------------------------------------------------
/server/src/user/update.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import passport from 'passport';
3 |
4 | // our packages
5 | import {loginTaken} from '../auth';
6 | import {User} from '../db';
7 | import {hash, asyncRequest} from '../util';
8 |
9 | export default (app) => {
10 | app.post('/api/user/:id', passport.authenticate('jwt', {session: false}), asyncRequest(async (req, res) => {
11 | const {login, password, passwordRepeat} = req.body;
12 |
13 | // check if user is changing his own profile
14 | if (req.user.id !== req.params.id) {
15 | res.status(403).send({error: 'Not enough rights to change other user profile!'});
16 | return;
17 | }
18 |
19 | let user;
20 | try {
21 | user = await User.get(req.params.id);
22 | } catch (e) {
23 | res.status(400).send({error: 'User does not exist'});
24 | return;
25 | }
26 |
27 | // check if user exists
28 | if (!user) {
29 | res.status(400).send({error: 'User does not exist'});
30 | return;
31 | }
32 |
33 | // check if data is actually changed
34 | const loginChanged = login && user.login !== login;
35 | const passwordChanged = password && user.password !== hash(password);
36 | // if not - just send OK
37 | if (!loginChanged && !passwordChanged) {
38 | delete user.password;
39 | res.send(user);
40 | return;
41 | }
42 |
43 | // check passwords for match
44 | if (passwordChanged && password !== passwordRepeat) {
45 | res.status(400).send({error: 'Passwords do not match!'});
46 | return;
47 | }
48 |
49 | // check if new login is taken
50 | if (loginChanged && await loginTaken(login)) {
51 | res.status(400).send({error: 'Login already taken!'});
52 | return;
53 | }
54 |
55 | // update data
56 | if (login) {
57 | user.login = login;
58 | }
59 | if (password) {
60 | user.password = hash(password);
61 | }
62 | // try to save
63 | await user.save();
64 |
65 | // send success
66 | delete user.password;
67 | res.send(user);
68 | }));
69 | };
70 |
--------------------------------------------------------------------------------
/server/src/util/asyncRequest.js:
--------------------------------------------------------------------------------
1 | import {logger} from './logger';
2 |
3 | export const asyncRequest = handler =>
4 | (req, res) =>
5 | handler(req, res).catch((e) => {
6 | logger.debug('Error during request:', e);
7 | res.status(400).send({error: e.toString()});
8 | });
9 |
--------------------------------------------------------------------------------
/server/src/util/hash.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import crypto from 'crypto';
3 |
4 | // our packages
5 | import {auth as authConfig} from '../../config';
6 |
7 | export const hash = (str) => {
8 | const sum = crypto.createHash('sha256');
9 | sum.update(str + authConfig.passwordSalt);
10 | return sum.digest('hex');
11 | };
12 |
--------------------------------------------------------------------------------
/server/src/util/index.js:
--------------------------------------------------------------------------------
1 | export {logger} from './logger';
2 | export {hash} from './hash';
3 | export {asyncRequest} from './asyncRequest';
4 |
--------------------------------------------------------------------------------
/server/src/util/logger.js:
--------------------------------------------------------------------------------
1 | import winston from 'winston';
2 |
3 | export const logger = new winston.Logger({
4 | transports: [
5 | new winston.transports.Console({
6 | level: do {
7 | // TODO: remove eslint disable lines once the bug is fixed
8 | // bugref: https://github.com/babel/eslint-plugin-babel/issues/13
9 | if (process.env.NODE_ENV === 'testing') {
10 | 'error'; // eslint-disable-line
11 | } else if (process.env.NODE_ENV === 'production') {
12 | 'info'; // eslint-disable-line
13 | } else {
14 | 'debug'; // eslint-disable-line
15 | }
16 | },
17 | colorize: true,
18 | timestamp: true,
19 | prettyPrint: true,
20 | label: 'experts-server',
21 | }),
22 | ],
23 | });
24 |
25 | // create stream for morgan
26 | logger.stream = {
27 | write: message => logger.info(message),
28 | };
29 |
--------------------------------------------------------------------------------
/server/test/core.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import request from 'supertest';
3 |
4 | // our packages
5 | import app from '../src/app';
6 |
7 | export default (test) => {
8 | test('GET /', (t) => {
9 | request(app)
10 | .get('/')
11 | .expect(200)
12 | .expect('Content-Type', /text\/html/)
13 | .end((err, res) => {
14 | const expectedBody = 'Hello world!';
15 | const actualBody = res.text;
16 |
17 | t.error(err, 'No error');
18 | t.equal(actualBody, expectedBody, 'Retrieve body');
19 | t.end();
20 | });
21 | });
22 |
23 | test('404 on nonexistant URL', (t) => {
24 | request(app)
25 | .get('/GETShouldFailOnRandomURL')
26 | .expect(404)
27 | .expect('Content-Type', /text\/html/)
28 | .end((err, res) => {
29 | const expectedBody = 'Cannot GET /GETShouldFailOnRandomURL\n';
30 | const actualBody = res.text;
31 |
32 | t.error(err, 'No error');
33 | t.equal(actualBody, expectedBody, 'Retrieve body');
34 | t.end();
35 | });
36 | });
37 | };
38 |
--------------------------------------------------------------------------------
/server/test/index.js:
--------------------------------------------------------------------------------
1 | /* eslint global-require: 0 */
2 | // say we're testing to evade excessive logging
3 | // usage of process.env is workaround for issues with setting env vars in windows
4 | process.env.NODE_ENV = 'testing';
5 | // require babel require hook
6 | require('babel-core/register');
7 | // require and start main tests
8 | require('./main');
9 |
--------------------------------------------------------------------------------
/server/test/login.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import request from 'supertest';
3 | import jwt from 'jsonwebtoken';
4 |
5 | // our packages
6 | import app from '../src/app';
7 | import {auth as authConfig} from '../config';
8 |
9 | export default (test) => {
10 | test('Should login with existing username and password', (t) => {
11 | request(app)
12 | .post('/api/login')
13 | .send({login: 'test', password: '123'})
14 | .expect(200)
15 | .expect('Content-Type', /json/)
16 | .end((err, res) => {
17 | const actualBody = res.body;
18 |
19 | t.error(err, 'No error');
20 | t.ok(actualBody.user, 'User exists');
21 | t.ok(actualBody.token, 'Token exists');
22 |
23 | const decodedUser = jwt.verify(actualBody.token, authConfig.jwtSecret);
24 | delete decodedUser.iat;
25 |
26 | t.equal(actualBody.user.login, 'test', 'Login matches request');
27 | t.notOk(actualBody.user.password, 'No password included');
28 | t.deepEqual(actualBody.user, decodedUser, 'User must match token');
29 |
30 | app.set('token', actualBody.token);
31 | app.set('user', actualBody.user);
32 |
33 | t.end();
34 | });
35 | });
36 |
37 | test('Should login with other existing username and password', (t) => {
38 | request(app)
39 | .post('/api/login')
40 | .send({login: 'other', password: '321'})
41 | .expect(200)
42 | .expect('Content-Type', /json/)
43 | .end((err, res) => {
44 | const actualBody = res.body;
45 |
46 | t.error(err, 'No error');
47 | t.ok(actualBody.user, 'User exists');
48 | t.ok(actualBody.token, 'Token exists');
49 |
50 | const decodedUser = jwt.verify(actualBody.token, authConfig.jwtSecret);
51 | delete decodedUser.iat;
52 |
53 | t.equal(actualBody.user.login, 'other', 'Login matches request');
54 | t.notOk(actualBody.user.password, 'No password included');
55 | t.deepEqual(actualBody.user, decodedUser, 'User must match token');
56 |
57 | app.set('other-token', actualBody.token);
58 | app.set('other-user', actualBody.user);
59 |
60 | t.end();
61 | });
62 | });
63 |
64 | test('Should fail to login with wrong password', (t) => {
65 | request(app)
66 | .post('/api/login')
67 | .send({login: 'test', password: 'aaa'})
68 | .expect(401)
69 | .end((err) => {
70 | t.error(err, 'No error');
71 | t.end();
72 | });
73 | });
74 |
75 | test('Should fail to login with non-existent user', (t) => {
76 | request(app)
77 | .post('/api/login')
78 | .send({login: 'donotexist', password: '123'})
79 | .expect(401)
80 | .end((err) => {
81 | t.error(err, 'No error');
82 | t.end();
83 | });
84 | });
85 | };
86 |
--------------------------------------------------------------------------------
/server/test/main.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import test from 'tape';
3 |
4 | // our packages
5 | import {thinky, r} from '../src/db';
6 |
7 | // tests
8 | import core from './core';
9 | import register from './register';
10 | import login from './login';
11 | import user from './user';
12 | import question from './question';
13 |
14 | thinky.dbReady().then(() => {
15 | // execute tests
16 | core(test);
17 | register(test);
18 | login(test);
19 | user(test);
20 | question(test);
21 |
22 | // close db connections
23 | test((t) => {
24 | setImmediate(() => r.getPoolMaster().drain());
25 | t.end();
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/server/test/question.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import request from 'supertest';
3 | import moment from 'moment';
4 |
5 | // our packages
6 | import app from '../src/app';
7 |
8 | export default (test) => {
9 | const sharedInput = {text: 'Do you like my awful coding?', expirationDate: moment().add(1, 'days').toDate()};
10 | const sharedInputOther = {text: 'Do you like things?', expirationDate: moment().add(2, 'days').toDate()};
11 | const updatedInput = {text: 'Update text question?', expirationDate: moment().add(3, 'days').toDate()};
12 |
13 | test('POST /api/question - should not create new question without text', (t) => {
14 | const input = {text: undefined, expirationDate: moment().add(1, 'days').toDate()};
15 | request(app)
16 | .post('/api/question')
17 | .set('x-access-token', app.get('token'))
18 | .send(input)
19 | .expect(400)
20 | .expect('Content-Type', /json/)
21 | .end((err, res) => {
22 | const expectedBody = {error: 'Text should be present!'};
23 | const actualBody = res.body;
24 |
25 | t.error(err, 'No error');
26 | t.deepEqual(actualBody, expectedBody, 'Retrieve correct error');
27 | t.end();
28 | });
29 | });
30 |
31 | test('POST /api/question - should not create new question with malformed date', (t) => {
32 | const input = {text: 'Am I a question?', expirationDate: 'not a date'};
33 | request(app)
34 | .post('/api/question')
35 | .set('x-access-token', app.get('token'))
36 | .send(input)
37 | .expect(400)
38 | .expect('Content-Type', /json/)
39 | .end((err, res) => {
40 | const expectedBody = {error: 'Date should be valid ISO Date!'};
41 | const actualBody = res.body;
42 |
43 | t.error(err, 'No error');
44 | t.deepEqual(actualBody, expectedBody, 'Retrieve correct error');
45 | t.end();
46 | });
47 | });
48 |
49 | test('GET /api/question - should get empty latest questions', (t) => {
50 | request(app)
51 | .get('/api/question')
52 | .set('x-access-token', app.get('token'))
53 | .expect(200)
54 | .expect('Content-Type', /json/)
55 | .end((err, res) => {
56 | const actualBody = res.body;
57 |
58 | t.error(err, 'No error');
59 | t.equal(actualBody.length, 0, 'Retrieve 0 questions');
60 |
61 | t.end();
62 | });
63 | });
64 |
65 | test('POST /api/question - create new question', (t) => {
66 | request(app)
67 | .post('/api/question')
68 | .set('x-access-token', app.get('token'))
69 | .send(sharedInput)
70 | .expect(200)
71 | .expect('Content-Type', /json/)
72 | .end((err, res) => {
73 | const actualBody = res.body;
74 |
75 | t.error(err, 'No error');
76 | t.equal(actualBody.text, sharedInput.text, 'Retrieve same question text');
77 | t.equal(actualBody.owner, app.get('user').id, 'Question belongs to correct user');
78 | t.ok(moment(actualBody.creationDate).isValid(), 'Creation date must be valid');
79 | t.ok(
80 | moment(actualBody.expirationDate).isSame(sharedInput.expirationDate),
81 | 'Retrieve same question expirationDate'
82 | );
83 |
84 | app.set('question', actualBody);
85 |
86 | t.end();
87 | });
88 | });
89 |
90 | test('POST /api/question/:id/answer - answer existing question', (t) => {
91 | const answer = 'test answer';
92 |
93 | request(app)
94 | .post(`/api/question/${app.get('question').id}/answer`)
95 | .set('x-access-token', app.get('token'))
96 | .send({answer})
97 | .expect(200)
98 | .expect('Content-Type', /json/)
99 | .end((err, res) => {
100 | const actualBody = res.body;
101 |
102 | t.error(err, 'No error');
103 | t.equal(actualBody.answers.length, 1, 'Retrieve one answer');
104 | t.equal(actualBody.answers[0].answer, answer, 'Retrieve same answer');
105 |
106 | app.set('question', actualBody);
107 |
108 | t.end();
109 | });
110 | });
111 |
112 | test('POST /api/question - create new question with different user', (t) => {
113 | request(app)
114 | .post('/api/question')
115 | .set('x-access-token', app.get('other-token'))
116 | .send(sharedInputOther)
117 | .expect(200)
118 | .expect('Content-Type', /json/)
119 | .end((err, res) => {
120 | const actualBody = res.body;
121 |
122 | t.error(err, 'No error');
123 | t.equal(actualBody.text, sharedInputOther.text, 'Retrieve same question text');
124 | t.equal(actualBody.owner, app.get('other-user').id, 'Question belongs to correct user');
125 | t.ok(moment(actualBody.creationDate).isValid(), 'Creation date must be valid');
126 | t.ok(
127 | moment(actualBody.expirationDate).isSame(sharedInputOther.expirationDate),
128 | 'Retrieve same question expirationDate'
129 | );
130 |
131 | app.set('other-question', actualBody);
132 |
133 | t.end();
134 | });
135 | });
136 |
137 | test('GET /api/question - get latest questions', (t) => {
138 | request(app)
139 | .get('/api/question')
140 | .set('x-access-token', app.get('token'))
141 | .expect(200)
142 | .expect('Content-Type', /json/)
143 | .end((err, res) => {
144 | const actualBody = res.body;
145 |
146 | t.error(err, 'No error');
147 | t.equal(actualBody.length, 2, 'Retrieve 2 questions');
148 | t.equal(actualBody[0].text, sharedInputOther.text, 'Retrieve same question text');
149 | t.equal(actualBody[0].owner.id, app.get('other-user').id, 'Question belongs to correct user');
150 | t.ok(moment(actualBody[0].creationDate).isValid(), 'Creation date must be valid');
151 | t.ok(
152 | moment(actualBody[0].expirationDate).isSame(sharedInputOther.expirationDate),
153 | 'Retrieve same question expirationDate'
154 | );
155 | t.equal(actualBody[1].text, sharedInput.text, 'Retrieve same question text');
156 | t.equal(actualBody[1].owner.id, app.get('user').id, 'Question belongs to correct user');
157 | t.ok(moment(actualBody[1].creationDate).isValid(), 'Creation date must be valid');
158 | t.ok(
159 | moment(actualBody[1].expirationDate).isSame(sharedInput.expirationDate),
160 | 'Retrieve same question expirationDate'
161 | );
162 |
163 | t.end();
164 | });
165 | });
166 |
167 | test('GET /api/question/:id - get question', (t) => {
168 | request(app)
169 | .get(`/api/question/${app.get('question').id}`)
170 | .set('x-access-token', app.get('token'))
171 | .expect(200)
172 | .expect('Content-Type', /json/)
173 | .end((err, res) => {
174 | const actualBody = res.body;
175 |
176 | t.error(err, 'No error');
177 | t.equal(actualBody.text, sharedInput.text, 'Retrieve same question text');
178 | t.equal(actualBody.owner, app.get('user').id, 'Question belongs to correct user');
179 | t.ok(moment(actualBody.creationDate).isValid(), 'Creation date must be valid');
180 | t.ok(
181 | moment(actualBody.expirationDate).isSame(sharedInput.expirationDate),
182 | 'Retrieve same question expirationDate'
183 | );
184 |
185 | t.end();
186 | });
187 | });
188 |
189 | test('POST /api/question/:id - should not update question without text', (t) => {
190 | request(app)
191 | .post(`/api/question/${app.get('question').id}`)
192 | .set('x-access-token', app.get('token'))
193 | .send({text: ''})
194 | .expect(400)
195 | .expect('Content-Type', /json/)
196 | .end((err, res) => {
197 | const expectedBody = {error: 'Text should be not empty!'};
198 | const actualBody = res.body;
199 |
200 | t.error(err, 'No error');
201 | t.deepEqual(actualBody, expectedBody, 'Retrieve correct error');
202 | t.end();
203 | });
204 | });
205 |
206 | test('POST /api/question/:id - should not update question with invalid date', (t) => {
207 | request(app)
208 | .post(`/api/question/${app.get('question').id}`)
209 | .set('x-access-token', app.get('token'))
210 | .send({expirationDate: 'not a date'})
211 | .expect(400)
212 | .expect('Content-Type', /json/)
213 | .end((err, res) => {
214 | const expectedBody = {error: 'Date should be valid ISO Date!'};
215 | const actualBody = res.body;
216 |
217 | t.error(err, 'No error');
218 | t.deepEqual(actualBody, expectedBody, 'Retrieve correct error');
219 | t.end();
220 | });
221 | });
222 |
223 | test('POST /api/question/:id - should not update non-existent question', (t) => {
224 | request(app)
225 | .post('/api/question/123')
226 | .set('x-access-token', app.get('token'))
227 | .send({text: 'Question?', expirationDate: moment().toDate()})
228 | .expect(400)
229 | .expect('Content-Type', /json/)
230 | .end((err, res) => {
231 | const actualBody = res.body;
232 |
233 | t.error(err, 'No error');
234 | t.ok(actualBody.error.indexOf('DocumentNotFoundError') !== -1, 'Retrieve correct error');
235 | t.end();
236 | });
237 | });
238 |
239 | test('POST /api/question/:id - should not update question of non-owner', (t) => {
240 | request(app)
241 | .post(`/api/question/${app.get('other-question').id}`)
242 | .set('x-access-token', app.get('token'))
243 | .send({text: 'Question?', expirationDate: moment().toDate()})
244 | .expect(403)
245 | .expect('Content-Type', /json/)
246 | .end((err, res) => {
247 | const expectedBody = {error: 'Not enough rights to change the question!'};
248 | const actualBody = res.body;
249 |
250 | t.error(err, 'No error');
251 | t.deepEqual(actualBody, expectedBody, 'Retrieve correct error');
252 | t.end();
253 | });
254 | });
255 |
256 | test('POST /api/question/:id - should get question back if same data is sent', (t) => {
257 | request(app)
258 | .post(`/api/question/${app.get('question').id}`)
259 | .set('x-access-token', app.get('token'))
260 | .send(sharedInput)
261 | .expect(200)
262 | .expect('Content-Type', /json/)
263 | .end((err, res) => {
264 | const expectedBody = app.get('question');
265 | const actualBody = res.body;
266 |
267 | t.error(err, 'No error');
268 | t.deepEqual(actualBody, expectedBody, 'Retrieve same question');
269 | t.end();
270 | });
271 | });
272 |
273 | test('POST /api/question/:id - should update question with new text', (t) => {
274 | request(app)
275 | .post(`/api/question/${app.get('question').id}`)
276 | .set('x-access-token', app.get('token'))
277 | .send({text: updatedInput.text})
278 | .expect(200)
279 | .expect('Content-Type', /json/)
280 | .end((err, res) => {
281 | const expectedBody = {
282 | ...app.get('question'),
283 | text: updatedInput.text,
284 | };
285 | const actualBody = res.body;
286 |
287 | t.error(err, 'No error');
288 | t.deepEqual(actualBody, expectedBody, 'Retrieve same question');
289 | t.end();
290 | });
291 | });
292 |
293 | test('POST /api/question/:id - should update question with new date', (t) => {
294 | request(app)
295 | .post(`/api/question/${app.get('question').id}`)
296 | .set('x-access-token', app.get('token'))
297 | .send({expirationDate: updatedInput.expirationDate})
298 | .expect(200)
299 | .expect('Content-Type', /json/)
300 | .end((err, res) => {
301 | const expectedBody = {
302 | ...app.get('question'),
303 | ...updatedInput,
304 | };
305 | const actualBody = res.body;
306 |
307 | // get dates
308 | const actualDate = actualBody.expirationDate;
309 | const expectedDate = expectedBody.expirationDate;
310 | // delete from objects
311 | delete actualBody.expirationDate;
312 | delete expectedBody.expirationDate;
313 |
314 | // compare
315 | t.error(err, 'No error');
316 | t.deepEqual(actualBody, expectedBody, 'Retrieve same question');
317 | t.ok(moment(actualDate).isSame(expectedDate), 'Retrieve same dates');
318 | t.end();
319 | });
320 | });
321 |
322 | test('DELETE /api/question/:id - should not delete question with different owner', (t) => {
323 | request(app)
324 | .delete(`/api/question/${app.get('other-question').id}`)
325 | .set('x-access-token', app.get('token'))
326 | .expect(403)
327 | .expect('Content-Type', /json/)
328 | .end((err, res) => {
329 | const expectedBody = {error: 'Not enough rights to delete the question!'};
330 | const actualBody = res.body;
331 |
332 | // compare
333 | t.error(err, 'No error');
334 | t.deepEqual(actualBody, expectedBody, 'Retrieve same question');
335 | t.end();
336 | });
337 | });
338 |
339 | test('DELETE /api/question/:id - should delete question', (t) => {
340 | request(app)
341 | .delete(`/api/question/${app.get('question').id}`)
342 | .set('x-access-token', app.get('token'))
343 | .expect(204)
344 | .end((err) => {
345 | // compare
346 | t.error(err, 'No error');
347 |
348 | // try to get it and expect to fail
349 | t.test(' - GET /api/question/:id - should fail to get deleted question', (st) => {
350 | request(app)
351 | .get(`/api/question/${app.get('question').id}`)
352 | .set('x-access-token', app.get('token'))
353 | .expect(400)
354 | .end((e, res) => {
355 | const actualBody = res.body;
356 |
357 | st.error(e, 'No error');
358 | st.ok(actualBody.error.indexOf('DocumentNotFoundError') !== -1, 'Retrieve correct error');
359 | st.end();
360 |
361 | // end delete test
362 | t.end();
363 | });
364 | });
365 | });
366 | });
367 | };
368 |
--------------------------------------------------------------------------------
/server/test/register.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import request from 'supertest';
3 |
4 | // our packages
5 | import app from '../src/app';
6 |
7 | export default (test) => {
8 | test('Should register with given username and password', (t) => {
9 | request(app)
10 | .post('/api/register')
11 | .send({login: 'test', password: '123', passwordRepeat: '123'})
12 | .expect(201)
13 | .end((err) => {
14 | t.error(err, 'No error');
15 | t.end();
16 | });
17 | });
18 |
19 | test('Should register second user with given username and password', (t) => {
20 | request(app)
21 | .post('/api/register')
22 | .send({login: 'other', password: '321', passwordRepeat: '321'})
23 | .expect(201)
24 | .end((err) => {
25 | t.error(err, 'No error');
26 | t.end();
27 | });
28 | });
29 |
30 | test('Should fail to register with same username', (t) => {
31 | request(app)
32 | .post('/api/register')
33 | .send({login: 'test', password: 'aaa', passwordRepeat: 'aaa'})
34 | .expect(403)
35 | .end((err, res) => {
36 | const expectedBody = {error: 'User already exists!'};
37 | const actualBody = res.body;
38 |
39 | t.error(err, 'No error');
40 | t.deepEqual(actualBody, expectedBody, 'Retrieve body');
41 | t.end();
42 | });
43 | });
44 |
45 | test('Should fail to register with mismatching passwords', (t) => {
46 | request(app)
47 | .post('/api/register')
48 | .send({login: 'test', password: '123', passwordRepeat: '321'})
49 | .expect(400)
50 | .expect('Content-Type', /json/)
51 | .end((err, res) => {
52 | const expectedBody = {error: 'Passwords do not match!'};
53 | const actualBody = res.body;
54 |
55 | t.error(err, 'No error');
56 | t.deepEqual(actualBody, expectedBody, 'Retrieve body');
57 | t.end();
58 | });
59 | });
60 | };
61 |
--------------------------------------------------------------------------------
/server/test/user.js:
--------------------------------------------------------------------------------
1 | // npm packages
2 | import request from 'supertest';
3 | import jwt from 'jsonwebtoken';
4 |
5 | // our packages
6 | import app from '../src/app';
7 | import {auth as authConfig} from '../config';
8 |
9 | export default (test) => {
10 | test('GET /api/user/:id', (t) => {
11 | request(app)
12 | .get(`/api/user/${app.get('user').id}`)
13 | .set('x-access-token', app.get('token'))
14 | .expect(200)
15 | .expect('Content-Type', /json/)
16 | .end((err, res) => {
17 | const expectedBody = app.get('user');
18 | const actualBody = res.body;
19 |
20 | t.error(err, 'No error');
21 | t.deepEqual(actualBody, expectedBody, 'Retrieve user');
22 | t.notOk(actualBody.password, 'No password included');
23 | t.end();
24 | });
25 | });
26 |
27 | test('GET /api/user/me', (t) => {
28 | request(app)
29 | .get('/api/user/me')
30 | .set('x-access-token', app.get('token'))
31 | .expect(200)
32 | .expect('Content-Type', /json/)
33 | .end((err, res) => {
34 | const expectedBody = app.get('user');
35 | const actualBody = res.body;
36 |
37 | t.error(err, 'No error');
38 | t.deepEqual(actualBody, expectedBody, 'Retrieve user');
39 | t.notOk(actualBody.password, 'No password included');
40 | t.end();
41 | });
42 | });
43 |
44 | test('GET /api/user/:id with non-existent id', (t) => {
45 | request(app)
46 | .get('/api/user/1234')
47 | .set('x-access-token', app.get('token'))
48 | .expect(400)
49 | .expect('Content-Type', /json/)
50 | .end((err, res) => {
51 | const expectedBody = {error: 'User does not exist'};
52 | const actualBody = res.body;
53 |
54 | t.error(err, 'No error');
55 | t.deepEqual(actualBody, expectedBody, 'Get correct error');
56 | t.end();
57 | });
58 | });
59 |
60 | test('POST /api/user/:id - should not allow change not self', (t) => {
61 | request(app)
62 | .post('/api/user/1234')
63 | .set('x-access-token', app.get('token'))
64 | .send({login: 'test123'})
65 | .expect(403)
66 | .expect('Content-Type', /json/)
67 | .end((err, res) => {
68 | const expectedBody = {error: 'Not enough rights to change other user profile!'};
69 | const actualBody = res.body;
70 |
71 | t.error(err, 'No error');
72 | t.deepEqual(actualBody, expectedBody, 'Get correct error');
73 | t.end();
74 | });
75 | });
76 |
77 | test('POST /api/user/:id - update with same data', (t) => {
78 | request(app)
79 | .post(`/api/user/${app.get('user').id}`)
80 | .set('x-access-token', app.get('token'))
81 | .send({login: 'test', password: '123'})
82 | .expect(200)
83 | .expect('Content-Type', /json/)
84 | .end((err, res) => {
85 | const expectedBody = app.get('user');
86 | const actualBody = res.body;
87 |
88 | t.error(err, 'No error');
89 | t.deepEqual(actualBody, expectedBody, 'Retrieve new user');
90 | t.notOk(actualBody.password, 'No password included');
91 | t.end();
92 | });
93 | });
94 |
95 | test('POST /api/user/:id - should throw error if new passwords do not match', (t) => {
96 | request(app)
97 | .post(`/api/user/${app.get('user').id}`)
98 | .set('x-access-token', app.get('token'))
99 | .send({password: '1234', passwordRepeat: '321'})
100 | .expect(400)
101 | .expect('Content-Type', /json/)
102 | .end((err, res) => {
103 | const expectedBody = {error: 'Passwords do not match!'};
104 | const actualBody = res.body;
105 |
106 | t.error(err, 'No error');
107 | t.deepEqual(actualBody, expectedBody, 'Retrieve correct error');
108 | t.end();
109 | });
110 | });
111 |
112 | test('POST /api/user/:id - update with existing login', (t) => {
113 | request(app)
114 | .post(`/api/user/${app.get('user').id}`)
115 | .set('x-access-token', app.get('token'))
116 | .send({login: 'other'})
117 | .expect(400)
118 | .expect('Content-Type', /json/)
119 | .end((err, res) => {
120 | const expectedBody = {error: 'Login already taken!'};
121 | const actualBody = res.body;
122 |
123 | t.error(err, 'No error');
124 | t.deepEqual(actualBody, expectedBody, 'Retrieve correct error');
125 | t.end();
126 | });
127 | });
128 |
129 | test('POST /api/user/:id - update with new login', (t) => {
130 | request(app)
131 | .post(`/api/user/${app.get('user').id}`)
132 | .set('x-access-token', app.get('token'))
133 | .send({login: 'test123'})
134 | .expect(200)
135 | .expect('Content-Type', /json/)
136 | .end((err, res) => {
137 | const expectedBody = {
138 | ...app.get('user'),
139 | login: 'test123',
140 | };
141 | const actualBody = res.body;
142 |
143 | t.error(err, 'No error');
144 | t.deepEqual(actualBody, expectedBody, 'Retrieve new user');
145 | t.notOk(actualBody.password, 'No password included');
146 | t.end();
147 | });
148 | });
149 |
150 | test('POST /api/user/:id - update with new password', (t) => {
151 | request(app)
152 | .post(`/api/user/${app.get('user').id}`)
153 | .set('x-access-token', app.get('token'))
154 | .send({password: 'asd', passwordRepeat: 'asd'})
155 | .expect(200)
156 | .expect('Content-Type', /json/)
157 | .end((err, res) => {
158 | const expectedBody = {
159 | ...app.get('user'),
160 | login: 'test123',
161 | };
162 | const actualBody = res.body;
163 |
164 | t.error(err, 'No error');
165 | t.deepEqual(actualBody, expectedBody, 'Retrieve new user');
166 | t.notOk(actualBody.password, 'No password included');
167 | t.end();
168 | });
169 | });
170 |
171 | test('Should login with updated username and password', (t) => {
172 | request(app)
173 | .post('/api/login')
174 | .send({login: 'test123', password: 'asd'})
175 | .expect(200)
176 | .expect('Content-Type', /json/)
177 | .end((err, res) => {
178 | const actualBody = res.body;
179 |
180 | t.error(err, 'No error');
181 | t.ok(actualBody.user, 'User exists');
182 | t.ok(actualBody.token, 'Token exists');
183 |
184 | const decodedUser = jwt.verify(actualBody.token, authConfig.jwtSecret);
185 | delete decodedUser.iat;
186 |
187 | t.equal(actualBody.user.login, 'test123', 'Login matches request');
188 | t.notOk(actualBody.user.password, 'No password included');
189 | t.deepEqual(actualBody.user, decodedUser, 'User must match token');
190 |
191 | app.set('token', actualBody.token);
192 | app.set('user', actualBody.user);
193 |
194 | t.end();
195 | });
196 | });
197 | };
198 |
--------------------------------------------------------------------------------
/server/util/db/create.js:
--------------------------------------------------------------------------------
1 | /* eslint no-console: 0 */
2 |
3 | // node module
4 | const exec = require('child_process').exec;
5 | const path = require('path');
6 |
7 | // dir to store the data in
8 | const dbPath = path.join(__dirname, '..', '..', 'db');
9 |
10 | // docker run command
11 | const cmd = `docker run -d -p 28015:28015 -p 8090:8080 -v ${dbPath}:/data --name expertsdb rethinkdb`;
12 |
13 | // execute command
14 | const start = exec(cmd);
15 |
16 | // remember if docker is installing image
17 | let dbImage = false;
18 |
19 | // runs when command writes to stdout
20 | start.stdout.on('data', (data) => {
21 | if (data) {
22 | console.log('Successfully created expertsdb\n');
23 | }
24 | });
25 |
26 | // runs when command writes to stderr
27 | start.stderr.on('data', (data) => {
28 | if (data === "Unable to find image 'rethinkdb:latest' locally\n" || dbImage) {
29 | console.log(data);
30 | dbImage = true;
31 | } else {
32 | console.log('Error while creating expertsdb:', data);
33 | }
34 | });
35 |
--------------------------------------------------------------------------------