TDD course with AdonisJs - 6. Validation

Published 9/28/2019

Currently it is possible to create a thread without a body or title. So let's add validation to our controller.

You can find all the changes in this commit: https://github.com/MZanggl/tdd-adonisjs/commit/5e1e4cb1c4f78ffc947cdeec00609f4dfc4648ba

As always, let's create the test first.

test('can not create thread with no body', async ({ client }) => {
  const user = await Factory.model('App/Models/User').create()
  const response = await client.post('/threads').loginVia(user).send({ title: 'test title' }).end()
  response.assertStatus(400)
})

And the response we get is:

  1. can not create thread with no body
  expected 200 to equal 400
  200 => 400

Let's make the test pass by adding validation. Before we go into creating a custom validation though, let's first apply the easiest, simplest and fastest solution we can possibly think of. Adding the validation manually in the ThreadController. Put this at the top of the store method.

if (!request.input('body')) {
   return response.badRequest()
}

And it passes!

Let's add the same thing for the title, we can do this in the same test even. It will look like this

test('can not create thread with no body or title', async ({ client }) => {
  const user = await Factory.model('App/Models/User').create()
  let response = await client.post('/threads').loginVia(user).send({ title: 'test title' }).end()
  response.assertStatus(400)

  response = await client.post('/threads').loginVia(user).send({ body: 'test body' }).end()
  response.assertStatus(400)
})

Because we only added validation for the 'body' field, it will fail with the same error as before, so let's also add validation for the title field as well.

if (!request.input('body') || !request.input('title')) {
  return response.badRequest()
}

And that makes the tests pass!

Check out my e-book!

Learn to simplify day-to-day code and the balance between over- and under-engineering.

Refactor

Let's try using Adonis' validation methods instead of the custom validation we have right now.

First, import the validator at the top of the ThreadController.

const { validate } = use('Validator')

Now, replace the custom validation with

const rules = { title: 'required', body: 'required' }
const validation = await validate(request.all(), rules)
if (validation.fails()) {
  return response.badRequest()
}

Running this will fail, if you console.log response.error in the tests, it will tell us that we haven't installed the validation dependency yet.

So let's do this by running the command

adonis install @adonisjs/validator

Next, go to start/app.js and add the validator to the providers array.

const providers = [
  // ...
  '@adonisjs/validator/providers/ValidatorProvider'
]

And the tests pass. Finally let's take all this logic and put it in a separate file. First, let's make a validator file by running the following command:

adonis make:validator StoreThread

Next let's copy the rules from the ThreadController to the StoreThread.js file.

get rules () {
    return {
      title: 'required', 
      body: 'required'
    }
  }

And the way we can apply the validator is by adding it to "start/routes.js".

// start/routes.js

Route.resource('threads', 'ThreadController').only(['store', 'destroy', 'update'])
    .middleware(new Map([
        [['store', 'destroy', 'update'], ['auth']],
        [['destroy', 'update'], ['modifyThreadPolicy']]
    ]))
    .validator(new Map([
        [['store'], ['StoreThread']],
    ]))

Let's refactor this later, it is becoming very complex...

Let's remove all the validation we had in the ThreadController. Then try running the tests again, still green!

Btw. we didn't add a unit test to the validator because that part is already tested by adonis, once we have a custom validator we would need to test it though.

Now that we have proper validation, we can also test the validation message it returns in our tests

  response.assertJSONSubset([{ message: 'required validation failed on body' }])

However, this fails with the error expected {} to contain subset [ Array(1) ].

Taking a look at the documentation, AdonisJs' validator respects the 'accept' header and just doesn't return JSON by default. Let's fix this by adding the "accept JSON" header to our test.

await client.post('/threads').header('accept', 'application/json')...

Do this for both of the API requests in our test.


Resource routes provided us a benefit in the beginning but with middleware and validators added, it now looks more complicated than it needs to be.

routes.js

Route.resource('threads', 'ThreadController').only(['store', 'destroy', 'update'])
    .middleware(new Map([
        [['store', 'destroy', 'update'], ['auth']],
        [['destroy', 'update'], ['modifyThreadPolicy']]
    ]))
    .validator(new Map([
        [['store'], ['StoreThread']],
    ]))

Let's simplify it again:

Route.group(() => {
    Route.post('', 'ThreadController.store').middleware('auth').validator('StoreThread')
    Route.put(':id', 'ThreadController.update').middleware('auth', 'modifyThreadPolicy')
    Route.delete(':id', 'ThreadController.destroy').middleware('auth', 'modifyThreadPolicy')
}).prefix('threads')

Thanks to the "luxury" of having tests, we can change things around the way we want and don't have to worry about breaking things! See for yourself and run the tests.


Let's also add the validation to updating threads:

test('can not update thread with no body or title', async ({ client }) => {
  const thread = await Factory.model('App/Models/Thread').create()
  const user = await thread.user().first()
  const put = () => client.put(thread.url()).header('accept', 'application/json').loginVia(user)

  let response = await put().send({ title: 'test title' }).end()
  response.assertStatus(400)
  response.assertJSONSubset([{ message: 'required validation failed on body' }])

  response = await put().send({ body: 'test body' }).end()
  response.assertStatus(400)
  response.assertJSONSubset([{ message: 'required validation failed on title' }])
})

This will fail, so let's also add the validator to the routes.js:

Route.put(':id', 'ThreadController.update').middleware('auth', 'modifyThreadPolicy').validator('StoreThread')

To complete all routes for our cruddy controller, let's add tests for fetching threads real quick.

test('can access single resource', async ({ client }) => {
  const thread = await Factory.model('App/Models/Thread').create()
  const response = await client.get(thread.url()).send().end()
  response.assertStatus(200)
  response.assertJSON({ thread: thread.toJSON() })
})

test('can access all resources', async ({ client }) => {
  const threads = await Factory.model('App/Models/Thread').createMany(3)
  const response = await client.get('threads').send().end()
  response.assertStatus(200)
  response.assertJSON({ threads: threads.map(thread => thread.toJSON()).sort((a, b) => a.id - b.id) })
})

The first test fetches a single thread, while the second one fetches all threads.

Note: if you are confused about the "sort" method: "Factory.createMany" currently creates the records in random order, and since we assign an autoincrement we have to sort the threads again to fix their order.

Here are the routes we have to add in "start/routes.js":

Route.get('', 'ThreadController.index')
Route.get(':id', 'ThreadController.show')

and the methods in "ThreadController":

    async index({ response }) {
        const threads = await Thread.all()
        return response.json({ threads })
    }

    async show({ params, response }) {
        const thread = await Thread.findOrFail(params.id)
        return response.json({ thread })
    }

And that's it. Next time we will revisit the existing authorization tests and add the possibility for moderators to modify and delete threads!