From 6113c57befce6286bf840159ddd985e95f9f8fc7 Mon Sep 17 00:00:00 2001 From: Daniel Benavides Date: Mon, 22 Jul 2019 14:44:04 -0500 Subject: [PATCH 1/6] Added Album sequelize model and migration. Added asociations with User model --- app/models/album.js | 22 +++++++++++ app/models/user.js | 4 ++ .../20190722191147-create-albums.js | 37 +++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 app/models/album.js create mode 100644 migrations/migrations/20190722191147-create-albums.js diff --git a/app/models/album.js b/app/models/album.js new file mode 100644 index 0000000..cb4d1a4 --- /dev/null +++ b/app/models/album.js @@ -0,0 +1,22 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const Album = sequelize.define( + 'Album', + { + id: { type: DataTypes.INTEGER, primaryKey: true, allowNull: false }, + title: { type: DataTypes.STRING, allowNull: false }, + artist: { type: DataTypes.INTEGER, allowNull: false }, + userId: { type: DataTypes.INTEGER, field: 'user_id', primaryKey: true, allowNull: false } + }, + { + tableName: 'albums', + underscored: true + } + ); + + Album.associate = models => { + Album.belongsTo(models.User, { foreignKey: 'userId' }); + }; + + return Album; +}; diff --git a/app/models/user.js b/app/models/user.js index 75be114..cb017ab 100755 --- a/app/models/user.js +++ b/app/models/user.js @@ -34,6 +34,10 @@ module.exports = (sequelize, DataTypes) => { } ); + User.associate = models => { + User.hasMany(models.Album); + }; + User.createModel = user => User.create(user); User.getOne = user => User.findOne({ where: user }); diff --git a/migrations/migrations/20190722191147-create-albums.js b/migrations/migrations/20190722191147-create-albums.js new file mode 100644 index 0000000..18f7de5 --- /dev/null +++ b/migrations/migrations/20190722191147-create-albums.js @@ -0,0 +1,37 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => + queryInterface.createTable('albums', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + allowNull: false + }, + title: { + type: Sequelize.STRING, + allowNull: false + }, + artist: { + type: Sequelize.INTEGER, + allowNull: false + }, + user_id: { + type: Sequelize.INTEGER, + primaryKey: true, + references: { + model: 'users', + key: 'id' + }, + allowNull: false + }, + created_at: { + type: Sequelize.DATE, + allowNull: false + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false + } + }), + down: queryInterface => queryInterface.dropTable('albums') +}; From da76635aa9e8425253dccd66e12deb8b474e340a Mon Sep 17 00:00:00 2001 From: Daniel Benavides Date: Mon, 22 Jul 2019 15:52:16 -0500 Subject: [PATCH 2/6] Implemented mutation for buying album --- app/errors.js | 4 +++- app/graphql/albums/index.js | 4 +++- app/graphql/albums/mutations.js | 14 ++++++++++++++ app/graphql/albums/resolvers.js | 11 +++++++++++ app/graphql/index.js | 3 ++- app/services/albums.js | 12 +++++++++++- 6 files changed, 44 insertions(+), 4 deletions(-) create mode 100755 app/graphql/albums/mutations.js diff --git a/app/errors.js b/app/errors.js index 69efae9..242240f 100755 --- a/app/errors.js +++ b/app/errors.js @@ -9,7 +9,8 @@ const errorCodes = { DATABASE_ERROR: 503, UNIQUE_EMAIL_ERROR: 409, INVALID_INPUT_ERROR: 422, - USER_NOT_FOUND_ERROR: 404 + USER_NOT_FOUND_ERROR: 404, + ITEM_NOT_FOUND_ERROR: 404 }; exports.defaultError = message => createError(message, errorCodes.DEFAULT_ERROR); @@ -20,3 +21,4 @@ exports.uniqueEmailError = message => createError(message, errorCodes.UNIQUE_EMA exports.invalidInputError = (message, invalidFields) => new UserInputError(message, { invalidFields }); exports.userNotFoundError = message => createError(message, errorCodes.USER_NOT_FOUND_ERROR); exports.badLogInError = message => new AuthenticationError(message); +exports.itemNotFoundError = message => createError(message, errorCodes.USER_NOT_FOUND_ERROR); diff --git a/app/graphql/albums/index.js b/app/graphql/albums/index.js index 2fa4cc3..64fa000 100644 --- a/app/graphql/albums/index.js +++ b/app/graphql/albums/index.js @@ -1,8 +1,10 @@ const { queries, schema: queriesSchema } = require('./queries'); +const { mutations, schema: mutationSchema } = require('./mutations'); const { typeResolvers } = require('./resolvers'); module.exports = { queries, + mutations, typeResolvers, - schemas: [queriesSchema] + schemas: [queriesSchema, mutationSchema] }; diff --git a/app/graphql/albums/mutations.js b/app/graphql/albums/mutations.js new file mode 100755 index 0000000..abf3fbd --- /dev/null +++ b/app/graphql/albums/mutations.js @@ -0,0 +1,14 @@ +const { gql } = require('apollo-server'); + +const resolvers = require('./resolvers'); + +module.exports = { + mutations: { + buyAlbum: resolvers.buyAlbum + }, + schema: gql` + extend type Mutation { + buyAlbum(albumId: ID!): Album! + } + ` +}; diff --git a/app/graphql/albums/resolvers.js b/app/graphql/albums/resolvers.js index bf9ed84..e301285 100644 --- a/app/graphql/albums/resolvers.js +++ b/app/graphql/albums/resolvers.js @@ -20,6 +20,17 @@ exports.getPhotos = (root, args) => { return albumsService.getPhotos({ albumId: id }); }; +exports.buyAlbum = (root, { albumId, userId }) => { + logger.info('Buying album with id:'); + return albumsService + .getAlbum(albumId) + .then(album => albumsService.addAlbum({ ...albumsHelpers.albumMapper(album), userId })) + .catch(error => { + logger.error(`Failed to buy album. Error: ${error.message}`); + throw error; + }); +}; + exports.typeResolvers = { photos: exports.getPhotos }; diff --git a/app/graphql/index.js b/app/graphql/index.js index d20e66a..71a6dfb 100755 --- a/app/graphql/index.js +++ b/app/graphql/index.js @@ -19,7 +19,8 @@ const schema = makeExecutableSchema({ ...albums.queries }, Mutation: { - ...users.mutations + ...users.mutations, + ...albums.mutations }, Subscription: { ...users.subscriptions diff --git a/app/services/albums.js b/app/services/albums.js index ad4d293..582750d 100644 --- a/app/services/albums.js +++ b/app/services/albums.js @@ -3,6 +3,7 @@ const util = require('util'); const errors = require('../errors'); const logger = require('../logger'); +const { Album } = require('../models'); const { albumsApi } = require('../../config').common; exports.executeRequest = options => { @@ -11,7 +12,9 @@ exports.executeRequest = options => { logger.info(`Headers: [${Object.keys(options.headers).join(', ')}]`); } return request(options).catch(error => { - throw errors.albumApiError(error.message); + throw error.statusCode === 404 + ? errors.itemNotFoundError('Item not found in external api') + : errors.albumApiError(error.message); }); }; @@ -42,3 +45,10 @@ exports.getPhotos = qs => { }; return exports.executeRequest(options); }; + +exports.addAlbum = album => + Album.create(album).catch(error => { + throw error.name === 'SequelizeUniqueConstraintError' + ? errors.uniqueEmailError(`The user has already bought album with id ${album.id}`) + : errors.databaseError(`${error.name}: ${error.message}`); + }); From 14e9d46978f96990f44a90dadf636714c1341884 Mon Sep 17 00:00:00 2001 From: Daniel Benavides Date: Mon, 22 Jul 2019 18:42:43 -0500 Subject: [PATCH 3/6] Added middleware to validate authetication --- app/errors.js | 3 ++- app/graphql/albums/index.js | 2 ++ app/graphql/albums/middlewares.js | 4 ++++ app/graphql/albums/resolvers.js | 6 +++--- app/graphql/index.js | 12 ++++++++---- app/graphql/users/resolvers.js | 2 +- app/helpers/users.js | 2 ++ app/validators/users.js | 17 +++++++++++++++++ server.js | 4 ++-- 9 files changed, 41 insertions(+), 11 deletions(-) create mode 100644 app/graphql/albums/middlewares.js create mode 100644 app/validators/users.js diff --git a/app/errors.js b/app/errors.js index 242240f..08b969d 100755 --- a/app/errors.js +++ b/app/errors.js @@ -20,5 +20,6 @@ exports.databaseError = message => createError(message, errorCodes.DATABASE_ERRO exports.uniqueEmailError = message => createError(message, errorCodes.UNIQUE_EMAIL_ERROR); exports.invalidInputError = (message, invalidFields) => new UserInputError(message, { invalidFields }); exports.userNotFoundError = message => createError(message, errorCodes.USER_NOT_FOUND_ERROR); -exports.badLogInError = message => new AuthenticationError(message); exports.itemNotFoundError = message => createError(message, errorCodes.USER_NOT_FOUND_ERROR); +exports.badLogInError = message => new AuthenticationError(message); +exports.sessionError = message => new AuthenticationError(message); diff --git a/app/graphql/albums/index.js b/app/graphql/albums/index.js index 64fa000..8b6851f 100644 --- a/app/graphql/albums/index.js +++ b/app/graphql/albums/index.js @@ -1,10 +1,12 @@ const { queries, schema: queriesSchema } = require('./queries'); const { mutations, schema: mutationSchema } = require('./mutations'); const { typeResolvers } = require('./resolvers'); +const middlewares = require('./middlewares'); module.exports = { queries, mutations, + middlewares, typeResolvers, schemas: [queriesSchema, mutationSchema] }; diff --git a/app/graphql/albums/middlewares.js b/app/graphql/albums/middlewares.js new file mode 100644 index 0000000..ae7e8a6 --- /dev/null +++ b/app/graphql/albums/middlewares.js @@ -0,0 +1,4 @@ +const usersValidators = require('../../validators/users'); + +exports.buyAlbum = (resolve, root, args, context) => + usersValidators.validateAuthetication(root, args, context).then(() => resolve(root, args, context)); diff --git a/app/graphql/albums/resolvers.js b/app/graphql/albums/resolvers.js index e301285..49ad5cc 100644 --- a/app/graphql/albums/resolvers.js +++ b/app/graphql/albums/resolvers.js @@ -20,11 +20,11 @@ exports.getPhotos = (root, args) => { return albumsService.getPhotos({ albumId: id }); }; -exports.buyAlbum = (root, { albumId, userId }) => { - logger.info('Buying album with id:'); +exports.buyAlbum = (root, { albumId, user }) => { + logger.info(`Buying album with id: ${albumId} for user: ${user.email}`); return albumsService .getAlbum(albumId) - .then(album => albumsService.addAlbum({ ...albumsHelpers.albumMapper(album), userId })) + .then(album => albumsService.addAlbum({ ...albumsHelpers.albumMapper(album), userId: user.id })) .catch(error => { logger.error(`Failed to buy album. Error: ${error.message}`); throw error; diff --git a/app/graphql/index.js b/app/graphql/index.js index 71a6dfb..da090f4 100755 --- a/app/graphql/index.js +++ b/app/graphql/index.js @@ -35,10 +35,14 @@ const schema = makeExecutableSchema({ } }); -const middlewares = { +const schemaWithMiddlewares = applyMiddleware(schema, { Mutation: { - ...users.middlewares + ...users.middlewares, + ...albums.middlewares } -}; +}); -module.exports = applyMiddleware(schema, middlewares); +module.exports = { + schema: schemaWithMiddlewares, + context: ({ req }) => ({ authorization: req.headers.authorization }) +}; diff --git a/app/graphql/users/resolvers.js b/app/graphql/users/resolvers.js index 0affac2..a6ac925 100644 --- a/app/graphql/users/resolvers.js +++ b/app/graphql/users/resolvers.js @@ -46,7 +46,7 @@ exports.logIn = (root, { user }) => { .validateCredentials(user) .then(storedUser => storedUser - ? userHelpers.generateToken(user) + ? userHelpers.generateToken(storedUser) : Promise.reject(errors.badLogInError('The email or password provided is incorrect')) ) .then(userHelpers.mapToken) diff --git a/app/helpers/users.js b/app/helpers/users.js index f02fb06..987894c 100644 --- a/app/helpers/users.js +++ b/app/helpers/users.js @@ -13,6 +13,8 @@ exports.generateToken = (user, secret = sessionConfig.secret, expiresIn = sessio return jwt.signAsync(payload, secret, { expiresIn }); }; +exports.validateToken = (token, secret = sessionConfig.secret) => jwt.verifyAsync(token, secret); + exports.decodeToken = token => jwt.decode(token); exports.validateCredentials = user => { diff --git a/app/validators/users.js b/app/validators/users.js new file mode 100644 index 0000000..128e9b4 --- /dev/null +++ b/app/validators/users.js @@ -0,0 +1,17 @@ +const errors = require('../errors'); +const usersHelpers = require('../helpers/users'); + +exports.validateAuthetication = (root, args, context) => { + const token = context.authorization; + if (token) { + return usersHelpers + .validateToken(token) + .then(decodedToken => { + args.user = decodedToken; + }) + .catch(error => { + throw errors.sessionError(`Session error: ${error.message}`); + }); + } + throw errors.sessionError('Session error: no token provided'); +}; diff --git a/server.js b/server.js index ca04f4e..e44a709 100755 --- a/server.js +++ b/server.js @@ -2,7 +2,7 @@ const { ApolloServer } = require('apollo-server'), config = require('./config'), migrationsManager = require('./migrations'), logger = require('./app/logger'), - schema = require('./app/graphql'); + { schema, context } = require('./app/graphql'); const port = config.common.api.port || 8080; @@ -14,7 +14,7 @@ migrationsManager enabled: !!config.common.rollbar.accessToken, environment: config.common.rollbar.environment || config.environment }); */ - new ApolloServer({ schema }).listen(port).then(({ url, subscriptionsUrl }) => { + new ApolloServer({ schema, context }).listen(port).then(({ url, subscriptionsUrl }) => { logger.info(`🚀 Server ready at ${url}`); logger.info(`🚀 Subscriptions ready at ${subscriptionsUrl}`); }) From 804ab73556c5f6c85ca42f77d0bd3c26756b10b6 Mon Sep 17 00:00:00 2001 From: Daniel Benavides Date: Tue, 23 Jul 2019 11:32:54 -0500 Subject: [PATCH 4/6] Added tests for buyAlbum resolver --- test/albums/resolvers.spec.js | 45 +++++++++++++++++++++++++++++++++++ test/server.spec.js | 2 +- test/utils/common.js | 12 ++++++++++ test/utils/mocks/albums.js | 6 +++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 test/albums/resolvers.spec.js create mode 100644 test/utils/common.js diff --git a/test/albums/resolvers.spec.js b/test/albums/resolvers.spec.js new file mode 100644 index 0000000..4c64e2a --- /dev/null +++ b/test/albums/resolvers.spec.js @@ -0,0 +1,45 @@ +const { createUser } = require('../utils/common'); +const albumMocks = require('../utils/mocks/albums'); +const albumsFactory = require('../utils/factories/albums'); +const { mutations } = require('../../app/graphql/albums/mutations'); + +describe('albums', () => { + describe('resolvers', () => { + describe('buyAlbum', () => { + it('should successfully buy album for user', () => { + const albumId = 1; + albumMocks.mockGetAlbumOK(albumId, albumsFactory.responseAlbumOK); + return createUser().then(({ data }) => + mutations.buyAlbum(undefined, { albumId, user: data.createUser }).then(response => { + expect(response.id).toEqual(albumsFactory.responseAlbumOK.id); + expect(response.artist).toEqual(albumsFactory.responseAlbumOK.userId); + expect(response.userId).toEqual(parseInt(data.createUser.id)); + }) + ); + }); + + it('should fail to add album due to user already bought it', () => { + const albumId = 1; + albumMocks.mockGetAlbumOK(albumId, albumsFactory.responseAlbumOK); + albumMocks.mockGetAlbumOK(albumId, albumsFactory.responseAlbumOK); + return createUser().then(({ data }) => + mutations.buyAlbum(undefined, { albumId, user: data.createUser }).then(() => + mutations.buyAlbum(undefined, { albumId, user: data.createUser }).catch(error => { + expect(error.message).toEqual(`The user has already bought album with id ${albumId}`); + }) + ) + ); + }); + + it('should fail to add album due album not found', () => { + const albumId = 'abc'; + albumMocks.mockGetAlbumNotFound(albumId); + return createUser().then(({ data }) => + mutations.buyAlbum(undefined, { albumId, user: data.createUser }).catch(error => { + expect(error.message).toEqual('Item not found in external api'); + }) + ); + }); + }); + }); +}); diff --git a/test/server.spec.js b/test/server.spec.js index 19d5c0b..312580b 100755 --- a/test/server.spec.js +++ b/test/server.spec.js @@ -1,6 +1,6 @@ const { createTestClient } = require('apollo-server-testing'), { ApolloServer } = require('apollo-server'), - schema = require('../app/graphql'); + { schema } = require('../app/graphql'); const { query: _query, mutate } = createTestClient(new ApolloServer({ schema })); diff --git a/test/utils/common.js b/test/utils/common.js new file mode 100644 index 0000000..588ef12 --- /dev/null +++ b/test/utils/common.js @@ -0,0 +1,12 @@ +const usersFactory = require('./factories/user'); +const { mutate } = require('../server.spec'); +const { createUser, logIn } = require('../users/graphql'); + +exports.createUserAndLogIn = () => + usersFactory + .attributes() + .then(user => + mutate(createUser(user)).then(() => mutate(logIn({ email: user.email, password: user.password }))) + ); + +exports.createUser = () => usersFactory.attributes().then(user => mutate(createUser(user))); diff --git a/test/utils/mocks/albums.js b/test/utils/mocks/albums.js index e86b173..252f032 100644 --- a/test/utils/mocks/albums.js +++ b/test/utils/mocks/albums.js @@ -15,6 +15,12 @@ exports.mockGetAlbumsOK = (responseAlbums = albumsFactory.albumsArray) => { .reply(200, responseAlbums); }; +exports.mockGetAlbumNotFound = albumId => { + nock(configAlbumsApi.endpoint) + .get(`${configAlbumsApi.routes.albums}/${albumId}`) + .reply(404, '404 - {}'); +}; + exports.mockGetPhotosOK = (albumId, responsePhotos = albumsFactory.photosArray) => { nock(configAlbumsApi.endpoint) .get(`${configAlbumsApi.routes.photos}`) From 8105e0d776654dcbf431f3087c726fd24397da5c Mon Sep 17 00:00:00 2001 From: Daniel Benavides Date: Tue, 23 Jul 2019 14:24:38 -0500 Subject: [PATCH 5/6] Fixed error throw --- app/errors.js | 1 + app/graphql/albums/resolvers.js | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/errors.js b/app/errors.js index 08b969d..d4f9c1b 100755 --- a/app/errors.js +++ b/app/errors.js @@ -23,3 +23,4 @@ exports.userNotFoundError = message => createError(message, errorCodes.USER_NOT_ exports.itemNotFoundError = message => createError(message, errorCodes.USER_NOT_FOUND_ERROR); exports.badLogInError = message => new AuthenticationError(message); exports.sessionError = message => new AuthenticationError(message); +exports.albumServiceError = (message, statusCode) => createError(message, statusCode); diff --git a/app/graphql/albums/resolvers.js b/app/graphql/albums/resolvers.js index 49ad5cc..73c3ea4 100644 --- a/app/graphql/albums/resolvers.js +++ b/app/graphql/albums/resolvers.js @@ -1,3 +1,4 @@ +const errors = require('../../errors'); const logger = require('../../logger'); const albumsHelpers = require('../../helpers/albums'); const albumsService = require('../../services/albums'); @@ -26,8 +27,9 @@ exports.buyAlbum = (root, { albumId, user }) => { .getAlbum(albumId) .then(album => albumsService.addAlbum({ ...albumsHelpers.albumMapper(album), userId: user.id })) .catch(error => { - logger.error(`Failed to buy album. Error: ${error.message}`); - throw error; + const errorMessage = `Failed to buy album. Error: ${error.message}`; + logger.error(errorMessage); + throw errors.albumServiceError(errorMessage, error.extensions.code); }); }; From 487c0e2bf0b06801bb3c725acd5801f54a8711e4 Mon Sep 17 00:00:00 2001 From: Daniel Benavides Date: Tue, 23 Jul 2019 14:27:25 -0500 Subject: [PATCH 6/6] Fixed assertions in tests --- test/albums/resolvers.spec.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/albums/resolvers.spec.js b/test/albums/resolvers.spec.js index 4c64e2a..b8cb51d 100644 --- a/test/albums/resolvers.spec.js +++ b/test/albums/resolvers.spec.js @@ -25,7 +25,9 @@ describe('albums', () => { return createUser().then(({ data }) => mutations.buyAlbum(undefined, { albumId, user: data.createUser }).then(() => mutations.buyAlbum(undefined, { albumId, user: data.createUser }).catch(error => { - expect(error.message).toEqual(`The user has already bought album with id ${albumId}`); + expect(error.message).toEqual( + `Failed to buy album. Error: The user has already bought album with id ${albumId}` + ); }) ) ); @@ -36,7 +38,7 @@ describe('albums', () => { albumMocks.mockGetAlbumNotFound(albumId); return createUser().then(({ data }) => mutations.buyAlbum(undefined, { albumId, user: data.createUser }).catch(error => { - expect(error.message).toEqual('Item not found in external api'); + expect(error.message).toEqual('Failed to buy album. Error: Item not found in external api'); }) ); });