Compare commits
No commits in common. "master" and "v2.6.2" have entirely different histories.
2
.babelrc
2
.babelrc
@ -10,5 +10,5 @@
|
||||
"transform-decorators-legacy",
|
||||
"transform-es2015-modules-commonjs"
|
||||
],
|
||||
"sourceMaps": "inline"
|
||||
"sourceMaps": true
|
||||
}
|
||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -33,9 +33,3 @@ node_modules
|
||||
|
||||
# Debug log from npm
|
||||
npm-debug.log
|
||||
|
||||
# System
|
||||
.DS_Store
|
||||
|
||||
coverage.lcov
|
||||
.nyc_output
|
||||
|
21
LICENSE
21
LICENSE
@ -1,21 +0,0 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2021 The hapi-sequelize-crud Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
109
README.md
109
README.md
@ -9,7 +9,7 @@ This plugin depends on [`hapi-sequelize`](https://github.com/danecando/hapi-sequ
|
||||
npm install -S hapi-sequelize-crud
|
||||
```
|
||||
|
||||
## Configure
|
||||
##Configure
|
||||
|
||||
Please note that you should register `hapi-sequelize-crud` after defining your
|
||||
associations.
|
||||
@ -52,20 +52,6 @@ await register({
|
||||
// `config` if provided, overrides the default config
|
||||
{model: 'bat', methods: ['list'], config: { ... }},
|
||||
{model: 'bat', methods: ['create']}
|
||||
{model: 'fly', config: {
|
||||
// interact with the request before hapi-sequelize-crud does
|
||||
, ext: {
|
||||
onPreHandler: (request, reply) => {
|
||||
if (request.auth.hasAccessToFly) reply.continue()
|
||||
else reply(Boom.unauthorized())
|
||||
}
|
||||
}
|
||||
// change the response data
|
||||
response: {
|
||||
schema: {id: joi.string()},
|
||||
modify: true
|
||||
}
|
||||
}}
|
||||
]
|
||||
}
|
||||
});
|
||||
@ -109,7 +95,7 @@ Getting related models is easy, just use a query parameter `include`.
|
||||
|
||||
```js
|
||||
// returns all teams with their related City model
|
||||
// GET /teams?include=city
|
||||
// GET /teams?include=City
|
||||
|
||||
// results in a Sequelize query:
|
||||
Team.findAll({include: City})
|
||||
@ -118,101 +104,12 @@ Team.findAll({include: City})
|
||||
If you want to get multiple related models, just pass multiple `include` parameters.
|
||||
```js
|
||||
// returns all teams with their related City and Uniform models
|
||||
// GET /teams?include[]=city&include[]=uniform
|
||||
// GET /teams?include=City&include=Uniform
|
||||
|
||||
// results in a Sequelize query:
|
||||
Team.findAll({include: [City, Uniform]})
|
||||
```
|
||||
|
||||
For models that have a many-to-many relationship, you can also pass the plural version of the association.
|
||||
```js
|
||||
// returns all teams with their related City and Uniform models
|
||||
// GET /teams?include=players
|
||||
|
||||
// results in a Sequelize query:
|
||||
Team.findAll({include: [Player]})
|
||||
```
|
||||
|
||||
## `limit` and `offset` queries
|
||||
Restricting list (`GET`) and scope queries to a restricted count can be done by passing `limit=<number>` and/or `offset=<number>`.
|
||||
|
||||
```js
|
||||
// returns 10 teams starting from the 10th
|
||||
// GET /teams?limit=10&offset=10
|
||||
|
||||
// results in a Sequelize query:
|
||||
Team.findAll({limit: 10, offset: 10})
|
||||
```
|
||||
|
||||
## `order` queries
|
||||
You can change the order of the resulting query by passing `order` to the query.
|
||||
|
||||
```js
|
||||
// returns the teams ordered by the name column
|
||||
// GET /teams?order[]=name
|
||||
|
||||
// results in a Sequelize query:
|
||||
Team.findAll({order: ['name']})
|
||||
```
|
||||
|
||||
```js
|
||||
// returns the teams ordered by the name column, descending
|
||||
// GET /teams?order[0]=name&order[0]=DESC
|
||||
// GET /teams?order=name%20DESC
|
||||
|
||||
// results in a Sequelize query:
|
||||
Team.findAll({order: [['name', 'DESC']]})
|
||||
```
|
||||
|
||||
```js
|
||||
// returns the teams ordered by the name, then the city columns, descending
|
||||
// GET /teams?order[0]=name&order[1]=city
|
||||
|
||||
// results in a Sequelize query:
|
||||
Team.findAll({order: [['name'], ['city']]})
|
||||
```
|
||||
|
||||
You can even order by associated models. Though there is a [sequelize bug](https://github.com/sequelize/sequelize/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20order%20join%20) that might prevent this from working properly. A workaround is to `&include` the model you're ordering by.
|
||||
```js
|
||||
// returns the players ordered by the team name
|
||||
// GET /players?order[0]={"model": "Team"}&order[0]=name
|
||||
|
||||
// results in a Sequelize query:
|
||||
Player.findAll({order: [[{model: Team}, 'name']]})
|
||||
|
||||
// if the above returns a Sequelize error: `No such column Team.name`,
|
||||
// you can work around this by forcing the join into the query:
|
||||
// GET /players?order[0]={"model": "Team"}&order[0]=name&include=team
|
||||
|
||||
// results in a Sequelize query:
|
||||
Player.findAll({order: [[{model: Team}, 'name']], include: [Team]})
|
||||
```
|
||||
|
||||
|
||||
## Authorization and other hooks
|
||||
You can use Hapi's [`ext` option](http://hapijs.com/api#route-options) to interact with the request both before and after this module does. This is useful if you want to enforce authorization, or modify the request before or after this module does. Hapi [has a full list of hooks](http://hapijs.com/api#request-lifecycle) you can use.
|
||||
|
||||
## Modify the response format
|
||||
By default, `hapi-sequelize-crud` routes will respond with the full model. You can modify this using the built-in [hapi settings](http://hapijs.com/tutorials/validation#output).
|
||||
|
||||
```js
|
||||
await register({
|
||||
register: require('hapi-sequelize-crud'),
|
||||
options: {
|
||||
…
|
||||
{model: 'fly', config: {
|
||||
response: {
|
||||
// setting this schema will restrict the response to only the id
|
||||
schema: { id: joi.string() },
|
||||
// This tells Hapi to restrict the response to the keys specified in `schema`
|
||||
modify: true
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
})
|
||||
```
|
||||
|
||||
## Full list of methods
|
||||
|
||||
Let's say you have a `many-to-many` association like this:
|
||||
|
@ -1,13 +1,9 @@
|
||||
machine:
|
||||
node:
|
||||
version: 6.9.0
|
||||
version: 6.5.0
|
||||
|
||||
dependencies:
|
||||
pre:
|
||||
- npm prune
|
||||
post:
|
||||
- mkdir -p $CIRCLE_TEST_REPORTS/ava
|
||||
|
||||
test:
|
||||
post:
|
||||
- npm run coverage
|
||||
|
57
package.json
57
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hapi-sequelize-crud",
|
||||
"version": "2.9.3",
|
||||
"version": "2.6.2",
|
||||
"description": "Hapi plugin that automatically generates RESTful API for CRUD",
|
||||
"main": "build/index.js",
|
||||
"config": {
|
||||
@ -10,11 +10,10 @@
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src",
|
||||
"test": "SCRIPTY_SILENT=true scripty",
|
||||
"coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov",
|
||||
"tdd": "ava --watch",
|
||||
"build": "SCRIPTY_SILENT=true scripty",
|
||||
"watch": "SCRIPTY_SILENT=true scripty"
|
||||
"test": "ava --require babel-register --source='src/**/*.js' --source='!build/**/*' --tap=${CI-false} src/**/*.test.js | $(if [ -z ${CI:-} ]; then echo 'tail'; else tap-xunit > $CIRCLE_TEST_REPORTS/ava/ava.xml; fi;)",
|
||||
"tdd": "ava --require babel-register --source='src/**/*.js' --source='!build/**/*' --watch src/**/*.test.js",
|
||||
"build": "scripty",
|
||||
"watch": "scripty"
|
||||
},
|
||||
"repository": {
|
||||
"git": "https://github.com/mdibaiee/hapi-sequelize-crud"
|
||||
@ -26,52 +25,28 @@
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"ava": "^0.16.0",
|
||||
"babel-cli": "^6.16.0",
|
||||
"babel-cli": "^6.14.0",
|
||||
"babel-plugin-add-module-exports": "^0.2.1",
|
||||
"babel-plugin-closure-elimination": "^1.0.6",
|
||||
"babel-plugin-transform-decorators-legacy": "^1.3.4",
|
||||
"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",
|
||||
"codecov": "^1.0.1",
|
||||
"eslint": "^3.8.1",
|
||||
"babel-plugin-transform-es2015-modules-commonjs": "^6.14.0",
|
||||
"babel-preset-stage-1": "^6.13.0",
|
||||
"babel-register": "^6.14.0",
|
||||
"eslint": "^3.4.0",
|
||||
"eslint-config-pichak": "^1.1.2",
|
||||
"eslint-plugin-ava": "^3.1.1",
|
||||
"eslint-plugin-ava": "^3.0.0",
|
||||
"ghooks": "^1.3.2",
|
||||
"hapi": "^15.2.0",
|
||||
"hapi-sequelize": "^3.0.4",
|
||||
"nyc": "^8.3.2",
|
||||
"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",
|
||||
"sinon": "^1.17.5",
|
||||
"sinon-bluebird": "^3.0.2",
|
||||
"tap-xunit": "^1.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"boom": "^4.2.0",
|
||||
"joi": "^9.2.0",
|
||||
"lodash": "^4.16.4"
|
||||
"boom": "^4.0.0",
|
||||
"joi": "^9.0.4",
|
||||
"lodash": "^4.15.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"babel-polyfill": "^6.13.0"
|
||||
},
|
||||
"nyc": {
|
||||
"cache": true
|
||||
},
|
||||
"ava": {
|
||||
"source": [
|
||||
"src/**/*.js",
|
||||
"!build/**/*"
|
||||
],
|
||||
"files": [
|
||||
"**/*.test.js",
|
||||
"!build/**/*"
|
||||
],
|
||||
"require": [
|
||||
"babel-register"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
# strict mode http://redsymbol.net/articles/unofficial-bash-strict-mode/
|
||||
set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
nyc=./node_modules/.bin/nyc
|
||||
ava=./node_modules/.bin/ava
|
||||
|
||||
if [ ! -z ${CI:-} ]; then
|
||||
$nyc $ava --tap=${CI-false} | tap-xunit > $CIRCLE_TEST_REPORTS/ava/ava.xml
|
||||
else
|
||||
$nyc $ava
|
||||
fi
|
||||
|
@ -1,44 +0,0 @@
|
||||
import test from 'ava';
|
||||
import 'sinon-bluebird';
|
||||
import setup from '../test/integration-setup.js';
|
||||
|
||||
const STATUS_OK = 200;
|
||||
const STATUS_NOT_FOUND = 404;
|
||||
const STATUS_BAD_REQUEST = 400;
|
||||
|
||||
setup(test);
|
||||
|
||||
test('where /player {name: "Chard"}', async (t) => {
|
||||
const { server, sequelize: { models: { Player } } } = t.context;
|
||||
const url = '/player';
|
||||
const method = 'POST';
|
||||
const payload = { name: 'Chard' };
|
||||
|
||||
const notPresentPlayer = await Player.findOne({ where: payload });
|
||||
t.falsy(notPresentPlayer);
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method, payload });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.truthy(result.id);
|
||||
t.is(result.name, payload.name);
|
||||
});
|
||||
|
||||
test('not found /notamodel {name: "Chard"}', async (t) => {
|
||||
const { server } = t.context;
|
||||
const url = '/notamodel';
|
||||
const method = 'POST';
|
||||
const payload = { name: 'Chard' };
|
||||
|
||||
const { statusCode } = await server.inject({ url, method, payload });
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
});
|
||||
|
||||
|
||||
test('no payload /player/1', async (t) => {
|
||||
const { server } = t.context;
|
||||
const url = '/player';
|
||||
const method = 'POST';
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_BAD_REQUEST);
|
||||
});
|
@ -1,177 +0,0 @@
|
||||
import test from 'ava';
|
||||
import 'sinon-bluebird';
|
||||
import setup from '../test/integration-setup.js';
|
||||
|
||||
const STATUS_OK = 200;
|
||||
const STATUS_NOT_FOUND = 404;
|
||||
const STATUS_BAD_REQUEST = 400;
|
||||
|
||||
setup(test);
|
||||
|
||||
test('destroy where /player?name=Baseball', async (t) => {
|
||||
const { server, instances, sequelize: { models: { Player } } } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
const url = `/player?name=${player1.name}`;
|
||||
const method = 'DELETE';
|
||||
|
||||
const presentPlayer = await Player.findById(player1.id);
|
||||
t.truthy(presentPlayer);
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, player1.id);
|
||||
|
||||
const deletedPlayer = await Player.findById(player1.id);
|
||||
t.falsy(deletedPlayer);
|
||||
const stillTherePlayer = await Player.findById(player2.id);
|
||||
t.truthy(stillTherePlayer);
|
||||
});
|
||||
|
||||
test('destroyAll where /players?name=Baseball', async (t) => {
|
||||
const { server, instances, sequelize: { models: { Player } } } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
const url = `/players?name=${player1.name}`;
|
||||
const method = 'DELETE';
|
||||
|
||||
const presentPlayer = await Player.findById(player1.id);
|
||||
t.truthy(presentPlayer);
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, player1.id);
|
||||
|
||||
const deletedPlayer = await Player.findById(player1.id);
|
||||
t.falsy(deletedPlayer);
|
||||
const stillTherePlayer = await Player.findById(player2.id);
|
||||
t.truthy(stillTherePlayer);
|
||||
});
|
||||
|
||||
test('destroyAll /players', async (t) => {
|
||||
const { server, instances, sequelize: { models: { Player } } } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
const url = '/players';
|
||||
const method = 'DELETE';
|
||||
|
||||
const presentPlayers = await Player.findAll();
|
||||
const playerIds = presentPlayers.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
const resultPlayerIds = result.map(({ id }) => id);
|
||||
t.truthy(resultPlayerIds.includes(player1.id));
|
||||
t.truthy(resultPlayerIds.includes(player2.id));
|
||||
|
||||
const deletedPlayers = await Player.findAll();
|
||||
t.is(deletedPlayers.length, 0);
|
||||
});
|
||||
|
||||
test('destroy not found /player/10', async (t) => {
|
||||
const { server, instances, sequelize: { models: { Player } } } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
// this doesn't exist in our fixtures
|
||||
const url = '/player/10';
|
||||
const method = 'DELETE';
|
||||
|
||||
const presentPlayers = await Player.findAll();
|
||||
const playerIds = presentPlayers.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
|
||||
const nonDeletedPlayers = await Player.findAll();
|
||||
t.is(nonDeletedPlayers.length, presentPlayers.length);
|
||||
});
|
||||
|
||||
test('destroyAll not found /players?name=no', async (t) => {
|
||||
const { server, instances, sequelize: { models: { Player } } } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
// this doesn't exist in our fixtures
|
||||
const url = '/players?name=no';
|
||||
const method = 'DELETE';
|
||||
|
||||
const presentPlayers = await Player.findAll();
|
||||
const playerIds = presentPlayers.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
|
||||
const nonDeletedPlayers = await Player.findAll();
|
||||
t.is(nonDeletedPlayers.length, presentPlayers.length);
|
||||
});
|
||||
|
||||
test('not found /notamodel', async (t) => {
|
||||
const { server } = t.context;
|
||||
const url = '/notamodel';
|
||||
const method = 'DELETE';
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('destroyScope /players/returnsOne', async (t) => {
|
||||
const { server, instances, sequelize: { models: { Player } } } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
// this doesn't exist in our fixtures
|
||||
const url = '/players/returnsOne';
|
||||
const method = 'DELETE';
|
||||
|
||||
const presentPlayers = await Player.findAll();
|
||||
const playerIds = presentPlayers.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, player1.id);
|
||||
|
||||
const nonDeletedPlayers = await Player.findAll();
|
||||
t.is(nonDeletedPlayers.length, presentPlayers.length - 1);
|
||||
});
|
||||
|
||||
test('destroyScope /players/returnsNone', async (t) => {
|
||||
const { server, instances, sequelize: { models: { Player } } } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
// this doesn't exist in our fixtures
|
||||
const url = '/players/returnsNone';
|
||||
const method = 'DELETE';
|
||||
|
||||
const presentPlayers = await Player.findAll();
|
||||
const playerIds = presentPlayers.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
|
||||
const nonDeletedPlayers = await Player.findAll();
|
||||
const nonDeletedPlayerIds = nonDeletedPlayers.map(({ id }) => id);
|
||||
t.truthy(nonDeletedPlayerIds.includes(player1.id));
|
||||
t.truthy(nonDeletedPlayerIds.includes(player2.id));
|
||||
});
|
||||
|
||||
test('destroyScope invalid scope /players/invalid', async (t) => {
|
||||
const { server, instances, sequelize: { models: { Player } } } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
// this doesn't exist in our fixtures
|
||||
const url = '/players/invalid';
|
||||
const method = 'DELETE';
|
||||
|
||||
const presentPlayers = await Player.findAll();
|
||||
const playerIds = presentPlayers.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_BAD_REQUEST);
|
||||
|
||||
const nonDeletedPlayers = await Player.findAll();
|
||||
const nonDeletedPlayerIds = nonDeletedPlayers.map(({ id }) => id);
|
||||
t.truthy(nonDeletedPlayerIds.includes(player1.id));
|
||||
t.truthy(nonDeletedPlayerIds.includes(player2.id));
|
||||
});
|
@ -1,87 +0,0 @@
|
||||
import test from 'ava';
|
||||
import 'sinon-bluebird';
|
||||
import setup from '../test/integration-setup.js';
|
||||
|
||||
const STATUS_OK = 200;
|
||||
|
||||
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, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
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, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
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, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
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, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
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, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
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, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
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);
|
||||
});
|
@ -1,94 +0,0 @@
|
||||
import test from 'ava';
|
||||
import 'sinon-bluebird';
|
||||
import setup from '../test/integration-setup.js';
|
||||
|
||||
const STATUS_OK = 200;
|
||||
const STATUS_NOT_FOUND = 404;
|
||||
|
||||
setup(test);
|
||||
|
||||
test('/players?limit=2', async (t) => {
|
||||
const { server } = t.context;
|
||||
const limit = 2;
|
||||
const url = `/players?limit=${limit}`;
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.length, limit);
|
||||
});
|
||||
|
||||
test('/players?limit=2&offset=1', async (t) => {
|
||||
const { server } = t.context;
|
||||
const limit = 2;
|
||||
const url = `/players?limit=${limit}&offset=1`;
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.length, limit);
|
||||
});
|
||||
|
||||
test('/players?limit=2&offset=2', async (t) => {
|
||||
const { server } = t.context;
|
||||
const limit = 2;
|
||||
const url = `/players?limit=${limit}&offset=2`;
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.length, 1, 'with only 3 players, only get 1 back with an offset of 2');
|
||||
});
|
||||
|
||||
test('/players?limit=2&offset=20', async (t) => {
|
||||
const { server } = t.context;
|
||||
const limit = 2;
|
||||
const url = `/players?limit=${limit}&offset=20`;
|
||||
const method = 'GET';
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_NOT_FOUND, 'with a offset/limit greater than the data, returns a 404');
|
||||
});
|
||||
|
||||
test('scope /players/returnsAll?limit=2', async (t) => {
|
||||
const { server } = t.context;
|
||||
const limit = 2;
|
||||
const url = `/players/returnsAll?limit=${limit}`;
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.length, limit);
|
||||
});
|
||||
|
||||
test('scope /players/returnsAll?limit=2&offset=1', async (t) => {
|
||||
const { server } = t.context;
|
||||
const limit = 2;
|
||||
const url = `/players/returnsAll?limit=${limit}&offset=1`;
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.length, limit);
|
||||
});
|
||||
|
||||
test('scope /players/returnsAll?limit=2&offset=2', async (t) => {
|
||||
const { server } = t.context;
|
||||
const limit = 2;
|
||||
const url = `/players/returnsAll?limit=${limit}&offset=2`;
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.length, 1, 'with only 3 players, only get 1 back with an offset of 2');
|
||||
});
|
||||
|
||||
test('scope /players/returnsAll?limit=2&offset=20', async (t) => {
|
||||
const { server } = t.context;
|
||||
const limit = 2;
|
||||
const url = `/players/returnsAll?limit=${limit}&offset=20`;
|
||||
const method = 'GET';
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_NOT_FOUND, 'with a offset/limit greater than the data, returns a 404');
|
||||
});
|
@ -1,141 +0,0 @@
|
||||
import test from 'ava';
|
||||
import 'sinon-bluebird';
|
||||
import setup from '../test/integration-setup.js';
|
||||
|
||||
const STATUS_OK = 200;
|
||||
const STATUS_BAD_QUERY = 502;
|
||||
|
||||
setup(test);
|
||||
|
||||
test('/players?order=name', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1, player2, player3 } = instances;
|
||||
const url = '/players?order=name';
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
// this is the order we'd expect the names to be in
|
||||
t.is(result[0].name, player1.name);
|
||||
t.is(result[1].name, player2.name);
|
||||
t.is(result[2].name, player3.name);
|
||||
});
|
||||
|
||||
test('/players?order=name%20ASC', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1, player2, player3 } = instances;
|
||||
const url = '/players?order=name%20ASC';
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
// this is the order we'd expect the names to be in
|
||||
t.is(result[0].name, player1.name);
|
||||
t.is(result[1].name, player2.name);
|
||||
t.is(result[2].name, player3.name);
|
||||
});
|
||||
|
||||
test('/players?order=name%20DESC', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1, player2, player3 } = instances;
|
||||
const url = '/players?order=name%20DESC';
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
// this is the order we'd expect the names to be in
|
||||
t.is(result[0].name, player3.name);
|
||||
t.is(result[1].name, player2.name);
|
||||
t.is(result[2].name, player1.name);
|
||||
});
|
||||
|
||||
test('/players?order[]=name', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1, player2, player3 } = instances;
|
||||
const url = '/players?order[]=name';
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
// this is the order we'd expect the names to be in
|
||||
t.is(result[0].name, player1.name);
|
||||
t.is(result[1].name, player2.name);
|
||||
t.is(result[2].name, player3.name);
|
||||
});
|
||||
|
||||
test('/players?order[0]=name&order[0]=DESC', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1, player2, player3 } = instances;
|
||||
const url = '/players?order[0]=name&order[0]=DESC';
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
// this is the order we'd expect the names to be in
|
||||
t.is(result[0].name, player3.name);
|
||||
t.is(result[1].name, player2.name);
|
||||
t.is(result[2].name, player1.name);
|
||||
});
|
||||
|
||||
// multiple sorts
|
||||
test('/players?order[0]=active&order[0]=DESC&order[1]=name&order[1]=DESC', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1, player2, player3 } = instances;
|
||||
const url = '/players?order[0]=name&order[0]=DESC&order[1]=teamId&order[1]=DESC';
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
// this is the order we'd expect the names to be in
|
||||
t.is(result[0].name, player3.name);
|
||||
t.is(result[1].name, player2.name);
|
||||
t.is(result[2].name, player1.name);
|
||||
});
|
||||
|
||||
// this will fail b/c sequelize doesn't correctly do the join when you pass
|
||||
// an order. There are many issues for this:
|
||||
// eslint-disable-next-line
|
||||
// https://github.com/sequelize/sequelize/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20order%20join%20
|
||||
//
|
||||
// https://github.com/sequelize/sequelize/issues/5353 is a good example
|
||||
// if this test passes, that's great! Just remove the workaround note in the
|
||||
// docs
|
||||
// eslint-disable-next-line
|
||||
test.failing('sequelize bug /players?order[0]={"model":"Team"}&order[0]=name&order[0]=DESC', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1, player2, player3 } = instances;
|
||||
const url = '/players?order[0]={"model":"Team"}&order[0]=name&order[0]=DESC';
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
// this is the order we'd expect the names to be in
|
||||
t.is(result[0].name, player3.name);
|
||||
t.is(result[1].name, player1.name);
|
||||
t.is(result[2].name, player2.name);
|
||||
});
|
||||
|
||||
// b/c the above fails, this is a work-around
|
||||
test('/players?order[0]={"model":"Team"}&order[0]=name&order[0]=DESC&include=team', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1, player2, player3 } = instances;
|
||||
const url = '/players?order[0]={"model":"Team"}&order[0]=name&order[0]=DESC&include=team';
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
// this is the order we'd expect the names to be in
|
||||
t.is(result[0].name, player3.name);
|
||||
t.is(result[1].name, player1.name);
|
||||
t.is(result[2].name, player2.name);
|
||||
});
|
||||
|
||||
test('invalid column /players?order[0]=invalid', async (t) => {
|
||||
const { server } = t.context;
|
||||
const url = '/players?order[]=invalid';
|
||||
const method = 'GET';
|
||||
|
||||
const { statusCode, result } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_BAD_QUERY);
|
||||
t.truthy(result.message.includes('invalid'));
|
||||
});
|
@ -1,26 +0,0 @@
|
||||
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' });
|
||||
});
|
@ -1,40 +0,0 @@
|
||||
import test from 'ava';
|
||||
import 'sinon-bluebird';
|
||||
import setup from '../test/integration-setup.js';
|
||||
|
||||
const STATUS_OK = 200;
|
||||
const STATUS_NOT_FOUND = 404;
|
||||
const STATUS_BAD_REQUEST = 400;
|
||||
|
||||
setup(test);
|
||||
|
||||
test('/players/returnsOne', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1 } = instances;
|
||||
const url = '/players/returnsOne';
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.length, 1);
|
||||
t.truthy(result[0].id, player1.id);
|
||||
});
|
||||
|
||||
test('/players/returnsNone', async (t) => {
|
||||
const { server } = t.context;
|
||||
const url = '/players/returnsNone';
|
||||
const method = 'GET';
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('invalid scope /players/invalid', async (t) => {
|
||||
const { server } = t.context;
|
||||
// this doesn't exist in our fixtures
|
||||
const url = '/players/invalid';
|
||||
const method = 'GET';
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_BAD_REQUEST);
|
||||
});
|
@ -1,54 +0,0 @@
|
||||
import test from 'ava';
|
||||
import 'sinon-bluebird';
|
||||
import setup from '../test/integration-setup.js';
|
||||
|
||||
const STATUS_OK = 200;
|
||||
const STATUS_NOT_FOUND = 404;
|
||||
const STATUS_BAD_REQUEST = 400;
|
||||
|
||||
setup(test);
|
||||
|
||||
test('where /player/1 {name: "Chard"}', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1 } = instances;
|
||||
const url = `/player/${player1.id}`;
|
||||
const method = 'PUT';
|
||||
const payload = { name: 'Chard' };
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method, payload });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, player1.id);
|
||||
t.is(result.name, payload.name);
|
||||
});
|
||||
|
||||
test('not found /player/10 {name: "Chard"}', async (t) => {
|
||||
const { server } = t.context;
|
||||
// this doesn't exist in our fixtures
|
||||
const url = '/player/10';
|
||||
const method = 'PUT';
|
||||
const payload = { name: 'Chard' };
|
||||
|
||||
const { statusCode } = await server.inject({ url, method, payload });
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
});
|
||||
|
||||
|
||||
test('no payload /player/1', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1 } = instances;
|
||||
const url = `/player/${player1.id}`;
|
||||
const method = 'PUT';
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_BAD_REQUEST);
|
||||
});
|
||||
|
||||
test('not found /notamodel {name: "Chard"}', async (t) => {
|
||||
const { server } = t.context;
|
||||
const url = '/notamodel';
|
||||
const method = 'PUT';
|
||||
const payload = { name: 'Chard' };
|
||||
|
||||
const { statusCode } = await server.inject({ url, method, payload });
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
});
|
@ -1,53 +0,0 @@
|
||||
import test from 'ava';
|
||||
import 'sinon-bluebird';
|
||||
import setup from '../test/integration-setup.js';
|
||||
|
||||
const STATUS_OK = 200;
|
||||
const STATUS_NOT_FOUND = 404;
|
||||
|
||||
setup(test);
|
||||
|
||||
test('single result /team?name=Baseball', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { team1 } = instances;
|
||||
const path = `/team?name=${team1.name}`;
|
||||
|
||||
const { result, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, team1.id);
|
||||
t.is(result.name, team1.name);
|
||||
});
|
||||
|
||||
test('no results /team?name=Baseball&id=2', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { team1 } = instances;
|
||||
// this doesn't exist in our fixtures
|
||||
const path = `/team?name=${team1.name}&id=2`;
|
||||
|
||||
const { statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('single result from list query /teams?name=Baseball', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { team1 } = instances;
|
||||
const path = `/team?name=${team1.name}`;
|
||||
|
||||
const { result, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, team1.id);
|
||||
t.is(result.name, team1.name);
|
||||
});
|
||||
|
||||
test('multiple results from list query /players?teamId=1', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { team1, player1, player2 } = instances;
|
||||
const path = `/players?teamId=${team1.id}`;
|
||||
|
||||
const { result, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
const playerIds = result.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
});
|
||||
|
52
src/crud.js
52
src/crud.js
@ -2,7 +2,7 @@ import joi from 'joi';
|
||||
import path from 'path';
|
||||
import error from './error';
|
||||
import _ from 'lodash';
|
||||
import { parseInclude, parseWhere, parseLimitAndOffset, parseOrder } from './utils';
|
||||
import { parseInclude, parseWhere } from './utils';
|
||||
import { notFound } from 'boom';
|
||||
import * as associations from './associations/index';
|
||||
import getConfigForMethod from './get-config-for-method.js';
|
||||
@ -55,27 +55,15 @@ models: {
|
||||
export default (server, model, { prefix, defaultConfig: config, models: permissions }) => {
|
||||
const modelName = model._singular;
|
||||
const modelAttributes = Object.keys(model.attributes);
|
||||
const associatedModelNames = Object.keys(model.associations);
|
||||
const modelAssociations = [
|
||||
...associatedModelNames,
|
||||
..._.flatMap(associatedModelNames, (associationName) => {
|
||||
const { target } = model.associations[associationName];
|
||||
const { _singular, _plural, _Singular, _Plural } = target;
|
||||
return [_singular, _plural, _Singular, _Plural];
|
||||
}),
|
||||
].filter(Boolean);
|
||||
const modelAssociations = Object.keys(model.associations);
|
||||
|
||||
const attributeValidation = modelAttributes.reduce((params, attribute) => {
|
||||
// TODO: use joi-sequelize
|
||||
params[attribute] = joi.any();
|
||||
return params;
|
||||
}, {});
|
||||
|
||||
const validAssociations = modelAssociations.length
|
||||
? joi.string().valid(...modelAssociations)
|
||||
: joi.valid(null);
|
||||
const associationValidation = {
|
||||
include: [joi.array().items(validAssociations), validAssociations],
|
||||
include: joi.array().items(joi.string().valid(...modelAssociations)),
|
||||
};
|
||||
|
||||
const scopes = Object.keys(model.options.scopes);
|
||||
@ -156,17 +144,13 @@ export const list = ({ server, model, prefix = '/', config }) => {
|
||||
async handler(request, reply) {
|
||||
const include = parseInclude(request);
|
||||
const where = parseWhere(request);
|
||||
const { limit, offset } = parseLimitAndOffset(request);
|
||||
const order = parseOrder(request);
|
||||
|
||||
if (include instanceof Error) return void reply(include);
|
||||
|
||||
const list = await model.findAll({
|
||||
where, include, limit, offset, order,
|
||||
where, include,
|
||||
});
|
||||
|
||||
if (!list.length) return void reply(notFound('Nothing found.'));
|
||||
|
||||
reply(list.map((item) => item.toJSON()));
|
||||
},
|
||||
|
||||
@ -207,16 +191,10 @@ export const scope = ({ server, model, prefix = '/', config }) => {
|
||||
async handler(request, reply) {
|
||||
const include = parseInclude(request);
|
||||
const where = parseWhere(request);
|
||||
const { limit, offset } = parseLimitAndOffset(request);
|
||||
const order = parseOrder(request);
|
||||
|
||||
if (include instanceof Error) return void reply(include);
|
||||
|
||||
const list = await model.scope(request.params.scope).findAll({
|
||||
include, where, limit, offset, order,
|
||||
});
|
||||
|
||||
if (!list.length) return void reply(notFound('Nothing found.'));
|
||||
const list = await model.scope(request.params.scope).findAll({ include, where });
|
||||
|
||||
reply(list.map((item) => item.toJSON()));
|
||||
},
|
||||
@ -248,18 +226,10 @@ export const destroy = ({ server, model, prefix = '/', config }) => {
|
||||
@error
|
||||
async handler(request, reply) {
|
||||
const where = parseWhere(request);
|
||||
const { id } = request.params;
|
||||
if (id) where[model.primaryKeyField] = id;
|
||||
if (request.params.id) where[model.primaryKeyField] = request.params.id;
|
||||
|
||||
const list = await model.findAll({ where });
|
||||
|
||||
if (!list.length) {
|
||||
return void reply(id
|
||||
? notFound(`${id} not found.`)
|
||||
: notFound('Nothing found.')
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(list.map(instance => instance.destroy()));
|
||||
|
||||
const listAsJSON = list.map((item) => item.toJSON());
|
||||
@ -278,17 +248,9 @@ export const destroyAll = ({ server, model, prefix = '/', config }) => {
|
||||
@error
|
||||
async handler(request, reply) {
|
||||
const where = parseWhere(request);
|
||||
const { id } = request.params;
|
||||
|
||||
const list = await model.findAll({ where });
|
||||
|
||||
if (!list.length) {
|
||||
return void reply(id
|
||||
? notFound(`${id} not found.`)
|
||||
: notFound('Nothing found.')
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(list.map(instance => instance.destroy()));
|
||||
|
||||
const listAsJSON = list.map((item) => item.toJSON());
|
||||
@ -313,8 +275,6 @@ export const destroyScope = ({ server, model, prefix = '/', config }) => {
|
||||
|
||||
const list = await model.scope(request.params.scope).findAll({ include, where });
|
||||
|
||||
if (!list.length) return void reply(notFound('Nothing found.'));
|
||||
|
||||
await Promise.all(list.map(instance => instance.destroy()));
|
||||
|
||||
const listAsJSON = list.map((item) => item.toJSON());
|
||||
|
@ -37,7 +37,7 @@ test.beforeEach('setup request stub', (t) => {
|
||||
t.context.request = {
|
||||
query: {},
|
||||
payload: {},
|
||||
models: t.context.models,
|
||||
models: [t.context.model],
|
||||
};
|
||||
});
|
||||
|
||||
@ -126,8 +126,6 @@ test('crud#list handler', async (t) => {
|
||||
|
||||
const response = reply.args[0][0];
|
||||
|
||||
t.falsy(response instanceof Error, response);
|
||||
|
||||
t.deepEqual(
|
||||
response,
|
||||
models.map(({ id }) => ({ id })),
|
||||
@ -157,75 +155,3 @@ test('crud#list handler if parseInclude errors', async (t) => {
|
||||
'responds with a Boom error'
|
||||
);
|
||||
});
|
||||
|
||||
test('crud#list handler with limit', async (t) => {
|
||||
const { server, model, request, reply, models } = t.context;
|
||||
const { findAll } = model;
|
||||
|
||||
// set the limit
|
||||
request.query.limit = 1;
|
||||
|
||||
list({ server, model });
|
||||
const { handler } = server.route.args[0][0];
|
||||
model.findAll.resolves(models);
|
||||
|
||||
try {
|
||||
await handler(request, reply);
|
||||
} catch (e) {
|
||||
t.ifError(e, 'does not error while handling');
|
||||
} finally {
|
||||
t.pass('does not error while handling');
|
||||
}
|
||||
|
||||
t.truthy(
|
||||
reply.calledOnce
|
||||
, 'calls reply only once'
|
||||
);
|
||||
|
||||
const response = reply.args[0][0];
|
||||
const findAllArgs = findAll.args[0][0];
|
||||
|
||||
t.falsy(response instanceof Error, response);
|
||||
|
||||
t.is(
|
||||
findAllArgs.limit,
|
||||
request.query.limit,
|
||||
'queries with the limit'
|
||||
);
|
||||
});
|
||||
|
||||
test('crud#list handler with order', async (t) => {
|
||||
const { server, model, request, reply, models } = t.context;
|
||||
const { findAll } = model;
|
||||
|
||||
// set the limit
|
||||
request.query.order = 'key';
|
||||
|
||||
list({ server, model });
|
||||
const { handler } = server.route.args[0][0];
|
||||
model.findAll.resolves(models);
|
||||
|
||||
try {
|
||||
await handler(request, reply);
|
||||
} catch (e) {
|
||||
t.ifError(e, 'does not error while handling');
|
||||
} finally {
|
||||
t.pass('does not error while handling');
|
||||
}
|
||||
|
||||
t.truthy(
|
||||
reply.calledOnce
|
||||
, 'calls reply only once'
|
||||
);
|
||||
|
||||
const response = reply.args[0][0];
|
||||
const findAllArgs = findAll.args[0][0];
|
||||
|
||||
t.falsy(response instanceof Error, response);
|
||||
|
||||
t.deepEqual(
|
||||
findAllArgs.order,
|
||||
[[request.query.order]],
|
||||
'queries with the order as an array b/c that\'s what sequelize wants'
|
||||
);
|
||||
});
|
||||
|
@ -66,11 +66,6 @@ export const idParamsMethods = [
|
||||
'update',
|
||||
];
|
||||
|
||||
export const restrictMethods = [
|
||||
'list',
|
||||
'scope',
|
||||
];
|
||||
|
||||
export default ({
|
||||
method, attributeValidation, associationValidation, scopes = [], config = {},
|
||||
}) => {
|
||||
@ -79,7 +74,6 @@ export default ({
|
||||
const hasPayload = payloadMethods.includes(method);
|
||||
const hasScopeParams = scopeParamsMethods.includes(method);
|
||||
const hasIdParams = idParamsMethods.includes(method);
|
||||
const hasRestrictMethods = restrictMethods.includes(method);
|
||||
// clone the config so we don't modify it on multiple passes.
|
||||
let methodConfig = { ...config, validate: { ...config.validate } };
|
||||
|
||||
@ -139,18 +133,5 @@ export default ({
|
||||
methodConfig = set(methodConfig, 'validate.params', params);
|
||||
}
|
||||
|
||||
if (hasRestrictMethods) {
|
||||
const query = concatToJoiObject(joi.object()
|
||||
.keys({
|
||||
limit: joi.number().min(0).integer(),
|
||||
offset: joi.number().min(0).integer(),
|
||||
order: [joi.array(), joi.string()],
|
||||
}),
|
||||
get(methodConfig, 'validate.query')
|
||||
);
|
||||
|
||||
methodConfig = set(methodConfig, 'validate.query', query);
|
||||
}
|
||||
|
||||
return methodConfig;
|
||||
};
|
||||
|
@ -7,7 +7,6 @@ import
|
||||
payloadMethods,
|
||||
scopeParamsMethods,
|
||||
idParamsMethods,
|
||||
restrictMethods,
|
||||
sequelizeOperators,
|
||||
} from './get-config-for-method.js';
|
||||
|
||||
@ -469,101 +468,6 @@ test('validate.payload idParamsMethods', (t) => {
|
||||
});
|
||||
});
|
||||
|
||||
test('validate.query restrictMethods', (t) => {
|
||||
restrictMethods.forEach((method) => {
|
||||
const configForMethod = getConfigForMethod({ method });
|
||||
const { query } = configForMethod.validate;
|
||||
const restrictKeys = ['limit', 'offset'];
|
||||
|
||||
restrictKeys.forEach((key) => {
|
||||
t.ifError(
|
||||
query.validate({ [key]: 0 }).error
|
||||
, `applies restriction (${key}) to validate.query`
|
||||
);
|
||||
});
|
||||
|
||||
t.ifError(
|
||||
query.validate({ order: ['thing', 'DESC'] }).error
|
||||
, 'applies `order` to validate.query'
|
||||
);
|
||||
|
||||
t.truthy(
|
||||
query.validate({ notAThing: true }).error
|
||||
, 'errors on a non-valid key'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('validate.query restrictMethods w/ config as plain object', (t) => {
|
||||
const config = {
|
||||
validate: {
|
||||
query: {
|
||||
aKey: joi.boolean(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
restrictMethods.forEach((method) => {
|
||||
const configForMethod = getConfigForMethod({
|
||||
method,
|
||||
config,
|
||||
});
|
||||
const { query } = configForMethod.validate;
|
||||
|
||||
const keys = [
|
||||
...Object.keys(config.validate.query),
|
||||
];
|
||||
|
||||
keys.forEach((key) => {
|
||||
t.ifError(
|
||||
query.validate({ [key]: true }).error
|
||||
, `applies ${key} to validate.query`
|
||||
);
|
||||
});
|
||||
|
||||
t.truthy(
|
||||
query.validate({ notAThing: true }).error
|
||||
, 'errors on a non-valid key'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('validate.query restrictMethods w/ config as joi object', (t) => {
|
||||
const queryKeys = {
|
||||
aKey: joi.boolean(),
|
||||
};
|
||||
const config = {
|
||||
validate: {
|
||||
query: joi.object().keys(queryKeys),
|
||||
},
|
||||
};
|
||||
|
||||
whereMethods.forEach((method) => {
|
||||
const configForMethod = getConfigForMethod({
|
||||
method,
|
||||
config,
|
||||
});
|
||||
const { query } = configForMethod.validate;
|
||||
|
||||
const keys = [
|
||||
...Object.keys(queryKeys),
|
||||
];
|
||||
|
||||
keys.forEach((key) => {
|
||||
t.ifError(
|
||||
query.validate({ [key]: true }).error
|
||||
, `applies ${key} to validate.query`
|
||||
);
|
||||
});
|
||||
|
||||
t.truthy(
|
||||
query.validate({ notAThing: true }).error
|
||||
, 'errors on a non-valid key'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test('does not modify initial config on multiple passes', (t) => {
|
||||
const { config } = t.context;
|
||||
const originalConfig = { ...config };
|
||||
|
17
src/index.js
17
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];
|
||||
@ -32,12 +32,12 @@ const register = (server, options = {}, next) => {
|
||||
const { plural, singular } = model.options.name;
|
||||
model._plural = plural.toLowerCase();
|
||||
model._singular = singular.toLowerCase();
|
||||
model._Plural = plural;
|
||||
model._Singular = singular;
|
||||
|
||||
// Join tables
|
||||
if (model.options.name.singular !== model.name) continue;
|
||||
|
||||
crud(server, model, options);
|
||||
|
||||
for (const key of Object.keys(model.associations)) {
|
||||
const association = model.associations[key];
|
||||
const { source, target } = association;
|
||||
@ -92,17 +92,6 @@ const register = (server, options = {}, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// build the methods for each model now that we've defined all the
|
||||
// associations
|
||||
Object.keys(models).filter((modelName) => {
|
||||
const model = models[modelName];
|
||||
return model.options.name.singular === model.name;
|
||||
}).forEach((modelName) => {
|
||||
const model = models[modelName];
|
||||
crud(server, model, options);
|
||||
});
|
||||
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
|
77
src/utils.js
77
src/utils.js
@ -1,11 +1,13 @@
|
||||
import { omit, identity, toNumber, isString, isUndefined } from 'lodash';
|
||||
import { omit, identity } from 'lodash';
|
||||
import { notImplemented } from 'boom';
|
||||
|
||||
const sequelizeKeys = ['include', 'order', 'limit', 'offset'];
|
||||
export const parseInclude = request => {
|
||||
const include = Array.isArray(request.query.include) ? request.query.include
|
||||
: [request.query.include];
|
||||
|
||||
const getModels = (request) => {
|
||||
const noGetDb = typeof request.getDb !== 'function';
|
||||
const noRequestModels = !request.models;
|
||||
|
||||
if (noGetDb && noRequestModels) {
|
||||
return notImplemented('`request.getDb` or `request.models` are not defined.'
|
||||
+ 'Be sure to load hapi-sequelize before hapi-sequelize-crud.');
|
||||
@ -13,26 +15,7 @@ const getModels = (request) => {
|
||||
|
||||
const { models } = noGetDb ? request : request.getDb();
|
||||
|
||||
return models;
|
||||
};
|
||||
|
||||
export const parseInclude = request => {
|
||||
const include = Array.isArray(request.query.include)
|
||||
? request.query.include
|
||||
: [request.query.include]
|
||||
;
|
||||
|
||||
const models = getModels(request);
|
||||
if (models.isBoom) return models;
|
||||
|
||||
return include.map(a => {
|
||||
const singluarOrPluralMatch = Object.keys(models).find((modelName) => {
|
||||
const { _singular, _plural } = models[modelName];
|
||||
return _singular === a || _plural === a;
|
||||
});
|
||||
|
||||
if (singluarOrPluralMatch) return models[singluarOrPluralMatch];
|
||||
|
||||
if (typeof a === 'string') return models[a];
|
||||
|
||||
if (a && typeof a.model === 'string' && a.model.length) {
|
||||
@ -44,7 +27,7 @@ export const parseInclude = request => {
|
||||
};
|
||||
|
||||
export const parseWhere = request => {
|
||||
const where = omit(request.query, sequelizeKeys);
|
||||
const where = omit(request.query, 'include');
|
||||
|
||||
for (const key of Object.keys(where)) {
|
||||
try {
|
||||
@ -57,54 +40,6 @@ export const parseWhere = request => {
|
||||
return where;
|
||||
};
|
||||
|
||||
export const parseLimitAndOffset = (request) => {
|
||||
const { limit, offset } = request.query;
|
||||
const out = {};
|
||||
if (!isUndefined(limit)) {
|
||||
out.limit = toNumber(limit);
|
||||
}
|
||||
if (!isUndefined(offset)) {
|
||||
out.offset = toNumber(offset);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const parseOrderArray = (order, models) => {
|
||||
return order.map((requestColumn) => {
|
||||
if (Array.isArray(requestColumn)) {
|
||||
return parseOrderArray(requestColumn, models);
|
||||
}
|
||||
|
||||
let column;
|
||||
try {
|
||||
column = JSON.parse(requestColumn);
|
||||
} catch (e) {
|
||||
column = requestColumn;
|
||||
}
|
||||
|
||||
if (column.model) column.model = models[column.model];
|
||||
|
||||
return column;
|
||||
});
|
||||
};
|
||||
|
||||
export const parseOrder = (request) => {
|
||||
const { order } = request.query;
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
const models = getModels(request);
|
||||
if (models.isBoom) return models;
|
||||
|
||||
// transform to an array so sequelize will escape the input for us and
|
||||
// maintain security. See http://docs.sequelizejs.com/en/latest/docs/querying/#ordering
|
||||
const requestOrderColumns = isString(order) ? [order.split(' ')] : order;
|
||||
|
||||
const parsedOrder = parseOrderArray(requestOrderColumns, models);
|
||||
|
||||
return parsedOrder;
|
||||
};
|
||||
|
||||
export const getMethod = (model, association, plural = true, method = 'get') => {
|
||||
const a = plural ? association.original.plural : association.original.singular;
|
||||
const b = plural ? association.original.singular : association.original.plural; // alternative
|
||||
|
@ -1,113 +0,0 @@
|
||||
import test from 'ava';
|
||||
import { parseLimitAndOffset, parseOrder, parseWhere } from './utils.js';
|
||||
|
||||
test.beforeEach((t) => {
|
||||
const models = t.context.models = { User: {} };
|
||||
t.context.request = { query: {}, models };
|
||||
});
|
||||
|
||||
test('parseLimitAndOffset is a function', (t) => {
|
||||
t.is(typeof parseLimitAndOffset, 'function');
|
||||
});
|
||||
|
||||
test('parseLimitAndOffset returns limit and offset', (t) => {
|
||||
const { request } = t.context;
|
||||
request.query.limit = 1;
|
||||
request.query.offset = 2;
|
||||
request.query.thing = 'hi';
|
||||
|
||||
t.is(
|
||||
parseLimitAndOffset(request).limit
|
||||
, request.query.limit
|
||||
);
|
||||
|
||||
t.is(
|
||||
parseLimitAndOffset(request).offset
|
||||
, request.query.offset
|
||||
);
|
||||
});
|
||||
|
||||
test('parseLimitAndOffset returns limit and offset as numbers', (t) => {
|
||||
const { request } = t.context;
|
||||
const limit = 1;
|
||||
const offset = 2;
|
||||
request.query.limit = `${limit}`;
|
||||
request.query.offset = `${offset}`;
|
||||
request.query.thing = 'hi';
|
||||
|
||||
t.is(
|
||||
parseLimitAndOffset(request).limit
|
||||
, limit
|
||||
);
|
||||
|
||||
t.is(
|
||||
parseLimitAndOffset(request).offset
|
||||
, offset
|
||||
);
|
||||
});
|
||||
|
||||
test('parseOrder is a function', (t) => {
|
||||
t.is(typeof parseOrder, 'function');
|
||||
});
|
||||
|
||||
test('parseOrder returns order when a string', (t) => {
|
||||
const { request } = t.context;
|
||||
const order = 'thing';
|
||||
request.query.order = order;
|
||||
request.query.thing = 'hi';
|
||||
|
||||
t.deepEqual(
|
||||
parseOrder(request)
|
||||
, [[order]]
|
||||
);
|
||||
});
|
||||
|
||||
test('parseOrder returns order when json', (t) => {
|
||||
const { request,models } = t.context;
|
||||
request.query.order = [JSON.stringify({ model: 'User' }), 'DESC'];
|
||||
request.query.thing = 'hi';
|
||||
|
||||
t.deepEqual(
|
||||
parseOrder(request)
|
||||
, [{ model: models.User }, 'DESC']
|
||||
);
|
||||
});
|
||||
|
||||
test('parseOrder returns null when not defined', (t) => {
|
||||
const { request } = t.context;
|
||||
request.query.thing = 'hi';
|
||||
|
||||
t.is(
|
||||
parseOrder(request)
|
||||
, null
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test('parseWhere is a function', (t) => {
|
||||
t.is(typeof parseWhere, 'function');
|
||||
});
|
||||
|
||||
test('parseWhere returns the non-sequelize keys', (t) => {
|
||||
const { request } = t.context;
|
||||
request.query.order = 'thing';
|
||||
request.query.include = 'User';
|
||||
request.query.limit = 2;
|
||||
request.query.thing = 'hi';
|
||||
|
||||
t.deepEqual(
|
||||
parseWhere(request)
|
||||
, { thing: 'hi' }
|
||||
);
|
||||
});
|
||||
|
||||
test('parseWhere returns json converted keys', (t) => {
|
||||
const { request } = t.context;
|
||||
request.query.order = 'hi';
|
||||
request.query.thing = '{"id": {"$in": [2, 3]}}';
|
||||
|
||||
t.deepEqual(
|
||||
parseWhere(request)
|
||||
, { thing: { id: { $in: [2, 3] } } }
|
||||
);
|
||||
});
|
18
test/fixtures/models/city.js
vendored
18
test/fixtures/models/city.js
vendored
@ -1,18 +0,0 @@
|
||||
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' },
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
39
test/fixtures/models/player.js
vendored
39
test/fixtures/models/player.js
vendored
@ -1,39 +0,0 @@
|
||||
export default (sequelize, DataTypes) => {
|
||||
return sequelize.define('Player', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
teamId: DataTypes.INTEGER,
|
||||
active: DataTypes.BOOLEAN,
|
||||
}, {
|
||||
classMethods: {
|
||||
associate: (models) => {
|
||||
models.Player.belongsTo(models.Team, {
|
||||
foreignKey: { name: 'teamId' },
|
||||
});
|
||||
},
|
||||
},
|
||||
scopes: {
|
||||
returnsOne: {
|
||||
where: {
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
returnsNone: {
|
||||
where: {
|
||||
name: 'notaname',
|
||||
},
|
||||
},
|
||||
returnsAll: {
|
||||
where: {
|
||||
name: {
|
||||
$ne: 'notaname',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
22
test/fixtures/models/team.js
vendored
22
test/fixtures/models/team.js
vendored
@ -1,22 +0,0 @@
|
||||
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' },
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
@ -1,74 +0,0 @@
|
||||
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 team2 = await Team.create({ name: 'Footballs', cityId: city1.id });
|
||||
const player1 = await Player.create({
|
||||
name: 'Cat', teamId: team1.id, active: true,
|
||||
});
|
||||
const player2 = await Player.create({ name: 'Pinot', teamId: team1.id });
|
||||
const player3 = await Player.create({ name: 'Syrah', teamId: team2.id });
|
||||
t.context.instances = { city1, team1, team2, player1, player2, player3 };
|
||||
});
|
||||
|
||||
// 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 };
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user