- clone it
- reset your origin url to a new GH url that you own
- add the repo on your code climate and circle CI account
- change the urls of all the above badges to reflect your repositories
- push up the repo and watch for circle and code climate to update
- do your thing
- fork, clone, setup locally following the 'set it up' instructions
- add remote "upstream" with this repo's ssh url
- checkout a branch and commit your work
- push branch to your repo
- submit pr
- periodically pull upstream master into master, and rebase the branch on top, force pushing the rebased branch when necessary
- prettier
- signup
- testing for signup fails - user must have unique email
- login
- testing for login fails
- users index - only users
- testing for malformed JWT
- users show - only users
- users update - only self
- users update feature test - get validation error
- model test - user cannot update email to a pre-existing email address
- model test - user cannot update email to a pre-existing email address regardless of case
- model test - user cannot update email to a pre-existing email address regardless of spaces on either end of entry
- migration - email must be unique on db
- update to Node 8xx I guess ๐
- object creation methods? createUser() or createUser(overrides)
-
createUser()
creates "unique" users each time with Math.rand or whatevs 4 random numbers? at the end of everything - import statements instead of requires where possible?
- model test - findBy other than email (everything on user)
- add circle, code climate
- make code climate happy with trailing commas, eslint or something
-
prettierlinter runs with test, throws and describes issue found - PR template
$ yarn install
$ cp .env.example .env
$ createdb exp_starter_app_test
$ createdb exp_starter_app_development
$ yarn db:migrate
$ yarn db:migrate:test
- rollback to a specific version:
$ MIGRATE_TO=<TIMESTAMP OF MIGRATION> yarn db:migrate
$ nodemon start
$ yarn global add nodemon
if you don't have it... this will restart your server on most changes
Tests (also runs linter on success)
$ yarn test
Test coverage and reports
$ yarn coverage
- runs tests and reports coverage$ yarn reports
- generates coverage artifacts
Linter alone
$ yarn lint
This outlines a large portion of basic beginning setup, but is no longer being extended. Subject to deletion.
$ express exp-starter-app
and cd into the created directory$ yarn install
$ git init
$ echo node_modules/ >> .gitignore
$ touch README.md
and start taking amazing notes
- delete public directory
- delete views directory
- within app.js:
- remove all lines referencing favicon
- remove lines referencing views and view engine setup
- remove line referencing static files, loading public directory
- change
res.render
tores.json
within the error handler, with the following argument:{ message: err.message, error: err, }
- remove unnecessary comments
- go to an undefined url to see the proper json error
- within routes:
- index: change response to
res.send("oh hai");
- users: change response to
res.json({users: []});
- add
res.status(200);
above both of the responses in these files - visit these routes to ensure all is well
- index: change response to
- within package.json:
- remove jade, serve-favicon
- don't forget to remove trailing commas!
- delete
node_modules
andyarn.lock
and re-yarn install
- click all the things again just to be sure!
- create a test directory
- create a test/features directory
$ touch test/features/welcome.test.js
and add the following content:const expect = require('expect'); const request = require('supertest'); const app = require('../../app'); describe('Root of API', () => { it('welcomes visitors', async () => { const res = await request(app) .get('/') .expect(200); expect(res.text).toEqual("failing! oh hai"); expect(res.body).toEqual({}); }); });
- add a test script to the package.json with the value:
"mocha --recursive"
$ yarn add mocha --dev
$ yarn add expect --dev
$ yarn add supertest --dev
- once you get a proper fail, update res.text to pass
- repeat similarly for users
-
create a new file
test/models/user.test.js
with the following content:const expect = require('expect'); const User = require('../../models/user.js') describe('User', () => { it('can be created', async () => { const usersBefore = await User.all(); expect(usersBefore.length).toBe(0); await User.create({ firstName: 'Elowyn', lastName: 'Platzer Bartel', email: '[email protected]', birthYear: 2015, student: true, password: 'password', }) const usersAfter = await User.all(); expect(usersAfter.length).toBe(1); }); });
-
Create the model, and add the following content:
const query = require('../db/index').query; module.exports = { all: async () => { const users = (await query('SELECT * FROM "users"')).rows; return users; }, }
-
Create the db pool file, and add the following content:
const { Pool } = require('pg'); const config = require('../dbConfig'); const pool = new Pool(config); module.exports = { query: (text, params) => pool.query(text, params) };
-
$ yarn add pg
-
Create the
dbConfig.js
file and add the following content:const url = require('url'); const params = url.parse(process.env.DATABASE_URL); const auth = params.auth ? params.auth.split(':') : [] module.exports = { user: auth[0], password: auth[1], host: params.hostname, port: params.port, database: params.pathname.split('/')[1], };
-
Create
.env.example
with the following content, then copy to .env:DATABASE_URL=postgres://localhost/exp_starter_app_development TEST_DATABASE_URL=postgres://localhost/exp_starter_app_test
-
$ yarn add dotenv --dev
-
Create
test/helpers.js
file with the following content:require('dotenv').config(); process.env.NODE_ENV = 'test'; process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
-
$ createdb exp_starter_app_test
-
$ createdb exp_starter_app_development
-
Create migration for users table:
- create files
migrations/<year><month><day><hour><minutes><seconds>.do.<description>.sql
with the following content:CREATE TABLE IF NOT EXISTS "users"( "id" SERIAL PRIMARY KEY NOT NULL, "firstName" VARCHAR(100) NOT NULL, "lastName" VARCHAR(100) NOT NULL, "email" VARCHAR(200) NOT NULL, "birthYear" INT, "student" BOOLEAN NOT NULL DEFAULT FALSE, "passwordDigest" VARCHAR(100) NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );
migrations/<same timestamp ^^ >.undo.<same description ^^ >.sql
with the following content:DROP TABLE IF EXISTS "users";
- add the migration scripts to package.json:
"db:migrate": "node postgrator.js", "db:migrate:test": "NODE_ENV=test node postgrator.js",
- add the postgrator.js file with the following content:
if (process.env.NODE_ENV !== 'production') { require('dotenv').config(); } if (process.env.NODE_ENV === 'test') { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; } const postgrator = require('postgrator'); postgrator.setConfig({ migrationDirectory: __dirname + '/migrations', driver: 'pg', connectionString: process.env.DATABASE_URL, }); // migrate to version specified, or supply 'max' to go all the way up postgrator.migrate('max', function(err, migrations) { if (err) { console.log(err); } else { if (migrations) { console.log( ['*******************'] .concat(migrations.map(migration => `checking ${migration.filename}`)) .join('\n') ); } } postgrator.endConnection(() => {}); });
$ yarn add postgrator
$ yarn db:migrate
and$ yarn db:migrate:test
- create files
-
add create property to the user model with the following async function content:
const saltRounds = 10; const salt = bcrypt.genSaltSync(saltRounds); const passwordDigest = bcrypt.hashSync(properties.password, salt); const createdUser = (await query( `INSERT INTO "users"( "firstName", "lastName", "email", "birthYear", "student", "passwordDigest" ) values ($1, $2, $3, $4, $5, $6) returning *`, [ properties.firstName, properties.lastName, properties.email, properties.birthYear, properties.student, passwordDigest, ] )).rows[0]; return createdUser;
-
at the top of the user model file, add:
const bcrypt = require('bcryptjs');
-
$ yarn add bcryptjs
-
Run your tests twice or more while passing... Oh no! No database cleanup after test runs!
- Add to bottom of test helpers:
const clearDB = require('../lib/clearDB'); afterEach(clearDB);
- create
lib/clearDB.js
file with the following content:const query = require('../db/index').query; module.exports = async () => { await query('delete from "users"'); };
- Write the test in
features/users.test.js
:it('can signup and receive a JWT', async () => { const res = await request(app) .post('/users') .send({ firstName: 'Elowyn', lastName: 'Platzer Bartel', email: '[email protected]', birthYear: 2015, student: true, password: 'password', }) .expect(200); expect(res.body.jwt).not.toBe(undefined); expect(res.body.user.id).not.toBe(undefined); expect(res.body.user.firstName).toEqual('Elowyn'); expect(res.body.user.lastName).toEqual('Platzer Bartel'); expect(res.body.user.email).toEqual('[email protected]'); expect(res.body.user.birthYear).toEqual(2015); expect(res.body.user.student).toEqual(true); expect(res.body.user.passwordDigest).toEqual(undefined); expect(res.body.user.createdAt).toEqual(undefined); expect(res.body.user.updatedAt).toEqual(undefined); });
- Add to the users routes:
router.post('/', usersController.create);
andconst usersController = require('../controllers/users')
at the top - Create the
controllers/user
with the following content:const jwt = require('jsonwebtoken'); const userSerializer = require('../serializers/user'); const User = require('../models/user'); module.exports = { create: async (req, res, next) => { const user = await User.create(req.body); const serializedUser = await userSerializer(user); const token = jwt.sign({ user: serializedUser }, process.env.JWT_SECRET); res.json({ jwt: token, user: serializedUser }); } }
- Add the
JWT_SECRET
to the.env.example
and.env
. Value doesn't really matter as long as it's the same to encode and decode the JWTs $ yarn add jsonwebtoken
- You will likely or eventually need to require
helpers.js
at the top of each test file (above everything except the package dependencies). If all tests are run, you will only need it to be required in a preceding run file, but if you run a single testyarn test test/models/user.test.js
you will be missing that requirement. - Add
serializers/user.js
with the following content:module.exports = user => { const serialized = { id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, birthYear: user.birthYear, student: user.student, }; return serialized; };
- Try curling the signup route (see curl docs)
- add
if (process.env.NODE_ENV !== 'production') { require('dotenv').config() }
to the top of the bin/www file and restart the server if needed
- add
We left the users route returning an empty array. Let's update that test and drive the rewrite to make this actually query the database.
- Update the feature test for users index:
it('can be listed, without users and with one added', async () => { const resNoUsers = await request(app) .get('/users') .expect(200); expect(resNoUsers.body).toEqual({users: []}); await User.create({ firstName: 'Elowyn', lastName: 'Platzer Bartel', email: '[email protected]', birthYear: 2015, student: true, password: 'password', }) const resWithUsers = await request(app) .get('/users') .expect(200); expect(resWithUsers.body.users.length).toEqual(1); const newUser = resWithUsers.body.users[0] expect(resWithUsers.jwt).toBe(undefined); expect(newUser.id).not.toBe(undefined); expect(newUser.firstName).toEqual('Elowyn'); expect(newUser.lastName).toEqual('Platzer Bartel'); expect(newUser.email).toEqual('[email protected]'); expect(newUser.birthYear).toEqual(2015); expect(newUser.student).toEqual(true); expect(newUser.passwordDigest).toEqual(undefined); expect(newUser.createdAt).toEqual(undefined); expect(newUser.updatedAt).toEqual(undefined); });
- Require the User model in the top of the test file
- Update the users index route to:
router.get('/', usersController.index);
- Update the users controller to add the index action like so:
index: async (req, res, next) => { const users = await User.all(); const serializedUsers = users.map(user => userSerializer(user)); res.json({ users: serializedUsers }); },
- Add a
features/authentication.test.js
with the following content:const expect = require('expect'); const request = require('supertest'); require('../helpers') const app = require('../../app'); const User = require('../../models/user') describe('Authentication - ', () => { it('users can log in and receive a JWT', async () => { const userParams = { firstName: 'Elowyn', lastName: 'Platzer Bartel', email: '[email protected]', birthYear: 2015, student: true, password: 'password', }; const user = await User.create(userParams); const res = await request(app) .post('/login') .send({ email: '[email protected]', password: 'password' }) .expect(200); expect(res.body.jwt).not.toBe(undefined); expect(res.body.user).toEqual({ id: user.id, firstName: 'Elowyn', lastName: 'Platzer Bartel', email: '[email protected]', birthYear: 2015, student: true, }); expect(res.body.user.passwordDigest).toEqual(undefined); expect(res.body.user.createdAt).toEqual(undefined); expect(res.body.user.updatedAt).toEqual(undefined); }); });
- Add the login route to app.js, and the login route file with the following content:
const express = require('express'); const router = express.Router(); const loginController = require('../controllers/login') router.post('/', loginController.create); module.exports = router;
- Add the login controller with the following content:
const User = require('../models/user'); exports.create = async (req, res, next) => { res.json(await User.authenticate(req.body)); };
- Add the authenticate method to the User model:
authenticate: async credentials => { const user = (await query('SELECT * FROM "users" WHERE "email" = ($1)', [ credentials.email, ])).rows[0]; const valid = user ? await bcrypt.compare(credentials.password, user.passwordDigest) : false; if (valid) { const serializedUser = await userSerializer(user); const token = jwt.sign({ user: serializedUser }, process.env.JWT_SECRET); return { jwt: token, user: serializedUser }; } else { return { errors: ['Email or Password is incorrect'] }; } },
We don't want to allow just anybody to get a list of users. Let's lock this route down.
- Update the user index feature test:
it('can be listed for a logged in user', async () => { const user = await User.create({ firstName: 'Elowyn', lastName: 'Platzer Bartel', email: '[email protected]', birthYear: 2015, student: true, password: 'password', }); serializedUser = await userSerializer(user); token = jwt.sign({ user: serializedUser }, process.env.JWT_SECRET); const resNotLoggedIn = await request(app) .get('/users') .expect(404); const resLoggedIn = await request(app) .get('/users') .set('jwt', token) .expect(200); expect(resLoggedIn.body.users.length).toEqual(1); const newUser = resLoggedIn.body.users[0] expect(resLoggedIn.jwt).toBe(undefined); expect(newUser.id).not.toBe(undefined); expect(newUser.firstName).toEqual('Elowyn'); expect(newUser.lastName).toEqual('Platzer Bartel'); expect(newUser.email).toEqual('[email protected]'); expect(newUser.birthYear).toEqual(2015); expect(newUser.student).toEqual(true); expect(newUser.passwordDigest).toEqual(undefined); expect(newUser.createdAt).toEqual(undefined); expect(newUser.updatedAt).toEqual(undefined); });
- reorder user routes and add the verifyLoggedInUser middleware, required at the top from a
lib/verifyLoggedInUser.js
:router.post('/', usersController.create); router.use(verifyLoggedInUser); router.get('/', usersController.index);
- Add the verifyLoggedInUser file with the following content:
const jwt = require('jsonwebtoken'); const currentUser = require('./currentUser'); module.exports = (req, res, next) => { const token = req.headers.jwt; if (!currentUser(token)) { const err = new Error('Not Found'); err.status = 404; next(err); } next(); };
- Add the currentUser file with the following content:
const jwt = require('jsonwebtoken'); module.exports = token => { try { return jwt.verify(token, process.env.JWT_SECRET).user; } catch (err) { return undefined } };
- Refactored
var
s intoconst
s (andlet
s where necessary) - Refactored module.exports to exports.method
$ yarn add prettier --dev
- add script to package.json:
"prettier": "prettier --single-quote --trailing-comma=es5 --list-different --write es5 './**/*.js'",
- run prettier and approve diffs if you like them!
- Add a model test:
it('can be found by property', async () => { const user = await User.create({ firstName: 'Elowyn', lastName: 'Platzer Bartel', email: '[email protected]', birthYear: 2015, student: true, password: 'password', }); const foundUser = await User.findBy({ email: '[email protected]' }); expect(foundUser.firstName).toEqual('Elowyn'); expect(foundUser.lastName).toEqual('Platzer Bartel'); expect(foundUser.email).toEqual('[email protected]'); expect(foundUser.birthYear).toEqual(2015); expect(foundUser.student).toEqual(true); });
- Add the method to the User model:
exports.findBy = async property => { const key = Object.keys(property)[0]; let findByQuery; switch (key) { case 'firstName': findByQuery = 'SELECT * FROM "users" WHERE "firstName" = $1 LIMIT 1'; break; case 'lastName': findByQuery = 'SELECT * FROM "users" WHERE "lastName" = $1 LIMIT 1'; break; case 'email': findByQuery = 'SELECT * FROM "users" WHERE "email" = $1 LIMIT 1'; break; case 'birthYear': findByQuery = 'SELECT * FROM "users" WHERE "birthYear" = $1 LIMIT 1'; break; case 'student': findByQuery = 'SELECT * FROM "users" WHERE "student" = $1 LIMIT 1'; break; }; const value = property[key]; const user = (await query(findByQuery, [value])).rows[0]; return user; };
-
Add a model test:
it('must have unique email', async () => { await User.create({ firstName: 'Elowyn', lastName: 'Platzer Bartel', email: '[email protected]', birthYear: 2015, student: true, password: 'password', }); const duplicateUser = await User.create({ firstName: 'Elowyn', lastName: 'Platzer Bartel', email: '[email protected]', birthYear: 2015, student: true, password: 'password', }); expect(duplicateUser).toEqual(['Email already taken']) const users = await User.all(); expect(users.length).toBe(1); });
-
Add the validation to the User model. Note, for extensibility of validations, add more if statements. Validations will get pushed to the errors array and finally be returned:
const errors = []; if (await this.findBy({email: properties.email})) { const error = 'Email already taken' errors.push(error); }; if (errors.length > 0) { return errors };
- Add the happy path test, "break it" to see a good red, then allow it to pass.
const expect = require('expect'); const jwt = require('jsonwebtoken'); require('../helpers/testSetup'); const currentUser = require('../../lib/currentUser'); const User = require('../../models/user'); const userSerializer = require('../../serializers/user'); describe('currentUser', () => { it('returns a User when passed a valid token', async () => { const createdUser = await User.create({ firstName: 'Elowyn', lastName: 'Platzer Bartel', email: '[email protected]', birthYear: 2015, student: true, password: 'password', }); const serializedUser = userSerializer(createdUser); const validToken = jwt.sign({ user: serializedUser }, process.env.JWT_SECRET); const user = currentUser(validToken); expect(user).toEqual(serializedUser); //break it here for example with `.toEqual(createdUser)` }); });
- Add the sad path test...
it('returns undefined when passed an invalid token', async () => { const invalidToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'; const user = currentUser(invalidToken); expect(user).toEqual(undefined); });
- Add tests for user login sad path (auth feature test):
it('users cannot login without valid credentials', async () => { const userParams = { firstName: 'Elowyn', lastName: 'Platzer Bartel', email: '[email protected]', birthYear: 2015, student: true, password: 'password', }; const user = await User.create(userParams); const wrongPasswordRes = await request(app) .post('/login') .send({ email: '[email protected]', password: 'wrong password' }) .expect(200); expect(wrongPasswordRes.body.jwt).toBe(undefined); expect(wrongPasswordRes.body.user).toEqual(undefined); expect(wrongPasswordRes.body.error).toEqual(['Email or Password is incorrect']); const noUserRes = await request(app) .post('/login') .send({ email: '[email protected]', password: 'password' }) .expect(200); expect(noUserRes.body.jwt).toBe(undefined); expect(noUserRes.body.user).toEqual(undefined); expect(noUserRes.body.errors).toEqual(['Email or Password is incorrect']); });
- Add the model test:
it('can be found by id', async () => { const user = await User.create({ firstName: 'Elowyn', lastName: 'Platzer Bartel', email: '[email protected]', birthYear: 2015, student: true, password: 'password', }); const foundUser = await User.find(user.id); expect(foundUser.firstName).toEqual('Elowyn'); expect(foundUser.lastName).toEqual('Platzer Bartel'); expect(foundUser.email).toEqual('[email protected]'); expect(foundUser.birthYear).toEqual(2015); expect(foundUser.student).toEqual(true); });
-
Add the feature test:
it('can be shown for a logged in user only', async () => { const user = await User.create({ firstName: 'Elowyn', lastName: 'Platzer Bartel', email: '[email protected]', birthYear: 2015, student: true, password: 'password', }); serializedUser = await userSerializer(user); token = jwt.sign({ user: serializedUser }, process.env.JWT_SECRET); const resNotLoggedIn = await request(app) .get(`/users/${user.id}`) .expect(404); const resLoggedIn = await request(app) .get(`/users/${user.id}`) .set('jwt', token) .expect(200); const showUser = resLoggedIn.body.user; expect(resLoggedIn.jwt).toBe(undefined); expect(showUser.id).not.toBe(undefined); expect(showUser.firstName).toEqual('Elowyn'); expect(showUser.lastName).toEqual('Platzer Bartel'); expect(showUser.email).toEqual('[email protected]'); expect(showUser.birthYear).toEqual(2015); expect(showUser.student).toEqual(true); expect(showUser.passwordDigest).toEqual(undefined); expect(showUser.createdAt).toEqual(undefined); expect(showUser.updatedAt).toEqual(undefined); });
-
Add the route and controller action:
router.get('/:id', usersController.show);
exports.show = async (req, res, next) => { const user = await User.find(req.params.id); const serializedUser = await userSerializer(user); res.json({ user: serializedUser }); }
- Update the test description to "can be shown with a valid user id for a logged in user only" and add the following request between the two requests of the last test:
const resLoggedInWrongId = await request(app) .get(`/users/${user.id+10}`) .set('jwt', token) .expect(404);
- Update users controller show action to:
exports.show = async (req, res, next) => { try { const user = await User.find(req.params.id); const serializedUser = await userSerializer(user); res.json({ user: serializedUser }); } catch (e) { const err = new Error('Not Found'); err.status = 404; next(err); } }
- Add to end of create user feature test:
const duplicateEmailRes = await request(app) .post('/users') .send({ firstName: 'Elowyn', lastName: 'Platzer Bartel', email: '[email protected]', birthYear: 2015, student: true, password: 'password', }) .expect(200); expect(duplicateEmailRes.body.jwt).toBe(undefined); expect(duplicateEmailRes.body.user.id).toBe(undefined); expect(duplicateEmailRes.body.user.errors).toEqual(['Email already taken']);
- Update the create user action in the controller:
const user = await User.create(req.body); if (user.errors) { res.json({ user: user }); } else { const serializedUser = await userSerializer(user); const token = jwt.sign({ user: serializedUser }, process.env.JWT_SECRET); res.json({ jwt: token, user: serializedUser }); }
- model test:
it('can be updated', async () => { const originalUser = await User.create({ firstName: 'Elowyn', lastName: 'Platzer Bartel', email: '[email protected]', birthYear: 2015, student: true, password: 'password', }); const updatedUser = await User.update({ id: originalUser.id, firstName: 'Freyja', lastName: 'Puppy', email: '[email protected]', birthYear: 2016, student: false, password: 'puppy password', }) expect(updatedUser.firstName).toBe('Freyja'); expect(updatedUser.lastName).toBe('Puppy'); expect(updatedUser.email).toBe('[email protected]'); expect(updatedUser.birthYear).toBe(2016); expect(updatedUser.student).toBe(false); expect(updatedUser.passwordDigest).not.toBe(originalUser.passwordDigest); });
- model method:
exports.update = async properties => { const saltRounds = 10; const salt = bcrypt.genSaltSync(saltRounds); const passwordDigest = bcrypt.hashSync(properties.password, salt); const updatedUser = (await query(`UPDATE "users" SET "firstName"=($1), "lastName"=($2), "email"=($3), "birthYear"=($4), "student"=($5), "passwordDigest"=($6) WHERE id=($7) RETURNING *`, [ properties.firstName, properties.lastName, properties.email, properties.birthYear, properties.student, passwordDigest, properties.id, ])).rows[0]; return updatedUser; }