See Project Run

I'm so excited to say that SeeProjectRun is now live and open to the public. I'm going to try and blog about the more technical hurdles I've overcome here and keep the startup posts over on the SeeProjectRun Blog.

If you're a freelancer or run your own business, I'd appreciate if you signed up and let me know what you think. All accounts have a 30 day free trial.

Eric Davis

Tagged: business seeprojectrun

The Refactoring

After a two hour Developer Meeting for Redmine this morning, I'm too drained to tackle the entire refactoring for #find_optional_project. I did use pull up method to remove the duplication in the GanttsController and IssuesController though.

Before

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  def find_optional_project
    @project = Project.find(params[:project_id]) unless params[:project_id].blank?
    allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
    allowed ? true : deny_access
  rescue ActiveRecord::RecordNotFound
    render_404
  end
end
# app/controllers/gantts_controller.rb
class GanttsController < ApplicationController
  def find_optional_project
    @project = Project.find(params[:project_id]) unless params[:project_id].blank?
    allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
    allowed ? true : deny_access
  rescue ActiveRecord::RecordNotFound
    render_404
  end
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # ...
end

After

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  # ...
end
# app/controllers/gantts_controller.rb
class GanttsController < ApplicationController
  # ...
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  def find_optional_project
    @project = Project.find(params[:project_id]) unless params[:project_id].blank?
    allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
    allowed ? true : deny_access
  rescue ActiveRecord::RecordNotFound
    render_404
  end
end

Review

This finishes up my refactoring of the GanttsController. I'm leaving the refactoring of the other duplicated #find_optional_project methods for another time.

Reference commit

Tagged: ruby redmine refactoring pull-up-method

The Refactoring

Today I cleaned up one of the duplications from yesterday's refactoring, using pull up method.

Before

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  def query_statement_invalid(exception)
    logger.error "Query::StatementInvalid: #{exception.message}" if logger
    session.delete(:query)
    sort_clear
    render_error "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
  end
end
# app/controllers/gantts_controller.rb
class GanttsController < ApplicationController
  def query_statement_invalid(exception)
    logger.error "Query::StatementInvalid: #{exception.message}" if logger
    session.delete(:query)
    sort_clear
    render_error "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
  end
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # ...
end

After

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  # ...
end
# app/controllers/gantts_controller.rb
class GanttsController < ApplicationController
  # ...
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  def query_statement_invalid(exception)
    logger.error "Query::StatementInvalid: #{exception.message}" if logger
    session.delete(:query)
    sort_clear if respond_to?(:sort_clear)
    render_error "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
  end
end

Review

This leaves one duplicated method left, GanttsController#find_optional_project. It turns out, it's duplicated seven times in Redmine but with slightly different content each time. Fixing this should be a good refactoring to wrap up the week.

Reference commit

Tagged: ruby redmine refactoring pull-up-method

The Refactoring

Today I finally tackled a larger refactoring that I've been wanting to do for awhile now. Redmine's IssuesController has accumulated a lot of extra actions over the years, one of which is an action that renders a Gantt chart. This has never made sense to me, since the Gantt chart collects a bunch of data from the project and it isn't only about issues. Today, I was able to use extract class and move the #gantt action to it's own controller.

Before

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  before_filter :authorize, :except => [:index, :changes, :gantt, :calendar, :preview, :context_menu]
  before_filter :find_optional_project, :only => [:index, :changes, :gantt, :calendar]

  def gantt
    @gantt = Redmine::Helpers::Gantt.new(params)
    retrieve_query
    @query.group_by = nil
    if @query.valid?
      events = []
      # Issues that have start and due dates
      events += @query.issues(:include => [:tracker, :assigned_to, :priority],
                              :order => "start_date, due_date",
                              :conditions => ["(((start_date>=? and start_date<=?) or (due_date>=? and due_date<=?) or (start_date<? and due_date>?)) and start_date is not null and due_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
                              )
      # Issues that don't have a due date but that are assigned to a version with a date
      events += @query.issues(:include => [:tracker, :assigned_to, :priority, :fixed_version],
                              :order => "start_date, effective_date",
                              :conditions => ["(((start_date>=? and start_date<=?) or (effective_date>=? and effective_date<=?) or (start_date<? and effective_date>?)) and start_date is not null and due_date is null and effective_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
                              )
      # Versions
      events += @query.versions(:conditions => ["effective_date BETWEEN ? AND ?", @gantt.date_from, @gantt.date_to])
                                   
      @gantt.events = events
    end
    
    basename = (@project ? "#{@project.identifier}-" : '') + 'gantt'
    
    respond_to do |format|
      format.html { render :template => "issues/gantt.rhtml", :layout => !request.xhr? }
      format.png  { send_data(@gantt.to_image, :disposition => 'inline', :type => 'image/png', :filename => "#{basename}.png") } if @gantt.respond_to?('to_image')
      format.pdf  { send_data(gantt_to_pdf(@gantt, @project), :type => 'application/pdf', :filename => "#{basename}.pdf") }
    end
  end
  
end

After

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  before_filter :authorize, :except => [:index, :changes, :calendar, :preview, :context_menu]
  before_filter :find_optional_project, :only => [:index, :changes, :calendar]

  # Removed gantt method
end
# app/controllers/gantts_controller.rb
class GanttsController < ApplicationController
  before_filter :find_optional_project

  rescue_from Query::StatementInvalid, :with => :query_statement_invalid

  helper :issues
  helper :projects
  helper :queries
  include QueriesHelper
  include Redmine::Export::PDF
  
  def show
    @gantt = Redmine::Helpers::Gantt.new(params)
    retrieve_query
    @query.group_by = nil
    if @query.valid?
      events = []
      # Issues that have start and due dates
      events += @query.issues(:include => [:tracker, :assigned_to, :priority],
                              :order => "start_date, due_date",
                              :conditions => ["(((start_date>=? and start_date<=?) or (due_date>=? and due_date<=?) or (start_date<? and due_date>?)) and start_date is not null and due_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
                              )
      # Issues that don't have a due date but that are assigned to a version with a date
      events += @query.issues(:include => [:tracker, :assigned_to, :priority, :fixed_version],
                              :order => "start_date, effective_date",
                              :conditions => ["(((start_date>=? and start_date<=?) or (effective_date>=? and effective_date<=?) or (start_date<? and effective_date>?)) and start_date is not null and due_date is null and effective_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
                              )
      # Versions
      events += @query.versions(:conditions => ["effective_date BETWEEN ? AND ?", @gantt.date_from, @gantt.date_to])
                                   
      @gantt.events = events
    end
    
    basename = (@project ? "#{@project.identifier}-" : '') + 'gantt'
    
    respond_to do |format|
      format.html { render :action => "show", :layout => !request.xhr? }
      format.png  { send_data(@gantt.to_image, :disposition => 'inline', :type => 'image/png', :filename => "#{basename}.png") } if @gantt.respond_to?('to_image')
      format.pdf  { send_data(gantt_to_pdf(@gantt, @project), :type => 'application/pdf', :filename => "#{basename}.pdf") }
    end
  end

  private

  # Rescues an invalid query statement. Just in case...
  # TODO: Refactor, move to ApplicationController with IssuesController
  def query_statement_invalid(exception)
    logger.error "Query::StatementInvalid: #{exception.message}" if logger
    session.delete(:query)
    sort_clear
    render_error "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
  end

  # TODO: Refactor, duplicates IssuesController
  def find_optional_project
    @project = Project.find(params[:project_id]) unless params[:project_id].blank?
    allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
    allowed ? true : deny_access
  rescue ActiveRecord::RecordNotFound
    render_404
  end
end

Review

In order to make this refactoring work, I had to duplicate a few of the private utility methods from IssuesController. Even if those don't get refactored soon, this refactoring is still a great win because of the responsibility split. There is some additional code that was needed in this commit that I've left out of this post, including the routing and view changes.

Reference commit

Tagged: ruby redmine refactoring extract-class

The Refactoring

To continue refactoring Redmine's IssuesController, I used move method to move a utility method to the QueriesHelper.

Before

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  # Retrieve query from session or build a new query
  def retrieve_query
    if !params[:query_id].blank?
      cond = "project_id IS NULL"
      cond << " OR project_id = #{@project.id}" if @project
      @query = Query.find(params[:query_id], :conditions => cond)
      @query.project = @project
      session[:query] = {:id => @query.id, :project_id => @query.project_id}
      sort_clear
    else
      if api_request? || params[:set_filter] || session[:query].nil? || session[:query][:project_id] != (@project ? @project.id : nil)
        # Give it a name, required to be valid
        @query = Query.new(:name => "_")
        @query.project = @project
        if params[:fields] and params[:fields].is_a? Array
          params[:fields].each do |field|
            @query.add_filter(field, params[:operators][field], params[:values][field])
          end
        else
          @query.available_filters.keys.each do |field|
            @query.add_short_filter(field, params[field]) if params[field]
          end
        end
        @query.group_by = params[:group_by]
        @query.column_names = params[:query] && params[:query][:column_names]
        session[:query] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by, :column_names => @query.column_names}
      else
        @query = Query.find_by_id(session[:query][:id]) if session[:query][:id]
        @query ||= Query.new(:name => "_", :project => @project, :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names])
        @query.project = @project
      end
    end
  end
end

After

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  # ...
end
module QueriesHelper
  # Retrieve query from session or build a new query
  def retrieve_query
    if !params[:query_id].blank?
      cond = "project_id IS NULL"
      cond << " OR project_id = #{@project.id}" if @project
      @query = Query.find(params[:query_id], :conditions => cond)
      @query.project = @project
      session[:query] = {:id => @query.id, :project_id => @query.project_id}
      sort_clear
    else
      if api_request? || params[:set_filter] || session[:query].nil? || session[:query][:project_id] != (@project ? @project.id : nil)
        # Give it a name, required to be valid
        @query = Query.new(:name => "_")
        @query.project = @project
        if params[:fields] and params[:fields].is_a? Array
          params[:fields].each do |field|
            @query.add_filter(field, params[:operators][field], params[:values][field])
          end
        else
          @query.available_filters.keys.each do |field|
            @query.add_short_filter(field, params[field]) if params[field]
          end
        end
        @query.group_by = params[:group_by]
        @query.column_names = params[:query] && params[:query][:column_names]
        session[:query] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by, :column_names => @query.column_names}
      else
        @query = Query.find_by_id(session[:query][:id]) if session[:query][:id]
        @query ||= Query.new(:name => "_", :project => @project, :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names])
        @query.project = @project
      end
    end
  end
end

Review

Though this refactoring is simple, it will help share the #retrieve_query method with other controllers. This will let me start to split up the 17 actions in IssuesController into new controllers.

Reference commit

Tagged: ruby redmine refactoring move-method

The Refactoring

Mark Thomas pointed out some odd code in Redmine's IssuesController#build_new_issue_from_params, specifically that it's doing some error checking in the middle of a method body.

Before

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  def build_new_issue_from_params
    @issue = Issue.new
    @issue.copy_from(params[:copy_from]) if params[:copy_from]
    @issue.project = @project
    # Tracker must be set before custom field values
    @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
    if @issue.tracker.nil?
      render_error l(:error_no_tracker_in_project)
      return false
    end
    if @issue.status.nil?
      render_error l(:error_no_default_issue_status)
      return false
    end
    if params[:issue].is_a?(Hash)
      @issue.safe_attributes = params[:issue]
      @issue.watcher_user_ids = params[:issue]['watcher_user_ids'] if User.current.allowed_to?(:add_issue_watchers, @project)
    end
    @issue.author = User.current
    @issue.start_date ||= Date.today
    @priorities = IssuePriority.all
    @allowed_statuses = @issue.new_statuses_allowed_to(User.current, true)
  end

end

After

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  before_filter :check_for_default_issue_status, :only => [:new, :create]

  def build_new_issue_from_params
    @issue = Issue.new
    @issue.copy_from(params[:copy_from]) if params[:copy_from]
    @issue.project = @project
    # Tracker must be set before custom field values
    @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
    if @issue.tracker.nil?
      render_error l(:error_no_tracker_in_project)
      return false
    end
    if params[:issue].is_a?(Hash)
      @issue.safe_attributes = params[:issue]
      @issue.watcher_user_ids = params[:issue]['watcher_user_ids'] if User.current.allowed_to?(:add_issue_watchers, @project)
    end
    @issue.author = User.current
    @issue.start_date ||= Date.today
    @priorities = IssuePriority.all
    @allowed_statuses = @issue.new_statuses_allowed_to(User.current, true)
  end

  def check_for_default_issue_status
    if IssueStatus.default.nil?
      render_error l(:error_no_default_issue_status)
      return false
    end
  end

end

Review

The error checking almost looks like they should be validations but they are done systemwide. By using extract method I was able to get the error checking out of the action until I can figure out the best way to handle systemwide validations.

Reference commit

Tagged: ruby redmine refactoring extract-method

The Refactoring

Today's refactoring follows up on yesterday's. Now that I have the #new and #create actions for REST, I can start to remove the duplication I introduced.

Before

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  def new
    @issue = Issue.new
    @issue.copy_from(params[:copy_from]) if params[:copy_from]
    @issue.project = @project
    # Tracker must be set before custom field values
    @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
    if @issue.tracker.nil?
      render_error l(:error_no_tracker_in_project)
      return
    end
    if @issue.status.nil?
      render_error l(:error_no_default_issue_status)
      return
    end
    if params[:issue].is_a?(Hash)
      @issue.safe_attributes = params[:issue]
      @issue.watcher_user_ids = params[:issue]['watcher_user_ids'] if User.current.allowed_to?(:add_issue_watchers, @project)
    end
    @issue.author = User.current
    @issue.start_date ||= Date.today
    @priorities = IssuePriority.all
    @allowed_statuses = @issue.new_statuses_allowed_to(User.current, true)
    render :action => 'new', :layout => !request.xhr?
  end

  def create
    @issue = Issue.new
    @issue.copy_from(params[:copy_from]) if params[:copy_from]
    @issue.project = @project
    # Tracker must be set before custom field values
    @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
    if @issue.tracker.nil?
      render_error l(:error_no_tracker_in_project)
      return
    end
    if @issue.status.nil?
      render_error l(:error_no_default_issue_status)
      return
    end
    if params[:issue].is_a?(Hash)
      @issue.safe_attributes = params[:issue]
      @issue.watcher_user_ids = params[:issue]['watcher_user_ids'] if User.current.allowed_to?(:add_issue_watchers, @project)
    end
    @issue.author = User.current

    @priorities = IssuePriority.all
    @allowed_statuses = @issue.new_statuses_allowed_to(User.current, true)

    call_hook(:controller_issues_new_before_save, { :params => params, :issue => @issue })
    if @issue.save
      attachments = Attachment.attach_files(@issue, params[:attachments])
      render_attachment_warning_if_needed(@issue)
      flash[:notice] = l(:notice_successful_create)
      call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
      respond_to do |format|
        format.html {
          redirect_to(params[:continue] ? { :action => 'new', :issue => {:tracker_id => @issue.tracker, :parent_issue_id => @issue.parent_issue_id}.reject {|k,v| v.nil?} } :
                      { :action => 'show', :id => @issue })
        }
        format.xml  { render :action => 'show', :status => :created, :location => url_for(:controller => 'issues', :action => 'show', :id => @issue) }
      end
      return
    else
      respond_to do |format|
        format.html { render :action => 'new' }
        format.xml  { render(:xml => @issue.errors, :status => :unprocessable_entity); return }
      end
    end
  end
end

After

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  before_filter :build_new_issue_from_params, :only => [:new, :create]

  def new
    render :action => 'new', :layout => !request.xhr?
  end

  def create
    call_hook(:controller_issues_new_before_save, { :params => params, :issue => @issue })
    if @issue.save
      attachments = Attachment.attach_files(@issue, params[:attachments])
      render_attachment_warning_if_needed(@issue)
      flash[:notice] = l(:notice_successful_create)
      call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
      respond_to do |format|
        format.html {
          redirect_to(params[:continue] ? { :action => 'new', :issue => {:tracker_id => @issue.tracker, :parent_issue_id => @issue.parent_issue_id}.reject {|k,v| v.nil?} } :
                      { :action => 'show', :id => @issue })
        }
        format.xml  { render :action => 'show', :status => :created, :location => url_for(:controller => 'issues', :action => 'show', :id => @issue) }
      end
      return
    else
      respond_to do |format|
        format.html { render :action => 'new' }
        format.xml  { render(:xml => @issue.errors, :status => :unprocessable_entity); return }
      end
    end
  end

  private

  def build_new_issue_from_params
    @issue = Issue.new
    @issue.copy_from(params[:copy_from]) if params[:copy_from]
    @issue.project = @project
    # Tracker must be set before custom field values
    @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
    if @issue.tracker.nil?
      render_error l(:error_no_tracker_in_project)
      return false
    end
    if @issue.status.nil?
      render_error l(:error_no_default_issue_status)
      return false
    end
    if params[:issue].is_a?(Hash)
      @issue.safe_attributes = params[:issue]
      @issue.watcher_user_ids = params[:issue]['watcher_user_ids'] if User.current.allowed_to?(:add_issue_watchers, @project)
    end
    @issue.author = User.current
    @issue.start_date ||= Date.today
    @priorities = IssuePriority.all
    @allowed_statuses = @issue.new_statuses_allowed_to(User.current, true)
  end

end

Review

By using a before_filter I was able to move a bunch of the setup logic to #build_new_issue_from_params. It also let me return false from the before_filter and handle the two error states properly.

There still is a lot of work that's needed in IssuesController but now it finally matches the Rails REST format. I'm going to have to think what I want to do next:

  1. move the extra actions out of the controller, or
  2. try to take the controller the full way to a REST controller (e.g. map.resources :issues)

What do you think?

Reference commit

Tagged: ruby redmine refactoring extract-method

Now I'm starting to refactor Redmine's IssuesController again in order to have it match the standard Rails REST controllers. This time I'm working on the new/create action pair.

The Refactoring

This refactoring splits the single #new action into the two separate actions.

Before

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  before_filter :find_project, :only => [:new, :update_form, :preview, :auto_complete]

  def new
    @issue = Issue.new
    @issue.copy_from(params[:copy_from]) if params[:copy_from]
    @issue.project = @project
    # Tracker must be set before custom field values
    @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
    if @issue.tracker.nil?
      render_error l(:error_no_tracker_in_project)
      return
    end
    if @issue.status.nil?
      render_error l(:error_no_default_issue_status)
      return
    end
    if params[:issue].is_a?(Hash)
      @issue.safe_attributes = params[:issue]
      @issue.watcher_user_ids = params[:issue]['watcher_user_ids'] if User.current.allowed_to?(:add_issue_watchers, @project)
    end
    @issue.author = User.current
    
    if request.get? || request.xhr?
      @issue.start_date ||= Date.today
    else
      call_hook(:controller_issues_new_before_save, { :params => params, :issue => @issue })
      if @issue.save
        attachments = Attachment.attach_files(@issue, params[:attachments])
        render_attachment_warning_if_needed(@issue)
        flash[:notice] = l(:notice_successful_create)
        call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
        respond_to do |format|
          format.html {
            redirect_to(params[:continue] ? { :action => 'new', :issue => {:tracker_id => @issue.tracker, 
                                                                           :parent_issue_id => @issue.parent_issue_id}.reject {|k,v| v.nil?} } :
                                            { :action => 'show', :id => @issue })
          }
          format.xml  { render :action => 'show', :status => :created, :location => url_for(:controller => 'issues', :action => 'show', :id => @issue) }
        end
        return
      else
        respond_to do |format|
          format.html { }
          format.xml  { render(:xml => @issue.errors, :status => :unprocessable_entity); return }
        end
      end
    end 
    @priorities = IssuePriority.all
    @allowed_statuses = @issue.new_statuses_allowed_to(User.current, true)
    render :layout => !request.xhr?
  end

end

After

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  before_filter :find_project, :only => [:new, :create, :update_form, :preview, :auto_complete]

  verify :method => :post, :only => :create, :render => {:nothing => true, :status => :method_not_allowed }

  def new
    @issue = Issue.new
    @issue.copy_from(params[:copy_from]) if params[:copy_from]
    @issue.project = @project
    # Tracker must be set before custom field values
    @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
    if @issue.tracker.nil?
      render_error l(:error_no_tracker_in_project)
      return
    end
    if @issue.status.nil?
      render_error l(:error_no_default_issue_status)
      return
    end
    if params[:issue].is_a?(Hash)
      @issue.safe_attributes = params[:issue]
      @issue.watcher_user_ids = params[:issue]['watcher_user_ids'] if User.current.allowed_to?(:add_issue_watchers, @project)
    end
    @issue.author = User.current
    @issue.start_date ||= Date.today
    @priorities = IssuePriority.all
    @allowed_statuses = @issue.new_statuses_allowed_to(User.current, true)
    render :action => 'new', :layout => !request.xhr?
  end

  def create
    @issue = Issue.new
    @issue.copy_from(params[:copy_from]) if params[:copy_from]
    @issue.project = @project
    # Tracker must be set before custom field values
    @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
    if @issue.tracker.nil?
      render_error l(:error_no_tracker_in_project)
      return
    end
    if @issue.status.nil?
      render_error l(:error_no_default_issue_status)
      return
    end
    if params[:issue].is_a?(Hash)
      @issue.safe_attributes = params[:issue]
      @issue.watcher_user_ids = params[:issue]['watcher_user_ids'] if User.current.allowed_to?(:add_issue_watchers, @project)
    end
    @issue.author = User.current

    @priorities = IssuePriority.all
    @allowed_statuses = @issue.new_statuses_allowed_to(User.current, true)

    call_hook(:controller_issues_new_before_save, { :params => params, :issue => @issue })
    if @issue.save
      attachments = Attachment.attach_files(@issue, params[:attachments])
      render_attachment_warning_if_needed(@issue)
      flash[:notice] = l(:notice_successful_create)
      call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
      respond_to do |format|
        format.html {
          redirect_to(params[:continue] ? { :action => 'new', :issue => {:tracker_id => @issue.tracker, :parent_issue_id => @issue.parent_issue_id}.reject {|k,v| v.nil?} } :
                      { :action => 'show', :id => @issue })
        }
        format.xml  { render :action => 'show', :status => :created, :location => url_for(:controller => 'issues', :action => 'show', :id => @issue) }
      end
      return
    else
      respond_to do |format|
        format.html { render :action => 'new' }
        format.xml  { render(:xml => @issue.errors, :status => :unprocessable_entity); return }
      end
    end
  end

end

Review

This helped #new because it's now not concerned with creating a new Issue, it only builds an Issue object for the view. Now #create will only need to be concerned with saving the Issue.

This refactoring did introduce a lot of duplication, which I'll be resolving over the next few days. Something taking on more code debt, in the form of duplication, is required over the short term in order to reach your goal.

Reference commit

Tagged: ruby redmine refactoring extract-method

The Refactoring

This morning I finished the tedious job of moving all of Redmine's routing tests into the single RoutingTest integration test. I'm only going to show the post refactoring code, the before code looked just like yesterday.

# test/integration/routing_test.rb
class RoutingTest < ActionController::IntegrationTest
  context "activities" do
    should_route :get, "/activity", :controller => 'projects', :action => 'activity', :id => nil
    should_route :get, "/activity.atom", :controller => 'projects', :action => 'activity', :id => nil, :format => 'atom'
  end

  context "attachments" do
    should_route :get, "/attachments/1", :controller => 'attachments', :action => 'show', :id => '1'
    should_route :get, "/attachments/1/filename.ext", :controller => 'attachments', :action => 'show', :id => '1', :filename => 'filename.ext'
    should_route :get, "/attachments/download/1", :controller => 'attachments', :action => 'download', :id => '1'
    should_route :get, "/attachments/download/1/filename.ext", :controller => 'attachments', :action => 'download', :id => '1', :filename => 'filename.ext'
  end
  
  context "boards" do
    should_route :get, "/projects/world_domination/boards", :controller => 'boards', :action => 'index', :project_id => 'world_domination'
    should_route :get, "/projects/world_domination/boards/new", :controller => 'boards', :action => 'new', :project_id => 'world_domination'
    should_route :get, "/projects/world_domination/boards/44", :controller => 'boards', :action => 'show', :project_id => 'world_domination', :id => '44'
    should_route :get, "/projects/world_domination/boards/44.atom", :controller => 'boards', :action => 'show', :project_id => 'world_domination', :id => '44', :format => 'atom'
    should_route :get, "/projects/world_domination/boards/44/edit", :controller => 'boards', :action => 'edit', :project_id => 'world_domination', :id => '44'

    should_route :post, "/projects/world_domination/boards/new", :controller => 'boards', :action => 'new', :project_id => 'world_domination'
    should_route :post, "/projects/world_domination/boards/44/edit", :controller => 'boards', :action => 'edit', :project_id => 'world_domination', :id => '44'
    should_route :post, "/projects/world_domination/boards/44/destroy", :controller => 'boards', :action => 'destroy', :project_id => 'world_domination', :id => '44'
    
  end

  context "documents" do
    should_route :get, "/projects/567/documents", :controller => 'documents', :action => 'index', :project_id => '567'
    should_route :get, "/projects/567/documents/new", :controller => 'documents', :action => 'new', :project_id => '567'
    should_route :get, "/documents/22", :controller => 'documents', :action => 'show', :id => '22'
    should_route :get, "/documents/22/edit", :controller => 'documents', :action => 'edit', :id => '22'

    should_route :post, "/projects/567/documents/new", :controller => 'documents', :action => 'new', :project_id => '567'
    should_route :post, "/documents/567/edit", :controller => 'documents', :action => 'edit', :id => '567'
    should_route :post, "/documents/567/destroy", :controller => 'documents', :action => 'destroy', :id => '567'
  end
  
  context "issues" do
    # REST actions
    should_route :get, "/issues", :controller => 'issues', :action => 'index'
    should_route :get, "/issues.pdf", :controller => 'issues', :action => 'index', :format => 'pdf'
    should_route :get, "/issues.atom", :controller => 'issues', :action => 'index', :format => 'atom'
    should_route :get, "/issues.xml", :controller => 'issues', :action => 'index', :format => 'xml'
    should_route :get, "/projects/23/issues", :controller => 'issues', :action => 'index', :project_id => '23'
    should_route :get, "/projects/23/issues.pdf", :controller => 'issues', :action => 'index', :project_id => '23', :format => 'pdf'
    should_route :get, "/projects/23/issues.atom", :controller => 'issues', :action => 'index', :project_id => '23', :format => 'atom'
    should_route :get, "/projects/23/issues.xml", :controller => 'issues', :action => 'index', :project_id => '23', :format => 'xml'
    should_route :get, "/issues/64", :controller => 'issues', :action => 'show', :id => '64'
    should_route :get, "/issues/64.pdf", :controller => 'issues', :action => 'show', :id => '64', :format => 'pdf'
    should_route :get, "/issues/64.atom", :controller => 'issues', :action => 'show', :id => '64', :format => 'atom'
    should_route :get, "/issues/64.xml", :controller => 'issues', :action => 'show', :id => '64', :format => 'xml'

    should_route :get, "/projects/23/issues/new", :controller => 'issues', :action => 'new', :project_id => '23'
    should_route :post, "/issues.xml", :controller => 'issues', :action => 'new', :format => 'xml'
      
    should_route :get, "/issues/64/edit", :controller => 'issues', :action => 'edit', :id => '64'
    # TODO: Should use PUT
    should_route :post, "/issues/64/edit", :controller => 'issues', :action => 'edit', :id => '64'
    should_route :put, "/issues/1.xml", :controller => 'issues', :action => 'update', :id => '1', :format => 'xml'

    # TODO: Should use DELETE
    should_route :post, "/issues/64/destroy", :controller => 'issues', :action => 'destroy', :id => '64'
    should_route :delete, "/issues/1.xml", :controller => 'issues', :action => 'destroy', :id => '1', :format => 'xml'
    
    # Extra actions
    should_route :get, "/projects/23/issues/64/copy", :controller => 'issues', :action => 'new', :project_id => '23', :copy_from => '64'

    should_route :get, "/issues/1/move", :controller => 'issues', :action => 'move', :id => '1'
    should_route :post, "/issues/1/move", :controller => 'issues', :action => 'move', :id => '1'
    
    should_route :post, "/issues/1/quoted", :controller => 'issues', :action => 'reply', :id => '1'

    should_route :get, "/issues/calendar", :controller => 'issues', :action => 'calendar'
    should_route :post, "/issues/calendar", :controller => 'issues', :action => 'calendar'
    should_route :get, "/projects/project-name/issues/calendar", :controller => 'issues', :action => 'calendar', :project_id => 'project-name'
    should_route :post, "/projects/project-name/issues/calendar", :controller => 'issues', :action => 'calendar', :project_id => 'project-name'

    should_route :get, "/issues/gantt", :controller => 'issues', :action => 'gantt'
    should_route :post, "/issues/gantt", :controller => 'issues', :action => 'gantt'
    should_route :get, "/projects/project-name/issues/gantt", :controller => 'issues', :action => 'gantt', :project_id => 'project-name'
    should_route :post, "/projects/project-name/issues/gantt", :controller => 'issues', :action => 'gantt', :project_id => 'project-name'

    should_route :get, "/issues/auto_complete", :controller => 'issues', :action => 'auto_complete'
  end

  context "issue categories" do
    should_route :get, "/projects/test/issue_categories/new", :controller => 'issue_categories', :action => 'new', :project_id => 'test'

    should_route :post, "/projects/test/issue_categories/new", :controller => 'issue_categories', :action => 'new', :project_id => 'test'
  end

  context "issue relations" do
    should_route :post, "/issues/1/relations", :controller => 'issue_relations', :action => 'new', :issue_id => '1'
    should_route :post, "/issues/1/relations/23/destroy", :controller => 'issue_relations', :action => 'destroy', :issue_id => '1', :id => '23'
  end
  
  context "issue reports" do
    should_route :get, "/projects/567/issues/report", :controller => 'reports', :action => 'issue_report', :id => '567'
    should_route :get, "/projects/567/issues/report/assigned_to", :controller => 'reports', :action => 'issue_report_details', :id => '567', :detail => 'assigned_to'
  end

  context "members" do
    should_route :post, "/projects/5234/members/new", :controller => 'members', :action => 'new', :id => '5234'
  end

  context "messages" do
    should_route :get, "/boards/22/topics/2", :controller => 'messages', :action => 'show', :id => '2', :board_id => '22'
    should_route :get, "/boards/lala/topics/new", :controller => 'messages', :action => 'new', :board_id => 'lala'
    should_route :get, "/boards/lala/topics/22/edit", :controller => 'messages', :action => 'edit', :id => '22', :board_id => 'lala'

    should_route :post, "/boards/lala/topics/new", :controller => 'messages', :action => 'new', :board_id => 'lala'
    should_route :post, "/boards/lala/topics/22/edit", :controller => 'messages', :action => 'edit', :id => '22', :board_id => 'lala'
    should_route :post, "/boards/22/topics/555/replies", :controller => 'messages', :action => 'reply', :id => '555', :board_id => '22'
    should_route :post, "/boards/22/topics/555/destroy", :controller => 'messages', :action => 'destroy', :id => '555', :board_id => '22'
  end

  context "news" do
    should_route :get, "/news", :controller => 'news', :action => 'index'
    should_route :get, "/news.atom", :controller => 'news', :action => 'index', :format => 'atom'
    should_route :get, "/news.xml", :controller => 'news', :action => 'index', :format => 'xml'
    should_route :get, "/news.json", :controller => 'news', :action => 'index', :format => 'json'
    should_route :get, "/projects/567/news", :controller => 'news', :action => 'index', :project_id => '567'
    should_route :get, "/projects/567/news.atom", :controller => 'news', :action => 'index', :format => 'atom', :project_id => '567'
    should_route :get, "/projects/567/news.xml", :controller => 'news', :action => 'index', :format => 'xml', :project_id => '567'
    should_route :get, "/projects/567/news.json", :controller => 'news', :action => 'index', :format => 'json', :project_id => '567'
    should_route :get, "/news/2", :controller => 'news', :action => 'show', :id => '2'
    should_route :get, "/projects/567/news/new", :controller => 'news', :action => 'new', :project_id => '567'
    should_route :get, "/news/234", :controller => 'news', :action => 'show', :id => '234'

    should_route :post, "/projects/567/news/new", :controller => 'news', :action => 'new', :project_id => '567'
    should_route :post, "/news/567/edit", :controller => 'news', :action => 'edit', :id => '567'
    should_route :post, "/news/567/destroy", :controller => 'news', :action => 'destroy', :id => '567'
  end

  context "projects" do
    should_route :get, "/projects", :controller => 'projects', :action => 'index'
    should_route :get, "/projects.atom", :controller => 'projects', :action => 'index', :format => 'atom'
    should_route :get, "/projects.xml", :controller => 'projects', :action => 'index', :format => 'xml'
    should_route :get, "/projects/new", :controller => 'projects', :action => 'add'
    should_route :get, "/projects/test", :controller => 'projects', :action => 'show', :id => 'test'
    should_route :get, "/projects/1.xml", :controller => 'projects', :action => 'show', :id => '1', :format => 'xml'
    should_route :get, "/projects/4223/settings", :controller => 'projects', :action => 'settings', :id => '4223'
    should_route :get, "/projects/4223/settings/members", :controller => 'projects', :action => 'settings', :id => '4223', :tab => 'members'
    should_route :get, "/projects/567/destroy", :controller => 'projects', :action => 'destroy', :id => '567'
    should_route :get, "/projects/33/files", :controller => 'projects', :action => 'list_files', :id => '33'
    should_route :get, "/projects/33/files/new", :controller => 'projects', :action => 'add_file', :id => '33'
    should_route :get, "/projects/33/roadmap", :controller => 'projects', :action => 'roadmap', :id => '33'
    should_route :get, "/projects/33/activity", :controller => 'projects', :action => 'activity', :id => '33'
    should_route :get, "/projects/33/activity.atom", :controller => 'projects', :action => 'activity', :id => '33', :format => 'atom'
    
    should_route :post, "/projects/new", :controller => 'projects', :action => 'add'
    should_route :post, "/projects.xml", :controller => 'projects', :action => 'add', :format => 'xml'
    should_route :post, "/projects/4223/edit", :controller => 'projects', :action => 'edit', :id => '4223'
    should_route :post, "/projects/64/destroy", :controller => 'projects', :action => 'destroy', :id => '64'
    should_route :post, "/projects/33/files/new", :controller => 'projects', :action => 'add_file', :id => '33'
    should_route :post, "/projects/64/archive", :controller => 'projects', :action => 'archive', :id => '64'
    should_route :post, "/projects/64/unarchive", :controller => 'projects', :action => 'unarchive', :id => '64'
    should_route :post, "/projects/64/activities/save", :controller => 'projects', :action => 'save_activities', :id => '64'

    should_route :put, "/projects/1.xml", :controller => 'projects', :action => 'edit', :id => '1', :format => 'xml'

    should_route :delete, "/projects/1.xml", :controller => 'projects', :action => 'destroy', :id => '1', :format => 'xml'
    should_route :delete, "/projects/64/reset_activities", :controller => 'projects', :action => 'reset_activities', :id => '64'
  end

  context "repositories" do
    should_route :get, "/projects/redmine/repository", :controller => 'repositories', :action => 'show', :id => 'redmine'
    should_route :get, "/projects/redmine/repository/edit", :controller => 'repositories', :action => 'edit', :id => 'redmine'
    should_route :get, "/projects/redmine/repository/revisions", :controller => 'repositories', :action => 'revisions', :id => 'redmine'
    should_route :get, "/projects/redmine/repository/revisions.atom", :controller => 'repositories', :action => 'revisions', :id => 'redmine', :format => 'atom'
    should_route :get, "/projects/redmine/repository/revisions/2457", :controller => 'repositories', :action => 'revision', :id => 'redmine', :rev => '2457'
    should_route :get, "/projects/redmine/repository/revisions/2457/diff", :controller => 'repositories', :action => 'diff', :id => 'redmine', :rev => '2457'
    should_route :get, "/projects/redmine/repository/revisions/2457/diff.diff", :controller => 'repositories', :action => 'diff', :id => 'redmine', :rev => '2457', :format => 'diff'
    should_route :get, "/projects/redmine/repository/diff/path/to/file.c", :controller => 'repositories', :action => 'diff', :id => 'redmine', :path => %w[path to file.c]
    should_route :get, "/projects/redmine/repository/revisions/2/diff/path/to/file.c", :controller => 'repositories', :action => 'diff', :id => 'redmine', :path => %w[path to file.c], :rev => '2'
    should_route :get, "/projects/redmine/repository/browse/path/to/file.c", :controller => 'repositories', :action => 'browse', :id => 'redmine', :path => %w[path to file.c]
    should_route :get, "/projects/redmine/repository/entry/path/to/file.c", :controller => 'repositories', :action => 'entry', :id => 'redmine', :path => %w[path to file.c]
    should_route :get, "/projects/redmine/repository/revisions/2/entry/path/to/file.c", :controller => 'repositories', :action => 'entry', :id => 'redmine', :path => %w[path to file.c], :rev => '2'
    should_route :get, "/projects/redmine/repository/raw/path/to/file.c", :controller => 'repositories', :action => 'entry', :id => 'redmine', :path => %w[path to file.c], :format => 'raw'
    should_route :get, "/projects/redmine/repository/revisions/2/raw/path/to/file.c", :controller => 'repositories', :action => 'entry', :id => 'redmine', :path => %w[path to file.c], :rev => '2', :format => 'raw'
    should_route :get, "/projects/redmine/repository/annotate/path/to/file.c", :controller => 'repositories', :action => 'annotate', :id => 'redmine', :path => %w[path to file.c]
    should_route :get, "/projects/redmine/repository/changes/path/to/file.c", :controller => 'repositories', :action => 'changes', :id => 'redmine', :path => %w[path to file.c]
    should_route :get, "/projects/redmine/repository/statistics", :controller => 'repositories', :action => 'stats', :id => 'redmine'
  
    
    should_route :post, "/projects/redmine/repository/edit", :controller => 'repositories', :action => 'edit', :id => 'redmine'
  end

  context "timelogs" do
    should_route :get, "/issues/567/time_entries/new", :controller => 'timelog', :action => 'edit', :issue_id => '567'
    should_route :get, "/projects/ecookbook/time_entries/new", :controller => 'timelog', :action => 'edit', :project_id => 'ecookbook'
    should_route :get, "/projects/ecookbook/issues/567/time_entries/new", :controller => 'timelog', :action => 'edit', :project_id => 'ecookbook', :issue_id => '567'
    should_route :get, "/time_entries/22/edit", :controller => 'timelog', :action => 'edit', :id => '22'
    should_route :get, "/time_entries/report", :controller => 'timelog', :action => 'report'
    should_route :get, "/projects/567/time_entries/report", :controller => 'timelog', :action => 'report', :project_id => '567'
    should_route :get, "/projects/567/time_entries/report.csv", :controller => 'timelog', :action => 'report', :project_id => '567', :format => 'csv'
    should_route :get, "/time_entries", :controller => 'timelog', :action => 'details'
    should_route :get, "/time_entries.csv", :controller => 'timelog', :action => 'details', :format => 'csv'
    should_route :get, "/time_entries.atom", :controller => 'timelog', :action => 'details', :format => 'atom'
    should_route :get, "/projects/567/time_entries", :controller => 'timelog', :action => 'details', :project_id => '567'
    should_route :get, "/projects/567/time_entries.csv", :controller => 'timelog', :action => 'details', :project_id => '567', :format => 'csv'
    should_route :get, "/projects/567/time_entries.atom", :controller => 'timelog', :action => 'details', :project_id => '567', :format => 'atom'
    should_route :get, "/issues/234/time_entries", :controller => 'timelog', :action => 'details', :issue_id => '234'
    should_route :get, "/issues/234/time_entries.csv", :controller => 'timelog', :action => 'details', :issue_id => '234', :format => 'csv'
    should_route :get, "/issues/234/time_entries.atom", :controller => 'timelog', :action => 'details', :issue_id => '234', :format => 'atom'
    should_route :get, "/projects/ecookbook/issues/123/time_entries", :controller => 'timelog', :action => 'details', :project_id => 'ecookbook', :issue_id => '123'

    should_route :post, "/time_entries/55/destroy", :controller => 'timelog', :action => 'destroy', :id => '55'
  end

  context "users" do
    should_route :get, "/users", :controller => 'users', :action => 'index'
    should_route :get, "/users/44", :controller => 'users', :action => 'show', :id => '44'
    should_route :get, "/users/new", :controller => 'users', :action => 'add'
    should_route :get, "/users/444/edit", :controller => 'users', :action => 'edit', :id => '444'
    should_route :get, "/users/222/edit/membership", :controller => 'users', :action => 'edit', :id => '222', :tab => 'membership'

    should_route :post, "/users/new", :controller => 'users', :action => 'add'
    should_route :post, "/users/444/edit", :controller => 'users', :action => 'edit', :id => '444'
    should_route :post, "/users/123/memberships", :controller => 'users', :action => 'edit_membership', :id => '123'
    should_route :post, "/users/123/memberships/55", :controller => 'users', :action => 'edit_membership', :id => '123', :membership_id => '55'
    should_route :post, "/users/567/memberships/12/destroy", :controller => 'users', :action => 'destroy_membership', :id => '567', :membership_id => '12'
  end

  context "versions" do
    should_route :get, "/projects/foo/versions/new", :controller => 'versions', :action => 'new', :project_id => 'foo'

    should_route :post, "/projects/foo/versions/new", :controller => 'versions', :action => 'new', :project_id => 'foo'
  end

  context "wiki (singular, project's pages)" do
    should_route :get, "/projects/567/wiki", :controller => 'wiki', :action => 'index', :id => '567'
    should_route :get, "/projects/567/wiki/lalala", :controller => 'wiki', :action => 'index', :id => '567', :page => 'lalala'
    should_route :get, "/projects/567/wiki/my_page/edit", :controller => 'wiki', :action => 'edit', :id => '567', :page => 'my_page'
    should_route :get, "/projects/1/wiki/CookBook_documentation/history", :controller => 'wiki', :action => 'history', :id => '1', :page => 'CookBook_documentation'
    should_route :get, "/projects/1/wiki/CookBook_documentation/diff/2/vs/1", :controller => 'wiki', :action => 'diff', :id => '1', :page => 'CookBook_documentation', :version => '2', :version_from => '1'
    should_route :get, "/projects/1/wiki/CookBook_documentation/annotate/2", :controller => 'wiki', :action => 'annotate', :id => '1', :page => 'CookBook_documentation', :version => '2'
    should_route :get, "/projects/22/wiki/ladida/rename", :controller => 'wiki', :action => 'rename', :id => '22', :page => 'ladida'
    should_route :get, "/projects/567/wiki/page_index", :controller => 'wiki', :action => 'special', :id => '567', :page => 'page_index'
    should_route :get, "/projects/567/wiki/Page_Index", :controller => 'wiki', :action => 'special', :id => '567', :page => 'Page_Index'
    should_route :get, "/projects/567/wiki/date_index", :controller => 'wiki', :action => 'special', :id => '567', :page => 'date_index'
    should_route :get, "/projects/567/wiki/export", :controller => 'wiki', :action => 'special', :id => '567', :page => 'export'
    
    should_route :post, "/projects/567/wiki/my_page/edit", :controller => 'wiki', :action => 'edit', :id => '567', :page => 'my_page'
    should_route :post, "/projects/567/wiki/CookBook_documentation/preview", :controller => 'wiki', :action => 'preview', :id => '567', :page => 'CookBook_documentation'
    should_route :post, "/projects/22/wiki/ladida/rename", :controller => 'wiki', :action => 'rename', :id => '22', :page => 'ladida'
    should_route :post, "/projects/22/wiki/ladida/destroy", :controller => 'wiki', :action => 'destroy', :id => '22', :page => 'ladida'
    should_route :post, "/projects/22/wiki/ladida/protect", :controller => 'wiki', :action => 'protect', :id => '22', :page => 'ladida'
  end

  context "wikis (plural, admin setup)" do
    should_route :get, "/projects/ladida/wiki/destroy", :controller => 'wikis', :action => 'destroy', :id => 'ladida'

    should_route :post, "/projects/ladida/wiki", :controller => 'wikis', :action => 'edit', :id => 'ladida'
    should_route :post, "/projects/ladida/wiki/destroy", :controller => 'wikis', :action => 'destroy', :id => 'ladida'
  end

  context "administration panel" do
    should_route :get, "/admin/projects", :controller => 'admin', :action => 'projects'
  end
end

Review

Now all of the routing tests are in one place and it's easy to see patterns in them. For example, I can easily see that Redmine isn't using the HTTP PUT or DELETE verbs very much. This would be because it's controllers are not defined as resources yet.

Now that the routing tests are in place, I can start to refactor the IssuesController in order to finish up it's REST API.

Reference commit

Tagged: ruby redmine refactoring move-method

Next, I'm going to work on refactoring Redmine's controllers in order to make them match the REST pattern and to take advantage of Rail's built in REST helpers. In order to do this though, I need to refactor how Redmine's routes are setup. Since the routes.rb file is over 290 lines long, the first thing I need to do is to make sure that the test suite is covering the main urls.

The Refactoring

Redmine's routing tests are scattered across several functional tests, so this refactoring pulls some of them into a single RoutingTest integration test.

Before

# test/integration/routing_test.rb
class RoutingTest < ActionController::IntegrationTest
  # ... existing tests
end
# test/functional/users_controller_test.rb
class UsersControllerTest < ActionController::TestCase
  def test_index_routing
    assert_generates(
      '/users',
      :controller => 'users', :action => 'index'
    )
    assert_routing(
      {:method => :get, :path => '/users'},
      :controller => 'users', :action => 'index'
    )
    assert_recognizes(
      {:controller => 'users', :action => 'index'},
      {:method => :get, :path => '/users'}
    )
  end

  def test_show_routing
    assert_routing(
      {:method => :get, :path => '/users/44'},
      :controller => 'users', :action => 'show', :id => '44'
    )
    assert_recognizes(
      {:controller => 'users', :action => 'show', :id => '44'},
      {:method => :get, :path => '/users/44'}
    )
  end
end

After

# test/integration/routing_test.rb
class RoutingTest < ActionController::IntegrationTest
  context "users" do
    should_route :get, "/users", :controller => 'users', :action => 'index'
    should_route :get, "/users/44", :controller => 'users', :action => 'show', :id => '44'
    should_route :get, "/users/new", :controller => 'users', :action => 'add'
    should_route :get, "/users/444/edit", :controller => 'users', :action => 'edit', :id => '444'
    should_route :get, "/users/222/edit/membership", :controller => 'users', :action => 'edit', :id => '222', :tab => 'membership'

    should_route :post, "/users/new", :controller => 'users', :action => 'add'
    should_route :post, "/users/444/edit", :controller => 'users', :action => 'edit', :id => '444'
    should_route :post, "/users/123/memberships", :controller => 'users', :action => 'edit_membership', :id => '123'
    should_route :post, "/users/123/memberships/55", :controller => 'users', :action => 'edit_membership', :id => '123', :membership_id => '55'
    should_route :post, "/users/567/memberships/12/destroy", :controller => 'users', :action => 'destroy_membership', :id => '567', :membership_id => '12'
  end

  context "versions" do
    should_route :get, "/projects/foo/versions/new", :controller => 'versions', :action => 'new', :project_id => 'foo'

    should_route :post, "/projects/foo/versions/new", :controller => 'versions', :action => 'new', :project_id => 'foo'
  end

  context "wikis" do
    should_route :get, "/projects/567/wiki", :controller => 'wiki', :action => 'index', :id => '567'
    should_route :get, "/projects/567/wiki/lalala", :controller => 'wiki', :action => 'index', :id => '567', :page => 'lalala'
    should_route :get, "/projects/567/wiki/my_page/edit", :controller => 'wiki', :action => 'edit', :id => '567', :page => 'my_page'
    should_route :get, "/projects/1/wiki/CookBook_documentation/history", :controller => 'wiki', :action => 'history', :id => '1', :page => 'CookBook_documentation'
    should_route :get, "/projects/1/wiki/CookBook_documentation/diff/2/vs/1", :controller => 'wiki', :action => 'diff', :id => '1', :page => 'CookBook_documentation', :version => '2', :version_from => '1'
    should_route :get, "/projects/1/wiki/CookBook_documentation/annotate/2", :controller => 'wiki', :action => 'annotate', :id => '1', :page => 'CookBook_documentation', :version => '2'
    should_route :get, "/projects/22/wiki/ladida/rename", :controller => 'wiki', :action => 'rename', :id => '22', :page => 'ladida'
    should_route :get, "/projects/567/wiki/page_index", :controller => 'wiki', :action => 'special', :id => '567', :page => 'page_index'
    should_route :get, "/projects/567/wiki/Page_Index", :controller => 'wiki', :action => 'special', :id => '567', :page => 'Page_Index'
    should_route :get, "/projects/567/wiki/date_index", :controller => 'wiki', :action => 'special', :id => '567', :page => 'date_index'
    should_route :get, "/projects/567/wiki/export", :controller => 'wiki', :action => 'special', :id => '567', :page => 'export'
    
    should_route :post, "/projects/567/wiki/my_page/edit", :controller => 'wiki', :action => 'edit', :id => '567', :page => 'my_page'
    should_route :post, "/projects/567/wiki/CookBook_documentation/preview", :controller => 'wiki', :action => 'preview', :id => '567', :page => 'CookBook_documentation'
    should_route :post, "/projects/22/wiki/ladida/rename", :controller => 'wiki', :action => 'rename', :id => '22', :page => 'ladida'
    should_route :post, "/projects/22/wiki/ladida/destroy", :controller => 'wiki', :action => 'destroy', :id => '22', :page => 'ladida'
    should_route :post, "/projects/22/wiki/ladida/protect", :controller => 'wiki', :action => 'protect', :id => '22', :page => 'ladida'
  end

  context "administration panel" do
    should_route :get, "/admin/projects", :controller => 'admin', :action => 'projects'
  end

end
# test/functional/users_controller_test.rb
class UsersControllerTest < ActionController::TestCase
  # ...
end

Review

I didn't show all of the changes but if you compare the before and after, it's easy to see that using shoulda and a single integration test will make routes easier to maintain.

Reference commit

Tagged: ruby redmine refactoring move-method