Lou
Lou

Reputation: 166

Cross controllers test in Rails

In a rails 6.1.3.1 API, I would like to implement an unusual test. Basically, a user can create a team with a many-to-many relational model. A team creation automatically triggers a new entry in the table JoinedTeamUser with two references (user_id and team_id), and a boolean can_edit in order to know whether the user can update/delete or not a team details.
What I would like to test is if a user accessing the update or destroy method of the team controller has the right to do so (meaning, the right entry in a model depending of another controller).

To give you a better view of the process, here is the file seed.rb.

3.times do |i|
  team = Team.create(name: Faker::Company.name, description: Faker::Company.catch_phrase)
  puts "Created TEAM ##{i+1} - #{team.name}"
end

10.times do |i|
  name = Faker::Name.first_name.downcase
  user = User.create! username: "#{name}", email: "#{name}@email.com", password: "azerty"
  puts "Created USER ##{i+1} - #{user.username}"
  
  joined_team_users = JoinedTeamUser.create!(
    user_id: user.id,
    team_id: Team.all.sample.id,
    can_edit: true
  )
  puts "USER ##{joined_team_users.user_id} joined TEAM ##{joined_team_users.team_id}"
end

Edit: As requested, here is the team controller (the filter of authorized to update user has still not been implemented) :

class Api::V1::TeamsController < ApplicationController
  before_action :set_team, only: %i[show update destroy]
  before_action :check_login

  def index
    render json: TeamSerializer.new(Team.all).serializable_hash.to_json
  end

  def show
    render json: TeamSerializer.new(@team).serializable_hash.to_json
  end

  def create
    team = current_user.teams.build(team_params)

    if team.save 
      if current_user&.is_admin === false
        JoinedTeamUser.create!(
          user_id: current_user.id,
          team_id: team.id,
          can_edit: true
        )
        render json: TeamSerializer.new(team).serializable_hash.to_json, status: :created
      else
        render json: TeamSerializer.new(team).serializable_hash.to_json, status: :created
      end
    else 
      render json: {errors: team.errors }, status: :unprocessable_entity
    end
  end

  def update
    if @team.update(team_params)
      render json: TeamSerializer.new(@team).serializable_hash.to_json, status: :ok
    else
      render json: @team.errors, status: :unprocessable_entity
    end
  end

  def destroy
    @team.destroy
    head 204
  end

  private
  
  def team_params
    params.require(:team).permit(:name, :description, :created_at, :updated_at)
  end

  def set_team
    @team = Team.find(params[:id])
  end

end

and the team controller tests :

require "test_helper"

class Api::V1::TeamsControllerTest < ActionDispatch::IntegrationTest
  setup do
    @team = teams(:one)
    @user = users(:one)
  end

  # INDEX
  test "should access team index" do
    get api_v1_teams_url,
    headers: { Authorization: JsonWebToken.encode(user_id: @user.id) }, 
    as: :json
    assert_response :success
  end

  test "should forbid team index" do
    get api_v1_teams_url, as: :json
    assert_response :forbidden
  end

  # SHOW
  test "should show team" do
    get api_v1_team_url(@team), 
    headers: { Authorization: JsonWebToken.encode(user_id: @user.id) }, 
    as: :json
    assert_response :success
  end

  # SHOW
  test "should forbid show team" do
    get api_v1_team_url(@team),
    as: :json
    assert_response :forbidden
  end

  # CREATE
  test "should create team" do
    assert_difference('Team.count') do
      post api_v1_teams_url,
      params: { team: { name: "Random name", description: "Random description" } },
      headers: { Authorization: JsonWebToken.encode(user_id: @user.id) },
      as: :json
    end
    assert_response :created
  end

  test "should not create team when not logged in" do
    assert_no_difference('Team.count') do
      post api_v1_teams_url,
      params: { team: { name: "Random name", description: "Random description" } },
      as: :json
    end
    assert_response :forbidden
  end

  test "should not create team with taken name" do
    assert_no_difference('Team.count') do
      post api_v1_teams_url,
      params: { team: { name: @team.name, description: "Random description" } },
      headers: { Authorization: JsonWebToken.encode(user_id: @user.id) },
      as: :json
    end
    assert_response :unprocessable_entity
  end

  test "should not create team without name" do
    assert_no_difference('Team.count') do
      post api_v1_teams_url,
      params: { team: { description: "Random description"} },
      headers: { Authorization: JsonWebToken.encode(user_id: @user.id) },
      as: :json
    end
    assert_response :unprocessable_entity
  end
  
  # UPDATE
  test "should update team" do
    patch api_v1_team_url(@team),
    params: { team: { name: "New name", description: "New description" } },
    headers: { Authorization: JsonWebToken.encode(user_id: @user.id) },
    as: :json
    assert_response :success
  end

  test "should not update team " do
    patch api_v1_team_url(@team),
    params: { team: { name: "New name", description: "New description" } },
    as: :json
    assert_response :forbidden
  end


  # DESTROY
  test "should destroy team" do
    assert_difference('Team.count', -1) do
      delete api_v1_team_url(@team),
      headers: { Authorization: JsonWebToken.encode(user_id: @user.id) },
      as: :json
    end
    assert_response :no_content
  end

  test "should forbid destroy team" do
    assert_no_difference('Team.count') do
      delete api_v1_team_url(@team), as: :json
    end
    assert_response :forbidden
  end
end

Thanks !

Upvotes: 0

Views: 59

Answers (1)

max
max

Reputation: 102036

I would start by actually fixing the models.

class Team < ApplicationRecord
  has_many :memberships
  has_many :members, through: :memberships
end

class Membership < ApplicationRecord
  belongs_to :team
  belongs_to :member, class_name: 'User'
end

class User < ApplicationRecord
  has_many :memberships, 
     foreign_key: :member_id
  has_many :teams, 
     through: :memberships
end

Joins should never be a part of the name of your model. Name your models after what they represent in your domain, not as peices of plumbing.

Whatever is_admin is should be admin?.

If you want to create a team and make the user who created the team a member at the same time you want to do:

# See https://github.com/rubocop/ruby-style-guide#namespace-definition
module Api
  module V1
    class TeamsController < ApplicationController
      def create
        @team = Team.new(team_params)
        @team.memberships.new(
          member: current_user,
          can_edit: current_user.admin?
        )
        if team.save 
          # why is your JSON rendering so clunky?
          render json: TeamSerializer.new(team).serializable_hash.to_json, status: :created
        else
          render json: {errors: team.errors }, status: :unprocessable_entity
        end
      end

      # ...
    end
  end
end

This will insert the Team and Membership in a single transaction and avoids creating more code paths in the controller.

require "test_helper"

module API
  module V1
    class TeamsControllerTest < ActionDispatch::IntegrationTest
      setup do
        @team = teams(:one)
        @user = users(:one)
      end

      # ...
      test "when creating a team the creator is added as a member" do
        post api_v1_teams_url,
          # add valid parameters to create a team
          params: { team: { ... } }, 
          # repeating this all over your tests is very smelly
          headers: { Authorization: JsonWebToken.encode(user_id: @user.id) },
          as: :json
        assert @user.team_memberships.exist?(
          team_id: Team.last.id
        ), 'expected user to be a member of team'
      end
    end
  end
end

As for actually adding the authorization checks I would recommend that you look into Pundit instead of reinventing the wheel, this also has a large degree of overlap with Rolify which is a much better alternative then adding a growning of can_x boolean columns.

Upvotes: 1

Related Questions