update, lint, prettify everything

put backend sources into src
setup circleci
don't use react-loadable because it seems unnecessary
This commit is contained in:
2021-03-13 19:58:06 +03:00
parent f19a55790c
commit afd1f98254
98 changed files with 13582 additions and 10832 deletions

73
.circleci/config.yml Normal file
View File

@@ -0,0 +1,73 @@
version: 2
jobs:
test-backend:
docker:
- image: circleci/node:14
working_directory: ~/todolist
steps:
- checkout
- restore_cache:
keys:
- backend-dependencies-{{ checksum "package.json" }}
- run:
name: install backend deps
command: npm i
- save_cache:
paths:
- node_modules
key: backend-dependencies-{{ checksum "package.json" }}
- run:
name: test backend
command: npm test
test-frontend:
docker:
- image: circleci/node:14
working_directory: ~/todolist/client
steps:
- checkout:
- restore_cache:
keys:
- backend-dependencies-{{ checksum "package.json" }}
- run:
name: install backend deps
command: npm i
- save_cache:
paths:
- node_modules
key: backend-dependencies-{{ checksum "package.json" }}
- restore_cache:
keys:
- frontend-dependencies-{{ checksum "package.json" }}
- run:
name: install frontend deps
command: cd client && npm i
- save_cache:
paths:
- client/node_modules
key: frontend-dependencies-{{ checksum "package.json" }}
- run:
name: test client
command: cd client && npm test
workflows:
version: 2
test:
jobs:
- test-backend
- test-frontend

View File

@@ -1,2 +1,5 @@
client/build/*
client/node_modules/*
*.css
*.scss
**package-lock.json

View File

@@ -1,39 +1,11 @@
{
"root": true,
"extends": [
"eslint:recommended",
"plugin:node/recommended",
"plugin:jest/recommended"
],
"rules": {
"no-unused-expressions": [
"error",
{
"allowTernary": true
}
],
"no-console": "warn",
"no-underscore-dangle": [
"warn",
{
"allow": ["_id"]
}
],
"func-names": "off",
"node/no-unsupported-features/es-syntax": [
"error",
{
"version": ">=10.0.0",
"ignores": []
}
],
"node/no-unsupported-features/es-builtins": [
"error",
{
"version": ">=10.0.0",
"ignores": []
}
]
},
"rules": {},
"parserOptions": {
"sourceType": "module"
},

View File

@@ -1,19 +0,0 @@
image: node:10
stages:
- test-backend
cache:
paths:
- node_modules/
- client/node_modules
test-backend:
stage: test-backend
services:
- mongo:4
script:
- npm i npm@latest -g
- npm i
- npm test

View File

@@ -1,4 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all"
"trailingComma": "all",
"tabWidth": 4,
"endOfLine": "auto"
}

11
.vscode/settings.json vendored
View File

@@ -1,6 +1,11 @@
{
"editor.tabSize": 2,
"prettier.eslintIntegration": true,
"eslint.workingDirectories": [
".",
"./client"
],
"search.exclude": {
"**/package-lock.json": true
},
"editor.insertSpaces": true,
"jest.pathToJest": "npm test --"
"editor.tabSize": 4
}

119
app.js
View File

@@ -1,119 +0,0 @@
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const morgan = require('morgan');
const cors = require('cors');
const path = require('path');
const hsts = require('hsts');
const compression = require('compression');
const { redirectToHTTPS } = require('express-http-to-https');
const db = require('./config/db');
const config = require('./config');
require('./models/TodoList');
require('./models/User');
require('./models/Todo');
const app = express();
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
app.use(cors());
app.use(compression());
process.env.NODE_ENV === 'production'
? app.use(morgan('combined'))
: app.use(morgan('dev'));
if (process.env.NODE_ENV === 'production' && process.env.HSTS === 'true') {
app.use(redirectToHTTPS([/localhost:(\d{4})/]));
app.use(
hsts({
maxAge: 31536000,
includeSubDomains: true,
}),
);
}
const passport = require('./config/passport');
app.use(passport.initialize());
// Addresses, starting with /__, are not cached by service worker
// https://github.com/facebook/create-react-app/issues/2237
app.use('/__/users', require('./routes/users'));
const auth = require('./routes/auth');
app.use('/__/lists', auth.required, require('./routes/lists'));
app.use('/__/todos', auth.required, require('./routes/todos'));
if (
process.env.NODE_ENV === 'production' ||
process.env.NODE_ENV === 'development'
) {
app.use(express.static(path.join(__dirname, 'client/build')));
app.use('*', express.static(path.join(__dirname, 'client/build/index.html')));
}
// 404 route
app.use((req, res) => {
res.status(404);
if (req.accepts('html')) {
res.send('404');
return;
}
if (req.accepts('json')) {
res.send({ error: 'Not found' });
return;
}
res.type('txt').send('not found');
});
// handle errors
app.use((error, req, res, next) => {
if (error.status) {
res.status(error.status);
} else {
switch (error.name) {
case 'ValidationError':
case 'MissingPasswordError':
case 'BadRequest':
case 'BadRequestError':
res.status(400);
break;
case 'AuthenticationError':
case 'UnauthorizedError':
res.status(401);
break;
case 'NotFound':
res.status(404);
break;
default:
res.status(500);
}
}
res.json({ success: false, error });
if (
process.env.NODE_ENV === 'production' ||
process.env.NODE_ENV === 'test'
) {
console.error(error);
}
next(error);
});
let server;
if (process.env.NODE_ENV !== 'test') {
db.connect();
server = app.listen(config.app.port, () => {
console.log(`Listening on port ${config.app.port}`);
console.log('Started!');
});
} else {
server = app;
}
module.exports = server;

View File

@@ -1,3 +0,0 @@
module.exports = fn => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};

View File

@@ -1,25 +1,22 @@
{
"root": true,
"extends": [
"eslint:recommended",
"plugin:jest/recommended",
"plugin:react/recommended"
],
"rules": {
"react/jsx-filename-extension": [
1,
{
"extensions": [".js", ".jsx"]
}
],
"linebreak-style": "off",
"react/forbid-prop-types": "off",
"node/no-unsupported-features/es-syntax": ["off"],
"node/no-unsupported-features/es-builtins": ["off"],
"node/no-unsupported-features/node-builtins": ["off"],
"react/display-name": ["warn"],
"no-console": "warn"
"react/display-name": "warn"
},
"parserOptions": {
"ecmaVersion": 2018,
"ecmaFeatures": {
"jsx": true
},
"sourceType": "module"
},
"env": {
"browser": true
"browser": true,
"node": true
}
}

11249
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,28 +3,29 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/core": "^4.11.3",
"@material-ui/icons": "^4.11.2",
"@redux-offline/redux-offline": "^2.6.0",
"http-proxy-middleware": "^1.0.6",
"prop-types": "^15.7.2",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-loadable": "^5.5.0",
"react-redux": "^7.2.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-redux": "^7.2.2",
"react-router-dom": "^5.2.0",
"react-router-redux": "^4.0.8",
"react-scripts": "3.4.3",
"react-spring": "^5.0.0",
"react-scripts": "4.0.3",
"react-spring": "^8.0.27",
"redux": "^4.0.5",
"redux-form": "^8.3.6",
"redux-form": "^8.3.7",
"redux-thunk": "^2.3.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
"eject": "react-scripts eject",
"lint": "eslint ./src/** --ext .js,.jsx,.ts,.tsx",
"lint-fix": "eslint ./src/** --ext .js,.jsx,.ts,.tsx --fix"
},
"browserslist": [
">0.2%",

View File

@@ -1,42 +1,42 @@
export const ADD_LIST = 'ADD_LIST';
export const REMOVE_LIST = 'REMOVE_LIST';
export const EDIT_LIST_NAME = 'EDIT_LIST_NAME';
export const RECIEVE_LISTS = 'RECIEVE_LISTS';
export const REQUEST_LISTS = 'REQUEST_LISTS';
export const INVALIDATE_LISTS = 'INVALIDATE_LISTS';
export const VALIDATE_LISTS = 'VALIDATE_LISTS';
export const CHANGE_LIST = 'CHANGE_LIST';
export const START_CREATE_LIST = 'START_CREATE_LIST';
export const START_EDIT_LIST = 'START_EDIT_LIST';
export const STOP_CREATE_LIST = 'STOP_CREATE_LIST';
export const STOP_EDIT_LIST = 'STOP_EDIT_LIST';
export const ADD_LIST = "ADD_LIST";
export const REMOVE_LIST = "REMOVE_LIST";
export const EDIT_LIST_NAME = "EDIT_LIST_NAME";
export const RECIEVE_LISTS = "RECIEVE_LISTS";
export const REQUEST_LISTS = "REQUEST_LISTS";
export const INVALIDATE_LISTS = "INVALIDATE_LISTS";
export const VALIDATE_LISTS = "VALIDATE_LISTS";
export const CHANGE_LIST = "CHANGE_LIST";
export const START_CREATE_LIST = "START_CREATE_LIST";
export const START_EDIT_LIST = "START_EDIT_LIST";
export const STOP_CREATE_LIST = "STOP_CREATE_LIST";
export const STOP_EDIT_LIST = "STOP_EDIT_LIST";
export const ADD_TODO = 'ADD_TODO';
export const REMOVE_TODO = 'REMOVE_TODO';
export const TOGGLE_TODO = 'TOGGLE_TODO';
export const EDIT_TODO = 'EDIT_TODO';
export const SET_VISIBILITY_FILTER = 'SET_VISIBILITY_FILTER';
export const RECIEVE_TODOS = 'RECIEVE_TODOS';
export const REQUEST_TODOS = 'REQUEST_TODOS';
export const INVALIDATE_TODOS = 'INVALIDATE_TODOS';
export const VALIDATE_TODOS = 'VALIDATE_TODOS';
export const ADD_TODO = "ADD_TODO";
export const REMOVE_TODO = "REMOVE_TODO";
export const TOGGLE_TODO = "TOGGLE_TODO";
export const EDIT_TODO = "EDIT_TODO";
export const SET_VISIBILITY_FILTER = "SET_VISIBILITY_FILTER";
export const RECIEVE_TODOS = "RECIEVE_TODOS";
export const REQUEST_TODOS = "REQUEST_TODOS";
export const INVALIDATE_TODOS = "INVALIDATE_TODOS";
export const VALIDATE_TODOS = "VALIDATE_TODOS";
export const VisibilityFilters = {
SHOW_ALL: 'SHOW_ALL',
SHOW_COMPLETED: 'SHOW_COMPLETED',
SHOW_ACTIVE: 'SHOW_ACTIVE',
SHOW_ALL: "SHOW_ALL",
SHOW_COMPLETED: "SHOW_COMPLETED",
SHOW_ACTIVE: "SHOW_ACTIVE",
};
export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
export const LOGIN_FAIL = 'LOGIN_FAIL';
export const SIGNUP_SUCCESS = 'SIGNUP_SUCCESS';
export const SIGNUP_FAIL = 'SIGNUP_FAIL';
export const LOGOUT = 'LOGOUT';
export const START_LOGIN = 'INVALIDATE_USER';
export const REQUEST_USER = 'REQUEST_USER';
export const VALIDATE_USER = 'VALIDATE_USER';
export const RESET_USER = 'RESET_USER';
export const EDIT_START = 'EDIT_START';
export const EDIT_SUCCESS = 'EDIT_SUCCESS';
export const EDIT_FAIL = 'EDIT_FAIL';
export const RESET_EDIT = 'RESET_EDIT';
export const LOGIN_SUCCESS = "LOGIN_SUCCESS";
export const LOGIN_FAIL = "LOGIN_FAIL";
export const SIGNUP_SUCCESS = "SIGNUP_SUCCESS";
export const SIGNUP_FAIL = "SIGNUP_FAIL";
export const LOGOUT = "LOGOUT";
export const START_LOGIN = "INVALIDATE_USER";
export const REQUEST_USER = "REQUEST_USER";
export const VALIDATE_USER = "VALIDATE_USER";
export const RESET_USER = "RESET_USER";
export const EDIT_START = "EDIT_START";
export const EDIT_SUCCESS = "EDIT_SUCCESS";
export const EDIT_FAIL = "EDIT_FAIL";
export const RESET_EDIT = "RESET_EDIT";

View File

@@ -11,9 +11,9 @@ import {
REMOVE_LIST,
EDIT_LIST_NAME,
RECIEVE_TODOS,
} from './defs';
} from "./defs";
import { API_ROOT, getToken, mongoObjectId } from './util';
import { API_ROOT, getToken, mongoObjectId } from "./util";
function requestLists() {
return { type: REQUEST_LISTS };
@@ -38,7 +38,7 @@ export function stopEditList() {
}
export function addList(name) {
return async dispatch => {
return async (dispatch) => {
const id = mongoObjectId();
dispatch({
type: ADD_LIST,
@@ -54,9 +54,9 @@ export function addList(name) {
body: JSON.stringify({ name, id }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
"content-type": "application/json",
},
method: 'POST',
method: "POST",
},
rollback: {
type: INVALIDATE_LISTS,
@@ -80,9 +80,9 @@ export function removeList() {
url: `${API_ROOT}/lists/${list}`,
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
"content-type": "application/json",
},
method: 'DELETE',
method: "DELETE",
},
rollback: {
type: INVALIDATE_LISTS,
@@ -108,9 +108,9 @@ export function editList(name) {
body: JSON.stringify({ name }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
"content-type": "application/json",
},
method: 'PATCH',
method: "PATCH",
},
rollback: {
type: INVALIDATE_LISTS,
@@ -135,7 +135,7 @@ function normalizeTodos(lists) {
}
export function fetchLists() {
return async dispatch => {
return async (dispatch) => {
dispatch(requestLists());
const response = await fetch(`${API_ROOT}/lists`, {
headers: {
@@ -151,7 +151,7 @@ export function fetchLists() {
fetching: false,
editing: false,
...curList,
todos: curList.todos.map(todo => todo.id),
todos: curList.todos.map((todo) => todo.id),
};
return newObj;
}, {});

View File

@@ -6,12 +6,12 @@ import {
TOGGLE_TODO,
EDIT_TODO,
INVALIDATE_LISTS,
} from './defs';
} from "./defs";
import { API_ROOT, getToken, mongoObjectId } from './util';
import { API_ROOT, getToken, mongoObjectId } from "./util";
export function fetchTodos() {
return async dispatch => {
return async (dispatch) => {
dispatch({ type: REQUEST_TODOS });
const response = await fetch(`${API_ROOT}/todos`, {
headers: {
@@ -44,9 +44,9 @@ export function addTodo(text) {
body: JSON.stringify({ text, id }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
"content-type": "application/json",
},
method: 'POST',
method: "POST",
},
rollback: {
type: INVALIDATE_LISTS,
@@ -59,7 +59,7 @@ export function addTodo(text) {
}
export function removeTodo(id) {
return async dispatch => {
return async (dispatch) => {
dispatch({
type: REMOVE_TODO,
id,
@@ -69,9 +69,9 @@ export function removeTodo(id) {
url: `${API_ROOT}/todos/${id}`,
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
"content-type": "application/json",
},
method: 'DELETE',
method: "DELETE",
},
rollback: {
type: INVALIDATE_LISTS,
@@ -97,9 +97,9 @@ export function toggleTodo(id) {
body: JSON.stringify({ completed }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
"content-type": "application/json",
},
method: 'PATCH',
method: "PATCH",
},
rollback: {
type: INVALIDATE_LISTS,
@@ -111,7 +111,7 @@ export function toggleTodo(id) {
}
export function editTodo(id, text) {
return async dispatch => {
return async (dispatch) => {
dispatch({
type: EDIT_TODO,
id,
@@ -123,9 +123,9 @@ export function editTodo(id, text) {
body: JSON.stringify({ text }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
"content-type": "application/json",
},
method: 'PATCH',
method: "PATCH",
},
rollback: {
type: INVALIDATE_LISTS,

View File

@@ -11,10 +11,10 @@ import {
EDIT_SUCCESS,
EDIT_FAIL,
RESET_EDIT,
} from './defs';
} from "./defs";
import { API_ROOT, getToken, setToken } from './util';
import { fetchLists } from './lists';
import { API_ROOT, getToken, setToken } from "./util";
import { fetchLists } from "./lists";
function startLogin() {
return { type: START_LOGIN };
@@ -33,14 +33,14 @@ function validateUser() {
}
export function loadUser() {
return async dispatch => {
return async (dispatch) => {
if (getToken()) {
const response = await fetch(`${API_ROOT}/users/user`, {
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
"content-type": "application/json",
},
method: 'GET',
method: "GET",
});
const json = await response.json();
if (json.success) {
@@ -56,14 +56,14 @@ export function loadUser() {
}
export function login(user) {
return async dispatch => {
return async (dispatch) => {
dispatch(startLogin());
const response = await fetch(`${API_ROOT}/users/login`, {
body: JSON.stringify(user),
headers: {
'content-type': 'application/json',
"content-type": "application/json",
},
method: 'POST',
method: "POST",
});
const json = await response.json();
if (json.success) {
@@ -77,14 +77,14 @@ export function login(user) {
}
export function loginJWT(jwt) {
return async dispatch => {
return async (dispatch) => {
dispatch(startLogin());
const response = await fetch(`${API_ROOT}/users/user`, {
headers: {
'content-type': 'application/json',
"content-type": "application/json",
Authorization: `Bearer ${jwt}`,
},
method: 'GET',
method: "GET",
});
const json = await response.json();
if (json.success) {
@@ -106,14 +106,14 @@ function signupFail(error) {
}
export function signup(user) {
return async dispatch => {
return async (dispatch) => {
dispatch(startLogin());
const response = await fetch(`${API_ROOT}/users`, {
body: JSON.stringify(user),
headers: {
'content-type': 'application/json',
"content-type": "application/json",
},
method: 'POST',
method: "POST",
});
const json = await response.json();
if (json.success) {
@@ -126,7 +126,6 @@ export function signup(user) {
};
}
function startEdit(user) {
return { type: EDIT_START, user };
}
@@ -140,15 +139,15 @@ function editFail(error) {
}
export function edit(user) {
return async dispatch => {
return async (dispatch) => {
dispatch(startEdit());
const response = await fetch(`${API_ROOT}/users/user`, {
body: JSON.stringify(user),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
"content-type": "application/json",
},
method: 'PATCH',
method: "PATCH",
});
const json = await response.json();
if (json.success) {
@@ -160,19 +159,18 @@ export function edit(user) {
}
export function deleteUser() {
return async dispatch => {
return async (dispatch) => {
await fetch(`${API_ROOT}/users/user`, {
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
"content-type": "application/json",
},
method: 'DELETE',
method: "DELETE",
});
dispatch(reset());
};
}
export function resetEdit() {
return { type: RESET_EDIT };
}
@@ -182,7 +180,7 @@ export function reset() {
}
export function logout() {
return async dispatch => {
return async (dispatch) => {
dispatch({ type: LOGOUT });
};
}

View File

@@ -1,4 +1,4 @@
export const API_ROOT = '/__';
export const API_ROOT = "/__";
let token = null;
@@ -15,7 +15,7 @@ export function mongoObjectId() {
const timestamp = ((new Date().getTime() / 1000) | 0).toString(16);
return (
timestamp +
'xxxxxxxxxxxxxxxx'
"xxxxxxxxxxxxxxxx"
// eslint-disable-next-line
.replace(/[x]/g, () => ((Math.random() * 16) | 0).toString(16))
.toLowerCase()

View File

@@ -1,4 +1,4 @@
import { SET_VISIBILITY_FILTER } from './defs';
import { SET_VISIBILITY_FILTER } from "./defs";
export default function setVisibilityFilter(filter) {
return { type: SET_VISIBILITY_FILTER, filter };

View File

@@ -64,7 +64,7 @@
#input {
color: black;
background: white;
font-family: 'Roboto';
font-family: "Roboto";
box-sizing: border-box;
font-size: 1rem;
flex-grow: 1;
@@ -215,7 +215,7 @@ textarea.todo--input {
width: 100%;
height: 100%;
max-height: 100%;
font-family: 'Roboto';
font-family: "Roboto";
font-size: 1rem;
transition: 0.1s ease-in-out;
}

View File

@@ -1,59 +1,24 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import CssBaseline from '@material-ui/core/CssBaseline';
import Loadable from 'react-loadable';
import * as React from "react";
import PropTypes from "prop-types";
import { BrowserRouter as Router, Route } from "react-router-dom";
import CssBaseline from "@material-ui/core/CssBaseline";
import Protected from './Protected';
import OnlyUnauth from './OnlyUnauth';
import './Container.css';
import './App.css';
import Protected from "./Protected";
import OnlyUnauth from "./OnlyUnauth";
import "./Container.css";
import "./App.css";
import TodosView from "./todolist/TodosView";
import LoginForm from "./user/LoginForm";
import SignupForm from "./user/SignupForm";
import EditForm from "./user/EditForm";
function Loading(props) {
if (props.error) {
return (
<div>
Error! <button onClick={props.retry}>Retry</button>
</div>
);
} else if (props.pastDelay) {
return <div>Loading...</div>;
} else {
return null;
}
}
const ProtectedTodosView = Protected(TodosView);
const LoadableTodosView = Protected(
Loadable({
loader: () => import('./todolist/TodosView'),
loading: () => Loading,
delay: 1000,
}),
);
const ProtectedLoginForm = OnlyUnauth(LoginForm);
const LoadableLoginForm = OnlyUnauth(
Loadable({
loader: () => import('./user/LoginForm'),
loading: () => Loading,
delay: 1000,
}),
);
const ProtectedSignupForm = OnlyUnauth(SignupForm);
const LoadableSignupForm = OnlyUnauth(
Loadable({
loader: () => import('./user/SignupForm'),
loading: () => Loading,
delay: 1000,
}),
);
const LoadableEditView = Protected(
Loadable({
loader: () => import('./user/EditForm'),
loading: () => Loading,
delay: 1000,
}),
);
const ProtectedEditView = Protected(EditForm);
export default class App extends React.PureComponent {
componentDidMount() {
@@ -67,10 +32,10 @@ export default class App extends React.PureComponent {
<CssBaseline />
<Router>
<div id="container">
<Route exact path="/" component={LoadableTodosView} />
<Route path="/login" component={LoadableLoginForm} />
<Route path="/signup" component={LoadableSignupForm} />
<Route path="/edit" component={LoadableEditView} />
<Route exact path="/" component={ProtectedTodosView} />
<Route path="/login" component={ProtectedLoginForm} />
<Route path="/signup" component={ProtectedSignupForm} />
<Route path="/edit" component={ProtectedEditView} />
</div>
</Router>
</React.Fragment>

View File

@@ -1,8 +1,8 @@
import { connect } from 'react-redux';
import { connect } from "react-redux";
import App from './App';
import App from "./App";
import { loadUser } from '../actions/user';
import { loadUser } from "../actions/user";
function mapStateToProps(state) {
return {
@@ -16,7 +16,4 @@ function mapDispatchToProps(dispatch) {
};
}
export default connect(
mapStateToProps,
mapDispatchToProps,
)(App);
export default connect(mapStateToProps, mapDispatchToProps)(App);

View File

@@ -1,9 +1,9 @@
@import url('https://fonts.googleapis.com/css?family=Roboto:300,400,500|Material+Icons');
@import url("https://fonts.googleapis.com/css?family=Roboto:300,400,500|Material+Icons");
body {
background: white;
color: black;
font-family: 'Roboto';
font-family: "Roboto";
user-select: none;
}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import UserHeader from './user/UserHeader';
import Lists from './lists/Lists';
import React from "react";
import UserHeader from "./user/UserHeader";
import Lists from "./lists/Lists";
export default function Header() {
return (

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { Redirect } from 'react-router-dom';
import { connect } from 'react-redux';
import * as React from "react";
import PropTypes from "prop-types";
import { Redirect } from "react-router-dom";
import { connect } from "react-redux";
export default function OnlyUnauth(WrappedComponent) {
function Component({ loggedIn }) {
@@ -18,8 +18,5 @@ export default function OnlyUnauth(WrappedComponent) {
};
}
return connect(
mapStateToProps,
null,
)(Component);
return connect(mapStateToProps, null)(Component);
}

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { Redirect } from 'react-router-dom';
import { connect } from 'react-redux';
import * as React from "react";
import PropTypes from "prop-types";
import { Redirect } from "react-router-dom";
import { connect } from "react-redux";
export default function Protected(WrappedComponent) {
function Component({ loggedIn }) {
@@ -18,8 +18,5 @@ export default function Protected(WrappedComponent) {
};
}
return connect(
mapStateToProps,
null,
)(Component);
return connect(mapStateToProps, null)(Component);
}

View File

@@ -1,23 +1,23 @@
import { connect } from 'react-redux';
import React from 'react';
import PropTypes from 'prop-types';
import { ButtonBase } from '@material-ui/core';
import setVisibilityFilter from '../../actions/visibilityFilter';
import { connect } from "react-redux";
import React from "react";
import PropTypes from "prop-types";
import { ButtonBase } from "@material-ui/core";
import setVisibilityFilter from "../../actions/visibilityFilter";
function Link({ active, onClick, children }) {
const classes = ['filter'];
const classes = ["filter"];
if (active) {
classes.push('filter--active');
classes.push("filter--active");
}
return (
<ButtonBase
style={{
padding: '0 1rem',
color: active ? 'black' : '#444444',
height: '2rem',
padding: "0 1rem",
color: active ? "black" : "#444444",
height: "2rem",
}}
className={classes.join(' ')}
onClick={e => {
className={classes.join(" ")}
onClick={(e) => {
e.preventDefault();
onClick();
}}
@@ -45,7 +45,4 @@ function mapDispatchToProps(dispatch, ownProps) {
};
}
export default connect(
mapStateToProps,
mapDispatchToProps,
)(Link);
export default connect(mapStateToProps, mapDispatchToProps)(Link);

View File

@@ -1,12 +1,14 @@
import React from 'react';
import FilterLink from './FilterLink';
import { VisibilityFilters } from '../../actions/defs';
import React from "react";
import FilterLink from "./FilterLink";
import { VisibilityFilters } from "../../actions/defs";
function Filters(styles) {
return (
<div style={styles} id="filters">
<FilterLink filter={VisibilityFilters.SHOW_ALL}>all</FilterLink>
<FilterLink filter={VisibilityFilters.SHOW_ACTIVE}>active</FilterLink>
<FilterLink filter={VisibilityFilters.SHOW_ACTIVE}>
active
</FilterLink>
<FilterLink filter={VisibilityFilters.SHOW_COMPLETED}>
completed
</FilterLink>

View File

@@ -1,11 +1,11 @@
import DeleteIcon from '@material-ui/icons/Delete';
import AddIcon from '@material-ui/icons/Add';
import EditIcon from '@material-ui/icons/Edit';
import BackButton from '@material-ui/icons/ArrowBack';
import { IconButton } from '@material-ui/core';
import React from 'react';
import PropTypes from 'prop-types';
import { Transition, config } from 'react-spring';
import DeleteIcon from "@material-ui/icons/Delete";
import AddIcon from "@material-ui/icons/Add";
import EditIcon from "@material-ui/icons/Edit";
import BackButton from "@material-ui/icons/ArrowBack";
import { IconButton } from "@material-ui/core";
import React from "react";
import PropTypes from "prop-types";
import { Transition, config } from "react-spring";
const button = {
width: 30,
@@ -37,7 +37,7 @@ export default function ListActions({
}
const actions = [];
if (!creating && !editing) {
actions.push(styles => (
actions.push((styles) => (
<IconButton
key="create"
style={{ ...button, ...styles }}
@@ -48,7 +48,7 @@ export default function ListActions({
));
}
if (list && !creating && !editing) {
actions.push(styles => (
actions.push((styles) => (
<IconButton
key="remove"
style={{ ...button, ...styles }}
@@ -59,7 +59,7 @@ export default function ListActions({
));
}
if (list && !creating && !editing) {
actions.push(styles => (
actions.push((styles) => (
<IconButton
key="edit"
style={{ ...button, ...styles }}
@@ -70,7 +70,7 @@ export default function ListActions({
));
}
if (creating || editing) {
actions.push(styles => (
actions.push((styles) => (
<IconButton
key="back"
style={{ ...button, ...styles }}
@@ -90,7 +90,7 @@ export default function ListActions({
restSpeedThreshold: 0.5,
restDisplacementThreshold: 0.5,
}}
keys={actions.map(action => action({}).key)}
keys={actions.map((action) => action({}).key)}
from={{ opacity: 0, height: 0, margin: 0, padding: 0 }}
enter={{ opacity: 1, height: 30, margin: 0, padding: 0 }}
leave={{ opacity: 0, height: 0, margin: 0, padding: 0 }}
@@ -102,7 +102,7 @@ export default function ListActions({
}
ListActions.defaultProps = {
list: '',
list: "",
};
ListActions.propTypes = {

View File

@@ -1,12 +1,12 @@
import { connect } from 'react-redux';
import ListActions from './ListActions';
import { connect } from "react-redux";
import ListActions from "./ListActions";
import {
startCreateList,
startEditList,
removeList,
stopCreateList,
stopEditList,
} from '../../actions/lists';
} from "../../actions/lists";
function mapStateToProps(state) {
return {
@@ -25,7 +25,4 @@ function mapDispatchToProps(dispatch) {
};
}
export default connect(
mapStateToProps,
mapDispatchToProps,
)(ListActions);
export default connect(mapStateToProps, mapDispatchToProps)(ListActions);

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { IconButton } from '@material-ui/core';
import React from "react";
import PropTypes from "prop-types";
import { IconButton } from "@material-ui/core";
const button = {
width: 36,
@@ -12,15 +12,15 @@ export default function ListInput({ onClick, children, defaultValue }) {
return (
<div id="listselector" className="list--input">
<input
ref={node => {
ref={(node) => {
input = node;
}}
defaultValue={defaultValue}
style={{ height: 40 }}
id="input"
type="text"
onKeyPress={e => {
if (e.key === 'Enter') {
onKeyPress={(e) => {
if (e.key === "Enter") {
onClick(input.value);
}
}}

View File

@@ -1,15 +1,14 @@
import { connect } from 'react-redux';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from "react-redux";
import React from "react";
import PropTypes from "prop-types";
import ListActionsContainer from './ListActionsContainer';
import SelectorContainer from './SelectorContainer';
import ListActionsContainer from "./ListActionsContainer";
import SelectorContainer from "./SelectorContainer";
function Lists({ userLoaded, listsLoaded }) {
return (
<div id="lists-header">
{userLoaded &&
listsLoaded && (
{userLoaded && listsLoaded && (
<div id="lists">
<ListActionsContainer />
<SelectorContainer />

View File

@@ -1,11 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Select, MenuItem } from '@material-ui/core';
import AddIcon from '@material-ui/icons/Add';
import CheckIcon from '@material-ui/icons/Check';
import React from "react";
import PropTypes from "prop-types";
import { Select, MenuItem } from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
import CheckIcon from "@material-ui/icons/Check";
import ListInput from './ListInput';
import './Selector.css';
import ListInput from "./ListInput";
import "./Selector.css";
const icon = {
fontSize: 24,
@@ -38,11 +38,11 @@ export default function Selector({
return (
<div id="listselector">
<Select
style={{ fontSize: '1.5rem', width: '100%', height: 40 }}
style={{ fontSize: "1.5rem", width: "100%", height: 40 }}
value={list}
onChange={e => onChange(e.target.value)}
onChange={(e) => onChange(e.target.value)}
>
{Object.values(lists.lists).map(elem => (
{Object.values(lists.lists).map((elem) => (
<MenuItem key={elem.id} value={elem.id}>
{elem.name}
</MenuItem>
@@ -55,7 +55,7 @@ export default function Selector({
}
Selector.defaultProps = {
list: '',
list: "",
};
Selector.propTypes = {

View File

@@ -1,6 +1,6 @@
import { connect } from 'react-redux';
import Selector from './Selector';
import { changeList, addList, editList } from '../../actions/lists';
import { connect } from "react-redux";
import Selector from "./Selector";
import { changeList, addList, editList } from "../../actions/lists";
function mapStateToProps(state) {
return {
@@ -13,13 +13,10 @@ function mapStateToProps(state) {
function mapDispatchToProps(dispatch) {
return {
onChange: list => dispatch(changeList(list)),
addList: name => dispatch(addList(name)),
editList: name => dispatch(editList(name)),
onChange: (list) => dispatch(changeList(list)),
addList: (name) => dispatch(addList(name)),
editList: (name) => dispatch(editList(name)),
};
}
export default connect(
mapStateToProps,
mapDispatchToProps,
)(Selector);
export default connect(mapStateToProps, mapDispatchToProps)(Selector);

View File

@@ -1,18 +1,18 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { animated } from 'react-spring';
import { ButtonBase } from '@material-ui/core';
import DeleteIcon from '@material-ui/icons/Delete';
import EditIcon from '@material-ui/icons/Edit';
import CheckIcon from '@material-ui/icons/Check';
import * as React from "react";
import PropTypes from "prop-types";
import { animated } from "react-spring";
import { ButtonBase } from "@material-ui/core";
import DeleteIcon from "@material-ui/icons/Delete";
import EditIcon from "@material-ui/icons/Edit";
import CheckIcon from "@material-ui/icons/Check";
const icon = {
fontSize: 24,
padding: 0,
};
const disabledAction = {
backgroundColor: '#fafafa',
color: '#dddddd',
backgroundColor: "#fafafa",
color: "#dddddd",
};
class Todo extends React.Component {
@@ -63,13 +63,13 @@ class Todo extends React.Component {
}
render() {
const deleteClasses = ['delete'];
const editClasses = ['edit'];
const deleteClasses = ["delete"];
const editClasses = ["edit"];
const { hover, editing } = this.state;
const { todo, removeTodo, toggleTodo, style } = this.props;
if (!hover) {
deleteClasses.push('disabled');
editClasses.push('disabled');
deleteClasses.push("disabled");
editClasses.push("disabled");
}
let input;
@@ -79,7 +79,7 @@ class Todo extends React.Component {
<textarea
className="todo--input"
defaultValue={todo.text}
ref={node => {
ref={(node) => {
input = node;
}}
/>
@@ -87,10 +87,10 @@ class Todo extends React.Component {
) : (
<ButtonBase
style={{
justifyContent: 'left',
paddingLeft: '1rem',
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#888888' : 'black',
justifyContent: "left",
paddingLeft: "1rem",
textDecoration: todo.completed ? "line-through" : "none",
color: todo.completed ? "#888888" : "black",
}}
className="todo"
onClick={() => {
@@ -104,7 +104,7 @@ class Todo extends React.Component {
? [
<ButtonBase
key="save"
style={{ backgroundColor: 'lightgreen' }}
style={{ backgroundColor: "lightgreen" }}
className="save"
onClick={() => this.stopEdit(input.value)}
>
@@ -114,16 +114,22 @@ class Todo extends React.Component {
: [
<ButtonBase
key="remove"
style={hover ? { backgroundColor: 'pink' } : disabledAction}
className={deleteClasses.join(' ')}
style={
hover ? { backgroundColor: "pink" } : disabledAction
}
className={deleteClasses.join(" ")}
onClick={removeTodo}
>
<DeleteIcon style={icon} />
</ButtonBase>,
<ButtonBase
key="edit"
style={hover ? { backgroundColor: 'lightcyan' } : disabledAction}
className={editClasses.join(' ')}
style={
hover
? { backgroundColor: "lightcyan" }
: disabledAction
}
className={editClasses.join(" ")}
onClick={this.startEdit}
>
<EditIcon style={icon} />
@@ -133,7 +139,7 @@ class Todo extends React.Component {
<animated.li
style={{
...style,
borderTop: '1px solid #f0f0f0',
borderTop: "1px solid #f0f0f0",
}}
onMouseOver={this.onMouseOver}
onFocus={this.onMouseOver}

View File

@@ -1,8 +1,8 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { Transition, config } from 'react-spring';
import * as React from "react";
import PropTypes from "prop-types";
import { Transition, config } from "react-spring";
import Todo from './Todo';
import Todo from "./Todo";
export default function TodosContainer({
todos,
@@ -21,35 +21,35 @@ export default function TodosContainer({
restDisplacementThreshold: 1,
}}
items={todos}
keys={todo => todo.id}
keys={(todo) => todo.id}
from={{
height: 0,
borderColor: '#f0f0f0',
borderColor: "#f0f0f0",
opacity: 0.7,
}}
enter={{
height: 60,
borderColor: '#f0f0f0',
borderColor: "#f0f0f0",
opacity: 1,
}}
leave={{
height: 0,
borderColor: '#ffffff',
borderColor: "#ffffff",
borderWidth: 0,
opacity: 0.3,
padding: 0,
margin: 0,
pointerEvents: 'none',
pointerEvents: "none",
}}
>
{todos.map(todo => styles => (
{todos.map((todo) => (styles) => (
<Todo
key={todo.id}
todo={todo}
style={styles}
toggleTodo={() => toggleTodo(todo.id)}
removeTodo={() => removeTodo(todo.id)}
editTodo={text => editTodo(todo.id, text)}
editTodo={(text) => editTodo(todo.id, text)}
/>
))}
</Transition>

View File

@@ -1,15 +1,15 @@
import { connect } from 'react-redux';
import TodoList from './TodoList';
import { toggleTodo, removeTodo, editTodo } from '../../actions/todos';
import { connect } from "react-redux";
import TodoList from "./TodoList";
import { toggleTodo, removeTodo, editTodo } from "../../actions/todos";
import getVisibleTodos from './getVisibleTodos';
import getVisibleTodos from "./getVisibleTodos";
function mapStateToProps(state) {
return {
todos: state.lists.list
? getVisibleTodos(
state.lists.lists[state.lists.list].todos.map(
id => state.todos.todos[id],
(id) => state.todos.todos[id],
),
state.visibilityFilter,
)
@@ -20,15 +20,12 @@ function mapStateToProps(state) {
function mapDispatchToProps(dispatch) {
return {
toggleTodo: id => dispatch(toggleTodo(id)),
removeTodo: id => dispatch(removeTodo(id)),
toggleTodo: (id) => dispatch(toggleTodo(id)),
removeTodo: (id) => dispatch(removeTodo(id)),
editTodo: (id, text) => dispatch(editTodo(id, text)),
};
}
const TodosContainer = connect(
mapStateToProps,
mapDispatchToProps,
)(TodoList);
const TodosContainer = connect(mapStateToProps, mapDispatchToProps)(TodoList);
export default TodosContainer;

View File

@@ -1,14 +1,14 @@
import { connect } from 'react-redux';
import { connect } from "react-redux";
import React from 'react';
import PropTypes from 'prop-types';
import { Transition } from 'react-spring';
import React from "react";
import PropTypes from "prop-types";
import { Transition } from "react-spring";
import withRouter from 'react-router-dom/withRouter';
import Input from '../todos/Input';
import TodoListContainer from './TodoListContainer';
import Header from '../Header';
import Filters from '../filters/Filters';
import withRouter from "react-router-dom/withRouter";
import Input from "../todos/Input";
import TodoListContainer from "./TodoListContainer";
import Header from "../Header";
import Filters from "../filters/Filters";
class Todos extends React.PureComponent {
render() {
@@ -21,7 +21,7 @@ class Todos extends React.PureComponent {
enter={{ opacity: 1, maxHeight: 38 }}
leave={{ opacity: 0, maxHeight: 0 }}
>
{list && (styles => <Input styles={styles} />)}
{list && ((styles) => <Input styles={styles} />)}
</Transition>
<TodoListContainer />
<Transition

View File

@@ -1,14 +1,18 @@
import { VisibilityFilters } from '../../actions/defs';
import { VisibilityFilters } from "../../actions/defs";
export default function getVisibleTodos(todos, filter) {
switch (filter) {
case VisibilityFilters.SHOW_ALL:
return todos.filter(todo => todo);
return todos.filter((todo) => todo);
case VisibilityFilters.SHOW_ACTIVE:
return todos.filter(todo => todo).filter(todo => !todo.completed);
return todos
.filter((todo) => todo)
.filter((todo) => !todo.completed);
case VisibilityFilters.SHOW_COMPLETED:
return todos.filter(todo => todo).filter(todo => todo.completed);
return todos
.filter((todo) => todo)
.filter((todo) => todo.completed);
default:
return todos.filter(todo => todo);
return todos.filter((todo) => todo);
}
}

View File

@@ -1,38 +1,42 @@
import { connect } from 'react-redux';
import { connect } from "react-redux";
import * as React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@material-ui/core';
import AddIcon from '@material-ui/icons/Add';
import { addTodo } from '../../actions/todos';
import * as React from "react";
import PropTypes from "prop-types";
import { Button } from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
import { addTodo } from "../../actions/todos";
function Input({ onClick, styles }) {
let input;
function submit() {
if (input.value.trim() !== '') {
if (input.value.trim() !== "") {
onClick(input.value);
}
input.value = '';
input.value = "";
}
return (
<div style={styles} id="inputs">
<input
aria-label="todo text"
ref={node => {
ref={(node) => {
input = node;
}}
id="input"
type="text"
placeholder="Add something!"
onKeyPress={e => {
if (e.key === 'Enter') {
onKeyPress={(e) => {
if (e.key === "Enter") {
submit();
}
}}
/>
<Button style={{ borderRadius: 0 }} id="add" onClick={() => submit()}>
<Button
style={{ borderRadius: 0 }}
id="add"
onClick={() => submit()}
>
<AddIcon />
</Button>
</div>
@@ -50,11 +54,8 @@ function mapStateToProps(state, ownProps) {
function mapDispatchToProps(dispatch) {
return {
onClick: text => dispatch(addTodo(text)),
onClick: (text) => dispatch(addTodo(text)),
};
}
export default connect(
mapStateToProps,
mapDispatchToProps,
)(Input);
export default connect(mapStateToProps, mapDispatchToProps)(Input);

View File

@@ -1,21 +1,21 @@
import React from 'react';
import { Field, reduxForm } from 'redux-form';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
import { ButtonBase, Button } from '@material-ui/core';
import React from "react";
import { Field, reduxForm } from "redux-form";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import PropTypes from "prop-types";
import { ButtonBase, Button } from "@material-ui/core";
import InputField from './InputField';
import UserErrors from './UserErrors';
import InputField from "./InputField";
import UserErrors from "./UserErrors";
import './Form.css';
import "./Form.css";
import { edit, resetEdit, deleteUser } from '../../actions/user';
import { edit, resetEdit, deleteUser } from "../../actions/user";
function validate(values) {
const errors = {};
if (values.password !== values.passwordRepeat) {
errors.passwordRepeat = 'Passwords should match';
errors.passwordRepeat = "Passwords should match";
}
return errors;
}
@@ -30,19 +30,19 @@ function EditForm({
}) {
if (user.user && user.editSuccess) {
reset();
history.push('/');
history.push("/");
}
return (
<React.Fragment>
<div id="user-header">
<ButtonBase
style={{
marginLeft: '0',
marginRight: 'auto',
padding: '0 0.5rem',
marginLeft: "0",
marginRight: "auto",
padding: "0 0.5rem",
}}
onClick={() => {
history.push('/');
history.push("/");
}}
>
todos
@@ -70,7 +70,9 @@ function EditForm({
type="password"
/>
<div id="buttons">
<Button onClick={() => deleteUser()}>Delete your account</Button>
<Button onClick={() => deleteUser()}>
Delete your account
</Button>
<Button
id="submitbutton"
variant="raised"
@@ -111,18 +113,11 @@ function mapDispatchToProps(dispatch) {
}
export default reduxForm({
form: 'editForm',
form: "editForm",
initialValues: {
username: '',
password: '',
passwordRepeat: '',
username: "",
password: "",
passwordRepeat: "",
},
validate,
})(
withRouter(
connect(
mapStateToProps,
mapDispatchToProps,
)(EditForm),
),
);
})(withRouter(connect(mapStateToProps, mapDispatchToProps)(EditForm)));

View File

@@ -1,17 +1,17 @@
import React from 'react';
import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
import { ButtonBase } from '@material-ui/core';
import React from "react";
import { withRouter } from "react-router-dom";
import PropTypes from "prop-types";
import { ButtonBase } from "@material-ui/core";
function Link({ history, to, text }) {
return (
<ButtonBase
style={{
marginLeft: '0',
marginRight: 'auto',
padding: '0 1rem',
marginLeft: "0",
marginRight: "auto",
padding: "0 1rem",
}}
onClick={e => {
onClick={(e) => {
e.preventDefault();
history.push(to);
}}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { TextField } from '@material-ui/core';
import React from "react";
import PropTypes from "prop-types";
import { TextField } from "@material-ui/core";
export default function InputField({
required,
@@ -16,7 +16,7 @@ export default function InputField({
required={required}
{...input}
type={type}
style={{ marginBottom: '1rem' }}
style={{ marginBottom: "1rem" }}
/>
{touched && error && <span className="error">{error}</span>}
</React.Fragment>

View File

@@ -1,23 +1,23 @@
import React from 'react';
import { Field, reduxForm } from 'redux-form';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { ButtonBase, Button } from '@material-ui/core';
import { withRouter } from 'react-router';
import React from "react";
import { Field, reduxForm } from "redux-form";
import { connect } from "react-redux";
import PropTypes from "prop-types";
import { ButtonBase, Button } from "@material-ui/core";
import { withRouter } from "react-router";
import InputField from './InputField';
import UserErrors from './UserErrors';
import InputField from "./InputField";
import UserErrors from "./UserErrors";
import './Form.css';
import "./Form.css";
import { login, reset, loginJWT } from '../../actions/user';
import { login, reset, loginJWT } from "../../actions/user";
class LoginForm extends React.PureComponent {
componentDidMount() {
const { setJWT } = this.props;
const params = new URLSearchParams(new URL(window.location).search);
if (params.has('jwt')) {
const jwt = params.get('jwt');
if (params.has("jwt")) {
const jwt = params.get("jwt");
setJWT(jwt);
}
}
@@ -29,13 +29,13 @@ class LoginForm extends React.PureComponent {
<div id="user-header">
<ButtonBase
style={{
marginRight: '1rem',
padding: '0 0.5rem',
borderRadius: '7px',
marginRight: "1rem",
padding: "0 0.5rem",
borderRadius: "7px",
}}
onClick={() => {
resetUser();
history.push('/signup');
history.push("/signup");
}}
>
signup
@@ -64,7 +64,7 @@ class LoginForm extends React.PureComponent {
id="googlebutton"
variant="raised"
onClick={() => {
window.location = '/__/users/login/google/';
window.location = "/__/users/login/google/";
}}
>
Google
@@ -105,21 +105,14 @@ function mapDispatchToProps(dispatch) {
resetUser: () => dispatch(reset()),
onLogin: ({ username, password }) =>
dispatch(login({ username, password })),
setJWT: jwt => dispatch(loginJWT(jwt)),
setJWT: (jwt) => dispatch(loginJWT(jwt)),
};
}
export default reduxForm({
form: 'loginForm',
form: "loginForm",
initialValues: {
username: '',
password: '',
username: "",
password: "",
},
})(
withRouter(
connect(
mapStateToProps,
mapDispatchToProps,
)(LoginForm)
),
);
})(withRouter(connect(mapStateToProps, mapDispatchToProps)(LoginForm)));

View File

@@ -1,19 +1,19 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { ButtonBase } from '@material-ui/core';
import React from "react";
import { connect } from "react-redux";
import PropTypes from "prop-types";
import { ButtonBase } from "@material-ui/core";
import { logout } from '../../actions/user';
import { logout } from "../../actions/user";
function Link({ onClick, children }) {
return (
<ButtonBase
style={{
marginLeft: 'auto',
marginLeft: "auto",
marginRight: 0,
padding: '0 1rem',
padding: "0 1rem",
}}
onClick={e => {
onClick={(e) => {
e.preventDefault();
onClick();
}}
@@ -34,7 +34,4 @@ function mapDispatchToProps(dispatch) {
};
}
export default connect(
null,
mapDispatchToProps,
)(Link);
export default connect(null, mapDispatchToProps)(Link);

View File

@@ -1,21 +1,21 @@
import React from 'react';
import { Field, reduxForm } from 'redux-form';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { ButtonBase, Button } from '@material-ui/core';
import { withRouter } from 'react-router';
import React from "react";
import { Field, reduxForm } from "redux-form";
import { connect } from "react-redux";
import PropTypes from "prop-types";
import { ButtonBase, Button } from "@material-ui/core";
import { withRouter } from "react-router";
import InputField from './InputField';
import UserErrors from './UserErrors';
import InputField from "./InputField";
import UserErrors from "./UserErrors";
import './Form.css';
import "./Form.css";
import { signup, reset } from '../../actions/user';
import { signup, reset } from "../../actions/user";
function validate(values) {
const errors = {};
if (values.password !== values.passwordRepeat) {
errors.passwordRepeat = 'Passwords should match';
errors.passwordRepeat = "Passwords should match";
}
return errors;
}
@@ -26,13 +26,13 @@ function SignupForm({ handleSubmit, onSignup, user, history, resetUser }) {
<div id="user-header">
<ButtonBase
style={{
marginRight: '1rem',
padding: '0 0.5rem',
borderRadius: '7px',
marginRight: "1rem",
padding: "0 0.5rem",
borderRadius: "7px",
}}
onClick={() => {
resetUser();
history.push('/login');
history.push("/login");
}}
>
login
@@ -101,18 +101,11 @@ function mapDispatchToProps(dispatch) {
}
export default reduxForm({
form: 'signupForm',
form: "signupForm",
initialValues: {
username: '',
password: '',
passwordRepeat: '',
username: "",
password: "",
passwordRepeat: "",
},
validate,
})(
withRouter(
connect(
mapStateToProps,
mapDispatchToProps,
)(SignupForm)
),
);
})(withRouter(connect(mapStateToProps, mapDispatchToProps)(SignupForm)));

View File

@@ -1,17 +1,17 @@
import React from 'react';
import React from "react";
function UserErrors({ user }) {
const errors = [];
if (user.errors) {
if (user.errors.name === 'AuthenticationError') {
if (user.errors.name === "AuthenticationError") {
errors.push(
<div key="wrongauth" className="error">
Wrong username or password
</div>,
);
}
if (user.errors.name === 'ValidationError') {
if (user.errors.message.split(' ').includes('unique.')) {
if (user.errors.name === "ValidationError") {
if (user.errors.message.split(" ").includes("unique.")) {
errors.push(
<div key="exists" className="error">
User already exists

View File

@@ -1,7 +1,7 @@
import React from 'react';
import React from "react";
import LogoutLink from './LogoutLink';
import HeaderLink from './HeaderLink';
import LogoutLink from "./LogoutLink";
import HeaderLink from "./HeaderLink";
export default function UserHeader() {
return (

View File

@@ -1,16 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom';
import thunk from 'redux-thunk';
import { Provider } from 'react-redux';
import { applyMiddleware, createStore, compose } from 'redux';
import { offline } from '@redux-offline/redux-offline';
import offlineConfig from '@redux-offline/redux-offline/lib/defaults';
import React from "react";
import ReactDOM from "react-dom";
import thunk from "redux-thunk";
import { Provider } from "react-redux";
import { applyMiddleware, createStore, compose } from "redux";
import { offline } from "@redux-offline/redux-offline";
import offlineConfig from "@redux-offline/redux-offline/lib/defaults";
import AppContainer from './components/AppContainer';
import registerServiceWorker from './registerServiceWorker';
import todoApp from './reducers';
import { setToken } from './actions/util';
import keepSynced from './middleware/keepSynced';
import AppContainer from "./components/AppContainer";
import registerServiceWorker from "./registerServiceWorker";
import todoApp from "./reducers";
import { setToken } from "./actions/util";
import keepSynced from "./middleware/keepSynced";
let store;
@@ -23,7 +23,7 @@ const persistCallback = () => {
<Provider store={store}>
<AppContainer />
</Provider>,
document.getElementById('root'),
document.getElementById("root"),
);
};

View File

@@ -1,9 +1,9 @@
import { REQUEST_LISTS, INVALIDATE_LISTS } from '../actions/defs';
import { fetchLists } from '../actions/lists';
import { REQUEST_LISTS, INVALIDATE_LISTS } from "../actions/defs";
import { fetchLists } from "../actions/lists";
export default store => next => action => {
export default (store) => (next) => (action) => {
next(action);
if (action.type !== REQUEST_LISTS && typeof action !== 'function') {
if (action.type !== REQUEST_LISTS && typeof action !== "function") {
const state = store.getState();
if (state.user.user) {
const dirtyLists = state.lists.dirty || false;

View File

@@ -1,10 +1,10 @@
import { combineReducers } from 'redux';
import { reducer as formReducer } from 'redux-form';
import { combineReducers } from "redux";
import { reducer as formReducer } from "redux-form";
import lists from './lists';
import visibilityFilter from './visibilityFilter';
import user from './user';
import todos from './todos';
import lists from "./lists";
import visibilityFilter from "./visibilityFilter";
import user from "./user";
import todos from "./todos";
const todoApp = combineReducers({
lists,

View File

@@ -14,7 +14,7 @@ import {
REMOVE_TODO,
ADD_TODO,
LOGOUT,
} from '../actions/defs';
} from "../actions/defs";
export default function lists(
state = {
@@ -45,7 +45,7 @@ export default function lists(
const newLists = Object.values(action.lists);
let { list } = state;
if (newLists.length !== 0) {
if (!newLists.some(curList => curList.id === list)) {
if (!newLists.some((curList) => curList.id === list)) {
list = newLists[0].id || null;
}
} else {
@@ -81,7 +81,9 @@ export default function lists(
const newLists = { ...state.lists };
delete newLists[action.list];
const listsObjs = Object.values(newLists);
const list = listsObjs.length ? listsObjs[listsObjs.length - 1].id : null;
const list = listsObjs.length
? listsObjs[listsObjs.length - 1].id
: null;
return {
...state,
list,
@@ -121,7 +123,7 @@ export default function lists(
[state.list]: {
...state.lists[state.list],
todos: state.lists[state.list].todos.filter(
todo => todo !== action.id,
(todo) => todo !== action.id,
),
},
},
@@ -134,7 +136,10 @@ export default function lists(
...state.lists,
[state.list]: {
...state.lists[state.list],
todos: [action.todo.id, ...state.lists[state.list].todos],
todos: [
action.todo.id,
...state.lists[state.list].todos,
],
},
},
};

View File

@@ -9,7 +9,7 @@ import {
EDIT_TODO,
REMOVE_LIST,
LOGOUT,
} from '../actions/defs';
} from "../actions/defs";
export default function todos(
state = {
@@ -53,7 +53,10 @@ export default function todos(
...state,
todos: {
...state.todos,
[action.id]: { ...state.todos[action.id], text: action.text },
[action.id]: {
...state.todos[action.id],
text: action.text,
},
},
};
case REQUEST_TODOS:
@@ -71,7 +74,7 @@ export default function todos(
}
case REMOVE_LIST: {
const newTodos = { ...state.todos };
Object.keys(newTodos).forEach(todoId => {
Object.keys(newTodos).forEach((todoId) => {
if (newTodos[todoId].list === action.list) {
delete newTodos[todoId];
}

View File

@@ -10,7 +10,7 @@ import {
EDIT_SUCCESS,
EDIT_FAIL,
RESET_EDIT,
} from '../actions/defs';
} from "../actions/defs";
export default function user(
state = {

View File

@@ -1,4 +1,4 @@
import { VisibilityFilters, SET_VISIBILITY_FILTER } from '../actions/defs';
import { VisibilityFilters, SET_VISIBILITY_FILTER } from "../actions/defs";
const { SHOW_ALL } = VisibilityFilters;

View File

@@ -9,9 +9,9 @@
// This link also includes instructions on opting out of this behavior.
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
window.location.hostname === "localhost" ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
window.location.hostname === "[::1]" ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/,
@@ -21,44 +21,47 @@ const isLocalhost = Boolean(
function registerValidSW(swUrl) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
.then((registration) => {
// eslint-disable-next-line no-param-reassign
registration.onupdatefound = () => {
const installingWorker = registration.installing;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (installingWorker.state === "installed") {
if (navigator.serviceWorker.controller) {
// At this point, the old content will have been purged and
// the fresh content will have been added to the cache.
// It's the perfect time to display a "New content is
// available; please refresh." message in your web app.
console.log('New content is available; please refresh.');
console.log(
"New content is available; please refresh.",
);
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
console.log("Content is cached for offline use.");
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
.catch((error) => {
console.error("Error during service worker registration:", error);
});
}
function checkValidServiceWorker(swUrl) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
if (
response.status === 404 ||
response.headers.get('content-type').indexOf('javascript') === -1
response.headers.get("content-type").indexOf("javascript") ===
-1
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
@@ -70,13 +73,13 @@ function checkValidServiceWorker(swUrl) {
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.',
"No internet connection found. App is running in offline mode.",
);
});
}
export default function register() {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
if (publicUrl.origin !== window.location.origin) {
@@ -86,7 +89,7 @@ export default function register() {
return;
}
window.addEventListener('load', () => {
window.addEventListener("load", () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
@@ -97,8 +100,8 @@ export default function register() {
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://goo.gl/SC7cgQ',
"This web app is being served cache-first by a service " +
"worker. To learn more, visit https://goo.gl/SC7cgQ",
);
});
} else {
@@ -110,8 +113,8 @@ export default function register() {
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready.then((registration) => {
registration.unregister();
});
}

View File

@@ -1,5 +1,5 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
const { createProxyMiddleware } = require("http-proxy-middleware");
module.exports = function (app) {
app.use(createProxyMiddleware('/__', { target: 'http://localhost:4000/' }));
app.use(createProxyMiddleware("/__", { target: "http://localhost:4000/" }));
};

View File

@@ -1,18 +0,0 @@
const mongoose = require('mongoose');
const config = require('./');
async function connect() {
await mongoose.connect(config.db.uri, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
}
async function disconnect() {
await mongoose.disconnect();
}
module.exports = {
connect,
disconnect,
};

View File

@@ -1,39 +0,0 @@
const env = process.env.NODE_ENV;
const production = {
app: {
port: process.env.PORT || 4000,
},
db: {
uri:
process.env.DB_URI ||
process.env.MONGODB_URI ||
'mongodb://localhost/todolist',
},
googleOAuth: {
googleEnabled:
process.env.GOOGLE_ENABLED ? process.env.GOOGLE_ENABLED.toUpperCase() === 'TRUE' : false,
googleClientId: process.env.GOOGLE_CLIENT_ID,
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
googleCallback: `${process.env.HOST}/__/users/login/google/callback`,
},
secret: process.env.SECRET,
};
const development = {
...production,
secret: process.env.SECRET || 'devsecret',
};
const test = {
...production,
secret: process.env.SECRET || 'testsecret',
};
const config = {
production,
development,
test,
};
module.exports = config[env] || config.production;

View File

@@ -1,31 +0,0 @@
const passport = require('passport');
const mongoose = require('mongoose');
const GoogleStrategy = require('passport-google-oauth').OAuth2Strategy;
const {
googleClientId,
googleClientSecret,
googleCallback,
googleEnabled,
} = require('./').googleOAuth;
const User = mongoose.model('User');
passport.use(User.createStrategy());
if (googleEnabled) {
passport.use(
new GoogleStrategy(
{
clientID: googleClientId,
clientSecret: googleClientSecret,
callbackURL: googleCallback,
},
(accessToken, refreshToken, profile, done) => {
User.findOrCreate({ googleId: profile.id }, (err, user) =>
done(err, user),
);
},
),
);
}
module.exports = passport;

View File

@@ -1,21 +0,0 @@
class NotFoundError extends Error {
constructor(text, ...args) {
super(...args);
Error.captureStackTrace(this, NotFoundError);
this.name = 'NotFound';
this.text = text;
this.status = 404;
}
}
class BadRequestError extends Error {
constructor(text, ...args) {
super(...args);
Error.captureStackTrace(this, NotFoundError);
this.name = 'BadRequest';
this.text = text;
this.status = 400;
}
}
module.exports = { NotFoundError, BadRequestError };

0
git
View File

View File

@@ -1,50 +0,0 @@
const mongoose = require('mongoose');
const { Schema } = mongoose;
const TodoSchema = Schema({
text: {
type: String,
required: true,
minLength: 1,
maxLength: 300,
trim: true,
},
list: { type: Schema.Types.ObjectId, ref: 'TodoList', required: true },
user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
completed: { type: Boolean, default: false },
});
TodoSchema.pre('save', async function() {
if (this.isNew) {
const user = await this.model('User').findById(this.user);
user.todos.push(this._id);
await user.save();
const list = await this.model('TodoList').findById(this.list);
list.todos.push(this._id);
await list.save();
}
});
TodoSchema.pre('remove', async function() {
const user = await this.model('User').findById(this.user);
user.todos.splice(user.todos.indexOf(this._id), 1);
await user.save();
const list = await this.model('TodoList').findById(this.list);
list.todos.splice(list.todos.indexOf(this._id), 1);
await list.save();
});
TodoSchema.methods.toJson = function() {
return {
id: this._id.toString(),
text: this.text,
list: this.list.toString(),
user: this.user.toString(),
completed: this.completed,
};
};
mongoose.model('Todo', TodoSchema);

View File

@@ -1,56 +0,0 @@
const mongoose = require('mongoose');
const { Schema } = mongoose;
const TodoListSchema = Schema({
name: {
type: String,
required: true,
minLength: 1,
maxLength: 100,
trim: true,
},
todos: [{ type: Schema.Types.ObjectId, ref: 'Todo' }],
user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
});
TodoListSchema.pre('save', async function() {
if (this.isNew) {
const user = await this.model('User').findById(this.user);
user.lists.push(this._id);
await user.save();
}
});
TodoListSchema.pre('remove', async function() {
const user = await this.model('User').findById(this.user);
user.lists.splice(user.lists.indexOf(this._id), 1);
// removing todos in parallel can cause VersionError
// so we remove todos from user
const todos = await this.model('Todo')
.find({ list: this._id })
.exec();
const ids = todos.map(todo => todo._id);
user.todos = user.todos.filter(todo => ids.includes(todo._id));
await user.save();
// and remove them from db
await this.model('Todo')
.find({ list: this._id })
.remove()
.exec();
});
TodoListSchema.methods.toJson = function() {
const todos = this.populated('todos')
? this.todos.map(todo => todo.toJson())
: this.todos;
return {
id: this._id.toString(),
user: this.user.toString(),
name: this.name,
todos,
};
};
mongoose.model('TodoList', TodoListSchema);

View File

@@ -1,76 +0,0 @@
const mongoose = require('mongoose');
const passportLocalMongoose = require('passport-local-mongoose');
const jwt = require('jsonwebtoken');
const uniqueValidator = require('mongoose-unique-validator');
const findOrCreate = require('mongoose-findorcreate');
const { BadRequestError } = require('../errors');
const { secret } = require('../config');
const { Schema } = mongoose;
const UserSchema = Schema({
username: {
type: String,
unique: true,
validate: /^\S*$/,
minLength: 3,
maxLength: 50,
trim: true,
sparse: true,
},
googleId: {
type: String,
unique: true,
sparse: true,
},
lists: [{ type: Schema.Types.ObjectId, ref: 'TodoList' }],
todos: [{ type: Schema.Types.ObjectId, ref: 'Todo' }],
});
UserSchema.plugin(passportLocalMongoose, {
limitAttempts: true,
maxAttempts: 20,
});
UserSchema.plugin(uniqueValidator);
UserSchema.plugin(findOrCreate);
UserSchema.pre('validate', async function() {
if (!this.username && !this.googleId) {
throw new BadRequestError('username is required');
}
});
UserSchema.pre('remove', async function() {
await this.model('TodoList')
.find({ user: this._id })
.remove()
.exec();
await this.model('Todo')
.find({ user: this._id })
.remove()
.exec();
});
UserSchema.methods.generateJwt = function() {
return jwt.sign({ id: this._id, username: this.username }, secret, {
expiresIn: '120d',
});
};
UserSchema.methods.toJson = function() {
return {
id: this._id,
username: this.username,
};
};
UserSchema.methods.toAuthJson = function() {
return {
id: this._id,
username: this.username,
jwt: this.generateJwt(),
};
};
mongoose.model('User', UserSchema);

5581
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,12 +5,22 @@
"description": "",
"main": "app.js",
"scripts": {
"start": "node ./app.js",
"start": "node ./src/app.js",
"dev": "npx concurrently npm:client npm:server -c 'blue,green'",
"client": "cd client && npm start",
"server": "npx cross-env NODE_ENV=development npx nodemon --inspect ./app.js",
"server": "npx cross-env NODE_ENV=development npx nodemon --inspect ./src/app.js",
"test": "npx cross-env NODE_ENV=test jest --runInBand",
"heroku-postbuild": "cd client && npm install && npm run build"
"test-frontend": "cd client && npm test",
"test-all": "npm test && npm run test-frontend",
"heroku-postbuild": "cd client && npm install && npm run build",
"lint": "eslint ./src/** --ext .js,.jsx,.ts,.tsx",
"lint-fix": "eslint ./src/** --ext .js,.jsx,.ts,.tsx --fix",
"lint-frontend": "cd client && npm run lint",
"lint-frontend-fix": "cd client && npm run lint-fix",
"lint-all": "npm run lint && npm run lint-frontend",
"lint-all-fix": "npm run lint-fix && npm run lint-frontend-fix",
"prettier-check": "prettier ./src/ ./client/src/ --check",
"prettify": "prettier ./src/ ./client/src/ --write"
},
"cacheDirectories": [
"client/node_modules",
@@ -30,36 +40,36 @@
"express-jwt": "^6.0.0",
"hsts": "^2.2.0",
"jsonwebtoken": "^8.5.1",
"mongoose": "^5.10.9",
"mongoose": "^5.12.0",
"mongoose-findorcreate": "^3.0.0",
"mongoose-unique-validator": "^2.0.3",
"morgan": "^1.10.0",
"passport": "^0.4.1",
"passport-google-oauth": "^2.0.0",
"passport-local": "^1.0.0",
"passport-local-mongoose": "^6.0.1"
"passport-local-mongoose": "^6.1.0"
},
"devDependencies": {
"concurrently": "^5.3.0",
"cross-env": "^7.0.2",
"eslint": "^6.0.0",
"eslint-config-airbnb-base": "^14.2.0",
"eslint-config-prettier": "^6.12.0",
"concurrently": "^6.0.0",
"cross-env": "^7.0.3",
"eslint": "^7.22.0",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "^23.20.0",
"eslint-plugin-jest": "^24.2.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-react": "^7.21.4",
"jest": "^24.0.0",
"mongodb-memory-server": "^6.9.2",
"nodemon": "^2.0.5",
"prettier-eslint": "^11.0.0",
"supertest": "^5.0.0"
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0",
"jest": "26.6.0",
"mongodb-memory-server": "^6.9.6",
"nodemon": "^2.0.7",
"prettier-eslint": "^12.0.0",
"supertest": "^6.1.3"
},
"jest": {
"testEnvironment": "node",
"roots": [
"<rootDir>/tests/"
"<rootDir>/src/tests/"
]
}
}

View File

@@ -1,7 +0,0 @@
const jwt = require('express-jwt');
const { secret } = require('../config');
module.exports = {
required: jwt({ secret, algorithms: ['HS256'] }),
optional: jwt({ secret, credentialsRequired: false, algorithms: ['HS256'] }),
};

View File

@@ -1,23 +0,0 @@
const express = require('express');
const passport = require('passport');
const router = express.Router();
const asyncHelper = require('../asyncHelper');
router.get(
'/google',
passport.authenticate('google', {
scope: ['https://www.googleapis.com/auth/plus.login'],
}),
);
router.get(
'/google/callback',
passport.authenticate('google', { session: false, failWithError: true }),
asyncHelper(async (req, res) => {
res.redirect(`/login?jwt=${req.user.generateJwt()}`);
}),
);
module.exports = router;

View File

@@ -1,74 +0,0 @@
const express = require('express');
const mongoose = require('mongoose');
const router = express.Router();
const TodoList = mongoose.model('TodoList');
const asyncHelper = require('../asyncHelper');
const { NotFoundError } = require('../errors');
// index
router.get(
'/',
asyncHelper(async (req, res) => {
const lists = await TodoList.find({ user: req.user.id })
.populate('todos')
.exec();
res.json({ success: true, data: lists.map(list => { list.todos.reverse(); return list.toJson() }) });
}),
);
// create
router.post(
'/',
asyncHelper(async (req, res) => {
const { name } = req.body;
const { id } = req.body || mongoose.Types.ObjectId();
const newList = new TodoList({ name, user: req.user.id, _id: id });
await newList.save();
res.json({ success: true, data: newList.toJson() });
}),
);
// delete
router.delete(
'/:listId',
asyncHelper(async (req, res) => {
const { listId } = req.params;
const list = await TodoList.findOne({
_id: listId,
user: req.user.id,
}).exec();
await list.remove();
res.json({ success: true });
}),
);
// update
router.patch(
'/:listId',
asyncHelper(async (req, res) => {
const { listId } = req.params;
const { name } = req.body;
const list = await TodoList.findOne({ _id: listId, user: req.user.id });
if (!list) {
throw new NotFoundError("can't find list");
}
if (name !== undefined) {
list.name = name;
}
await list.save();
res.json({ success: true, data: list.toJson() });
}),
);
router.use(
'/:listId/todos',
(req, res, next) => {
res.locals.listId = req.params.listId;
next();
},
require('./todos'),
);
module.exports = router;

View File

@@ -1,71 +0,0 @@
const express = require('express');
const mongoose = require('mongoose');
const router = express.Router();
const Todo = mongoose.model('Todo');
const asyncHelper = require('../asyncHelper');
const { NotFoundError } = require('../errors');
// index
router.get(
'/',
asyncHelper(async (req, res) => {
const { listId } = res.locals || req.body;
const todos = listId
? await Todo.find({ list: listId, user: req.user.id }).exec()
: await Todo.find({ user: req.user.id }).exec();
res.json({ success: true, data: todos.reverse().map(todo => todo.toJson()) });
}),
);
// create
router.post(
'/',
asyncHelper(async (req, res) => {
const { listId } = res.locals || req.body;
const { text } = req.body;
const { id } = req.body || mongoose.Types.ObjectId();
const todo = new Todo({ text, list: listId, user: req.user.id, _id: id });
await todo.save();
res.json({ success: true, data: todo.toJson() });
}),
);
// update
router.patch(
'/:todoId',
asyncHelper(async (req, res) => {
const { todoId } = req.params;
const { text, completed } = req.body;
const todo = await Todo.findOne({ _id: todoId, user: req.user.id });
if (!todo) {
throw new NotFoundError("can't find todo");
}
if (text !== undefined) {
todo.text = text;
}
if (completed !== undefined) {
todo.completed = completed;
}
await todo.save();
res.json({ success: true, data: todo.toJson() });
}),
);
// delete
router.delete(
'/:todoId',
asyncHelper(async (req, res) => {
const { todoId } = req.params;
const todo = await Todo.findOne({ _id: todoId, user: req.user.id }).exec();
if (!todo) {
throw new NotFoundError(`can't find todo with id ${todoId}`);
}
await todo.remove();
res.json({ success: true });
}),
);
module.exports = router;

View File

@@ -1,93 +0,0 @@
const express = require('express');
const mongoose = require('mongoose');
const passport = require('passport');
const User = mongoose.model('User');
const router = express.Router();
const asyncHelper = require('../asyncHelper');
const auth = require('./auth');
const { googleEnabled } = require('../config').googleOAuth;
const googleOAuth = require('./google');
const { NotFoundError } = require('../errors');
router.get(
'/user',
auth.required,
asyncHelper(async (req, res) => {
const { id } = req.user;
const user = await User.findById(id).exec();
res.json({ success: true, data: user.toAuthJson() });
}),
);
router.post(
'/',
asyncHelper(async (req, res) => {
const { username, password } = req.body;
const user = new User({ username });
await user.setPassword(password);
await user.save();
res.json({ success: true, data: user.toAuthJson() });
}),
);
router.patch(
'/user',
auth.required,
asyncHelper(async (req, res) => {
const { username, password, google } = req.body;
const patch = {};
if (username !== undefined && username != '') {
patch.username = username;
}
if (google === null) {
patch.googleId = null;
}
let user;
if (patch !== {}) {
user = await User.findOneAndUpdate(
{ _id: req.user.id },
{ $set: patch },
{ runValidators: true, context: 'query', new: true },
).exec();
} else {
user = await User.findById(req.user.id);
}
if (!user) {
throw new NotFoundError(
`can't find user with username ${req.user.username}`,
);
}
if (password !== undefined) {
await user.setPassword(password);
await user.save();
}
res.json({ success: true, data: user.toAuthJson() });
}),
);
router.delete(
'/user',
auth.required,
asyncHelper(async (req, res) => {
const user = await User.findById(req.user.id).exec();
await user.remove();
res.json({ success: true });
}),
);
if (googleEnabled) {
router.use('/login', googleOAuth);
}
router.post(
'/login',
passport.authenticate('local', { session: false, failWithError: true }),
asyncHelper(async (req, res) => {
res.json({ success: true, data: req.user.toAuthJson() });
}),
);
module.exports = router;

122
src/app.js Normal file
View File

@@ -0,0 +1,122 @@
require("dotenv").config();
const express = require("express");
const bodyParser = require("body-parser");
const morgan = require("morgan");
const cors = require("cors");
const path = require("path");
const hsts = require("hsts");
const compression = require("compression");
const { redirectToHTTPS } = require("express-http-to-https");
const db = require("./config/db");
const config = require("./config");
require("./models/TodoList");
require("./models/User");
require("./models/Todo");
const app = express();
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
app.use(cors());
app.use(compression());
process.env.NODE_ENV === "production"
? app.use(morgan("combined"))
: app.use(morgan("dev"));
if (process.env.NODE_ENV === "production" && process.env.HSTS === "true") {
app.use(redirectToHTTPS([/localhost:(\d{4})/]));
app.use(
hsts({
maxAge: 31536000,
includeSubDomains: true,
}),
);
}
const passport = require("./config/passport");
app.use(passport.initialize());
// Addresses, starting with /__, are not cached by service worker
// https://github.com/facebook/create-react-app/issues/2237
app.use("/__/users", require("./routes/users"));
const auth = require("./routes/auth");
app.use("/__/lists", auth.required, require("./routes/lists"));
app.use("/__/todos", auth.required, require("./routes/todos"));
if (
process.env.NODE_ENV === "production" ||
process.env.NODE_ENV === "development"
) {
app.use(express.static(path.join(__dirname, "client/build")));
app.use(
"*",
express.static(path.join(__dirname, "client/build/index.html")),
);
}
// 404 route
app.use((req, res) => {
res.status(404);
if (req.accepts("html")) {
res.send("404");
return;
}
if (req.accepts("json")) {
res.send({ error: "Not found" });
return;
}
res.type("txt").send("not found");
});
// handle errors
app.use((error, req, res, next) => {
if (error.status) {
res.status(error.status);
} else {
switch (error.name) {
case "ValidationError":
case "MissingPasswordError":
case "BadRequest":
case "BadRequestError":
res.status(400);
break;
case "AuthenticationError":
case "UnauthorizedError":
res.status(401);
break;
case "NotFound":
res.status(404);
break;
default:
res.status(500);
}
}
res.json({ success: false, error });
if (
process.env.NODE_ENV === "production" ||
process.env.NODE_ENV === "test"
) {
console.error(error);
}
next(error);
});
let server;
if (process.env.NODE_ENV !== "test") {
db.connect();
server = app.listen(config.app.port, () => {
console.log(`Listening on port ${config.app.port}`);
console.log("Started!");
});
} else {
server = app;
}
module.exports = server;

3
src/asyncHelper.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};

18
src/config/db.js Normal file
View File

@@ -0,0 +1,18 @@
const mongoose = require("mongoose");
const config = require("./");
async function connect() {
await mongoose.connect(config.db.uri, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
}
async function disconnect() {
await mongoose.disconnect();
}
module.exports = {
connect,
disconnect,
};

40
src/config/index.js Normal file
View File

@@ -0,0 +1,40 @@
const env = process.env.NODE_ENV;
const production = {
app: {
port: process.env.PORT || 4000,
},
db: {
uri:
process.env.DB_URI ||
process.env.MONGODB_URI ||
"mongodb://localhost/todolist",
},
googleOAuth: {
googleEnabled: process.env.GOOGLE_ENABLED
? process.env.GOOGLE_ENABLED.toUpperCase() === "TRUE"
: false,
googleClientId: process.env.GOOGLE_CLIENT_ID,
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
googleCallback: `${process.env.HOST}/__/users/login/google/callback`,
},
secret: process.env.SECRET,
};
const development = {
...production,
secret: process.env.SECRET || "devsecret",
};
const test = {
...production,
secret: process.env.SECRET || "testsecret",
};
const config = {
production,
development,
test,
};
module.exports = config[env] || config.production;

31
src/config/passport.js Normal file
View File

@@ -0,0 +1,31 @@
const passport = require("passport");
const mongoose = require("mongoose");
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy;
const {
googleClientId,
googleClientSecret,
googleCallback,
googleEnabled,
} = require(".").googleOAuth;
const User = mongoose.model("User");
passport.use(User.createStrategy());
if (googleEnabled) {
passport.use(
new GoogleStrategy(
{
clientID: googleClientId,
clientSecret: googleClientSecret,
callbackURL: googleCallback,
},
(accessToken, refreshToken, profile, done) => {
User.findOrCreate({ googleId: profile.id }, (err, user) =>
done(err, user),
);
},
),
);
}
module.exports = passport;

21
src/errors/index.js Normal file
View File

@@ -0,0 +1,21 @@
class NotFoundError extends Error {
constructor(text, ...args) {
super(...args);
Error.captureStackTrace(this, NotFoundError);
this.name = "NotFound";
this.text = text;
this.status = 404;
}
}
class BadRequestError extends Error {
constructor(text, ...args) {
super(...args);
Error.captureStackTrace(this, NotFoundError);
this.name = "BadRequest";
this.text = text;
this.status = 400;
}
}
module.exports = { NotFoundError, BadRequestError };

50
src/models/Todo.js Normal file
View File

@@ -0,0 +1,50 @@
const mongoose = require("mongoose");
const { Schema } = mongoose;
const TodoSchema = Schema({
text: {
type: String,
required: true,
minLength: 1,
maxLength: 300,
trim: true,
},
list: { type: Schema.Types.ObjectId, ref: "TodoList", required: true },
user: { type: Schema.Types.ObjectId, ref: "User", required: true },
completed: { type: Boolean, default: false },
});
TodoSchema.pre("save", async function () {
if (this.isNew) {
const user = await this.model("User").findById(this.user);
user.todos.push(this._id);
await user.save();
const list = await this.model("TodoList").findById(this.list);
list.todos.push(this._id);
await list.save();
}
});
TodoSchema.pre("remove", async function () {
const user = await this.model("User").findById(this.user);
user.todos.splice(user.todos.indexOf(this._id), 1);
await user.save();
const list = await this.model("TodoList").findById(this.list);
list.todos.splice(list.todos.indexOf(this._id), 1);
await list.save();
});
TodoSchema.methods.toJson = function () {
return {
id: this._id.toString(),
text: this.text,
list: this.list.toString(),
user: this.user.toString(),
completed: this.completed,
};
};
mongoose.model("Todo", TodoSchema);

51
src/models/TodoList.js Normal file
View File

@@ -0,0 +1,51 @@
const mongoose = require("mongoose");
const { Schema } = mongoose;
const TodoListSchema = Schema({
name: {
type: String,
required: true,
minLength: 1,
maxLength: 100,
trim: true,
},
todos: [{ type: Schema.Types.ObjectId, ref: "Todo" }],
user: { type: Schema.Types.ObjectId, ref: "User", required: true },
});
TodoListSchema.pre("save", async function () {
if (this.isNew) {
const user = await this.model("User").findById(this.user);
user.lists.push(this._id);
await user.save();
}
});
TodoListSchema.pre("remove", async function () {
const user = await this.model("User").findById(this.user);
user.lists.splice(user.lists.indexOf(this._id), 1);
// removing todos in parallel can cause VersionError
// so we remove todos from user
const todos = await this.model("Todo").find({ list: this._id }).exec();
const ids = todos.map((todo) => todo._id);
user.todos = user.todos.filter((todo) => ids.includes(todo._id));
await user.save();
// and remove them from db
await this.model("Todo").find({ list: this._id }).remove().exec();
});
TodoListSchema.methods.toJson = function () {
const todos = this.populated("todos")
? this.todos.map((todo) => todo.toJson())
: this.todos;
return {
id: this._id.toString(),
user: this.user.toString(),
name: this.name,
todos,
};
};
mongoose.model("TodoList", TodoListSchema);

70
src/models/User.js Normal file
View File

@@ -0,0 +1,70 @@
const mongoose = require("mongoose");
const passportLocalMongoose = require("passport-local-mongoose");
const jwt = require("jsonwebtoken");
const uniqueValidator = require("mongoose-unique-validator");
const findOrCreate = require("mongoose-findorcreate");
const { BadRequestError } = require("../errors");
const { secret } = require("../config");
const { Schema } = mongoose;
const UserSchema = Schema({
username: {
type: String,
unique: true,
validate: /^\S*$/,
minLength: 3,
maxLength: 50,
trim: true,
sparse: true,
},
googleId: {
type: String,
unique: true,
sparse: true,
},
lists: [{ type: Schema.Types.ObjectId, ref: "TodoList" }],
todos: [{ type: Schema.Types.ObjectId, ref: "Todo" }],
});
UserSchema.plugin(passportLocalMongoose, {
limitAttempts: true,
maxAttempts: 20,
});
UserSchema.plugin(uniqueValidator);
UserSchema.plugin(findOrCreate);
UserSchema.pre("validate", async function () {
if (!this.username && !this.googleId) {
throw new BadRequestError("username is required");
}
});
UserSchema.pre("remove", async function () {
await this.model("TodoList").find({ user: this._id }).remove().exec();
await this.model("Todo").find({ user: this._id }).remove().exec();
});
UserSchema.methods.generateJwt = function () {
return jwt.sign({ id: this._id, username: this.username }, secret, {
expiresIn: "120d",
});
};
UserSchema.methods.toJson = function () {
return {
id: this._id,
username: this.username,
};
};
UserSchema.methods.toAuthJson = function () {
return {
id: this._id,
username: this.username,
jwt: this.generateJwt(),
};
};
mongoose.model("User", UserSchema);

11
src/routes/auth.js Normal file
View File

@@ -0,0 +1,11 @@
const jwt = require("express-jwt");
const { secret } = require("../config");
module.exports = {
required: jwt({ secret, algorithms: ["HS256"] }),
optional: jwt({
secret,
credentialsRequired: false,
algorithms: ["HS256"],
}),
};

23
src/routes/google.js Normal file
View File

@@ -0,0 +1,23 @@
const express = require("express");
const passport = require("passport");
const router = express.Router();
const asyncHelper = require("../asyncHelper");
router.get(
"/google",
passport.authenticate("google", {
scope: ["https://www.googleapis.com/auth/plus.login"],
}),
);
router.get(
"/google/callback",
passport.authenticate("google", { session: false, failWithError: true }),
asyncHelper(async (req, res) => {
res.redirect(`/login?jwt=${req.user.generateJwt()}`);
}),
);
module.exports = router;

80
src/routes/lists.js Normal file
View File

@@ -0,0 +1,80 @@
const express = require("express");
const mongoose = require("mongoose");
const router = express.Router();
const TodoList = mongoose.model("TodoList");
const asyncHelper = require("../asyncHelper");
const { NotFoundError } = require("../errors");
// index
router.get(
"/",
asyncHelper(async (req, res) => {
const lists = await TodoList.find({ user: req.user.id })
.populate("todos")
.exec();
res.json({
success: true,
data: lists.map((list) => {
list.todos.reverse();
return list.toJson();
}),
});
}),
);
// create
router.post(
"/",
asyncHelper(async (req, res) => {
const { name } = req.body;
const { id } = req.body || mongoose.Types.ObjectId();
const newList = new TodoList({ name, user: req.user.id, _id: id });
await newList.save();
res.json({ success: true, data: newList.toJson() });
}),
);
// delete
router.delete(
"/:listId",
asyncHelper(async (req, res) => {
const { listId } = req.params;
const list = await TodoList.findOne({
_id: listId,
user: req.user.id,
}).exec();
await list.remove();
res.json({ success: true });
}),
);
// update
router.patch(
"/:listId",
asyncHelper(async (req, res) => {
const { listId } = req.params;
const { name } = req.body;
const list = await TodoList.findOne({ _id: listId, user: req.user.id });
if (!list) {
throw new NotFoundError("can't find list");
}
if (name !== undefined) {
list.name = name;
}
await list.save();
res.json({ success: true, data: list.toJson() });
}),
);
router.use(
"/:listId/todos",
(req, res, next) => {
res.locals.listId = req.params.listId;
next();
},
require("./todos"),
);
module.exports = router;

82
src/routes/todos.js Normal file
View File

@@ -0,0 +1,82 @@
const express = require("express");
const mongoose = require("mongoose");
const router = express.Router();
const Todo = mongoose.model("Todo");
const asyncHelper = require("../asyncHelper");
const { NotFoundError } = require("../errors");
// index
router.get(
"/",
asyncHelper(async (req, res) => {
const { listId } = res.locals || req.body;
const todos = listId
? await Todo.find({ list: listId, user: req.user.id }).exec()
: await Todo.find({ user: req.user.id }).exec();
res.json({
success: true,
data: todos.reverse().map((todo) => todo.toJson()),
});
}),
);
// create
router.post(
"/",
asyncHelper(async (req, res) => {
const { listId } = res.locals || req.body;
const { text } = req.body;
const { id } = req.body || mongoose.Types.ObjectId();
const todo = new Todo({
text,
list: listId,
user: req.user.id,
_id: id,
});
await todo.save();
res.json({ success: true, data: todo.toJson() });
}),
);
// update
router.patch(
"/:todoId",
asyncHelper(async (req, res) => {
const { todoId } = req.params;
const { text, completed } = req.body;
const todo = await Todo.findOne({ _id: todoId, user: req.user.id });
if (!todo) {
throw new NotFoundError("can't find todo");
}
if (text !== undefined) {
todo.text = text;
}
if (completed !== undefined) {
todo.completed = completed;
}
await todo.save();
res.json({ success: true, data: todo.toJson() });
}),
);
// delete
router.delete(
"/:todoId",
asyncHelper(async (req, res) => {
const { todoId } = req.params;
const todo = await Todo.findOne({
_id: todoId,
user: req.user.id,
}).exec();
if (!todo) {
throw new NotFoundError(`can't find todo with id ${todoId}`);
}
await todo.remove();
res.json({ success: true });
}),
);
module.exports = router;

93
src/routes/users.js Normal file
View File

@@ -0,0 +1,93 @@
const express = require("express");
const mongoose = require("mongoose");
const passport = require("passport");
const User = mongoose.model("User");
const router = express.Router();
const asyncHelper = require("../asyncHelper");
const auth = require("./auth");
const { googleEnabled } = require("../config").googleOAuth;
const googleOAuth = require("./google");
const { NotFoundError } = require("../errors");
router.get(
"/user",
auth.required,
asyncHelper(async (req, res) => {
const { id } = req.user;
const user = await User.findById(id).exec();
res.json({ success: true, data: user.toAuthJson() });
}),
);
router.post(
"/",
asyncHelper(async (req, res) => {
const { username, password } = req.body;
const user = new User({ username });
await user.setPassword(password);
await user.save();
res.json({ success: true, data: user.toAuthJson() });
}),
);
router.patch(
"/user",
auth.required,
asyncHelper(async (req, res) => {
const { username, password, google } = req.body;
const patch = {};
if (username !== undefined && username != "") {
patch.username = username;
}
if (google === null) {
patch.googleId = null;
}
let user;
if (patch !== {}) {
user = await User.findOneAndUpdate(
{ _id: req.user.id },
{ $set: patch },
{ runValidators: true, context: "query", new: true },
).exec();
} else {
user = await User.findById(req.user.id);
}
if (!user) {
throw new NotFoundError(
`can't find user with username ${req.user.username}`,
);
}
if (password !== undefined) {
await user.setPassword(password);
await user.save();
}
res.json({ success: true, data: user.toAuthJson() });
}),
);
router.delete(
"/user",
auth.required,
asyncHelper(async (req, res) => {
const user = await User.findById(req.user.id).exec();
await user.remove();
res.json({ success: true });
}),
);
if (googleEnabled) {
router.use("/login", googleOAuth);
}
router.post(
"/login",
passport.authenticate("local", { session: false, failWithError: true }),
asyncHelper(async (req, res) => {
res.json({ success: true, data: req.user.toAuthJson() });
}),
);
module.exports = router;

5
src/tests/.eslintrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"rules": {
"node/no-unpublished-require": "off"
}
}

View File

@@ -0,0 +1,165 @@
const request = require("supertest");
const mongoose = require("mongoose");
require("../../models/Todo");
require("../../models/TodoList");
require("../../models/User");
const Todo = mongoose.model("Todo");
const TodoList = mongoose.model("TodoList");
const User = mongoose.model("User");
jest.setTimeout(60000);
const MongoDBMemoryServer = require("mongodb-memory-server").default;
const server = require("../../app.js");
const { seed, clean, mongodbMemoryServerConfig } = require("./utils");
let user;
let token;
let list;
let todo;
let mongoServer;
beforeAll(async () => {
mongoServer = new MongoDBMemoryServer(mongodbMemoryServerConfig);
const mongoUri = await mongoServer.getUri();
await mongoose.connect(mongoUri);
});
beforeEach(async () => {
({ user, token, list, todo } = await seed());
});
afterEach(async () => {
await clean();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
describe("test lists", () => {
test("should index lists", async () => {
const response = await request(server)
.get("/__/lists")
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(response.body.data[0].name).toEqual("List1");
});
test("should not index lists without authentication", async () => {
await request(server)
.get("/__/lists")
.set("Accept", "application/json")
.expect(401);
});
test("should create list", async () => {
const response = await request(server)
.post("/__/lists")
.send({
name: "List2",
})
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(await TodoList.findOne({ name: "List2" })).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists.map((l) => String(l))).toContain(
response.body.data.id,
);
});
test("should create list with custom id", async () => {
const id = mongoose.Types.ObjectId();
const response = await request(server)
.post("/__/lists")
.send({
name: "List2",
id,
})
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(await TodoList.findOne({ name: "List2", _id: id })).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists.map((l) => String(l))).toContain(
response.body.data.id,
);
});
test("should not create list without authentication", async () => {
await request(server)
.post("/__/lists")
.send({
name: "List2",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
});
test("should update list", async () => {
const response = await request(server)
.patch(`/__/lists/${list._id}`)
.send({
name: "List2",
})
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(await TodoList.findOne({ name: "List2" })).toBeTruthy();
});
test("should not update list without authentication", async () => {
await request(server)
.patch(`/__/lists/${list._id}`)
.send({
name: "List2",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
expect(await TodoList.findOne({ name: "List2" })).toBeFalsy();
});
test("should remove list", async () => {
const response = await request(server)
.delete(`/__/lists/${list._id}`)
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(await TodoList.findOne({ name: "List1" }).exec()).toBeFalsy();
expect(await Todo.findOne({ text: "Todo1" }).exec()).toBeFalsy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists).not.toContain(list._id);
expect(freshUser.todos).not.toContain(todo._id);
});
test("should not remove list without authentication", async () => {
await request(server)
.delete(`/__/lists/${list._id}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
expect(await TodoList.findOne({ name: "List1" }).exec()).toBeTruthy();
expect(await Todo.findOne({ text: "Todo1" }).exec()).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists.map((l) => String(l))).toContain(
String(list._id),
);
expect(freshUser.todos.map((t) => String(t))).toContain(
String(todo._id),
);
});
});

View File

@@ -0,0 +1,30 @@
const server = require("../../app.js");
const request = require("supertest");
describe("test not found", () => {
test("respond not found with json", async () => {
const response = await request(server)
.get("/")
.set("Accept", "application/json")
.expect(404)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body).toEqual({ error: "Not found" });
});
test("respond not found with html", async () => {
const response = await request(server)
.get("/")
.set("Accept", "text/html")
.expect(404)
.expect("Content-Type", "text/html; charset=utf-8");
expect(response.text).toEqual("404");
});
test("respond not found with plain text", async () => {
const response = await request(server)
.get("/")
.set("Accept", "text/plain")
.expect(404)
.expect("Content-Type", "text/plain; charset=utf-8");
expect(response.text).toEqual("not found");
});
});

View File

@@ -0,0 +1,181 @@
const request = require("supertest");
const mongoose = require("mongoose");
require("../../models/Todo");
require("../../models/TodoList");
require("../../models/User");
const Todo = mongoose.model("Todo");
const TodoList = mongoose.model("TodoList");
const User = mongoose.model("User");
jest.setTimeout(60000);
const MongoDBMemoryServer = require("mongodb-memory-server").default;
const server = require("../../app.js");
const { seed, clean, mongodbMemoryServerConfig } = require("./utils");
let user;
let token;
let list;
let todo;
let mongoServer;
beforeAll(async () => {
mongoServer = new MongoDBMemoryServer(mongodbMemoryServerConfig);
const mongoUri = await mongoServer.getUri();
await mongoose.connect(mongoUri);
});
beforeEach(async () => {
({ user, token, list, todo } = await seed());
});
afterEach(async () => {
await clean();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
describe("test todos", () => {
test("should index todos", async () => {
const response = await request(server)
.get(`/__/lists/${list._id}/todos`)
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(response.body.data[0].text).toEqual("Todo1");
});
test("should index all todos", async () => {
const response = await request(server)
.get(`/__/todos`)
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(response.body.data[0].text).toEqual("Todo1");
});
test("should not index todos without authentication", async () => {
await request(server)
.get(`/__/lists/${list._id}/todos`)
.set("Accept", "application/json")
.expect(401);
});
test("should create todo", async () => {
const response = await request(server)
.post(`/__/lists/${list._id}/todos`)
.send({
text: "Todo2",
})
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(
await Todo.findOne({ text: "Todo2", list: list._id }),
).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.todos.map((t) => String(t))).toContain(
response.body.data.id,
);
const freshList = await TodoList.findById(list.id).exec();
expect(freshList.todos.map((t) => String(t))).toContain(
response.body.data.id,
);
});
test("should create todo with custom id", async () => {
const id = mongoose.Types.ObjectId();
const response = await request(server)
.post(`/__/lists/${list._id}/todos`)
.send({
text: "Todo2",
id,
})
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(
await Todo.findOne({ text: "Todo2", list: list._id, _id: id }),
).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.todos.map((t) => String(t))).toContain(
response.body.data.id,
);
const freshList = await TodoList.findById(list.id).exec();
expect(freshList.todos.map((t) => String(t))).toContain(
response.body.data.id,
);
});
test("should not create todo without authentication", async () => {
await request(server)
.post(`/__/lists/${list._id}/todos`)
.send({
text: "Todo1",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
});
test("should update todo", async () => {
const response = await request(server)
.patch(`/__/lists/${list._id}/todos/${todo._id}`)
.send({
text: "Todo2",
})
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(await Todo.findOne({ text: "Todo2" })).toBeTruthy();
expect(await Todo.findOne({ text: "Todo1" })).toBeFalsy();
});
test("should not update todo without authentication", async () => {
await request(server)
.patch(`/__/lists/${list._id}/todos/${todo._id}`)
.send({
text: "Todo2",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
expect(await Todo.findOne({ text: "Todo1" })).toBeTruthy();
expect(await Todo.findOne({ text: "Todo2" })).toBeFalsy();
});
test("should remove todo", async () => {
const response = await request(server)
.delete(`/__/lists/${list._id}/todos/${todo._id}`)
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(await Todo.findOne({ text: "Todo1" }).exec()).toBeFalsy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.todos).not.toContain(todo.id);
const freshList = await TodoList.findById(list.id).exec();
expect(freshList.todos).not.toContain(todo.id);
});
test("should not remove todo without authentication", async () => {
await request(server)
.delete(`/__/lists/${list._id}/todos/${todo._id}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
expect(await Todo.findOne({ text: "Todo1" }).exec()).toBeTruthy();
});
});

View File

@@ -0,0 +1,189 @@
const request = require("supertest");
const mongoose = require("mongoose");
const jwt = require("jsonwebtoken");
require("../../models/Todo");
require("../../models/TodoList");
require("../../models/User");
const Todo = mongoose.model("Todo");
const TodoList = mongoose.model("TodoList");
const User = mongoose.model("User");
jest.setTimeout(60000);
const MongoDBMemoryServer = require("mongodb-memory-server").default;
const server = require("../../app.js");
const { seed, clean, mongodbMemoryServerConfig } = require("./utils");
const { secret } = require("../../config");
let token;
let user;
let mongoServer;
beforeAll(async () => {
mongoServer = new MongoDBMemoryServer(mongodbMemoryServerConfig);
const mongoUri = await mongoServer.getUri();
await mongoose.connect(mongoUri);
});
beforeEach(async () => {
({ token, user } = await seed());
});
afterEach(async () => {
await clean();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
describe("test users", () => {
test("should get user", async () => {
const response = await request(server)
.get("/__/users/user")
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(response.body.data.id).toBe(user._id.toString());
expect(response.body.data.username).toBe(user.username);
});
test("should create user", async () => {
const response = await request(server)
.post("/__/users")
.send({
username: "User2",
password: "password2",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
const tokenDecoded = jwt.verify(response.body.data.jwt, secret);
expect(tokenDecoded.username).toEqual("User2");
const userAuth = await User.authenticate()("User2", "password2");
expect(userAuth.user).toBeTruthy();
});
test("should not create user with no username", async () => {
const response = await request(server)
.post("/__/users")
.send({
username: "",
password: "password2",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(400)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeFalsy();
});
test("should not create user with no password", async () => {
const response = await request(server)
.post("/__/users")
.send({
username: "User",
password: "",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(400)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeFalsy();
});
test("should login user", async () => {
const response = await request(server)
.post("/__/users/login")
.send({
username: "User1",
password: "password1",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
const tokenDecoded = jwt.verify(response.body.data.jwt, secret);
expect(tokenDecoded.username).toEqual("User1");
});
test("should not login user with no name", async () => {
await request(server)
.post("/__/users/login")
.send({
username: "",
password: "notpassword",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(400);
});
test("should not login user with wrong password", async () => {
await request(server)
.post("/__/users/login")
.send({
username: "User",
password: "notpassword",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
});
test("should update user", async () => {
const response = await request(server)
.patch("/__/users/user")
.send({
username: "User2",
password: "password2",
})
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
const tokenDecoded = jwt.verify(response.body.data.jwt, secret);
expect(tokenDecoded.username).toEqual("User2");
const userAuth = await User.authenticate()("User2", "password2");
expect(userAuth.user).toBeTruthy();
});
test("should not update user without authentication", async () => {
const response = await request(server)
.patch("/__/users/user")
.send({
username: "User2",
password: "password2",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
expect(response.body.success).toBeFalsy();
});
test("should not delete user without authentication", async () => {
const response = await request(server)
.delete("/__/users/user")
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
expect(response.body.success).toBeFalsy();
expect(await User.findOne({ username: "User1" }).exec()).toBeTruthy();
expect(await TodoList.findOne({ name: "List1" }).exec()).toBeTruthy();
expect(await Todo.findOne({ text: "Todo1" })).toBeTruthy();
});
test("should delete user", async () => {
const response = await request(server)
.delete("/__/users/user")
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(await User.findOne({ username: "User1" }).exec()).toBeFalsy();
expect(await TodoList.findOne({ name: "List1" }).exec()).toBeFalsy();
expect(await Todo.findOne({ text: "Todo1" }).exec()).toBeFalsy();
});
});

View File

@@ -0,0 +1,46 @@
const mongoose = require("mongoose");
require("../../models/Todo");
require("../../models/TodoList");
require("../../models/User");
const User = mongoose.model("User");
const Todo = mongoose.model("Todo");
const TodoList = mongoose.model("TodoList");
async function seed() {
const user = new User({ username: "User1" });
await user.setPassword("password1");
await user.save();
const token = user.generateJwt();
const list = new TodoList({ name: "List1", user: user._id });
const todo = new Todo({ text: "Todo1", list: list._id, user: user._id });
await list.save();
await todo.save();
return {
user,
token,
list,
todo,
};
}
async function clean() {
await TodoList.remove({}).exec();
await Todo.remove({}).exec();
await User.remove({}).exec();
}
const mongodbMemoryServerConfig = {
binary: {
version: "4.0.14",
},
instance: {
args: ["--enableMajorityReadConcern=false"],
},
};
module.exports = { seed, clean, mongodbMemoryServerConfig };

View File

@@ -1,5 +0,0 @@
{
"rules": {
"node/no-unpublished-require": "off"
}
}

View File

@@ -1,157 +0,0 @@
const request = require('supertest');
const mongoose = require('mongoose');
require('../../models/Todo');
require('../../models/TodoList');
require('../../models/User');
const Todo = mongoose.model('Todo');
const TodoList = mongoose.model('TodoList');
const User = mongoose.model('User');
jest.setTimeout(60000);
const MongoDBMemoryServer = require('mongodb-memory-server').default;
const server = require('../../app.js');
const { seed, clean, mongodbMemoryServerConfig } = require('./utils');
let user;
let token;
let list;
let todo;
let mongoServer;
beforeAll(async () => {
mongoServer = new MongoDBMemoryServer(mongodbMemoryServerConfig);
const mongoUri = await mongoServer.getUri();
await mongoose.connect(mongoUri);
});
beforeEach(async () => {
({ user, token, list, todo } = await seed());
});
afterEach(async () => {
await clean();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
describe('test lists', () => {
test('should index lists', async () => {
const response = await request(server)
.get('/__/lists')
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(response.body.data[0].name).toEqual('List1');
});
test('should not index lists without authentication', async () => {
await request(server)
.get('/__/lists')
.set('Accept', 'application/json')
.expect(401);
});
test('should create list', async () => {
const response = await request(server)
.post('/__/lists')
.send({
name: 'List2',
})
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(await TodoList.findOne({ name: 'List2' })).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists.map(l => String(l))).toContain(response.body.data.id);
});
test('should create list with custom id', async () => {
const id = mongoose.Types.ObjectId();
const response = await request(server)
.post('/__/lists')
.send({
name: 'List2',
id,
})
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(await TodoList.findOne({ name: 'List2', _id: id })).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists.map(l => String(l))).toContain(response.body.data.id);
});
test('should not create list without authentication', async () => {
await request(server)
.post('/__/lists')
.send({
name: 'List2',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
});
test('should update list', async () => {
const response = await request(server)
.patch(`/__/lists/${list._id}`)
.send({
name: 'List2',
})
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(await TodoList.findOne({ name: 'List2' })).toBeTruthy();
});
test('should not update list without authentication', async () => {
await request(server)
.patch(`/__/lists/${list._id}`)
.send({
name: 'List2',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
expect(await TodoList.findOne({ name: 'List2' })).toBeFalsy();
});
test('should remove list', async () => {
const response = await request(server)
.delete(`/__/lists/${list._id}`)
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(await TodoList.findOne({ name: 'List1' }).exec()).toBeFalsy();
expect(await Todo.findOne({ text: 'Todo1' }).exec()).toBeFalsy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists).not.toContain(list._id);
expect(freshUser.todos).not.toContain(todo._id);
});
test('should not remove list without authentication', async () => {
await request(server)
.delete(`/__/lists/${list._id}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
expect(await TodoList.findOne({ name: 'List1' }).exec()).toBeTruthy();
expect(await Todo.findOne({ text: 'Todo1' }).exec()).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists.map(l => String(l))).toContain(String(list._id));
expect(freshUser.todos.map(t => String(t))).toContain(String(todo._id));
});
});

View File

@@ -1,30 +0,0 @@
const server = require('../../app.js');
const request = require('supertest');
describe('test not found', () => {
test('respond not found with json', async () => {
const response = await request(server)
.get('/')
.set('Accept', 'application/json')
.expect(404)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body).toEqual({ error: 'Not found' });
});
test('respond not found with html', async () => {
const response = await request(server)
.get('/')
.set('Accept', 'text/html')
.expect(404)
.expect('Content-Type', 'text/html; charset=utf-8');
expect(response.text).toEqual('404');
});
test('respond not found with plain text', async () => {
const response = await request(server)
.get('/')
.set('Accept', 'text/plain')
.expect(404)
.expect('Content-Type', 'text/plain; charset=utf-8');
expect(response.text).toEqual('not found');
});
});

View File

@@ -1,171 +0,0 @@
const request = require('supertest');
const mongoose = require('mongoose');
require('../../models/Todo');
require('../../models/TodoList');
require('../../models/User');
const Todo = mongoose.model('Todo');
const TodoList = mongoose.model('TodoList');
const User = mongoose.model('User');
jest.setTimeout(60000);
const MongoDBMemoryServer = require('mongodb-memory-server').default;
const server = require('../../app.js');
const { seed, clean, mongodbMemoryServerConfig } = require('./utils');
let user;
let token;
let list;
let todo;
let mongoServer;
beforeAll(async () => {
mongoServer = new MongoDBMemoryServer(mongodbMemoryServerConfig);
const mongoUri = await mongoServer.getUri();
await mongoose.connect(mongoUri);
});
beforeEach(async () => {
({ user, token, list, todo } = await seed());
});
afterEach(async () => {
await clean();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
describe('test todos', () => {
test('should index todos', async () => {
const response = await request(server)
.get(`/__/lists/${list._id}/todos`)
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(response.body.data[0].text).toEqual('Todo1');
});
test('should index all todos', async () => {
const response = await request(server)
.get(`/__/todos`)
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(response.body.data[0].text).toEqual('Todo1');
});
test('should not index todos without authentication', async () => {
await request(server)
.get(`/__/lists/${list._id}/todos`)
.set('Accept', 'application/json')
.expect(401);
});
test('should create todo', async () => {
const response = await request(server)
.post(`/__/lists/${list._id}/todos`)
.send({
text: 'Todo2',
})
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(await Todo.findOne({ text: 'Todo2', list: list._id })).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.todos.map(t => String(t))).toContain(response.body.data.id);
const freshList = await TodoList.findById(list.id).exec();
expect(freshList.todos.map(t => String(t))).toContain(response.body.data.id);
});
test('should create todo with custom id', async () => {
const id = mongoose.Types.ObjectId();
const response = await request(server)
.post(`/__/lists/${list._id}/todos`)
.send({
text: 'Todo2',
id,
})
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(
await Todo.findOne({ text: 'Todo2', list: list._id, _id: id }),
).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.todos.map(t => String(t))).toContain(response.body.data.id);
const freshList = await TodoList.findById(list.id).exec();
expect(freshList.todos.map(t => String(t))).toContain(response.body.data.id);
});
test('should not create todo without authentication', async () => {
await request(server)
.post(`/__/lists/${list._id}/todos`)
.send({
text: 'Todo1',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
});
test('should update todo', async () => {
const response = await request(server)
.patch(`/__/lists/${list._id}/todos/${todo._id}`)
.send({
text: 'Todo2',
})
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(await Todo.findOne({ text: 'Todo2' })).toBeTruthy();
expect(await Todo.findOne({ text: 'Todo1' })).toBeFalsy();
});
test('should not update todo without authentication', async () => {
await request(server)
.patch(`/__/lists/${list._id}/todos/${todo._id}`)
.send({
text: 'Todo2',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
expect(await Todo.findOne({ text: 'Todo1' })).toBeTruthy();
expect(await Todo.findOne({ text: 'Todo2' })).toBeFalsy();
});
test('should remove todo', async () => {
const response = await request(server)
.delete(`/__/lists/${list._id}/todos/${todo._id}`)
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(await Todo.findOne({ text: 'Todo1' }).exec()).toBeFalsy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.todos).not.toContain(todo.id);
const freshList = await TodoList.findById(list.id).exec();
expect(freshList.todos).not.toContain(todo.id);
});
test('should not remove todo without authentication', async () => {
await request(server)
.delete(`/__/lists/${list._id}/todos/${todo._id}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
expect(await Todo.findOne({ text: 'Todo1' }).exec()).toBeTruthy();
});
});

View File

@@ -1,189 +0,0 @@
const request = require('supertest');
const mongoose = require('mongoose');
const jwt = require('jsonwebtoken');
require('../../models/Todo');
require('../../models/TodoList');
require('../../models/User');
const Todo = mongoose.model('Todo');
const TodoList = mongoose.model('TodoList');
const User = mongoose.model('User');
jest.setTimeout(60000);
const MongoDBMemoryServer = require('mongodb-memory-server').default;
const server = require('../../app.js');
const { seed, clean, mongodbMemoryServerConfig } = require('./utils');
const { secret } = require('../../config');
let token;
let user;
let mongoServer;
beforeAll(async () => {
mongoServer = new MongoDBMemoryServer(mongodbMemoryServerConfig);
const mongoUri = await mongoServer.getUri();
await mongoose.connect(mongoUri);
});
beforeEach(async () => {
({ token, user } = await seed());
});
afterEach(async () => {
await clean();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
describe('test users', () => {
test('should get user', async () => {
const response = await request(server)
.get('/__/users/user')
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(response.body.data.id).toBe(user._id.toString());
expect(response.body.data.username).toBe(user.username);
});
test('should create user', async () => {
const response = await request(server)
.post('/__/users')
.send({
username: 'User2',
password: 'password2',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
const tokenDecoded = jwt.verify(response.body.data.jwt, secret);
expect(tokenDecoded.username).toEqual('User2');
const userAuth = await User.authenticate()('User2', 'password2');
expect(userAuth.user).toBeTruthy();
});
test('should not create user with no username', async () => {
const response = await request(server)
.post('/__/users')
.send({
username: '',
password: 'password2',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(400)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeFalsy();
});
test('should not create user with no password', async () => {
const response = await request(server)
.post('/__/users')
.send({
username: 'User',
password: '',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(400)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeFalsy();
});
test('should login user', async () => {
const response = await request(server)
.post('/__/users/login')
.send({
username: 'User1',
password: 'password1',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
const tokenDecoded = jwt.verify(response.body.data.jwt, secret);
expect(tokenDecoded.username).toEqual('User1');
});
test('should not login user with no name', async () => {
await request(server)
.post('/__/users/login')
.send({
username: '',
password: 'notpassword',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(400);
});
test('should not login user with wrong password', async () => {
await request(server)
.post('/__/users/login')
.send({
username: 'User',
password: 'notpassword',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
});
test('should update user', async () => {
const response = await request(server)
.patch('/__/users/user')
.send({
username: 'User2',
password: 'password2',
})
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
const tokenDecoded = jwt.verify(response.body.data.jwt, secret);
expect(tokenDecoded.username).toEqual('User2');
const userAuth = await User.authenticate()('User2', 'password2');
expect(userAuth.user).toBeTruthy();
});
test('should not update user without authentication', async () => {
const response = await request(server)
.patch('/__/users/user')
.send({
username: 'User2',
password: 'password2',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
expect(response.body.success).toBeFalsy();
});
test('should not delete user without authentication', async () => {
const response = await request(server)
.delete('/__/users/user')
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
expect(response.body.success).toBeFalsy();
expect(await User.findOne({ username: 'User1' }).exec()).toBeTruthy();
expect(await TodoList.findOne({ name: 'List1' }).exec()).toBeTruthy();
expect(await Todo.findOne({ text: 'Todo1' })).toBeTruthy();
});
test('should delete user', async () => {
const response = await request(server)
.delete('/__/users/user')
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(await User.findOne({ username: 'User1' }).exec()).toBeFalsy();
expect(await TodoList.findOne({ name: 'List1' }).exec()).toBeFalsy();
expect(await Todo.findOne({ text: 'Todo1' }).exec()).toBeFalsy();
});
});

View File

@@ -1,46 +0,0 @@
const mongoose = require('mongoose');
require('../../models/Todo');
require('../../models/TodoList');
require('../../models/User');
const User = mongoose.model('User');
const Todo = mongoose.model('Todo');
const TodoList = mongoose.model('TodoList');
async function seed() {
const user = new User({ username: 'User1' });
await user.setPassword('password1');
await user.save();
const token = user.generateJwt();
const list = new TodoList({ name: 'List1', user: user._id });
const todo = new Todo({ text: 'Todo1', list: list._id, user: user._id });
await list.save();
await todo.save();
return {
user,
token,
list,
todo,
};
}
async function clean() {
await TodoList.remove({}).exec();
await Todo.remove({}).exec();
await User.remove({}).exec();
}
const mongodbMemoryServerConfig = {
binary: {
version: 'latest',
},
instance: {
args: ['--enableMajorityReadConcern=false'],
},
};
module.exports = { seed, clean, mongodbMemoryServerConfig };