├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .nvmrc ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── knexfile.js ├── migrations ├── 20180123085237_blog.js └── 20180226134020_remove_username.js ├── package-lock.json ├── package.json ├── schema └── schema.graphql ├── seeds └── test │ └── test_data.js └── src ├── db.js ├── index.js ├── loaders.js ├── schema.js └── schema.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ] 11 | ] 12 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /coverage 3 | /node_modules -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base", "prettier"], 3 | "env": { 4 | "jest": true 5 | } 6 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | dist/ -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v8.9.3 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | services: 3 | - postgresql 4 | before_script: 5 | - psql -c 'create database "blog-test";' -U postgres 6 | - npm run eslint 7 | script: npm run coveralls -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at mattysmith@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Guidelines for contributing: 2 | - to contribute, fork, branch and submit pull requests for review 3 | - use ES 6 with async/await 4 | - `npm test` and maintain 100% coverage 5 | - `npm run prettier` 6 | - `npm run eslint` and maintain 0 errors 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 matt-sm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-express-postgres [![Build Status](https://travis-ci.org/matt-sm/graphql-express-postgres.svg?branch=master)](https://travis-ci.org/matt-sm/graphql-express-postgres) [![Coverage Status](https://coveralls.io/repos/github/matt-sm/graphql-express-postgres/badge.svg?branch=master)](https://coveralls.io/github/matt-sm/graphql-express-postgres?branch=master) 2 | A reference graphql api built with node and postgres. 3 | 4 | The schema follows the classic user, posts, comments structure. 5 | 6 | - Uses [Apollo Server](https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-express) 7 | - Examples of sql query caching using [data loader](https://github.com/facebook/dataloader) 8 | - Schema definitions are built using [graphql-tools](https://github.com/apollographql/graphql-tools). 9 | - Data access built on the [Objection](https://github.com/Vincit/objection.js/) ORM and [knex](https://github.com/tgriesser/knex). 10 | - Authentication handled by [express-jwt](https://github.com/auth0/express-jwt) middleware. 11 | - For protected resolvers simply wrap the function in `authenticated()`. 12 | - Tests use [jest](https://github.com/facebook/jest) and execute against a database instance seeded with test data. 13 | ## Install: 14 | - run locally with nodemon + babel: `npm run dev` 15 | - build and run on a server: `npm start` 16 | - db migrations: `npm run migrate` 17 | - format code: `npm run prettier` 18 | - lint code: `npm run eslint` 19 | - tests: `createdb blog-test` then `npm test` 20 | 21 | ## Samples 22 | ### Queries 23 | ``` 24 | { 25 | viewer { 26 | email 27 | posts { 28 | title 29 | comments { 30 | body 31 | } 32 | } 33 | } 34 | } 35 | ``` 36 | ### Mutations 37 | ``` 38 | mutation{addUser(name:"User", email:"user@test.com", password:"password") } 39 | mutation{createToken(email:"user@test.com", password:"password") } 40 | ``` 41 | ## Database 42 | ### Manually run the migrations/seeds 43 | ``` 44 | createdb blog-test 45 | ./node_modules/.bin/babel-node ./node_modules/.bin/knex migrate:latest --env test 46 | ./node_modules/.bin/babel-node ./node_modules/.bin/knex seed:run --env test 47 | ``` 48 | ## Prior Art: 49 | - express/babel setup: [https://github.com/vmasto/express-babel](https://github.com/vmasto/express-babel) 50 | - knex ideas: [https://github.com/dYale/knexBlogBackend](https://github.com/dYale/knexBlogBackend) 51 | - graphql schema: [https://github.com/mrblueblue/graphql-express-sqlite](https://github.com/mrblueblue/graphql-express-sqlite) 52 | - authentication: [https://scaphold.io/community/blog/authentication-in-graphql/](https://scaphold.io/community/blog/authentication-in-graphql/) 53 | - data loader: [https://spin.atomicobject.com/2017/05/15/optimize-graphql-queries/](https://spin.atomicobject.com/2017/05/15/optimize-graphql-queries/) 54 | -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | // Update with your config settings. 2 | 3 | module.exports = { 4 | development: { 5 | client: 'postgresql', 6 | connection: { 7 | database: 'blog' 8 | } 9 | }, 10 | test: { 11 | client: 'postgresql', 12 | connection: { 13 | database: 'blog-test' 14 | }, 15 | seeds: { 16 | directory: `${__dirname }/seeds/test` 17 | } 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /migrations/20180123085237_blog.js: -------------------------------------------------------------------------------- 1 | export const up = async knex => { 2 | await knex.schema.createTable('user', table => { 3 | table.increments('id').primary() 4 | table.string('username') 5 | table.string('password') 6 | table.string('name') 7 | table.string('email') 8 | table.timestamps() 9 | }) 10 | 11 | await knex.schema.createTable('post', table => { 12 | table.increments('id').primary() 13 | table.string('title') 14 | table.string('body') 15 | table 16 | .integer('author_id') 17 | .references('id') 18 | .inTable('user') 19 | table.dateTime('postDate') 20 | }) 21 | 22 | return knex.schema.createTable('comment', table => { 23 | table.increments('id').primary() 24 | table.string('body') 25 | table 26 | .integer('author_id') 27 | .references('id') 28 | .inTable('user') 29 | table 30 | .integer('post_id') 31 | .references('id') 32 | .inTable('post') 33 | table.dateTime('postDate') 34 | }) 35 | } 36 | 37 | export const down = async knex => { 38 | await knex.schema.dropTable('comment') 39 | await knex.schema.dropTable('post') 40 | return knex.schema.dropTable('user') 41 | } 42 | -------------------------------------------------------------------------------- /migrations/20180226134020_remove_username.js: -------------------------------------------------------------------------------- 1 | export const up = async knex => 2 | knex.schema.table('user', table => { 3 | table.dropColumn('username') 4 | }) 5 | 6 | export const down = async knex => 7 | knex.schema.table('user', table => { 8 | table.string('username') 9 | }) 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MyApp", 3 | "main": "src/index.js", 4 | "engines": { 5 | "node": ">=0.10.0" 6 | }, 7 | "dependencies": { 8 | "apollo-server-express": "^1.3.6", 9 | "babel-cli": "^6.26.0", 10 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 11 | "babel-preset-env": "^1.6.1", 12 | "bcrypt": "^1.0.3", 13 | "body-parser": "^1.18.2", 14 | "dataloader": "^1.4.0", 15 | "express": "^4.16.2", 16 | "express-jwt": "^5.3.0", 17 | "graphql": "^0.12.3", 18 | "graphql-import": "^0.4.1", 19 | "graphql-tools": "^2.19.0", 20 | "jsonwebtoken": "^8.1.1", 21 | "knex": "^0.14.2", 22 | "morgan": "^1.9.0", 23 | "nodemon": "^1.14.11", 24 | "objection": "^0.9.4", 25 | "pg": "^7.4.1" 26 | }, 27 | "scripts": { 28 | "prestart": "npm run -s build", 29 | "start": "node dist/index.js", 30 | "dev": "nodemon src/index.js --exec \"node -r babel-register\"", 31 | "clean": "rimraf dist", 32 | "build": "npm run clean && mkdir -p dist && babel src -s -D -d dist", 33 | "prettier": "./node_modules/.bin/prettier --print-width 120 --single-quote --no-semi --write \"{migrations,src}/**/*.js\"", 34 | "eslint": "./node_modules/.bin/eslint .", 35 | "migrate": "./node_modules/.bin/babel-node ./node_modules/.bin/knex migrate:latest", 36 | "test": "jest --watch", 37 | "coveralls": "jest --coverage && cat ./coverage/lcov.info | coveralls" 38 | }, 39 | "devDependencies": { 40 | "babel-plugin-transform-class-properties": "^6.24.1", 41 | "babel-register": "^6.26.0", 42 | "coveralls": "^3.0.0", 43 | "eslint": "^4.19.1", 44 | "eslint-config-airbnb-base": "^12.1.0", 45 | "eslint-config-prettier": "^2.9.0", 46 | "eslint-plugin-import": "^2.10.0", 47 | "jest": "^22.4.2", 48 | "prettier": "1.10.2", 49 | "rimraf": "^2.6.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /schema/schema.graphql: -------------------------------------------------------------------------------- 1 | type User { 2 | id: Int! 3 | name: String 4 | email: String 5 | posts(id: Int): [Post] 6 | comments: [Comment] 7 | } 8 | 9 | type Post { 10 | id: Int! 11 | title: String 12 | body: String 13 | user: User 14 | comments: [Comment] 15 | } 16 | 17 | type Comment { 18 | id: Int! 19 | body: String 20 | user: User 21 | post: Post 22 | } 23 | 24 | type Query { 25 | viewer: User 26 | } 27 | 28 | type Mutation { 29 | createToken(email: String!, password: String!): String 30 | addUser(name: String!, email: String!, password: String!): User 31 | addPost(title: String!, body: String!): Post 32 | addComment(body: String!, post_id: Int!): Comment 33 | } -------------------------------------------------------------------------------- /seeds/test/test_data.js: -------------------------------------------------------------------------------- 1 | export const seed = async knex => { 2 | await knex.schema.raw('truncate table "user" restart identity cascade;') 3 | 4 | await knex('user').insert([ 5 | {email: 'user@test.com', password: '$2a$10$WEG6o/BJE6k.TAPc.g8HJue39EcXxnwahHnxWVqaKk2.Fjx06xdWu'} 6 | ]); 7 | await knex('post').insert([ 8 | {title: 'post title', body: 'post body', author_id: 1} 9 | ]); 10 | return knex('comment').insert([ 11 | {body: 'comment body', post_id: 1, author_id: 1} 12 | ]); 13 | }; 14 | 15 | export default seed -------------------------------------------------------------------------------- /src/db.js: -------------------------------------------------------------------------------- 1 | import knex from 'knex' 2 | import { Model } from 'objection' 3 | import config from '../knexfile' 4 | 5 | export const db = knex(config[process.env.NODE_ENV || 'development']) 6 | 7 | Model.knex(db) 8 | 9 | export class User extends Model { 10 | static get tableName() { 11 | return 'user' 12 | } 13 | } 14 | 15 | export class Post extends Model { 16 | static get tableName() { 17 | return 'post' 18 | } 19 | } 20 | 21 | export class Comment extends Model { 22 | static get tableName() { 23 | return 'comment' 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import bodyParser from 'body-parser' 3 | import { graphqlExpress, graphiqlExpress } from 'apollo-server-express' 4 | import morgan from 'morgan' 5 | import jwt from 'express-jwt' 6 | import schema from './schema' 7 | import { User } from './db' 8 | 9 | const app = express() 10 | 11 | app.use(morgan('tiny')) 12 | app.use( 13 | '/graphql', 14 | jwt({ 15 | secret: 'shhhhhhared-secret', 16 | requestProperty: 'auth', 17 | credentialsRequired: false 18 | }) 19 | ) 20 | 21 | app.use('/graphql', async (req, res, next) => { 22 | if (req.auth) { 23 | const user = await User.query().findOne({ email: req.auth.sub }) 24 | req.context = { 25 | user 26 | } 27 | } 28 | next() 29 | }) 30 | 31 | app.use('/graphql', bodyParser.json(), graphqlExpress(req => ({ schema, context: req.context }))) 32 | app.use('/graphiql', graphiqlExpress({ endpointURL: '/graphql' })) 33 | 34 | app.listen(4000) 35 | console.log('Listening on port 4000') // eslint-disable-line 36 | -------------------------------------------------------------------------------- /src/loaders.js: -------------------------------------------------------------------------------- 1 | import DataLoader from 'dataloader' 2 | import { Comment } from './db' 3 | 4 | const findCommentsLoader = new DataLoader(async posts => { 5 | const comments = await Comment.query().whereIn('post_id', posts.map(p => p.id)) 6 | return posts.map(p => comments.filter(c => c.post_id === p.id)) 7 | }) 8 | 9 | export default findCommentsLoader 10 | -------------------------------------------------------------------------------- /src/schema.js: -------------------------------------------------------------------------------- 1 | import { makeExecutableSchema } from 'graphql-tools' 2 | import { importSchema } from 'graphql-import' 3 | import jwt from 'jsonwebtoken' 4 | import bcrypt from 'bcrypt' 5 | import findCommentsLoader from './loaders' 6 | import { User, Post, Comment } from './db' 7 | 8 | const typeDefs = importSchema('./schema/schema.graphql') 9 | const saltRounds = 10 10 | 11 | const authenticated = fn => (parent, args, context, info) => { 12 | if (context && context.user) { 13 | return fn(parent, args, context, info) 14 | } 15 | throw new Error('User is not authenticated') 16 | } 17 | 18 | export const resolvers = { 19 | Query: { 20 | viewer: authenticated((parent, args, context) => context.user) 21 | }, 22 | User: { 23 | posts: async (user, args) => { 24 | const query = Post.query().where({ author_id: user.id }) 25 | 26 | if (args.id) { 27 | query.where({ id: args.id }) 28 | } 29 | 30 | return query 31 | }, 32 | comments: async user => Comment.query().where({ author_id: user.id }) 33 | }, 34 | Post: { 35 | user: async post => User.query().findOne({ id: post.author_id }), 36 | comments: async post => findCommentsLoader.load(post) 37 | }, 38 | Mutation: { 39 | createToken: async (parent, { email, password }) => { 40 | const user = await User.query().findOne({ email }) 41 | if (user && (await bcrypt.compare(password, user.password))) { 42 | return jwt.sign({ sub: user.email }, 'shhhhhhared-secret') 43 | } 44 | 45 | throw new Error('Invalid email or password.') 46 | }, 47 | addUser: async (parent, { name, email, password }) => { 48 | const currentUser = await User.query().findOne({ email }) 49 | if (currentUser) { 50 | throw new Error(`User ${email} already exists.`) 51 | } 52 | 53 | const hash = await bcrypt.hash(password, saltRounds) 54 | return User.query().insert({ name, email, password: hash }) 55 | }, 56 | addPost: authenticated(async (parent, { title, body }, context) => 57 | Post.query().insert({ title, body, author_id: context.user.id }) 58 | ), 59 | addComment: authenticated(async (parent, args, context) => 60 | Comment.query().insert({ body: args.body, post_id: args.post_id, author_id: context.user.id }) 61 | ) 62 | } 63 | } 64 | 65 | const schema = makeExecutableSchema({ 66 | typeDefs, 67 | resolvers 68 | }) 69 | 70 | export default schema 71 | -------------------------------------------------------------------------------- /src/schema.test.js: -------------------------------------------------------------------------------- 1 | import { resolvers } from './schema' 2 | import { db } from './db' 3 | 4 | beforeAll(async () => { 5 | await db.migrate.latest() 6 | }) 7 | 8 | afterAll(async () => { 9 | await db.migrate.rollback() 10 | await db.destroy() 11 | }) 12 | 13 | beforeEach(async () => { 14 | await db.seed.run() 15 | }) 16 | 17 | test('root resolver returns context user', () => { 18 | const user = { email: 'user@test.com' } 19 | expect(resolvers.Query.viewer(null, null, { user })).toBe(user) 20 | }) 21 | 22 | test('root resolver throws when invalid context', () => { 23 | expect(() => resolvers.Query.viewer(null, null, {})).toThrowError('User is not authenticated') 24 | }) 25 | 26 | test('post.user returns single user', async () => { 27 | expect(await resolvers.Post.user({ author_id: 1 })).toHaveProperty('email', 'user@test.com') 28 | }) 29 | 30 | test('post.comments returns comments for post', async () => { 31 | const expected = [{ author_id: 1, body: 'comment body', id: 1, postDate: null, post_id: 1 }] 32 | expect(await resolvers.Post.comments({ id: 1 })).toEqual(expected) 33 | }) 34 | 35 | test('user.posts returns posts', async () => { 36 | const expected = [{ author_id: 1, body: 'post body', id: 1, postDate: null, title: 'post title' }] 37 | expect(await resolvers.User.posts({ id: 1 }, {})).toEqual(expected) 38 | expect(await resolvers.User.posts({ id: 1 }, { id: 1 })).toEqual(expected) 39 | }) 40 | 41 | test('user.comments returns comments', async () => { 42 | const expected = [{ author_id: 1, body: 'comment body', id: 1, postDate: null, post_id: 1 }] 43 | expect(await resolvers.User.comments({ id: 1 })).toEqual(expected) 44 | }) 45 | 46 | test('createToken does not throw when valid credentials', async () => { 47 | expect(await resolvers.Mutation.createToken(null, { email: 'user@test.com', password: 'password' })).not.toBeNull() 48 | }) 49 | 50 | test('createToken throws when invalid password', async () => { 51 | expect.assertions(1) 52 | await expect( 53 | resolvers.Mutation.createToken(null, { email: 'user@test.com', password: 'invalid' }) 54 | ).rejects.toMatchObject({ 55 | message: 'Invalid email or password.' 56 | }) 57 | }) 58 | 59 | test('createToken throws when invalid email', async () => { 60 | expect.assertions(1) 61 | await expect( 62 | resolvers.Mutation.createToken(null, { email: 'user2@test.com', password: 'password' }) 63 | ).rejects.toMatchObject({ 64 | message: 'Invalid email or password.' 65 | }) 66 | }) 67 | 68 | test('addUser throws when duplicate email', async () => { 69 | expect.assertions(1) 70 | await expect( 71 | resolvers.Mutation.addUser(null, { name: '', email: 'user@test.com', password: '' }) 72 | ).rejects.toMatchObject({ 73 | message: 'User user@test.com already exists.' 74 | }) 75 | }) 76 | 77 | test('addUser creates a new user', async () => { 78 | const expected = await resolvers.Mutation.addUser(null, { 79 | name: 'John Doe', 80 | email: 'jd@test.com', 81 | password: 'password' 82 | }) 83 | expect(expected).toHaveProperty('email', 'jd@test.com') 84 | expect(expected).toHaveProperty('name', 'John Doe') 85 | }) 86 | 87 | test('addPost creates a new post', async () => { 88 | const expected = await resolvers.Mutation.addPost( 89 | null, 90 | { title: 'test post title', body: 'test post body' }, 91 | { user: { id: 1 } } 92 | ) 93 | expect(expected).toHaveProperty('title', 'test post title') 94 | expect(expected).toHaveProperty('body', 'test post body') 95 | }) 96 | 97 | test('addComment creates a new comment', async () => { 98 | const expected = await resolvers.Mutation.addComment( 99 | null, 100 | { post_id: 1, body: 'test comment body' }, 101 | { user: { id: 1 } } 102 | ) 103 | expect(expected).toHaveProperty('post_id', 1) 104 | expect(expected).toHaveProperty('author_id', 1) 105 | expect(expected).toHaveProperty('body', 'test comment body') 106 | }) 107 | --------------------------------------------------------------------------------