cult3

Confirming Users with Trailblazer

Jun 22, 2016

Table of contents:

  1. Adding the Routes
  2. Adding the Controller and the View
  3. Defining the Contract
  4. Creating the Operation
  5. Building the View
  6. Adding some Controller tests
  7. Conclusion

Over the last couple of weeks we’ve started building a registration process using Ruby on Rails and Trailblazer.

In Using Inheritance for Trailblazer Operations we created the functionality to create new users and import users from an existing application.

When a new user signs up to the application they will need to click on a link that will be emailed to them in order to confirm their email address. We implemented this functionality in Building out a User Confirmation flow in Trailblazer.

However, we also need to confirm the imported users and give them an opportunity to pick a username and set a password.

This presents an awkward situation where we need to accept data based upon the state of the user. Normally this would require messy if statements in the Controller and the View.

Fortunately, thanks to Trailblazer’s abstraction, this should be pretty easy to deal with in this application!

In today’s tutorial we will be taking a look at how to build this functionality using Trailblazer and Ruby on Rails. If you missed the previous tutorials, you should probably take a look at those to set the context for this tutorial.

Adding the Routes

The first thing I’m going to do is to define the routes:

get 'confirmation/confirm', to: 'confirmation/confirm#new'
post 'confirmation/confirm', to: 'confirmation/confirm#create'

Firstly, I need a GET request route that will display the form when the user clicks on their confirmation link from the email I send them.

Secondly, I will need a POST route that will accept the form request. In the case of normal users, this will just consist of the user’s confirmation token, but in the case of imported users this will also include their chosen username and password.

Adding the Controller and the View

The next thing we need to do is to add the Controller for accepting the requests:

module Confirmation
  class ConfirmController < ApplicationController
    def new
      form Confirmation::Confirm::Operation::Base
    end

    def create
      run Confirmation::Confirm::Operation::Base do |op|
        return redirect_to login_url
      end

      render :new, status: 400
    end
  end
end

As you can see, I’m namespacing this Controller under Confirmation as I did with in last week’s tutorial. This Controller is basically the same as all of the Controllers we’ve looked at so far in this series.

The one thing to note is that I’m using Confirmation::Confirm::Operation::Base as the Operation. This is because I need to return the correct Operation class depending on the user who the confirmation token belongs to.

I’m also not sure I like these long ass namespaces, so that might change in the future.

We will also need a View for the #new method to keep Rails happy:

h1 Confirm your account = concept("confirmation/confirm/cell", @operation)

As you can see, we’re going to be using a Cell as we did last week. We’ll be creating the Cell later in this tutorial.

Defining the Contract

Next up we need to define the Contract for the two Operations, one for imported users, and one for the default users:

module Confirmation::Confirm::Contract
  class Base < Reform::Form
    model User
    property :token, from: :confirmation_token
  end

  class Imported < Base
    property :username
    property :password, virtual: true

    validates :username, presence: true, username: true
    validates :password, presence: true, length: { minimum: 8 }
    validate :username_is_unique

    def username_is_unique
      errors.add(:username, :taken) if User.find_by(username: username)
    end
  end

  class Default < Base
  end
end

First I’m going to define the Base class that will act as an abstract class for both child classes. This class simply defines the token property so I can use it later.

In the Imported class we can define the username and password properties as well as the validation rules that should apply.

One thing to note is that I had to add a custom method to check for uniqueness of the username. However, this is a good example of how easy it is to define your own validation rules by simply defining a method on the Contract.

Finally, the Default Contract does not need any additional parameters or validations and so it can simply extend from the Base Contract class.

Creating the Operation

With the Contracts in place, we can now turn our attention to the Operation classes. Create a new file called operation.rb in the relevant concept directory:

module Confirmation::Confirm::Operation
  class Base < Trailblazer::Operation
  end

  class Imported < Base
  end

  class Default < Base
  end
end

As we saw in the Controller, we’re going to be using the Base class as the interface to the two underlying Operation implementations. The Base class needs to be able to delegate based upon whether the token belongs to an imported user or a default user.

Here is what the Base class looks like:

class Base < Trailblazer::Operation
  include Resolver
  include Model
  model User

  builds ->(model, policy, params) {
           return Confirmation::Confirm::Operation::Imported if model.imported?
           return Confirmation::Confirm::Operation::Default
         }

  def self.model!(params)
    User.find_by!(confirmation_token: params[:token], confirmed_at: nil)
  end

  def confirm
    model.confirmed_at = Time.now
  end
end

First I override the self.model! class method to find the user by the given token. If the user is not found an Exception will be thrown to halt the process. This sets the model on the Operation.

The next thing to note is the builds block that will return the correct implementation based upon if the model is imported or not.

Finally I’m including a confirm method that will set the confirmed_at timestamp on the model because I’m going to need it in both of the child classes.

Next up I will add the Imported class:

class Imported < Base
  contract Confirmation::Confirm::Contract::Imported

  def process(params)
    validate(params[:user]) do |f|
      generate_digest
      confirm
      f.save
    end
  end

  def generate_digest
    model.password_digest = BCrypt::Password.create(contract.password)
  end
end

This is pretty much a standard Operation that we’ve seen a couple of times now so there isn’t really much to explain.

First I set the contract that we defined earlier.

Next in the process method we first validate the incoming parameters. If the validation passes we generate the password digest, confirm the user and then call save.

Here is the Default class:

class Default < Base
  contract Confirmation::Confirm::Contract::Default

  def process(params)
    validate(params) do |f|
      confirm
      f.save
    end
  end
end

Again there is even less to explain here. First I set the contract from earlier again. Next inside the process method I confirm the user and then call save.

Here is this file in full:

require 'bcrypt'

module Confirmation::Confirm::Operation
  class Base < Trailblazer::Operation
    include Resolver
    include Model
    model User

    builds ->(model, policy, params) {
             if model.imported?
               return Confirmation::Confirm::Operation::Imported
             end
             return Confirmation::Confirm::Operation::Default
           }

    def self.model!(params)
      User.find_by!(confirmation_token: params[:token], confirmed_at: nil)
    end

    def confirm
      model.confirmed_at = Time.now
    end
  end

  class Imported < Base
    contract Confirmation::Confirm::Contract::Imported

    def process(params)
      validate(params[:user]) do |f|
        generate_digest
        confirm
        f.save
      end
    end

    def generate_digest
      model.password_digest = BCrypt::Password.create(contract.password)
    end
  end

  class Default < Base
    contract Confirmation::Confirm::Contract::Default

    def process(params)
      validate(params) do |f|
        confirm
        f.save
      end
    end
  end
end

With the Operation classes in place, now I’ll write some tests to make sure everything is working as it should be:

require 'test_helper'

module Confirmation::Confirm::OperationTest
  class TestCase < ActiveSupport::TestCase
    def setup
      @default =
        User::Create::Operation::Default.(user: attributes_for(:user)).model
      @imported =
        User::Create::Operation::Imported.(user: attributes_for(:imported_user))
          .model
    end
  end
end

First I’m going to define a TestCase class so I can reuse the same setup method in each of my implementation test classes.

Here I’m simply creating a default user and an imported user using the correct Operation implementation for each as a factory.

I’m not sure if this a best practice or not, but it works pretty well.

Next up I will write a couple of tests for the Base class:

class BaseTest < TestCase
  test 'throw exception on invalid token' do
    assert_raises ActiveRecord::RecordNotFound do
      Confirmation::Confirm::Operation::Base.run(token: 'abc')
    end
  end

  test 'build Imported for imported user' do
    op =
      Confirmation::Confirm::Operation::Base.present(
        token: @imported.confirmation_token
      )

    assert_instance_of(Confirmation::Confirm::Operation::Imported, op)
  end

  test 'build Default for default user' do
    op =
      Confirmation::Confirm::Operation::Base.present(
        token: @default.confirmation_token
      )

    assert_instance_of(Confirmation::Confirm::Operation::Default, op)
  end
end

First I make sure a ActiveRecord::RecordNotFound Exception is thrown if the confirmation token does not exist. Next I assert that the Base class instantiates the correct child class given a user token.

Next up I will write some tests for the Imported class:

class ImportedTest < TestCase
  test 'require presence of username and password' do
    res, op =
      Confirmation::Confirm::Operation::Imported.run(
        token: @imported.confirmation_token,
        user: {}
      )

    assert_not(res)
    assert_includes(op.errors[:username], "can't be blank")
    assert_includes(op.errors[:password], "can't be blank")
  end

  test 'username should be a valid username' do
    res, op =
      Confirmation::Confirm::Operation::Imported.run(
        token: @imported.confirmation_token,
        user: {
          username: 'invalid username'
        }
      )

    assert_not(res)
    assert_includes(op.errors[:username], 'is invalid')
  end

  test 'username should be unique' do
    res, op =
      Confirmation::Confirm::Operation::Imported.run(
        token: @imported.confirmation_token,
        user: {
          username: @default.username
        }
      )

    assert_not(res)
    assert_includes(op.errors[:username], 'has already been taken')
  end

  test 'password should be greater than 8 characters' do
    res, op =
      Confirmation::Confirm::Operation::Imported.run(
        token: @imported.confirmation_token,
        user: {
          password: 'abc'
        }
      )

    assert_not(res)
    assert_includes(
      op.errors[:password],
      'is too short (minimum is 8 characters)'
    )
  end

  test 'confirm user' do
    res, op =
      Confirmation::Confirm::Operation::Imported.run(
        token: @imported.confirmation_token,
        user: {
          username: 'username',
          password: 'password'
        }
      )

    assert(res)
    assert(op.model.confirmed?)
  end
end

If you have been following along with this series these tests should be fairly familiar to you now.

Finally I will write a quick test for the Default class too:

class DefaultTest < TestCase
  test 'confirm user' do
    res, op =
      Confirmation::Confirm::Operation::Default.run(
        token: @default.confirmation_token
      )

    assert(res)
    assert(op.model.confirmed?)
  end
end

Here is that test file in full:

require 'test_helper'

module Confirmation::Confirm::OperationTest
  class TestCase < ActiveSupport::TestCase
    def setup
      @default =
        User::Create::Operation::Default.(user: attributes_for(:user)).model
      @imported =
        User::Create::Operation::Imported.(user: attributes_for(:imported_user))
          .model
    end
  end

  class BaseTest < TestCase
    test 'throw exception on invalid token' do
      assert_raises ActiveRecord::RecordNotFound do
        Confirmation::Confirm::Operation::Base.run(token: 'abc')
      end
    end

    test 'build Imported for imported user' do
      op =
        Confirmation::Confirm::Operation::Base.present(
          token: @imported.confirmation_token
        )

      assert_instance_of(Confirmation::Confirm::Operation::Imported, op)
    end

    test 'build Default for default user' do
      op =
        Confirmation::Confirm::Operation::Base.present(
          token: @default.confirmation_token
        )

      assert_instance_of(Confirmation::Confirm::Operation::Default, op)
    end
  end

  class ImportedTest < TestCase
    test 'require presence of username and password' do
      res, op =
        Confirmation::Confirm::Operation::Imported.run(
          token: @imported.confirmation_token,
          user: {}
        )

      assert_not(res)
      assert_includes(op.errors[:username], "can't be blank")
      assert_includes(op.errors[:password], "can't be blank")
    end

    test 'username should be a valid username' do
      res, op =
        Confirmation::Confirm::Operation::Imported.run(
          token: @imported.confirmation_token,
          user: {
            username: 'invalid username'
          }
        )

      assert_not(res)
      assert_includes(op.errors[:username], 'is invalid')
    end

    test 'username should be unique' do
      res, op =
        Confirmation::Confirm::Operation::Imported.run(
          token: @imported.confirmation_token,
          user: {
            username: @default.username
          }
        )

      assert_not(res)
      assert_includes(op.errors[:username], 'has already been taken')
    end

    test 'password should be greater than 8 characters' do
      res, op =
        Confirmation::Confirm::Operation::Imported.run(
          token: @imported.confirmation_token,
          user: {
            password: 'abc'
          }
        )

      assert_not(res)
      assert_includes(
        op.errors[:password],
        'is too short (minimum is 8 characters)'
      )
    end

    test 'confirm user' do
      res, op =
        Confirmation::Confirm::Operation::Imported.run(
          token: @imported.confirmation_token,
          user: {
            username: 'username',
            password: 'password'
          }
        )

      assert(res)
      assert(op.model.confirmed?)
    end
  end

  class DefaultTest < TestCase
    test 'confirm user' do
      res, op =
        Confirmation::Confirm::Operation::Default.run(
          token: @default.confirmation_token
        )

      assert(res)
      assert(op.model.confirmed?)
    end
  end
end

Building the View

The final piece of this puzzle is to display the correct form based upon whether the user has been imported or not. Normally this would require a messy if statement in the View, but we’re going to be using Cells so we don’t have to deal with this nastiness.

Create a new file called cell.rb in the concept directory:

module Confirmation::Confirm
  class Cell < Trailblazer::Cell
    builds { |op, options| op.model.imported? ? Imported : Default }

    class Imported < Culttt::Cells::Form
      def show
        render
      end
    end

    class Default < Culttt::Cells::Form
      def show
        render
      end
    end
  end
end

Here again we use a similar technique as to what we used in the Operation to “build” the correct implementation given the state of the user.

Both of the components are going to be forms so we the Cells implementations look pretty similar to the code from last week other than the higher level Cell that does the building.

Here are the actual Slim templates we’re going to need. First up we have the imported user form:

- if form.errors.any? ul - form.errors.full_messages.each do |msg| li = msg =
form_for form, html: {class: "imported-confirmation-form"}, url:
confirmation_confirm_url, method: :post do |f| div = f.label :username, class:
"qa-username-label" = f.text_field :username, class: "qa-username-input" div =
f.label :password, class: "qa-password-label" = f.password_field :password,
class: "qa-password-input" div = hidden_field_tag :token, form.token, class:
"qa-token-input" div = f.submit "Confirm", class: "qa-submit"

As you can see, this form contains input elements for the username and password. I’ve also included the token as a hidden input field.

The default user form is pretty much the same but without the extra form elements:

- if form.errors.any? ul - form.errors.full_messages.each do |msg| li = msg =
form_for form, html: {class: "default-confirmation-form"}, url:
confirmation_confirm_url, method: :post do |f| div = hidden_field_tag :token,
form.token, class: "qa-token-input" div = f.submit "Confirm", class: "qa-submit"

With the Cell in place, I’ll next write some tests to make sure it’s work as I expect it to. First I will write some tests to make sure that the correct implementation is instantiated correctly:

require 'test_helper'

module Confirmation::Confirm::CellTest
  class CellTest < ActiveSupport::TestCase
    def setup
      @default =
        User::Create::Operation::Default.(user: attributes_for(:user)).model
      @imported =
        User::Create::Operation::Imported.(user: attributes_for(:imported_user))
          .model
    end

    test 'build imported cell for imported user' do
      cell =
        Confirmation::Confirm::Cell.(
          Confirmation::Confirm::Operation::Base.present(
            token: @imported.confirmation_token
          )
        )

      assert_instance_of(Confirmation::Confirm::Cell::Imported, cell)
    end

    test 'build default cell for default user' do
      cell =
        Confirmation::Confirm::Cell.(
          Confirmation::Confirm::Operation::Base.present(
            token: @default.confirmation_token
          )
        )

      assert_instance_of(Confirmation::Confirm::Cell::Default, cell)
    end
  end
end

Next I will write a test to make sure the Imported cell has the correct markup:

class ImportedTest < Cell::TestCase
  controller Confirmation::ConfirmController

  def setup
    @user =
      User::Create::Operation::Imported.(user: attributes_for(:imported_user))
        .model
    @operation =
      Confirmation::Confirm::Operation::Imported.present(
        token: @user.confirmation_token
      )
  end

  test 'has correct markup' do
    html = concept('confirmation/confirm/cell', @operation).()

    html.must_have_selector('form.imported-confirmation-form')
    html.must_have_selector('label.qa-username-label')
    html.must_have_selector('input.qa-username-input')
    html.must_have_selector('label.qa-password-label')
    html.must_have_selector('input.qa-password-input')
    html.must_have_selector('input.qa-submit')
  end
end

And finally I will do the same for the Default class:

class DefaultTest < Cell::TestCase
  controller Confirmation::ConfirmController

  def setup
    @user = User::Create::Operation::Default.(user: attributes_for(:user)).model
    @operation =
      Confirmation::Confirm::Operation::Default.present(
        token: @user.confirmation_token
      )
  end

  test 'has correct markup' do
    html = concept('confirmation/confirm/cell', @operation).()

    html.must_have_selector('form.default-confirmation-form')
    html.wont_have_selector('label.qa-username-label')
    html.wont_have_selector('input.qa-username-input')
    html.wont_have_selector('label.qa-password-label')
    html.wont_have_selector('input.qa-password-input')
    html.must_have_selector('input.qa-submit')
  end
end

Here is that test class in full:

require 'test_helper'

module Confirmation::Confirm::CellTest
  class CellTest < ActiveSupport::TestCase
    def setup
      @default =
        User::Create::Operation::Default.(user: attributes_for(:user)).model
      @imported =
        User::Create::Operation::Imported.(user: attributes_for(:imported_user))
          .model
    end

    test 'build imported cell for imported user' do
      cell =
        Confirmation::Confirm::Cell.(
          Confirmation::Confirm::Operation::Base.present(
            token: @imported.confirmation_token
          )
        )

      assert_instance_of(Confirmation::Confirm::Cell::Imported, cell)
    end

    test 'build default cell for default user' do
      cell =
        Confirmation::Confirm::Cell.(
          Confirmation::Confirm::Operation::Base.present(
            token: @default.confirmation_token
          )
        )

      assert_instance_of(Confirmation::Confirm::Cell::Default, cell)
    end
  end

  class ImportedTest < Cell::TestCase
    controller Confirmation::ConfirmController

    def setup
      @user =
        User::Create::Operation::Imported.(user: attributes_for(:imported_user))
          .model
      @operation =
        Confirmation::Confirm::Operation::Imported.present(
          token: @user.confirmation_token
        )
    end

    test 'has correct markup' do
      html = concept('confirmation/confirm/cell', @operation).()

      html.must_have_selector('form.imported-confirmation-form')
      html.must_have_selector('label.qa-username-label')
      html.must_have_selector('input.qa-username-input')
      html.must_have_selector('label.qa-password-label')
      html.must_have_selector('input.qa-password-input')
      html.must_have_selector('input.qa-submit')
    end
  end

  class DefaultTest < Cell::TestCase
    controller Confirmation::ConfirmController

    def setup
      @user =
        User::Create::Operation::Default.(user: attributes_for(:user)).model
      @operation =
        Confirmation::Confirm::Operation::Default.present(
          token: @user.confirmation_token
        )
    end

    test 'has correct markup' do
      html = concept('confirmation/confirm/cell', @operation).()

      html.must_have_selector('form.default-confirmation-form')
      html.wont_have_selector('label.qa-username-label')
      html.wont_have_selector('input.qa-username-input')
      html.wont_have_selector('label.qa-password-label')
      html.wont_have_selector('input.qa-password-input')
      html.must_have_selector('input.qa-submit')
    end
  end
end

Adding some Controller tests

Finally, to make sure that everything is working correctly, I will write a couple of Controller tests as verification:

require 'test_helper'

module Confirmation
  class ConfirmControllerTest < ActionController::TestCase
    def setup
      @default =
        User::Create::Operation::Default.(user: attributes_for(:user)).model
      @imported =
        User::Create::Operation::Imported.(user: attributes_for(:imported_user))
          .model
    end

    test 'return 404 on invalid token' do
      assert_raises ActiveRecord::RecordNotFound do
        get :new
      end
    end

    test 'display imported user confirmation form' do
      get :new, token: @imported.confirmation_token

      assert_response(:success)
    end

    test 'display default user confirmation form' do
      get :new, token: @default.confirmation_token

      assert_response(:success)
    end

    test 'fail with invalid imported user data' do
      post :create, token: @imported.confirmation_token, user: {}

      assert_response(400)
    end

    test 'confirm imported user' do
      post :create,
           token: @imported.confirmation_token,
           user: {
             username: 'username',
             password: 'password'
           }

      assert_response(302)
    end

    test 'confirm default user' do
      post :create, token: @default.confirmation_token

      assert_response(302)
      assert_redirected_to(login_url)
    end
  end
end

First I check to make sure that I’m returned a 404 when the token does not exist. Next I do a quick to make sure the form is returned correctly.

Next I check to make sure the user is redirected when attempting to confirm an imported user with invalid data. And finally I make sure that confirming both an imported user and a default user works as it should.

Conclusion

Phew, finally finished! Well done for getting this far.

This might seem like a bit of a contrived example because it is so specific to my application, but hopefully you can see how powerful the polymorphism aspect of Trailblazer’s ability to build the correct implementation of an Operation or a Cell.

Instead of having to deal with nasty conditionals we can move that logic inside of the class and let good old object-oriented programming deal with creating the class.

This provides a beautifully simple interface and it prevents that logic from slipping out into the controller or the view.

Philip Brown

@philipbrown

© Yellow Flag Ltd 2024.