Users with Employments and Invitations

Why have a separate Employment model?

For many use cases it's fine to have a single User model which contains both login details and app domain specific fields but there are some cases where it makes sense to separate Employment from User:

Read more about the reasons for separating models here.

What you'll build

This pattern demonstrates how to create an app with User, Business and Employment models where a user can sign up, creating the first business and employment at the same time. Note these models could also be named User, Team and Membership.

The signed in user can then invite other users to the business. Accepting an invitation to a business will allow you to access that business. Signed in users can switch between businesses they have access to.

Users can end an employment which will remove the user's access to the business.

Users can signup directly to a business.

For consistency, throughout the app, we will associate objects with the Employment model rather than the User model. The User model will be used for logging in and for the user to maintain details about themselves that are shared across all employments.

App setup

We start with the pattern_base1 base app which can also be created with the following commands:

APP_NAME=employments
rails new $APP_NAME -d postgresql -c tailwind --skip-jbuilder --skip-test
cd $APP_NAME
git add -A && git commit -m "rails new $APP_NAME -d postgresql --skip-jbuilder --skip-test"

Then we add and configure the following gems to the Gemfile:

rails db:create
bundle add devise
bundle add letter_opener --group "development"
rails g devise:install
# config/environments/development.rb

config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
config.action_mailer.delivery_method = :letter_opener
config.action_mailer.perform_deliveries = true

Domain model

Let's get our Devise User model created and scaffold up our other models:

rails g devise user
rails g scaffold business name:string --no-helper
rails g scaffold employment user:references business:references role:string started_at:datetime ended_at:datetime --no-helper
rails g scaffold invite sender:belongs_to recipient:belongs_to invitable:references{polymorphic} email:string token:string accepted_at:datetime --no-helper

Then we'll tweak the employment migration to allow for a blank user. This is because we create an employment when we create an invite but we don't have a user at that point. We'll also add an index to prevent duplicate employments.

# db/migrate/20240101010000_create_employments.rb

class CreateEmployments < ActiveRecord::Migration[7.0]
  def change
    create_table :employments do |t|
      t.references :user, null: true, foreign_key: true
      t.references :business, null: false, foreign_key: true
      t.string :role
      t.datetime :started_at
      t.datetime :ended_at

      t.index [:user_id, :business_id], unique: true, where: "ended_at IS NULL AND user_id IS NOT NULL"

      t.timestamps
    end
  end
end

In the invite migration we link sender and recipient to the employment table:

# db/migrate/20240101010000_create_invites.rb

class CreateInvites < ActiveRecord::Migration[7.0]
  def change
    create_table :invites do |t|
      t.belongs_to :sender, null: false, foreign_key: { to_table: :employments }
      t.belongs_to :recipient, null: false, foreign_key: { to_table: :employments }
      t.references :invitable, polymorphic: true, null: false
      t.string :email
      t.string :token
      t.datetime :accepted_at

      t.timestamps
    end
  end
end

We could index email with invitable here but that would prevent us from re-inviting a user after their employment has ended.

Then we run the migrations with:

rails db:migrate

and add the following code to our new models:

# app/models/business.rb

class Business < ApplicationRecord
  has_many :employments, dependent: :destroy
  has_many :users, through: :employments

  has_many :invites, as: :invitable

  alias_attribute :to_s, :name
end
# app/models/user.rb

class User < ApplicationRecord
  devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable

  has_many :employments, dependent: :destroy
  has_many :businesses, through: :employments
  accepts_nested_attributes_for :businesses

  alias_attribute :to_s, :email
end
# app/models/employment.rb

class Employment < ApplicationRecord
  ROLES = %w[owner employee].freeze

  belongs_to :user, optional: true
  belongs_to :business

  has_many :sent_invites, class_name: :Invite, foreign_key: :sender_id, inverse_of: :sender, dependent: :destroy
  has_many :received_invites, class_name: :Invite, foreign_key: :recipient_id, inverse_of: :recipient, dependent: :destroy

  delegate :to_s, to: :user

  scope :started, -> { where.not(started_at: nil) }
  scope :not_ended, -> { where(ended_at: nil) }
  scope :ended, -> { where.not(ended_at: nil) }
  scope :active, -> { started.not_ended }

  default_scope { not_ended }

  def end
    ActiveRecord::Base.transaction do
      update!(ended_at: Time.current)
    end
  rescue StandardError
    false
  else
    true
  end
end

We use default scope here to hide ended employments everywhere in the app. You can use .unscoped to access ended employments.

# app/models/invite.rb

class Invite < ActiveRecord::Base
  belongs_to :sender, class_name: :Employment, inverse_of: :sent_invites
  belongs_to :recipient, class_name: :Employment, inverse_of: :received_invites
  accepts_nested_attributes_for :recipient
  belongs_to :invitable, polymorphic: true

  before_create :generate_token

  after_commit do
    destroy_unclaimed_employment and next if destroyed?
    send_invite_email and next if send_invite_email?
  end

  validates :email, presence: true, format: { with: Devise.email_regexp }, uniqueness: { scope: [:invitable_id, :invitable_type], case_sensitive: false }
  validates :invitable, presence: true
  validates :sender, presence: true
  validate :email_is_not_in_employments

  attribute :resend, :boolean, default: false

  alias_attribute :to_s, :email

  def accept!(user)
    ActiveRecord::Base.transaction do
      now = Time.zone.now
      update!(accepted_at: now)
      recipient.update!(started_at: now, user:)
    end
  end

  def send_invite_email
    # InviteMailer.send(user? ? :existing_user : :new_user, self).deliver_later
  end

  def destroy_unclaimed_employment
    recipient.destroy! if recipient&.user.nil?
  end

  def generate_token
    self.token = SecureRandom.hex(16)
  end

  def send_invite_email?
    recipient.previously_new_record? || resend?
  end

  def email_is_not_in_employments
    errors.add(:email, "is already in use") if invitable.employments.excluding(recipient).includes(:user).where(user: { email: }).any?
  end

  def user_params
    { email: }
  end

  def user?
    user.present?
  end

  def user
    User.find_by(email:)
  end
end

A few points to note on Invite:

Sign up flow

With the data model in place, let's setup the sign up flow. We'll overload the devise registration controller to allow for:

First let's customise the default devise registration form to accept business name and allow for an invite token:

mkdir -p app/views/devise/registrations && touch app/views/devise/registrations/new.html.erb
<!-- app/views/devise/registrations/new.html.erb -->

<%= form_for(resource, as: resource_name, url: registration_path(resource_name), data: { turbo: false }, html: { class: 'form' }) do |f| %>
  <%= render "devise/shared/error_messages", resource: resource %>

  <% if @invite %>
    <%= hidden_field_tag :invite_token, @invite.token %>
    <p class="mb-2">You are being invited to join the <strong><%= @invite.invitable %></strong> team.</p>
  <% else %>
    <%= f.fields_for :businesses do |bf| %>
      <div class="mt-2">
        <%= bf.label :name, "Business name" %><br />
        <%= bf.text_field :name %>
      </div>
    <% end %>
  <% end %>

  <% if @invite&.user? %>
    <p class="mb-2">You are already a member of the following teams:</p>

    <ul class="list-disc list-inside font-bold mb-2">
      <% @invite.user.businesses.each do |business| %>
        <li><%= business.name %></li>
      <% end %>
    </ul>

    <p class="mb-2">You will still be able to access these teams after you accept this invitation.</p>

    <%= f.submit 'Accept invitation', class: "cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" %>
  <% else %>
    <div class="mt-2">
      <%= f.label :email %><br />
      <%= f.email_field :email %>
    </div>

    <div class="mt-2">
      <%= f.label :password %>

      <% if @minimum_password_length %>
        <em>(<%= @minimum_password_length %> characters minimum)</em>
      <% end %>
      <br />

      <%= f.password_field :password, autocomplete: "new-password" %>
    </div>

    <div class="mt-2">
      <%= f.label :password_confirmation %><br />
      <%= f.password_field :password_confirmation, autocomplete: "new-password" %>
    </div>

    <div class="mt-4">
      <%= f.submit "Sign up", class: "cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" %>
    </div>
  <% end %>
<% end %>

We'll need to customise the devise controller as well. We first copy it into our app with:

rails g devise:controllers users -c registrations

Then we can customise it as follows:

# app/controllers/users/registrations_controller.rb

class Users::RegistrationsController < Devise::RegistrationsController
  before_action :configure_sign_up_params, only: [:create]
  before_action :set_invite, only: [:new, :create]

  def new
    super do |resource|
      if params[:invite_token].present?
        flash[:alert] = "Invite not found" and return unless @invite.present?
        resource.assign_attributes(@invite.user_params)
      else
        resource.businesses.build
      end
    end
  end

  def create
    if params[:invite_token].present?
      flash[:alert] = "Invite not found" and return unless @invite.present?

      if @invite.user?
        self.resource = @invite.user
      else
        build_resource(sign_up_params.reverse_merge(@invite.user_params))
      end

      @invite.accept!(resource)
    else
      build_resource(sign_up_params)
      resource.save
    end

    if resource.persisted?
      if resource.active_for_authentication?
        set_flash_message! :notice, :signed_up
        sign_up(resource_name, resource)
        respond_with resource, location: after_sign_up_path_for(resource)
      else
        set_flash_message! :notice, :"signed_up_but_#{resource.inactive_message}"
        expire_data_after_sign_in!
        respond_with resource, location: after_inactive_sign_up_path_for(resource)
      end
    else
      clean_up_passwords resource
      set_minimum_password_length
      respond_with resource
    end
  end

  protected

  def set_invite
    @invite = Invite.find_by(token: params[:invite_token])
  end

  def configure_sign_up_params
    devise_parameter_sanitizer.permit(:sign_up, keys: [{ businesses_attributes: [:name] }])
  end
end

We are doing a few things here:

So that we can test the user journey let's add a home page to the root app:

rails g controller pages home --no-helper
<!-- app/views/pages/home.html.erb -->
<div class="prose">
  <h1 class="font-bold text-4xl">Home</h1>

  <% if current_user %>
    <p><%= link_to "Businesses", businesses_path %></p>
    <p><%= link_to "Invites", invites_path %></p>
    <p><%= link_to "Employments", employments_path %></p>
    <p><%= link_to "Ended employments", ended_employments_path %></p>
    <p><%= link_to "Sign out", destroy_user_session_path, data: { turbo_method: :delete } %></p>
  <% else %>
    <p><%= link_to "Sign in", new_user_session_path %></p>
    <p><%= link_to "Sign up", new_user_registration_path %></p>
  <% end %>
</div>

Now we'll tweak the routes to add devise and our home page. We'll also remove some of the default routes we dont need and add a route to list ended employments. We'll back this up with controllers and views in a later section:

Rails.application.routes.draw do
  root to: "pages#home"

  devise_for :users, controllers: { registrations: "users/registrations" }

  resources :invites
  resources :businesses, except: %i[new create destroy]
  resources :employments, except: %i[new create] do
    get :ended, on: :collection
  end
end

You can now test the signup flow for a new user with a business.

Current employment

Let's now add a current employment to the user model. This will allow us to switch between businesses the user has access to.

rails g migration add_current_employment_to_users current_employment:belongs_to

And tweak it to reference the employments table and allow null (it's possible for all employments to end):

# db/migrate/20240101010000_add_current_employment_to_users.rb

class AddCurrentEmploymentToUsers < ActiveRecord::Migration[7.0]
  def change
    add_reference :users, :current_employment, null: true, foreign_key: { to_table: :employments }
  end
end
rails db:migrate

Then we'll add the following to our models:

# app/models/user.rb

belongs_to :current_employment, class_name: :Employment, inverse_of: :current_users, optional: true
# app/models/employment.rb

concerning :CurrentEmployment do
  included do
    has_many :current_users, class_name: :User, foreign_key: :current_employment_id, inverse_of: :current_employment
    after_create :set_as_current_employment
  end

  def set_as_current_employment
    return if user.blank?

    user.update!(current_employment: self)
    update!(started_at: Time.current) if started_at.blank?
  end
end

def end
  ActiveRecord::Base.transaction do
    update!(ended_at: Time.current)
    user.update!(current_employment: user.employments.order(created_at: :desc).first)
  end
rescue StandardError
  false
else
  true
end

Here we set the current employment on the user when the employment is created and we set the current employment on the user to the most recently created employment when an employment ends.

# app/models/invite.rb

def accept!(user)
  ActiveRecord::Base.transaction do
    now = Time.zone.now
    update!(accepted_at: now)
    recipient.update!(started_at: now, user:)
    user.update!(current_employment: recipient)
  end
end

Now let's create the current employment switcher controller:

touch app/controllers/current_user_controller.rb
# config/routes.rb

get :switch_employment, to: 'current_user#switch_employment'
# app/controllers/current_user_controller.rb

class CurrentUserController < ApplicationController
  before_action :authenticate_user!

  def switch_employment
    current_user.update_columns(current_employment_id: params[:employment_id]) if current_user.employment_ids.include?(params[:employment_id].to_i)
    redirect_to params[:redirect_to].presence || root_url
  end
end

We check that the user has the employment they are trying to switch to and we also add a redirect param so we can send them back to where they came from.

Controllers and views

Let's tidy up our controllers and views to make use of the current employment and business.

First make the current employment and business available in all controllers and views:

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  helper_method :current_business, :current_employment

  private

  def current_employment
    current_user.current_employment
  end

  def current_business
    current_employment.business
  end
end

Then tighten up the main controllers to use the current business:

# app/controllers/businesses_controller.rb

class BusinessesController < ApplicationController
  before_action :set_business, only: %i[ show edit update destroy ]

  def index
    @businesses = base_relation
  end

  def show; end

  def edit; end

  def update
    if @business.update(business_params)
      redirect_to @business, notice: "Business was successfully updated.", status: :see_other
    else
      render :edit, status: :unprocessable_entity
    end
  end

  private

  def base_relation
    current_user.businesses
  end

  def set_business
    @business = base_relation.find(params[:id])
  end

  def business_params
    params.require(:business).permit(:name)
  end
end
# app/controllers/employments_controller.rb

class EmploymentsController < ApplicationController
  before_action :set_employment, only: %i[ show edit update destroy ]

  def index
    @employments = base_relation
  end

  def ended
    @employments = current_business.employments.unscope(:where).ended
  end

  def show; end

  def edit; end

  def update
    if @employment.update(employment_params)
      redirect_to @employment, notice: "Employment was successfully updated.", status: :see_other
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    if @employment.end
      redirect_to ended_employments_url, notice: "Employment was successfully ended."
    else
      @employment.errors.add(:base, "Employment could not be ended.")
      render :edit, status: :unprocessable_entity
    end
  end

  private

  def base_relation
    current_business.employments.active
  end

  def set_employment
    @employment = base_relation.find(params[:id])
  end

  def employment_params
    params.require(:employment).permit(:user_id, :business_id, :started_at, :ended_at)
  end
end

# app/controllers/invites_controller.rb

class InvitesController < ApplicationController
  before_action :set_invite, only: %i[ show edit update destroy ]

  def index
    @invites = base_relation
  end

  def show; end

  def new
    @invite = base_relation.new(sender: current_employment)
    @invite.recipient = current_business.employments.build
  end

  def edit; end

  def create
    @invite = base_relation.new({ sender: current_employment }.merge(invite_params))
    @invite.recipient.business = current_business

    if @invite.save
      redirect_to @invite, notice: "Invite was successfully created."
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @invite.update(invite_params)
      redirect_to @invite, notice: "Invite was successfully updated.", status: :see_other
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @invite.destroy
    redirect_to invites_url, notice: "Invite was successfully destroyed.", status: :see_other
  end

  private

  def set_invite
    @invite = base_relation.find(params[:id])
  end

  def base_relation
    current_business.invites
  end

  def invite_params
    params.require(:invite).permit(:email, :resend, recipient_attributes: [:id, :role])
  end
end

Views

Let's first remove the views we don't need:

rm app/views/businesses/new.html.erb app/views/employments/new.html.erb

Now for the views let's add the recipient object fields to the invite form:

<!-- app/views/invites/_form.html.erb -->

<%= form_with(model: invite, class: "contents") do |form| %>
  <% if invite.errors.any? %>
    <div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
      <h2><%= pluralize(invite.errors.count, "error") %> prohibited this invite from being saved:</h2>

      <ul>
        <% invite.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="my-5">
    <%= form.label :email %>
    <%= form.email_field :email, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
  </div>

  <%= form.fields_for :recipient do |rf| %>
    <div class="my-5">
      <%= rf.label :role %>
      <%= rf.select :role, Employment::ROLES, { include_blank: true }, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
    </div>
  <% end %>

  <% if form.object.persisted? %>
    <div class="my-5">
      <%= form.check_box :resend, class: "inline-block rounded-md border border-gray-200 outline-none mr-2" %>
      <%= form.label :resend %>
    </div>
  <% end %>

  <div class="inline">
    <%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
  </div>
<% end %>

You can copy the rest of the views out of the full diff below as there are a lot of repetitive changes.

Mailer

Now let's setup the mailer and remove the text views for brevity:

rails g mailer Invite new_user existing_user
rm app/views/invite_mailer/new_user.text.erb app/views/invite_mailer/existing_user.text.erb
# app/mailers/invite_mailer.rb

class InviteMailer < ApplicationMailer
  def existing_user(invite)
    @invite = invite
    @user_registration_url = new_user_registration_url(invite_token: @invite.token)
    mail to: @invite.email
  end

  def new_user(invite)
    @invite = invite
    @user_registration_url = new_user_registration_url(invite_token: @invite.token)
    mail to: @invite.email
  end
end
en:
  invite_mailer:
    new_user:
      subject: You have been invited to join us new
    existing_user:
      subject: You have been invited to join us existing
<!-- app/views/invite_mailer/new_user.html.erb -->
<p>Hi <%= @invite %>,</p>

<p><%= @invite.sender %> with <%= @invite.invitable %> has invited you to collaborate with them. Use the link below to set up your account and get started:</p>

<p><%= link_to "Set up account", new_user_registration_url(invite_token: @invite.token) %></p>

We use the same view for existing_user.html.erb

We can now uncomment the mailer code in the invite model:

# app/models/invite.rb

def send_invite_email
  InviteMailer.send(user? ? :existing_user : :new_user, self).deliver_later
end

You can now give the invite process a test - you should receive an email with a link to sign up. Follow that in an incognito window and you will be added to the business.

Try creating another business in another browser and inviting the same email to the other business. The invitee should be able to accept then switch between businesses.

Testing

Test framework setup

We use our standard test framework setup layer which includes RSpec, FactoryBot, Cuprite and Capybara, based on the Evil Martians system of a test.

 Factories

Let's add some factories:

touch {spec/factories/invites.rb,spec/factories/users.rb,spec/factories/businesses.rb,spec/factories/employments.rb}

Integration tests

touch spec/system/end_employment_spec.rb
touch spec/system/invite_existing_user_spec.rb
touch spec/system/invite_new_user_spec.rb
touch spec/system/resend_invite_spec.rb
touch spec/system/sign_up_spec.rb
touch spec/system/switch_between_employments_spec.rb
# spec/system/end_employment_spec.rb

require "system_helper"

RSpec.describe "End an employment" do
  let(:business) { create(:business, :with_user) }
  let(:user) { business.users.first }
  let!(:other_employment) { create(:employment, business: business) }
  let(:other_user) { other_employment.user }

  it "allows a user to end an employment" do
    using_session "User" do
      sign_in user

      visit employments_path

      within "#employment_#{other_employment.id}" do
        accept_confirm do
          click_on "End this employment"
        end
      end

      expect(page).to have_content("Employment was successfully ended.")
      expect(page).to have_content("Ended employments")
      expect(page).to have_content(other_user.email)

      visit employments_path

      expect(page).not_to have_content(other_user.email)
    end

    using_session "Other user" do
      sign_in other_user

      visit businesses_path

      expect(page).not_to have_content(business.name)
    end
  end
end
# spec/system/invite_existing_user_spec.rb
require "system_helper"

RSpec.describe "Invite an existing user" do
  let(:business) { create(:business, :with_user) }
  let(:other_business) { create(:business, :with_user) }
  let(:inviter) { business.users.first }
  let(:invitee) { other_business.users.first }

  it "allows us to invite an existing user to the system" do
    sign_in inviter

    visit new_invite_path

    fill_in "Email", with: invitee.email
    select 'employee', from: 'Role'
    click_button "Create Invite"

    expect(page).to have_content("Invite was successfully created.")

    using_session "Other user" do
      open_email(invitee.email)

      expect(current_email.subject).to have_content "You have been invited to join us existing"
      expect(current_email).to have_content invitee.email
      expect(current_email).to have_content inviter.email
      expect(current_email).to have_content business.name

      current_email.click_link "Set up account"

      expect(page).to have_content("You are being invited to join the #{business.name} team.")

      expect(page).to have_content("You are already a member of the following teams:")
      expect(page).to have_content(other_business.name)

      click_on "Accept invitation"

      expect(page).to have_content("Welcome! You have signed up successfully.")

      click_on "Businesses"

      expect(page).to have_content(business.name)
    end
  end
end
# spec/system/invite_new_user_spec.rb

require "system_helper"

RSpec.describe "Invite a new user to a business" do
  let(:business) { create(:business, :with_user) }
  let(:inviter) { business.users.first }
  let(:invitee) { build(:user) }

  it "allows a user to sign up as a business" do
    sign_in inviter

    visit new_invite_path

    fill_in "Email", with: invitee.email
    select 'employee', from: 'Role'
    click_button "Create Invite"

    expect(page).to have_content("Invite was successfully created.")

    using_session "Other user" do
      open_email(invitee.email)

      expect(current_email.subject).to have_content "You have been invited to join us new"
      expect(current_email).to have_content invitee.email
      expect(current_email).to have_content inviter.email
      expect(current_email).to have_content business.name

      current_email.click_on "Set up account"

      expect(page).to have_content("You are being invited to join the #{business.name} team.")

      expect(page).to have_field("Email", with: invitee.email)

      fill_in "Password", with: invitee.password
      fill_in "Password confirmation", with: invitee.password

      click_on "Sign up"

      expect(page).to have_content("Welcome! You have signed up successfully.")

      click_on "Businesses"

      expect(page).to have_content(business.name)

      click_on "Home"
      click_on "Employments"

      expect(page).to have_content(inviter.email)
      expect(page).to have_content(invitee.email)
    end
  end
end
# spec/system/resend_invite_spec.rb

require "system_helper"

RSpec.describe "Resend invite" do
  let(:business) { create(:business, :with_user) }
  let(:user) { business.users.first }
  let!(:invite) { create(:invite, invitable: business, sender: business.employments.first) }

  it "allows a business to resend an invite unedited" do
    clear_emails

    sign_in user

    visit invites_path

    click_on "Edit this invite"
    check "Resend"
    click_on "Update Invite"

    expect(page).to have_content("Invite was successfully updated")

    open_email(invite.email)
    expect(current_email.subject).to have_content "You have been invited to join us new"
  end

  it "allows a business to resend an invite and edit it" do
    new_email = "new@email.com"
    clear_emails

    sign_in user

    visit invites_path

    click_on "Edit this invite"
    fill_in "Email", with: new_email
    check "Resend"
    click_on "Update Invite"

    expect(page).to have_content("Invite was successfully updated")

    expect(page).to have_content(new_email)

    open_email(new_email)

    expect(current_email.subject).to have_content "You have been invited to join us new"
  end
end
# spec/system/sign_up_spec.rb

require "system_helper"

RSpec.describe "Signup with Business" do
  let(:business) { build(:business) }
  let(:user) { build(:user) }

  it "allows a user to sign up as a business" do
    visit new_user_registration_path

    fill_in "Business name", with: business.name
    fill_in "Email", with: user.email
    fill_in "Password", with: user.password
    fill_in "Password confirmation", with: user.password
    click_button "Sign up"

    expect(page).to have_content("Welcome! You have signed up successfully.")

    click_on "Businesses"

    expect(page).to have_content(business.name)

    click_on "Home"
    click_on "Employments"

    expect(page).to have_content(user.email)

    click_on "Home"
    click_on "Sign out"

    expect(page).to have_content("Signed out successfully.")
  end
end
# spec/system/switch_between_employments_spec.rb

require "system_helper"

RSpec.describe "Switch between employments" do
  let(:business) { create(:business, :with_user) }
  let(:other_business) { create(:business, :with_user) }
  let(:user) { business.users.first }
  let(:other_user) { other_business.users.first }
  let!(:employment) { create(:employment, :started, user: other_user, business:) }


  it "allows a user to switch between multiple employments" do
    sign_in other_user

    visit root_path

    expect(page).to have_content("Current business: #{business.name}")
    click_on "Switch to: #{other_business.name}"

    expect(page).to have_content("Current business: #{other_business.name}")
    click_on "Switch to: #{business.name}"

    expect(page).to have_content("Current business: #{business.name}")
  end
end

Todo

Full code example

Full diff