diff --git a/package.json b/package.json index 56fae9a..5f7ba9c 100644 --- a/package.json +++ b/package.json @@ -32,13 +32,19 @@ "babel-plugin-transform-es2015-modules-commonjs": "^6.16.0", "babel-preset-stage-1": "^6.16.0", "babel-register": "^6.16.3", + "bluebird": "^3.4.6", "eslint": "^3.8.1", "eslint-config-pichak": "^1.1.2", "eslint-plugin-ava": "^3.1.1", "ghooks": "^1.3.2", + "hapi": "^15.2.0", + "hapi-sequelize": "^3.0.4", + "portfinder": "^1.0.9", "scripty": "^1.6.0", + "sequelize": "^3.24.6", "sinon": "^1.17.6", "sinon-bluebird": "^3.1.0", + "sqlite3": "^3.1.7", "tap-xunit": "^1.4.0" }, "dependencies": { diff --git a/src/crud-include.integration.test.js b/src/crud-include.integration.test.js new file mode 100644 index 0000000..e436f9b --- /dev/null +++ b/src/crud-include.integration.test.js @@ -0,0 +1,85 @@ +import test from 'ava'; +import 'sinon-bluebird'; +import setup from '../test/integration-setup.js'; + +setup(test); + +test('belongsTo /team?include=city', async (t) => { + const { server, instances } = t.context; + const { team1, city1 } = instances; + const path = `/team/${team1.id}?include=city`; + + const { result, response } = await server.inject(path); + t.falsy(response instanceof Error); + t.is(result.id, team1.id); + t.is(result.City.id, city1.id); +}); + +test('belongsTo /team?include=cities', async (t) => { + const { server, instances } = t.context; + const { team1, city1 } = instances; + const path = `/team/${team1.id}?include=cities`; + + const { result, response } = await server.inject(path); + t.falsy(response instanceof Error); + t.is(result.id, team1.id); + t.is(result.City.id, city1.id); +}); + +test('hasMany /team?include=player', async (t) => { + const { server, instances } = t.context; + const { team1, player1, player2 } = instances; + const path = `/team/${team1.id}?include=player`; + + const { result, response } = await server.inject(path); + t.falsy(response instanceof Error); + t.is(result.id, team1.id); + + const playerIds = result.Players.map(({ id }) => id); + t.truthy(playerIds.includes(player1.id)); + t.truthy(playerIds.includes(player2.id)); +}); + +test('hasMany /team?include=players', async (t) => { + const { server, instances } = t.context; + const { team1, player1, player2 } = instances; + const path = `/team/${team1.id}?include=players`; + + const { result, response } = await server.inject(path); + t.falsy(response instanceof Error); + t.is(result.id, team1.id); + + const playerIds = result.Players.map(({ id }) => id); + t.truthy(playerIds.includes(player1.id)); + t.truthy(playerIds.includes(player2.id)); +}); + +test('multiple includes /team?include=players&include=city', async (t) => { + const { server, instances } = t.context; + const { team1, player1, player2, city1 } = instances; + const path = `/team/${team1.id}?include=players&include=city`; + + const { result, response } = await server.inject(path); + t.falsy(response instanceof Error); + t.is(result.id, team1.id); + + const playerIds = result.Players.map(({ id }) => id); + t.truthy(playerIds.includes(player1.id)); + t.truthy(playerIds.includes(player2.id)); + t.is(result.City.id, city1.id); +}); + +test('multiple includes /team?include[]=players&include[]=city', async (t) => { + const { server, instances } = t.context; + const { team1, player1, player2, city1 } = instances; + const path = `/team/${team1.id}?include[]=players&include[]=city`; + + const { result, response } = await server.inject(path); + t.falsy(response instanceof Error); + t.is(result.id, team1.id); + + const playerIds = result.Players.map(({ id }) => id); + t.truthy(playerIds.includes(player1.id)); + t.truthy(playerIds.includes(player2.id)); + t.is(result.City.id, city1.id); +}); diff --git a/src/crud-route-creation.integration.test.js b/src/crud-route-creation.integration.test.js new file mode 100644 index 0000000..f0e8677 --- /dev/null +++ b/src/crud-route-creation.integration.test.js @@ -0,0 +1,26 @@ +import test from 'ava'; +import 'sinon-bluebird'; +import setup from '../test/integration-setup.js'; + +const { modelNames } = setup(test); + +const confirmRoute = (t, { path, method }) => { + const { server } = t.context; + // there's only one connection, so just get the first table + const routes = server.table()[0].table; + + t.truthy(routes.find((route) => { + return route.path = path + && route.method === method; + })); +}; + +modelNames.forEach(({ singular, plural }) => { + test('get', confirmRoute, { path: `/${singular}/{id}`, method: 'get' }); + test('list', confirmRoute, { path: `/${plural}/{id}`, method: 'get' }); + test('scope', confirmRoute, { path: `/${plural}/{scope}`, method: 'get' }); + test('create', confirmRoute, { path: `/${singular}`, method: 'post' }); + test('destroy', confirmRoute, { path: `/${plural}`, method: 'delete' }); + test('destroyScope', confirmRoute, { path: `/${plural}/{scope}`, method: 'delete' }); + test('update', confirmRoute, { path: `/${singular}/{id}`, method: 'put' }); +}); diff --git a/src/crud.js b/src/crud.js index 6a9211d..da0b756 100644 --- a/src/crud.js +++ b/src/crud.js @@ -63,14 +63,17 @@ export default (server, model, { prefix, defaultConfig: config, models: permissi const { _singular, _plural, _Singular, _Plural } = target; return [_singular, _plural, _Singular, _Plural]; }), - ]; + ].filter(Boolean); const attributeValidation = modelAttributes.reduce((params, attribute) => { + // TODO: use joi-sequelize params[attribute] = joi.any(); return params; }, {}); - const validAssociations = joi.string().valid(...modelAssociations); + const validAssociations = modelAssociations.length + ? joi.string().valid(...modelAssociations) + : joi.valid(null); const associationValidation = { include: [joi.array().items(validAssociations), validAssociations], }; diff --git a/src/index.js b/src/index.js index 7580a15..1a9710c 100644 --- a/src/index.js +++ b/src/index.js @@ -7,7 +7,7 @@ import url from 'url'; import qs from 'qs'; const register = (server, options = {}, next) => { - options.prefix = options.prefix || ''; + options.prefix = options.prefix || '/'; options.name = options.name || 'db'; const db = server.plugins['hapi-sequelize'][options.name]; diff --git a/test/fixtures/models/city.js b/test/fixtures/models/city.js new file mode 100644 index 0000000..50e5ad6 --- /dev/null +++ b/test/fixtures/models/city.js @@ -0,0 +1,18 @@ +export default (sequelize, DataTypes) => { + return sequelize.define('City', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + name: DataTypes.STRING, + }, { + classMethods: { + associate: (models) => { + models.City.hasMany(models.Team, { + foreignKey: { name: 'cityId' }, + }); + }, + }, + }); +}; diff --git a/test/fixtures/models/player.js b/test/fixtures/models/player.js new file mode 100644 index 0000000..0f678b3 --- /dev/null +++ b/test/fixtures/models/player.js @@ -0,0 +1,19 @@ +export default (sequelize, DataTypes) => { + return sequelize.define('Player', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + name: DataTypes.STRING, + teamId: DataTypes.INTEGER, + }, { + classMethods: { + associate: (models) => { + models.Player.belongsTo(models.Team, { + foreignKey: { name: 'teamId' }, + }); + }, + }, + }); +}; diff --git a/test/fixtures/models/team.js b/test/fixtures/models/team.js new file mode 100644 index 0000000..8d0e568 --- /dev/null +++ b/test/fixtures/models/team.js @@ -0,0 +1,22 @@ +export default (sequelize, DataTypes) => { + return sequelize.define('Team', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + name: DataTypes.STRING, + cityId: DataTypes.INTEGER, + }, { + classMethods: { + associate: (models) => { + models.Team.belongsTo(models.City, { + foreignKey: { name: 'cityId' }, + }); + models.Team.hasMany(models.Player, { + foreignKey: { name: 'teamId' }, + }); + }, + }, + }); +}; diff --git a/test/integration-setup.js b/test/integration-setup.js new file mode 100644 index 0000000..f5ae745 --- /dev/null +++ b/test/integration-setup.js @@ -0,0 +1,70 @@ +import hapi from 'hapi'; +import Sequelize from 'sequelize'; +import portfinder from 'portfinder'; +import path from 'path'; +import Promise from 'bluebird'; + +const getPort = Promise.promisify(portfinder.getPort); +const modelsPath = path.join(__dirname, 'fixtures', 'models'); +const modelsGlob = path.join(modelsPath, '**', '*.js'); +const dbName = 'db'; + +// these are what's in the fixtures dir +const modelNames = [ + { Singluar: 'City', singular: 'city', Plural: 'Cities', plural: 'cities' }, + { Singluar: 'Team', singular: 'team', Plural: 'Teams', plural: 'teams' }, + { Singluar: 'Player', singular: 'player', Plural: 'Players', plural: 'players' }, +]; + + +export default (test) => { + test.beforeEach('get an open port', async (t) => { + t.context.port = await getPort(); + }); + + test.beforeEach('setup server', async (t) => { + const sequelize = t.context.sequelize = new Sequelize({ + dialect: 'sqlite', + logging: false, + }); + + const server = t.context.server = new hapi.Server(); + server.connection({ + host: '0.0.0.0', + port: t.context.port, + }); + + await server.register({ + register: require('hapi-sequelize'), + options: { + name: dbName, + models: [modelsGlob], + sequelize, + sync: true, + forceSync: true, + }, + }); + + await server.register({ + register: require('../src/index.js'), + options: { + name: dbName, + }, + }, + ); + }); + + test.beforeEach('create data', async (t) => { + const { Player, Team, City } = t.context.sequelize.models; + const city1 = await City.create({ name: 'Healdsburg' }); + const team1 = await Team.create({ name: 'Baseballs', cityId: city1.id }); + const player1 = await Player.create({ name: 'Pinot', teamId: team1.id }); + const player2 = await Player.create({ name: 'Syrah', teamId: team1.id }); + t.context.instances = { city1, team1, player1, player2 }; + }); + + // kill the server so that we can exit and don't leak memory + test.afterEach('stop the server', (t) => t.context.server.stop()); + + return { modelNames }; +};