1
Fork 0

typescript(lunargame/api): basic jest setup

Signed-off-by: prescientmoon <git@moonythm.dev>
This commit is contained in:
Mateiadrielrafael 2019-08-11 12:14:56 +03:00 committed by prescientmoon
parent 175cea413f
commit dfab3750f9
Signed by: prescientmoon
SSH key fingerprint: SHA256:UUF9JT2s8Xfyv76b8ZuVL7XrmimH4o49p4b+iexbVH4
50 changed files with 3313 additions and 743 deletions

View file

@ -1,2 +1,4 @@
node_modules
.env
.env
test/db.sqlite
db/db.sqlite

View file

@ -0,0 +1,17 @@
// in case i want to change it
// it's alwys a pain to change it everywhere
const tableName = 'simulation'
exports.up = function up(knex) {
return knex.schema.createTable(tableName, table => {
// this is the id of the simulation
table.increments()
// this is the actual name of the simulation
table.text('name').notNull()
})
}
exports.down = function down(knex) {
return knex.schema.dropTable(tableName)
}

View file

@ -0,0 +1,9 @@
module.exports = {
roots: ['<rootDir>/src'],
transform: {
'^.+\\.tsx?$': 'ts-jest'
},
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
testEnvironment: 'node'
}

View file

@ -1,17 +1,53 @@
export const development = {
client: 'pg',
connection: {
port: '5432',
host: 'localhost',
database: 'lunarbox',
user: 'postgres',
password: 'drielrafael11'
import { iNode_env } from './src/modules/core/node_env'
import { Config } from 'knex'
import { resolve } from 'path'
// This is the name of the db file
const dbName = 'db.sqlite'
// Ive made those to prevent repetition
const dbFolder = resolve(__dirname, 'db')
const testFolder = resolve(__dirname, 'test')
// This is used in all configs
const migrations: Config['migrations'] = {
directory: resolve(dbFolder, 'migrations'),
tableName: 'migrations'
}
// This is the confg we are going to esport
// Im making a separate variable instead of
// default exporting it because i want to
// also eport each prop by name
const config: Partial<Record<iNode_env, Config>> = {
development: {
client: 'sqlite3',
connection: {
filename: resolve(dbFolder, dbName)
},
migrations,
seeds: {
directory: resolve(dbFolder, 'seeds')
}
},
migrations: {
directory: './migrations',
tablename: 'migrations'
},
seeds: {
directory: './seeds'
test: {
client: 'sqlite3',
connection: {
filename: resolve(testFolder, dbName)
},
migrations,
seeds: {
directory: resolve(testFolder, 'seeds')
}
}
}
// These are exposed to knex
const { development, test } = config
// This is the export wich should be used in th eactua app
export default config
// For migartions to work
// If i dont include this knex will throw an error
export { development, test }

View file

@ -1,24 +0,0 @@
exports.up = function up(knex) {
return knex.schema.createTable('account', table => {
table.text('uid').primary()
table.text('verificationToken').notNullable()
table.text('name').notNullable()
table.text('email').notNullable()
table
.text('description')
.defaultTo('')
.notNullable()
table
.text('avatar')
.defaultTo('')
.notNullable()
table
.boolean('verified')
.defaultTo(false)
.notNullable()
})
}
exports.down = function down(knex) {
return knex.schema.dropTable('account')
}

View file

@ -1,29 +0,0 @@
exports.up = function up(knex) {
return knex.schema.createTable('game', table => {
table.increments()
table
.text('name')
.notNull()
.defaultTo('myGame')
table
.text('avatar')
.notNull()
.defaultTo('')
table
.text('thumbail')
.notNull()
.defaultTo('')
table
.text('description')
.notNull()
.defaultTo('This is my game!')
table
.boolean('public')
.notNull()
.defaultTo(false)
})
}
exports.down = function down(knex) {
return knex.schema.dropTable('game')
}

View file

@ -1,15 +0,0 @@
exports.up = function up(knex) {
return knex.schema.createTable('user-password', table => {
table.increments()
table.text('uid').notNull()
table.text('value').notNull()
table
.boolean('secure')
.defaultTo(true)
.notNull()
})
}
exports.down = function down(knex) {
return knex.schema.dropTable('user-password')
}

File diff suppressed because it is too large Load diff

View file

@ -3,13 +3,15 @@
"version": "1.0.0",
"scripts": {
"start": "nodemon",
"reset:db": "knex migrate:rollback && knex migrate:latest && knex seed:run"
"reset:db": "knex migrate:rollback && knex migrate:latest && knex seed:run",
"test": "cross-env NODE_ENV=test && jest --runInBand"
},
"main": "index.js",
"private": true,
"devDependencies": {
"@types/bcryptjs": "^2.4.2",
"@types/dotenv": "^6.1.1",
"@types/jest": "^24.0.17",
"@types/joi": "^14.3.3",
"@types/koa": "^2.0.49",
"@types/koa-bodyparser": "^4.3.0",
@ -19,7 +21,11 @@
"@types/node": "^12.0.10",
"@types/nodemailer": "^6.2.0",
"@types/uuid": "^3.4.5",
"cross-env": "^5.2.0",
"jest": "^24.8.0",
"nodemon": "^1.19.1",
"sqlite3": "^4.0.9",
"ts-jest": "^24.0.2",
"ts-node": "^8.3.0",
"typescript": "^3.5.2"
},
@ -32,7 +38,6 @@
"joi": "^14.3.1",
"knex": "^0.18.1",
"koa": "^2.7.0",
"koa-async-validator": "^0.4.1",
"koa-bodyparser": "^4.2.1",
"koa-router": "^7.4.0",
"koa-session": "^5.12.0",

View file

@ -1,19 +0,0 @@
import * as Knex from 'knex'
export async function seed(knex: Knex): Promise<any> {
return knex('account')
.del()
.then(() => {
return knex('account').insert([
{
uid: '1',
email: 'rafaeladriel11@gmail.com',
name: 'Mock account',
description: 'just a mock account',
verificationToken: '0123456789',
avatar:
'https://cdn.vox-cdn.com/thumbor/YuWeAOQKc880Dpo1NYGS1sDBG4A=/1400x1400/filters:format(png)/cdn.vox-cdn.com/uploads/chorus_asset/file/13591799/Screen_Shot_2018_11_30_at_9.47.55_AM.png'
}
])
})
}

View file

@ -1,11 +0,0 @@
import * as Knex from 'knex'
export async function seed(knex: Knex): Promise<any> {
return knex('user-password')
.del()
.then(() => {
return knex('user-password').insert([
{ uid: 1, value: '7777', secure: false }
])
})
}

View file

@ -1,51 +0,0 @@
import * as Knex from 'knex'
export async function seed(knex: Knex): Promise<any> {
return knex('game')
.del()
.then(() => {
return knex('game').insert(
[...Array(300)]
.fill(true)
.map(() => [
{
name: 'Spacefilght Simularor',
description:
'A simulator where you build & fly rockets',
public: true,
thumbail:
'https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/fa/72/0f/fa720ff4-accb-85de-e558-71b1821399c8/source/512x512bb.jpg'
},
{
name: 'Rocket league',
description: 'Basically football - but for cars',
public: true,
thumbail:
'https://steamcdn-a.akamaihd.net/steam/apps/252950/ss_b7e945ac18d86c48b279f26ff6884b5ded2aa1b7.1920x1080.jpg?t=1561064854'
},
{
name: 'Portal',
description: 'The cake is a lie',
public: true,
thumbail:
'https://upload.wikimedia.org/wikipedia/en/thumb/9/9f/Portal_standalonebox.jpg/220px-Portal_standalonebox.jpg'
},
{
name: 'Portal 2',
description: 'The cake is still a lie',
public: true,
thumbail:
'https://steamcdn-a.akamaihd.net/steam/apps/620/ss_8a772608d29ffd56ac013d2ac7c4388b96e87a21.1920x1080.jpg?t=1512411524'
},
{
name: 'The Stanley parable',
description: 'And Stanley... was happy',
public: true,
thumbail:
'https://steamcdn-a.akamaihd.net/steam/apps/221910/ss_49e682563292992309e3047f30128f3dba4c39ce.1920x1080.jpg?t=1465254276'
}
])
.flat()
)
})
}

View file

@ -0,0 +1,22 @@
import { randomElement } from './randomElement'
describe('The randomElement function', () => {
test('should return the only element in an array of length 1', () => {
const element = 7
expect(randomElement([element])).toBe(element)
})
test('should throw an error when passing an empty array', () => {
let error: Error | undefined
try {
randomElement([])
} catch (catchedError) {
//
error = catchedError
}
expect(error).toBeTruthy()
})
})

View file

@ -0,0 +1,13 @@
/**
* Returns a random element from an array
*
* @param arr The array to select the element from
* @throws Error if the array has length 0
*/
export const randomElement = <T>(arr: T[]): T => {
if (!arr.length) {
throw new Error('Cannot choose a random element from array of length 0')
}
return arr[Math.floor(arr.length * Math.random())]
}

View file

@ -1,29 +0,0 @@
import Joi from 'joi'
export const token = Joi.string().required()
export const field = token.alphanum()
// not merging them cause i'll add more to the password in the future
export const name = field.max(30).min(3)
export const password = field.min(3).max(30)
export const email = Joi.string()
.required()
.email()
.min(3)
.max(30)
export const hasName = Joi.object({ name })
export const hasEmail = Joi.object({ email })
export const hasVerificationToken = Joi.object({ token })
export const loginSchema = Joi.object({
email,
password
})
export const createUserSchema = Joi.object({
name,
email,
password
})

View file

@ -1,15 +0,0 @@
import { Account } from './types/Account'
export type AccountField = keyof Account
export const publicAccountFields: AccountField[] = [
'name',
'email',
'description',
'avatar'
]
export const privateAccountFields: AccountField[] = [
...publicAccountFields,
'verified',
'uid'
]

View file

@ -1,17 +0,0 @@
import { privateAccountFields } from '../constants'
import { Account } from '../types/Account'
export const filterPrivateAccountData = <T>(account: Account & T) => {
const result: Record<string, unknown> = {}
for (const key of Object.keys(account)) {
// for ts to shut up
const typedKey = (key as unknown) as keyof Account
if (privateAccountFields.includes(typedKey)) {
result[typedKey] = account[typedKey]
}
}
return result
}

View file

@ -1,17 +0,0 @@
import { Account } from '../types/Account'
import { Password } from '../types/Password'
import { compare } from 'bcryptjs'
export const checkPassword = async (
account: Account & Password,
password: string
) => {
if (
(account.secure && (await compare(password, account.value))) || // prod
(!account.secure && account.value === password) // dev
) {
return true
}
return false
}

View file

@ -1,8 +0,0 @@
import { genSalt, hash } from 'bcryptjs'
export const encryptPassword = async (saltRounds = 10, password: string) => {
const salt = await genSalt(saltRounds)
const passwordHash = await hash(password, salt)
return passwordHash
}

View file

@ -1,6 +0,0 @@
export const subject = 'Lunarbox verification'
export const text = (token: string, name: string) => `
Hey ${name}! Welcome to lunarbox! To verify your email, click the link bellow: ${
process.env.SERVER_URL
}/account/verify/${token}
`

View file

@ -1,9 +0,0 @@
import { Middleware } from 'koa'
import { HttpError } from '../../network/classes/HttpError'
export const isGuest = (): Middleware => (context, next) => {
if (context.session.uid === null || context.session.uid === undefined)
return next()
throw new HttpError(400, 'Logged in.')
}

View file

@ -1,9 +0,0 @@
import { Middleware } from 'koa'
import { HttpError } from '../../network/classes/HttpError'
export const notGuest = (): Middleware => (context, next) => {
if (context.session.uid === null || context.session.uid === undefined)
throw new HttpError(401)
return next()
}

View file

@ -1,13 +0,0 @@
import { Middleware } from 'koa'
import { emailIsTaken } from '../queries/emailIsTaken'
import { HttpError } from '../../network/classes/HttpError'
export const uniqueEmail = (): Middleware => async (context, next) => {
const { email } = context.request.body
if (!(await emailIsTaken(email))) {
return next()
}
throw new HttpError(400, 'Email is already in use.')
}

View file

@ -1,25 +0,0 @@
import { Middleware } from 'koa'
import { HttpError } from '../../network/classes/HttpError'
import { mode } from '../../core/constants'
const strongRegex = new RegExp(
'^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})'
)
export const validatePassword = (prouctondOnly = true): Middleware => (
context,
next
) => {
const password = context.request.body.password
if (!password) {
throw new HttpError(400, 'No password recived.')
} else if (
(mode === 'production' || !prouctondOnly) &&
!strongRegex.test(context.request.body.password)
) {
throw new HttpError(400, 'Bad password.')
} else {
return next()
}
}

View file

@ -1,18 +0,0 @@
import { DbManager } from '../../db/classes/DbManager'
import { Password } from '../types/Password'
const { connection } = new DbManager()
export const createPassword = (
password: string,
uid: string
): Promise<Password> => {
return connection
.from('user-password')
.insert<Password>({
secure: true,
uid,
value: password
})
.returning('*')
}

View file

@ -1,29 +0,0 @@
import { Account } from '../types/Account'
import { DbManager } from '../../db/classes/DbManager'
const { connection } = new DbManager()
const defaultAvatar =
'https://themango.co/wp-content/uploads/2018/03/Mango-Default-Profile-Pic.png'
export const createUser = async (
name: string,
email: string,
uid: string,
token: string
) => {
const data: Account = {
name,
email,
uid,
avatar: defaultAvatar,
description: '',
verified: false,
verificationToken: token
}
return connection
.from('account')
.insert(data)
.returning('*')
}

View file

@ -1,11 +0,0 @@
import { DbManager } from '../../db/classes/DbManager'
const { connection } = new DbManager()
export const emailIsTaken = async (email: string) => {
return await connection
.from('account')
.select('email')
.where('email', email)
.first()
}

View file

@ -1,17 +0,0 @@
import { Account } from '../types/Account'
import { Password } from '../types/Password'
import { DbManager } from '../../db/classes/DbManager'
import { publicAccountFields, AccountField } from '../constants'
const db = new DbManager()
export function getUserWithPassword(
field: string,
value: string
): Promise<Account & Password | null> {
return db.connection
.from('account')
.innerJoin('user-password', 'account.uid', 'user-password.uid')
.where(`account.${field}`, value)
.first()
}

View file

@ -1,12 +0,0 @@
import { DbManager } from '../../db/classes/DbManager'
import { publicAccountFields } from '../constants'
const { connection } = new DbManager()
export const getPublicAccountData = (field: string, value: string) => {
return connection
.from('account')
.select(...publicAccountFields)
.where(field, value)
.first()
}

View file

@ -1,12 +0,0 @@
import { Account } from '../types/Account'
import { DbManager } from '../../db/classes/DbManager'
const db = new DbManager()
export function getUserByUid(uid: string): Promise<Account | null> {
return db.connection
.from('account')
.select('*')
.where('uid', uid)
.first()
}

View file

@ -1,11 +0,0 @@
import { DbManager } from '../../db/classes/DbManager'
const { connection } = new DbManager()
export const verifyAccount = (token: string) => {
return connection
.from('account')
.update('verified', true)
.where('verificationToken', token)
.returning('verified')
}

View file

@ -1,59 +0,0 @@
import { getUserByUid } from '../queries/getUserById'
import Router from 'koa-router'
import { HttpError } from '../../network/classes/HttpError'
import { notGuest } from '../middleware/notGuest'
import { verifyAccount } from '../queries/verifyEmail'
import { mode } from '../../core/constants'
import { filterPrivateAccountData } from '../helpers/accountDataFilters'
import { validate } from '../../../common/validation/middleware/validate'
import { hasVerificationToken } from '../authSchemas'
const router = new Router()
router.get('/uid', notGuest(), async (context, next) => {
const uid: string = context.session.uid
context.body = {
uid
}
next()
})
router.delete('/uid', (context, next) => {
context.session.uid = undefined
context.body = {
succes: true
}
next()
})
router.get('/', notGuest(), async (context, next) => {
const uid: string = context.session.uid
const account = await getUserByUid(uid)
if (!account) throw new HttpError(404)
context.body = {
data: filterPrivateAccountData(account)
}
next()
})
router.get(
'/verify/:token',
validate(hasVerificationToken, 'params'),
async (context, next) => {
const token = context.params.token
await verifyAccount(token)
context.body = `Succesfully verified account!`
return next()
}
)
export { router }

View file

@ -1,75 +0,0 @@
import uuid from 'uuid/v4'
import Router from 'koa-router'
import { getUserWithPassword } from '../queries/getAccountWithPassword'
import { checkPassword } from '../helpers/checkPassword'
import { HttpError } from '../../network/classes/HttpError'
import { isGuest } from '../middleware/isGuest'
import { createPassword } from '../queries/createPassword'
import { createUser } from '../queries/createUser'
import { encryptPassword } from '../helpers/encryptPassword'
import { uniqueEmail } from '../middleware/uniqueEmail'
import { EmailManager } from '../../network/classes/EmailManager'
import { subject, text } from '../helpers/verificationEmail'
import { validate } from '../../../common/validation/middleware/validate'
import { createUserSchema, hasEmail, loginSchema } from '../authSchemas'
import { filterPrivateAccountData } from '../helpers/accountDataFilters'
const router = new Router()
const emailManager = new EmailManager()
router.post(
'/login',
isGuest(),
validate(loginSchema, 'body'),
async (context, next) => {
const account = await getUserWithPassword(
'email',
context.request.body.email
)
const password: string = context.request.body.password
if (!account) {
throw new HttpError(400, "Account does't exist")
} else if (!(await checkPassword(account, password))) {
throw new HttpError(400, 'Wrong password')
}
context.session.uid = account.uid
context.body = {
data: filterPrivateAccountData(account)
}
return next()
}
)
router.post(
'/create',
isGuest(),
validate(createUserSchema, 'body'),
uniqueEmail(),
async (context, next) => {
const { email, name, password } = context.request.body
const hash = await encryptPassword(10, password)
const uid = uuid()
const token = uuid()
const [, account] = await Promise.all([
createPassword(hash, uid),
createUser(name, email, uid, token),
emailManager.send(email, subject, text(token, name))
])
context.session.uid = uid
context.body = {
data: filterPrivateAccountData(account[0])
}
return next()
}
)
export { router }

View file

@ -1,26 +0,0 @@
import Router from 'koa-router'
import { hasName } from '../authSchemas'
import { validate } from '../../../common/validation/middleware/validate'
import { getPublicAccountData } from '../queries/getPublicAccountData'
import { HttpError } from '../../network/classes/HttpError'
const router = new Router()
router.get(
'/name/:name',
validate(hasName, 'params'),
async (context, next) => {
const name: string = context.params.name
const account = await getPublicAccountData('name', name)
if (!account) throw new HttpError(404, `User ${name} does not exist.`)
context.body = {
data: account
}
return next()
}
)
export { router }

View file

@ -1,9 +0,0 @@
export interface Account {
name: string
email: string
avatar: string
description: string
uid: string
verified: boolean
verificationToken: string
}

View file

@ -1,5 +0,0 @@
export interface Password {
uid: string
value: string
secure: boolean
}

View file

@ -1,4 +0,0 @@
type processMode = 'development' | 'production' | 'test'
export const mode: processMode =
(process.env.NODE_ENV as processMode) || 'development'

View file

@ -0,0 +1,8 @@
// this is the type wich the node_env constant can take
export type iNode_env = 'development' | 'production' | 'test'
/**
* Type safe version of process.env.NODE_ENV
*/
export const node_env: iNode_env =
(process.env.NODE_ENV as iNode_env) || 'development'

View file

@ -1,14 +1,7 @@
import { router as accountRouter } from '../auth/routes/account'
import { router as authRouter } from '../auth/routes/auth'
import { router as userRouter } from '../auth/routes/user'
import { router as gameRouter } from '../game/routes/game'
import Router from 'koa-router'
const router = new Router()
router.use('/account', accountRouter.middleware())
router.use('/auth', authRouter.middleware())
router.use('/user', userRouter.middleware())
router.use('/game', gameRouter.middleware())
router.use('/game')
export { router }

View file

@ -1,9 +0,0 @@
import { Singleton } from '@eix/utils'
import * as config from '../../../../knexfile'
import knex from 'knex'
@Singleton
export class DbManager {
public mode: 'development' = 'development'
public connection = knex(config[this.mode])
}

View file

@ -0,0 +1,6 @@
import config from '../../../knexfile'
import knex, { Config } from 'knex'
import { node_env } from '../core/node_env'
// TODO: remove the as Config after finshnig the knexfile
export const connection = knex(config[node_env] as Config)

View file

@ -1,13 +0,0 @@
import Joi from 'joi'
export const pageSize = Joi.number()
.required()
.max(50)
.min(3)
export const page = Joi.number().required()
export const chunkSchema = Joi.object({
pageSize,
page
})

View file

@ -1,13 +0,0 @@
import { DbManager } from '../../db/classes/DbManager'
const { connection } = new DbManager()
export const getGameChunk = (page: number, pageSize: number) => {
const offset = page * pageSize
return connection
.from('game')
.select('id', 'avatar', 'thumbail')
.offset(offset)
.limit(pageSize)
}

View file

@ -1,13 +0,0 @@
import { DbManager } from '../../db/classes/DbManager'
import { CountData } from '../../../common/rest/types/CountData'
const { connection } = new DbManager()
export const getGameCount = async () => {
const { count } = await connection
.from('game')
.count('id')
.first<CountData>()
return Number(count)
}

View file

@ -1,29 +0,0 @@
import Router from 'koa-router'
import { validate } from '../../../common/validation/middleware/validate'
import { getGameCount } from '../queries/getGameCount'
import { chunkSchema } from '../gameSchemas'
import { getGameChunk } from '../queries/getGameChunk'
const router = new Router()
router.get('/count', async (context, next) => {
const result = await getGameCount()
context.body = {
data: result
}
return next()
})
router.get('/chunk', validate(chunkSchema, 'query'), async (context, next) => {
const { page, pageSize } = context.request.query
context.body = {
data: await getGameChunk(page, pageSize)
}
return next()
})
export { router }

View file

@ -1,24 +0,0 @@
import { Singleton } from '@eix/utils'
import { config } from 'dotenv'
import sendgrid from '@sendgrid/mail'
config()
@Singleton
export class EmailManager {
private email_adress = process.env.EMAIL_ADRESS
private key = process.env.SENDGRID_API_KEY
constructor() {
sendgrid.setApiKey(this.key)
}
public send(to: string, subject: string, text: string) {
return sendgrid.send({
from: this.email_adress,
to,
subject,
text
})
}
}

View file

@ -0,0 +1,29 @@
import { HttpError, HTTP_REASONS, HttpStatus, httpSymbol } from './HttpError'
describe('The HttpError class', () => {
test('should allow passing a custom message', () => {
const status = Math.random()
const reason = 'testing'
const error = new HttpError(status, reason)
expect(error.toString()).toBe(`HttpError: ${status} - ${reason}`)
})
test('should use the default reason for the status when passing no second arg', () => {
// ts will always consider it a string
for (let untypedStatus in HTTP_REASONS) {
// this forces ts to belive its an actual status
const status = (untypedStatus as unknown) as HttpStatus
const error = new HttpError(status)
expect(error.reason).toBe(HTTP_REASONS[status])
}
})
test('should always have the http error symbol set to true', () => {
const error = new HttpError()
expect(error[httpSymbol]).toBe(true)
})
})

View file

@ -1,6 +1,11 @@
import { Middleware } from 'koa'
import { httpSymbol } from '../../network/classes/HttpError'
/**
* Midlware for error handling
*
* Not testing it because its made by Enitoni
*/
export const handleError = (): Middleware => async (context, next) => {
try {
await next()

View file

@ -1,13 +1,18 @@
import Koa, { Middleware } from 'koa'
import session from 'koa-session'
import knexSessionStore from 'koa-session-knex-store'
import { DbManager } from '../../db/classes/DbManager'
import { connection } from '../../db/connection'
const { connection } = new DbManager()
// The store sessions are saved to
const store = knexSessionStore(connection, {
createtable: true
})
/**
* Middleware factory for handling sessions
*
* @param app The app to handle sessions for
*/
export const handleSessions = (app: Koa): Middleware =>
session(
{

View file

@ -1,15 +0,0 @@
import { Middleware } from 'koa'
import { HttpError } from '../classes/HttpError'
export const hasFields = (
...fields: string[]
): Middleware<{ field: string }> => async (context, next) => {
for (const value of fields) {
if (context.request.body[value]) {
context.state.field = value
return next()
}
}
throw new HttpError(400, `None of the fields ${fields.join(' ')} included.`)
}