Create your own SaaS Rails application
The welcome word
The most fascinating in being a programmer is that you can create stunning things. And the best about being a Ruby programmer is that you can create them so easy. Just take a look at basecamp.com, twitter, github, hulu or shopify. Those are all great products, that were (well, at least initially) created in Ruby.
The awesome screencast How to build a blog engine in 15 minutes with Ruby on Rails made by DHH (and the power of used TextMate) was probably my initial point of interest. It started the fire, that made us switch from Pylons (Pyramid framework now) and Django (which is also nice, btw) to Ruby on Rails.
Learning curve was absolutely great, simple as that, out of nowhere we started to have some time to work on our own tools (none of which was ever shown to the public, to be fair) and started our interest in the SaaS market. Because how many Social Media apps one can build??
My dream from the point when I started my adventure with Rails, was to create something like Basecamp, but somehow different. I loved the product, but I was missing some features here and there. It just wasn’t working for our scenario, or maybe it’s just me who couldn’t be flexible enough.
Anyway - by creating this “course” we’ve decided, that something like basecamp clone would be a great introduction (and I’m using its name just to give you a general idea of the product we plan to build here). An introduction to all kinds of not-so-experienced Rails programmers who would like to get familiar with the technology itself.
So, what’s exactly all the fuss about SaaS?
Software as a Service (SaaS) is a web-based cloud service that offers remote access to the software (a program or an application). All you need is an electronic device with an Internet connection.
To be more specific it is a box version of ready-to-use product. A package of services that creates a running application. The difference between the traditional approach to the software management and SaaS is that the code, database and servers are maintained by the SaaS vendors.
In other words, SaaS is a box version of the product combined with the back-end requirements.
Disclaimer
We got quite a big feedback from you when we introduced this course. At first it was meant to be a series of rather short blog posts describing how we do SaaS apps. With the feedback we received, we feel that majority of users who signed up to our newsletter are more beginners, than advanced Ruby/Rails coders.
Remembering our own beginnings and knowing current Rails learning curve, we’ve decided, that this course will be dedicated to the first group. Nonetheless, if you’re a more advanced coder, you (hopefully) will still find some useful info here.
Do you think that Ruby on Rails is the only framework of choice for building SaaS applications?
No, of course not. We just know Rails quite well and it just works for us. If you plan to build your own SaaS application and you already know PHP/Python/whatever, do not learn Rails just for it - use your current knowledge.
Is your way the only “correct” way?
No, not at all. This course will be opinionated, it will use some of our proven practices, that turned out to work pretty well during the few last years of gaining experience. We absolutely don’t want to walk in the shoes of the-only-blessed-and-knowing. What we want is just to share our knowledge, show you how we do some things.
And have a little fun, of course.
Table of contents
This course is divided into several parts, each of them will be published every (other) week. Please note, that this is just a plan, some aspects may change, some may be added, some removed. As for now, it will be structured in the following way:
The welcome word and app bootstraping, account model with owner via simple Membership model
We’re going to introduce course content (as we already did) and start making basic app. We’ll begin with a simple account creation and making subdomains work.
Users, Membership, Invitations
With the new account, we’re going to create admin profile that lets to invite new users. Newly invited users should be able to register themselves, but to make an account slightly more secure, individual token should be validated. We should be able to see when an invitation was sent and when it was used (or in other words - when an invited user is registered). If the user already has an account, he should be prompted for his credentials and added as a new member to given account.
Starting a real multi-tenant app: projects, permissions, to do lists and items
Data from one account should not, by any means, be visible to members of another accounts. Fortunately, there’s a gem for that. We will let our users create projects, assign permissions to them and probably create a to-do lists and to-do items.
Messages and discussions
Users should be able to create new discussions (messages) as well as discuss already created to-do items (comment on them)
Timeline and e-mail notifications
We’ll create a timeline - a brief summary of what was happening lately. We’ll also allow users to receive one notification every night.
API
We’ll create an API for above features, to let manage their data ie. using console.
Payments and Tickets
We'll try to implement payment support as well, probably without subscriptions though.
Prerequisites
There are some prerequisites you (or, to be precise - your system) should meet:
you have Ruby installed properly, we’ll be working always on the newest, stable version available, as of date publishing of this post it’s 2.2.2 (2015.05.08)
the same applies to Ruby On Rails - you need to have it installed, the newest, stable version (4.2.1 now) (2015.05.08)
we’ll be using PostgreSQL (version 9.4) as database engine. In near future we’ll make a use of imagemagick
The idea of this course is to show you how things can be made. And once more - this is opinionated, etc. Remember, we’ll be happy to help you, answer your questions, and so on. However, at the same time, we’d like you to be able to solve your issues with ruby/rails/etc installations or anything similar, by your own.
Last things worth noticing
This is how the commands should look typed in a console. Every command starts with the ‘$’ - console greeting symbol, not a part of our code. We’re going to present you commands with stripped-down, simplified output. Just to show only the most important aspects. Until stated differently all command blocks assume that they run from the app root directory.
$ sample command
output
...
...
some text
Code samples will be displayed in the following way:
echo 'Hello World'
# output: 'Hello World'
Ok, let’s get this baby rolling.
Let’s create a new app. We’re gonna call it getwolfpack (but you can call it whatever you like, it’s really not that important) and use PostgreSQL as a database engine.
Why PostgreSQL?
Because of a few reasons: it’s one of the most powerful db engines out there, it’s fast, it scales well, it will be probably used in production environment (for sure on heroku.com). Plus, we’re going to use some of its more advanced features later on. I also believe that you should be developing in an environment the most similar to the production - this really saves a lot of time and spares troubles that could occur later. Of course - we could use a virtualized environment via vagrant or docker, but this would be overcomplicating things for our needs here.
Let's start coding
Open your terminal and run following command. It will create app for you, copy all required files, run bundle install
after completing. Additional parameter -d
informs this generator, that it should use PostgreSQL as database. If you want to learn more about this commant, run rails new -h
.
$ rails new getwolfpack -d postgresql
create
create README.rdoc
create Rakefile
create config.ru
...
After command completes it's job, cd
into directory of just created app. From this moment on we will assume, that all commands are run from within this directory:
$ cd getwolfpack
Let’s start with setting up our database connection. I like to have separate user per each app in development, so my config/database.yml
file looks like this:
default: &default
adapter: postgresql
encoding: unicode
username: getwolfpack
password: getwolfpack
host: localhost
development:
<<: *default
database: getwolfpack_development
test:
<<: *default
database: getwolfpack_test
database.yml
file defines connections to db - it tells Rails how to connect to database. By default databases are named after environments in which application is run. So if you're running your app in development environment (default when writing the app), Rails will make use of development
config. When you'll be writing tests, test
environment will be used. You can read more about it here.
Rails can create databases for you, just run this command to create all databases listed in database.yml
file:
$ bundle exec rake db:create:all
Normally we use HAML to structure HTML content. Of course, there are other choices, equally good, we just got used to HAML. Why not erb? It simple - HAML enforces cleaner markup via proper indentation. In our opinion, it’s more readable.
Add haml-rails to the Gemfile:
gem 'haml-rails'
and run bundle install
then, via html2haml
tool , convert default erb layout to HAML format:
$ cd app/views/layouts/
app/views/layouts/ $ html2haml application.html.erb application.html.haml
app/views/layouts/ $ rm application.html.erb
This could be done with one command: 'rake haml:erb2haml'
. It changes all your .erb
views into .haml
and in some scenarios this may be harmful. I belive, that it's good to know your toolset, but sometimes doing something by hand and explicite is a better way - leaves you more control.
Once we’ve made changes to the markup used by the app, let’s add Bootstrap as well. We use bootstrap because of its well known opinionated structure, that helps making responsive apps really fast. It also provides some elements, that we might be using later. To add Bootstrap libraries to our app we could do one of following - use gem or link to it directly. We will add bootstrap-sass gem to Gemfile
. We could add direct links i.e. to bootstrap CDN, but by embedding it via gem we’re sure that all required libraries will be available for offline development. Before making final deploy to production, it may be worth to remove it from gemfile and link from CDN though - in this way the website will load slightly faster.
gem 'bootstrap-sass'
and then run bundle install
Now, rename app/assets/stylesheets/applications.css
to app/assets/stylesheets/application.css.scss
- by doing so you let Rails to know, that it should parse this file using SCSS parser.
$ mv app/assets/stylesheets/application.css app/assets/stylesheets/application.css.scss
Thanks to that we can change applications.css.scss
file to load bootstrap assets:
@import "bootstrap-sprockets";
@import "bootstrap";
Also, just as a precaution and in order not to forget that in the future, load bootstrap javascript files in application.js (after loading jquery!)
//= require bootstrap-sprockets
Ok, once we have everything set up, let’s create the first controller and check if everything works as we expect (something loads)
$ bin/rails g controller welcome index
This rails generator will create a controller file, view directory with index.html.haml
in it, add it to routes and generate simple test for it. Let’s make it default route.
In config/routes.rb
change get 'welcome/index'
into root to: 'welcome#index'
. At this point we’d like to do one more thing to make our app more readable and structured better - in app/assets/stylesheets
and app/assets/javascripts
create directory controllers
in which we will keep controller-specific asset files (controller specific javascript bindings or stylesheet declarations).
To do so, create both directories and move just generated asset files to them:
$ mkdir app/assets/{javascripts,stylesheets}/controllers
$ mv app/assets/javascripts/welcome.coffee app/assets/javascripts/controllers/
$ mv app/assets/stylesheets/welcome.scss app/assets/stylesheets/controllers/
Now require this directory in both application.js
and application.css.scss
files in application.js
//= require_tree ./controllers
also from application.js remove last //=require_tree .
in application.css.scss (above *= require_self
)
*= require_tree ./controllers
and as with application.js
- remove *= require_tree
.
As an explanation - we don’t want every script to be loaded automatically - this can lead to serious and hard to debug mess in assets. By moving controller specific files to subdirectories and requiring only this subdirectory we keep some control while still having asset loading automated.
The last thing you might wanna do before checking your results is to add .container
class to application.html.haml
layout - it’s a top element of bootstrap based layouts - http://getbootstrap.com/css/#overview-container
!!!
%html
%head
%meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/
%title GetWolfpack
= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true
= javascript_include_tag 'application', 'data-turbolinks-track' => true
= csrf_meta_tags
%body
.container
= yield
Ok, it’s about time to start our app:
$ bin/rails s
=> Booting WEBrick
=> Rails 4.2.1 application starting in development on http://localhost:3000
=> Run `rails server -h` for more startup options
=> Ctrl-C to shutdown server
[2015-04-16 16:54:47] INFO WEBrick 1.3.1
[2015-04-16 16:54:47] INFO ruby 2.2.2 (2015-04-13) [x86_64-darwin14]
[2015-04-16 16:54:47] INFO WEBrick::HTTPServer#start: pid=39737 port=3000
In your browser you should see this:
And just to check it from tests perspective, run
$ bin/rake test
Run options: --seed 16338
# Running:
.
Finished in 6.489450s, 0.1541 runs/s, 0.1541 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
Current WelcomeController
is auto-generated and checks only two things: if actions end with success
response and if template for this action is generated properly. As we haven’t made any additional changes here, everything should pass as we expected. We’ll leave it as for now.
Ok, it would be good to finally add our code to code repository. We’ll use git.
$ git init
Initialized empty Git repository in /Users/mlitwiniuk/Sites/r4/saas-course/getwolfpack/.git/
Before adding new files, edit .gitignore
file and add /config/database.yml
to it - we don’t want our database config to be added to the repository. Sometimes, to speed up things a little, we copy config files (database.yml
in this example) file to FILE_NAME.sample.
By doing such, a good and working version of the file is stored and maintained for other developers: if necessary, they can relate to it, copy or edit the file to reflect the environment. Doing this is up to you, it’s not obligatory
Let’s add all files to git repository
$ git add .
$ git commit -am “Initial commit with WelcomeController”
[master (root-commit) 4b3a261] Initial commit with WelcomeController
62 files changed, 1001 insertions(+)
create mode 100644 .gitignore
create mode 100644 Gemfile
create mode 100644 Gemfile.lock
...
First model
Ok, it’s time for account model. Account will be an object representing something like organization / company. Identified by it's own subdomain it will group all data belonging to one organization. Ie. by accessing prograils.getwolfpack.com
you should be able to access account of organization 'prograils'. As for now it should have only its own name and subdomain.
$ bin/rails g model Account name:string subdomain:string
invoke active_record
create db/migrate/20150417074811_create_accounts.rb
create app/models/account.rb
invoke test_unit
create test/models/account_test.rb
create test/fixtures/accounts.yml
Before running migration it would be a good idea to modify it a little and add a unique index for the subdomain - as an additional check, to make sure that no one will ever register a second account with the same subdomain as already existing one.
Migrations are instructions for Rails how to perform changes to database. They are always run in alphabetic orders. When generated, migrations are "timestamped" - they always start with current timestamp, so in order to find last created, just open last file in db/migrate/
directory.
Edit create_account
migration like so:
t.string :subdomain, index: { unique: true }
and run migration:
$ bin/rake db:migrate
== 20150417074811 CreateAccounts: migrating ===================================
-- create_table(:accounts)
-> 0.0421s
== 20150417074811 CreateAccounts: migrated (0.0422s) ==========================
Let’s add some validations to our model. Our account should contain both name and a subdomain. Its presence is crucial. What is more, subdomain must be validated - it should be initialized by the letter, then contain an alphanumeric or ‘dash’ symbol, and finally, end with an alphanumeric symbol. So, let’s check if app/models/account.rb
meets those requirements:
validates :name, :subdomain, presence: true
validates :subdomain,
format: { with: /\A[a-z][a-z0-9\-]+[a-z0-9]\Z/ },
uniqueness: true
\A
means beginning of the string (not the line!), \Z
indicates the end of the string (once again, not the line!). This is often forgotten and skipping it may lead to security issues in some cases. To get familiar with regular expressions in Ruby, try out Rubylar - it's awesome expression tester.
We’re going to check if we meet above requirements by writing some tests. But before doing so, we’ll add FactoryGirl for generating test data - build-in fixtures are not our favorites. So in Gemfile
add factory_girl_rails in development and test group. We’re adding it to development group as well, because we want factories to be generated automatically when we generate models.
group :development, :test do
...
gem 'factory_girl_rails'
...
end
Install it and then stop spring - we have to reload it to make sure it’s (spring) aware of the new gem:
$ bin/spring stop
Spring stopped
Now, run model generator once again, this time adding the option --skip
- this won`t change anything, it will generate factory for us though:
$ bin/rails g model Account name:string subdomain:string --skip
...
identical app/models/account.rb
invoke test_unit
identical test/models/account_test.rb
invoke factory_girl
create test/factories/accounts.rb
Please, notice the last line of output.
Remove old fixture test/fixtures/accounts.rb
and fix factory to support sequences:
FactoryGirl.define do
factory :account do
sequence(:name) { |n| "Account #{n}" }
sequence(:subdomain) { |n| "subdomain-#{n}" }
end
end
Before moving forward, let’s add minitest-rails
and minitest-spec-rails
gems to Gemfile
to have a nice minitest support for Rails and use spec-like format.
group :test do
gem 'minitest-rails'
gem 'minitest-spec-rails'
end
We should require minitest/rails
in our test_helper.rb
- put it just below the last require:
require 'minitest/rails'
The next step is to test if our factory is valid - fortunately FactoryGirl can do this for us. Create file test/models/factory_girl_test.rb
and let’s check if Factories are OK:
require 'test_helper'
class FactoryGirlTest < ActiveSupport::TestCase
it 'lint all factories' do
FactoryGirl.lint
end
end
Go ahead and run it:
$ ruby -Itest test/models/factory_girl_test.rb
Run options: --seed 18063
# Running:
.
Finished in 0.090845s, 11.0077 runs/s, 0.0000 assertions/s.
1 runs, 0 assertions, 0 failures, 0 errors, 0 skips
Now it’s time to test the Account
model. Let’s do this in test/models/account_test.rb
require 'test_helper'
class AccountTest < ActiveSupport::TestCase
let(:account) { FactoryGirl.build(:account) }
it 'validates subdomain format' do
account.subdomain = ''
account.valid?.must_equal false
account.subdomain = 'ab'
account.valid?.must_equal false
account.subdomain = '---'
account.valid?.must_equal false
account.subdomain = 'ab-'
account.valid?.must_equal false
account.subdomain = '-ab'
account.valid?.must_equal false
account.subdomain = '1abc'
account.valid?.must_equal false
account.subdomain = 'abc1'
account.valid?.must_equal true
account.subdomain = 'abc'
account.valid?.must_equal true
account.subdomain = 'a-b1c'
account.valid?.must_equal true
end
end
We don’t have to check if the name is present - this is done by FactoryGirl’s lint
method. What we could do though is to write additional test to ensure, that the subdomain is unique. Lets do this:
it 'checks for subdomain uniqueness' do
account.save!
a = FactoryGirl.build(:account)
a.valid?.must_equal true
a.subdomain = account.subdomain
a.valid?.must_equal false
end
Run test to check if everything is ok:
$ bin/rake test
Run options: --seed 23309
# Running:
....
Finished in 0.531356s, 7.5279 runs/s, 22.5837 assertions/s.
4 runs, 12 assertions, 0 failures, 0 errors, 0 skips
It seems everything is all right. Now it may be a good time to commit last changes:
$ git add .
$ git commit -am "added account model"
[master 1f69197] added account model
8 files changed, 117 insertions(+)
create mode 100644 app/models/account.rb
create mode 100644 db/migrate/20150417074811_create_accounts.rb
create mode 100644 db/schema.rb
create mode 100644 test/factories/accounts.rb
create mode 100644 test/models/account_test.rb
create mode 100644 test/models/factory_girl_test.rb
Account owner - first user of our app and basic memberships
Time to add the owner to the account. We will use devise to authorize user and its generator to generate a model and migration. Add devise
to gemfile:
gem 'devise'
and run bundle install
Now run devise installer:
$ bin/rails g devise:install
create config/initializers/devise.rb
create config/locales/devise.en.yml
...
Generate user model via devise:
$ bin/rails g devise user
invoke active_record
...
route devise_for :users
Now modify user migration to add two additional fields: first_name
and last_name
. We tend to add such modification on top of devise migration - thanks to it, it’s more readable, plus when you operate on raw sql data those custom columns are displayed as first.
class DeviseCreateUsers < ActiveRecord::Migration
def change
create_table(:users) do |t|
t.string :first_name
t.string :last_name
Before migrating let’s add additional model - Membership. This model will be used to tell us if the user has access to the account.
$ bin/rails g model Membership account:references user:references role:string
...
Now modify a membership migration and force role to be always set by adding null: false
to role
column declaration:
t.string :role, null: false
After that, run migrations - apply changes to the database:
$ bundle exec rake db:migrate
As role
should be one of predefined values, we should store them somewhere. We tend to put most of system-wide used constants in custom initializer - config/initializers/constants.rb
. Create and edit this file, add constant with possible roles to it:
SYSTEM_ROLES = %w(owner admin user)
Now we can validate that in Membership model. Membership model must belong to both account and user, they should be able to validate as well:
validates :account, :user, presence: true
validates :role, inclusion: { in: SYSTEM_ROLES }
Also - let’s add relations to both Account
has_many :memberships, dependent: :destroy
has_many :users, through: :memberships
and User
models:
has_many :memberships, dependent: :destroy
has_many :accounts, through: :memberships
Right now tests won’t pass, as there are some issues with factories. Let’s fix them.
In factories/users.rb
we should define at least e-mail and password:
factory :user do
sequence(:email) { |n| "email_#{n}@getwolfpack.dev" }
password '12345678abc'
end
while in factories/memberships.rb
we should fix associations and add role:
factory :membership do
account
user
role SYSTEM_ROLES.first
end
Tests should pass now.
Ok, time to create account controller and add the possibility to create a new account. Let’s generate controller (without actions, we’ll add them by hand):
$ bin/rails g controller accounts
create app/controllers/accounts_controller.rb
invoke haml
create app/views/accounts
invoke test_unit
create test/controllers/accounts_controller_test.rb
invoke helper
create app/helpers/accounts_helper.rb
invoke test_unit
invoke assets
invoke coffee
create app/assets/javascripts/accounts.coffee
invoke scss
create app/assets/stylesheets/accounts.scss
Remember about moving controller-specific assets to controller directory. I will also remove accounts_helper.rb
as it won’t be needed at this point.
What happens now is that we’d like you to create an account with you as an owner assigned to it. We assume, that when the new account is created, the owner is automatically asked to provide his credentials so he could log in and manage this account at once.
We want to create account together with its owner - that is - we assume for now, that when user (site visitor) is creating new account, he should provide his credentials as well. To make is possible, we will accept nested attributes for owner
- specific relation, that we have to add (Owner is user, who belongs to account with membership role == ‘owner’).
Unfortunately, we can’t define has_one :owner
relation directly - we have to make it in a tricky way:
has_one :owner_membership, -> { where(role: 'owner') }, class_name: 'Membership'
has_one :owner, through: :owner_membership, source: :user, class_name: 'User'
First, we create relation to one owner membership that account can have. Then we create relation to User
model via owner_membership
. On the other hand user will have many owner_accounts
records - it’s the account that has only one owner.
Before we start creating views, let’s just add simple_form gem to Gemfile
to make form creation easier:
gem 'simple_form'
After installing it, run its generator to create config files and add support for bootstrap structure:
$ bin/rails g simple_form:install --bootstrap
Before anything we should add account to config/routes.rb
.
resource :account, only: [:new, :create]
We specify singular resource because we won’t be listing account. We also limit actions only to new
and create
- we don’t need more actions right now. It's better to practice limiting something explicitly earlier, that to leave it for undefined future and, as a result, forget about it.
In the controller we should add a new action and build both account and its owner:
def new
@account = Account.new
@account.owner = User.new #!
end
Normally, with direct has_one
relation you should be able to do something like @account.build_owner
instead of above construction, but as this relation is “tricky”, we have to use a workaround.
Let’s add nested attributes support for owner in Account
- in account model add:
accepts_nested_attributes_for :owner
Now, add account form view (in app/views/accounts/new.html.haml
)
= simple_form_for @account, url: account_path do |f|
= f.input :name
= f.input :subdomain
= f.fields_for :owner do |fo|
= fo.input :email
= fo.input :first_name
= fo.input :last_name
= fo.input :password
= fo.input :password_confirmation
= f.submit
Also , take a look at welcome/index.html.haml
- let’s finally change a default text and add link to the new account form. Classes btn btn-success
are provided by bootstrap and will make our link look slightly nicer.
= link_to 'Create new account', new_account_path, class: 'btn btn-success'
Make sure, that our form displays correctly - let’s write a test for it. In test/controllers/accounts_controller_test.rb
add test for new
action:
it 'shows new account form' do
get :new
must_respond_with :success # 1
must_render_template :new
assigns(:account).must_be_instance_of Account
assigns(:account).owner.must_be_instance_of User
end
must_respond_with
is a custom alias to assertion method assert_response
- in our opinion must_xxx
methods are more readable. We’ve added those aliases in test/test_helper.rb
module MiniTest::Expectations
infect_an_assertion :assert_redirected_to, :must_redirect_to
infect_an_assertion :assert_template, :must_render_template
infect_an_assertion :assert_response, :must_respond_with
end
Now you should be able to see the form, but you can’t submit it. It’s because create action is not added yet. Add it to AccountsController
:
def create
@account = Account.new(account_params) # 1.
@account.owner = User.new(user_params) # 2.
if @account.save # 3.
redirect_to root_url
else
render :new
end
end
private
def account_params
params.require(:account).permit(:name, :subdomain)
end
def user_params # 4.
params.require(:account).require(:owner_attributes).permit(:email, :first_name, :last_name, :password, :password_confirmation)
end
Normally we should create only account and accept resources for its owner (if the owner belongs directly to the account) in account_params
. In our situation (with has_one :owner
relation via :owner_membership
) we have to deal with that differently. So, first (1.)
we're initializing account from params, then (2.)
we're initializing it's owner. Only single save
(3.)
call is required to perform all validations - Rails are clever enough to check owner (User
) model as well. Also (4.)
- in user_params
method we require params to have the owner - so it’s not possible to have an account created without the owner. We have defined accepts_nested_attributes_for :owner
method in model - thanks to that Rails will perform validations on owner as well.
Let’s test it (accounts_controller_test.rb
). Before making new account let’s build hash of attributes, that will be sent in form.
let(:account_attributes) do
FactoryGirl.attributes_for(:account).
merge(owner_attributes: FactoryGirl.attributes_for(:user))
end
Method let
with its block is basically equivalent of following method declaration (this is some simplification, but we can live with it):
def account_attributes
FactoryGirl.attributes_for(:account).
merge(owner_attributes: FactoryGirl.attributes_for(:user))
end
let
is just more readable in tests in our opinion.
So let's test create
action of AccountsController
- add this new test to accounts_controller_test.rb
:
it 'creates new account' do
lambda {
lambda {
post :create, account: account_attributes
}.must_change 'User.count', 1
}.must_change 'Account.count', 1
must_respond_with :redirect
must_redirect_to root_url
end
Ok, this proves, that Account
and User
are in fact created, after that user is redirected to root_url
. Let’s check if system reacts properly, or if there's something wrong with the form itself:
it 'renders form for invalid params' do
post :create, account: { owner_attributes: { first_name: '' } }
must_render_template :new
end
Dashboard for authenticated users
Now we can create new accounts. It would be great if their owner could also sign in to new account. All important actions are going to happen in accounts dashboard - there in the future we will be displaying timeline with recent activity. Let’s create a controller for it. Accessing it will require user to be authenticated.
$ rails g controller dashboards
...
As previously, remember about moving controller-specific assets.
$ mv app/assets/javascripts/dashboards.coffee app/assets/javascripts/controllers/
$ mv app/assets/stylesheets/dashboards.scss app/assets/stylesheets/controllers/
You may also remove generated helper file (rm app/helpers/dashboards_helper.rb
).
Add routes for it, for now we will only have show
action
resource :dashboard, only: [:show]
And define show action in controller:
def show
end
and create view file (app/views/dashboard/show.html.haml
). Add whatever you want to it.
If you go to localhost:3000/dashboard
it should work. Ok, let’s show this view only to authorized users. On top of dashboards_controller.rb
add before_action
before_action :authenticate_user!
before_action
method is fired on every request before getting into action methods. If method called will return any type of response methods (ie. redirect_to
), the whole chain will be stoped and response will be sent to user. :authenticate_user!
is method provided by devise which checks if user is signed in and if not - it redirects user to sign in form.
After applying above change, when you will try to show this view again, you will be redirected to sign in form.
When you take a closer look at sign in form, you’ll notice, that a user is able to sign up as well. This is not expected behavior right now (as user would sign up to the system, but wouldn’t have an account assigned to it). Let’s block it for now. To do so in User
model remove :registerable
from devise
method call. Voila, magic happens.
It still does not make much sense to access dashboard via /dashboard link. It should be available when you visit root url of your account (ie. prograils.getwolfpack.com/ ). Let’s start with requiring to have subdomain present to access dashboard. Remove resource :dashboard
line from routes.rb
and add following block:
constraints SubdomainRequired do
root to: 'dashboards#show', as: 'subdomain_root'
end
Constraint make sure, that given routing block is available only, when certain criteria are met. Here - SubdomainRequired
must check if subdomain is present. On top of routes.rb
define this constraint:
class SubdomainRequired
def self.matches?(request)
request.subdomain.present? && request.subdomain != "www"
end
end
Now accessing localhost:3000/dashboard
shouldn’t work anymore. Small hint here. As you probably see, anything.localhost:3000
won’t work. You can use one of domains, that is resolving always to localhost, like lvh.me, or wait for it... prograils.io ;) So visiting prograils.io:3000
should call your welcome controller, while anything.prograils.io:3000
should give you login form. Try that.
Time to force our users to visit only their accounts. For now, unless we fully implement memberships and invitation, we will redirect user to their first account (as they can’t create a second one anyway). Such global checks should be done in ApplicationController
- edit it and add the following code block:
before_action :redirect_to_subdomain # 1.
private
def redirect_to_subdomain
return unless user_signed_in? # 2.
if request.subdomain.blank? # 3.
if current_user.accounts.any?
redirect_to root_url(subdomain: current_user.accounts.first.subdomain)
end
else # 4.
unless current_user.accounts.pluck(:subdomain).include?(request.subdomain)
redirect_to root_url(subdomain: current_user.accounts.first.subdomain)
end
end
end
What it does:
It runs redirect_to_subdomain method on every request.
It skips further checking if user is not signed in.
Otherwise it checks, if subdomain exists. If not, user is redirected to root_url of his first account.
If subdomain exists, system checks if it belongs to the account of which user is a member. If not, he’s redirected to his account.
It’s about time to check if everything we’ve done so far is ok. Before moving forward with tests, let’s just apply additional feature, that will help us with testing a code, that requires users to be signed in. Let’s monkey-patch ActionController::TestCase
in test_helper.rb
:
class ActionController::TestCase
include Devise::TestHelpers
end
So… in dashboards_controller_test.rb
let’s check if user is redirected to sign_in form if there is subdomain present
it 'redirects to sign in user is not signed in' do
request.host = 'subdomain.prograils.io'
get :show
must_respond_with :redirect
must_redirect_to new_user_session_url(subdomain: 'subdomain')
end
That should be it. Now we should check if the system behaves properly if a user is signed in and trying to access his account. He should see show
action of DashboardController
.
Start with adding lazy factories (via let
) to test:
describe DashboardsController do
let(:user) { FactoryGirl.create(:user) }
let(:account) { FactoryGirl.create(:account, owner: user) }
# ...
then let's add basic test
describe 'for signed in user' do
before do
account # preload
sign_in user
end
it 'renders show template if user is signed in' do
request.host = "#{account.subdomain}.prograils.io"
get :show
must_respond_with :success
must_render_template :show
end
end
Now let’s check if a user is redirected to his domain, if he’s signed in, but when subdomain is missing. Add following block in describe 'for signed in user'
block defined above:
it 'redirects to subdomain if subdomain is absent for signed in user' do
request.host = 'prograils.io'
get :show
must_redirect_to "http://#{account.subdomain}.prograils.io/"
end
And finally, check if signed in user is being redirected to his account, when he’s trying to access different account (also put it in same describe
block):
it 'redirect to proper subdomain' do
request.host = "wrong.prograils.io"
get :show
must_redirect_to "http://#{account.subdomain}.prograils.io/"
end
Let’s run tests one last time (in this part)
$ bin/rake test
Run options: --seed 58678
# Running:
...........
Finished in 4.036164s, 2.7254 runs/s, 6.6895 assertions/s.
11 runs, 27 assertions, 0 failures, 0 errors, 0 skips
Regarding tests and order of writing them - we know, that this is not fully TDD. But full TDD is hard to achieve, sometimes is counter-productive and hard to make in this course. Our main idea is not to teach you how to do tests, you can find plenty of them in the Internet. We belive that this simplification is ok enough to be continued in the next part, while it still puts some impact on testing and shows where they are important. Generally, you should write tests before writing the code, that proves they are ok. Generally.
See you next time.
P.S. Do not forget to add all files to Git and then commit them.
If you have problems, we have published app in the same state on github - this links to tagged commit and should contain more or less same code, as presented in this chapter.