##// END OF EJS Templates
Support for accent insensitive search with PostgreSQL (#18801)....
Jean-Philippe Lang -
r13607:af5872b7d432
parent child
Show More
@@ -0,0 +1,53
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 module Redmine
19 # Helper module to get information about the Redmine database
20 module Database
21 class << self
22 # Returns true if the database is PostgreSQL
23 def postgresql?
24 (ActiveRecord::Base.connection.adapter_name =~ /postgresql/i).present?
25 end
26
27 # Returns the PostgreSQL version or nil if another DBMS is used
28 def postgresql_version
29 postgresql? ? ActiveRecord::Base.connection.send(:postgresql_version) : nil
30 end
31
32 # Returns true if the database is a PostgreSQL >=9.0 database with the unaccent extension installed
33 def postgresql_unaccent?
34 if postgresql?
35 return @postgresql_unaccent unless @postgresql_unaccent.nil?
36 begin
37 sql = "SELECT name FROM pg_available_extensions WHERE installed_version IS NOT NULL and name = 'unaccent'"
38 @postgresql_unaccent = postgresql_version >= 90000 && ActiveRecord::Base.connection.select_value(sql).present?
39 rescue
40 false
41 end
42 else
43 false
44 end
45 end
46
47 # Resets database information
48 def reset
49 @postgresql_unaccent = nil
50 end
51 end
52 end
53 end
@@ -1,222 +1,225
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 module Redmine
18 module Redmine
19 module Acts
19 module Acts
20 module Searchable
20 module Searchable
21 def self.included(base)
21 def self.included(base)
22 base.extend ClassMethods
22 base.extend ClassMethods
23 end
23 end
24
24
25 module ClassMethods
25 module ClassMethods
26 # Adds the search methods to the class.
26 # Adds the search methods to the class.
27 #
27 #
28 # Options:
28 # Options:
29 # * :columns - a column or an array of columns to search
29 # * :columns - a column or an array of columns to search
30 # * :project_key - project foreign key (default to project_id)
30 # * :project_key - project foreign key (default to project_id)
31 # * :date_column - name of the datetime column used to sort results (default to :created_on)
31 # * :date_column - name of the datetime column used to sort results (default to :created_on)
32 # * :permission - permission required to search the model
32 # * :permission - permission required to search the model
33 # * :scope - scope used to search results
33 # * :scope - scope used to search results
34 # * :preload - associations to preload when loading results for display
34 # * :preload - associations to preload when loading results for display
35 def acts_as_searchable(options = {})
35 def acts_as_searchable(options = {})
36 return if self.included_modules.include?(Redmine::Acts::Searchable::InstanceMethods)
36 return if self.included_modules.include?(Redmine::Acts::Searchable::InstanceMethods)
37 options.assert_valid_keys(:columns, :project_key, :date_column, :permission, :scope, :preload)
37 options.assert_valid_keys(:columns, :project_key, :date_column, :permission, :scope, :preload)
38
38
39 cattr_accessor :searchable_options
39 cattr_accessor :searchable_options
40 self.searchable_options = options
40 self.searchable_options = options
41
41
42 if searchable_options[:columns].nil?
42 if searchable_options[:columns].nil?
43 raise 'No searchable column defined.'
43 raise 'No searchable column defined.'
44 elsif !searchable_options[:columns].is_a?(Array)
44 elsif !searchable_options[:columns].is_a?(Array)
45 searchable_options[:columns] = [] << searchable_options[:columns]
45 searchable_options[:columns] = [] << searchable_options[:columns]
46 end
46 end
47
47
48 searchable_options[:project_key] ||= "#{table_name}.project_id"
48 searchable_options[:project_key] ||= "#{table_name}.project_id"
49 searchable_options[:date_column] ||= :created_on
49 searchable_options[:date_column] ||= :created_on
50
50
51 # Should we search additional associations on this model ?
51 # Should we search additional associations on this model ?
52 searchable_options[:search_custom_fields] = reflect_on_association(:custom_values).present?
52 searchable_options[:search_custom_fields] = reflect_on_association(:custom_values).present?
53 searchable_options[:search_attachments] = reflect_on_association(:attachments).present?
53 searchable_options[:search_attachments] = reflect_on_association(:attachments).present?
54 searchable_options[:search_journals] = reflect_on_association(:journals).present?
54 searchable_options[:search_journals] = reflect_on_association(:journals).present?
55
55
56 send :include, Redmine::Acts::Searchable::InstanceMethods
56 send :include, Redmine::Acts::Searchable::InstanceMethods
57 end
57 end
58 end
58 end
59
59
60 module InstanceMethods
60 module InstanceMethods
61 def self.included(base)
61 def self.included(base)
62 base.extend ClassMethods
62 base.extend ClassMethods
63 end
63 end
64
64
65 module ClassMethods
65 module ClassMethods
66 # Searches the model for the given tokens and user visibility.
66 # Searches the model for the given tokens and user visibility.
67 # The projects argument can be either nil (will search all projects), a project or an array of projects.
67 # The projects argument can be either nil (will search all projects), a project or an array of projects.
68 # Returns an array that contains the rank and id of all results.
68 # Returns an array that contains the rank and id of all results.
69 # In current implementation, the rank is the record timestamp converted as an integer.
69 # In current implementation, the rank is the record timestamp converted as an integer.
70 #
70 #
71 # Valid options:
71 # Valid options:
72 # * :titles_only - searches tokens in the first searchable column only
72 # * :titles_only - searches tokens in the first searchable column only
73 # * :all_words - searches results that match all token
73 # * :all_words - searches results that match all token
74 # * :
74 # * :
75 # * :limit - maximum number of results to return
75 # * :limit - maximum number of results to return
76 #
76 #
77 # Example:
77 # Example:
78 # Issue.search_result_ranks_and_ids("foo")
78 # Issue.search_result_ranks_and_ids("foo")
79 # # => [[1419595329, 69], [1419595622, 123]]
79 # # => [[1419595329, 69], [1419595622, 123]]
80 def search_result_ranks_and_ids(tokens, user=User.current, projects=nil, options={})
80 def search_result_ranks_and_ids(tokens, user=User.current, projects=nil, options={})
81 tokens = [] << tokens unless tokens.is_a?(Array)
81 tokens = [] << tokens unless tokens.is_a?(Array)
82 projects = [] << projects if projects.is_a?(Project)
82 projects = [] << projects if projects.is_a?(Project)
83
83
84 columns = searchable_options[:columns]
84 columns = searchable_options[:columns]
85 columns = columns[0..0] if options[:titles_only]
85 columns = columns[0..0] if options[:titles_only]
86
86
87 r = []
87 r = []
88 queries = 0
88 queries = 0
89
89
90 unless options[:attachments] == 'only'
90 unless options[:attachments] == 'only'
91 r = fetch_ranks_and_ids(
91 r = fetch_ranks_and_ids(
92 search_scope(user, projects, options).
92 search_scope(user, projects, options).
93 where(search_tokens_condition(columns, tokens, options[:all_words])),
93 where(search_tokens_condition(columns, tokens, options[:all_words])),
94 options[:limit]
94 options[:limit]
95 )
95 )
96 queries += 1
96 queries += 1
97
97
98 if !options[:titles_only] && searchable_options[:search_custom_fields]
98 if !options[:titles_only] && searchable_options[:search_custom_fields]
99 searchable_custom_fields = CustomField.where(:type => "#{self.name}CustomField", :searchable => true).to_a
99 searchable_custom_fields = CustomField.where(:type => "#{self.name}CustomField", :searchable => true).to_a
100
100
101 if searchable_custom_fields.any?
101 if searchable_custom_fields.any?
102 fields_by_visibility = searchable_custom_fields.group_by {|field|
102 fields_by_visibility = searchable_custom_fields.group_by {|field|
103 field.visibility_by_project_condition(searchable_options[:project_key], user, "#{CustomValue.table_name}.custom_field_id")
103 field.visibility_by_project_condition(searchable_options[:project_key], user, "#{CustomValue.table_name}.custom_field_id")
104 }
104 }
105 clauses = []
105 clauses = []
106 fields_by_visibility.each do |visibility, fields|
106 fields_by_visibility.each do |visibility, fields|
107 clauses << "(#{CustomValue.table_name}.custom_field_id IN (#{fields.map(&:id).join(',')}) AND (#{visibility}))"
107 clauses << "(#{CustomValue.table_name}.custom_field_id IN (#{fields.map(&:id).join(',')}) AND (#{visibility}))"
108 end
108 end
109 visibility = clauses.join(' OR ')
109 visibility = clauses.join(' OR ')
110
110
111 r |= fetch_ranks_and_ids(
111 r |= fetch_ranks_and_ids(
112 search_scope(user, projects, options).
112 search_scope(user, projects, options).
113 joins(:custom_values).
113 joins(:custom_values).
114 where(visibility).
114 where(visibility).
115 where(search_tokens_condition(["#{CustomValue.table_name}.value"], tokens, options[:all_words])),
115 where(search_tokens_condition(["#{CustomValue.table_name}.value"], tokens, options[:all_words])),
116 options[:limit]
116 options[:limit]
117 )
117 )
118 queries += 1
118 queries += 1
119 end
119 end
120 end
120 end
121
121
122 if !options[:titles_only] && searchable_options[:search_journals]
122 if !options[:titles_only] && searchable_options[:search_journals]
123 r |= fetch_ranks_and_ids(
123 r |= fetch_ranks_and_ids(
124 search_scope(user, projects, options).
124 search_scope(user, projects, options).
125 joins(:journals).
125 joins(:journals).
126 where("#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(user, :view_private_notes)})", false).
126 where("#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(user, :view_private_notes)})", false).
127 where(search_tokens_condition(["#{Journal.table_name}.notes"], tokens, options[:all_words])),
127 where(search_tokens_condition(["#{Journal.table_name}.notes"], tokens, options[:all_words])),
128 options[:limit]
128 options[:limit]
129 )
129 )
130 queries += 1
130 queries += 1
131 end
131 end
132 end
132 end
133
133
134 if searchable_options[:search_attachments] && (options[:titles_only] ? options[:attachments] == 'only' : options[:attachments] != '0')
134 if searchable_options[:search_attachments] && (options[:titles_only] ? options[:attachments] == 'only' : options[:attachments] != '0')
135 r |= fetch_ranks_and_ids(
135 r |= fetch_ranks_and_ids(
136 search_scope(user, projects, options).
136 search_scope(user, projects, options).
137 joins(:attachments).
137 joins(:attachments).
138 where(search_tokens_condition(["#{Attachment.table_name}.filename", "#{Attachment.table_name}.description"], tokens, options[:all_words])),
138 where(search_tokens_condition(["#{Attachment.table_name}.filename", "#{Attachment.table_name}.description"], tokens, options[:all_words])),
139 options[:limit]
139 options[:limit]
140 )
140 )
141 queries += 1
141 queries += 1
142 end
142 end
143
143
144 if queries > 1
144 if queries > 1
145 r = r.sort.reverse
145 r = r.sort.reverse
146 if options[:limit] && r.size > options[:limit]
146 if options[:limit] && r.size > options[:limit]
147 r = r[0, options[:limit]]
147 r = r[0, options[:limit]]
148 end
148 end
149 end
149 end
150
150
151 r
151 r
152 end
152 end
153
153
154 def search_tokens_condition(columns, tokens, all_words)
154 def search_tokens_condition(columns, tokens, all_words)
155 token_clauses = columns.map {|column| "(#{search_token_match_statement(column)})"}
155 token_clauses = columns.map {|column| "(#{search_token_match_statement(column)})"}
156 sql = (['(' + token_clauses.join(' OR ') + ')'] * tokens.size).join(all_words ? ' AND ' : ' OR ')
156 sql = (['(' + token_clauses.join(' OR ') + ')'] * tokens.size).join(all_words ? ' AND ' : ' OR ')
157 [sql, * (tokens.collect {|w| "%#{w}%"} * token_clauses.size).sort]
157 [sql, * (tokens.collect {|w| "%#{w}%"} * token_clauses.size).sort]
158 end
158 end
159 private :search_tokens_condition
159 private :search_tokens_condition
160
160
161 def search_token_match_statement(column, value='?')
161 def search_token_match_statement(column, value='?')
162 case connection.adapter_name
162 if Redmine::Database.postgresql?
163 when /postgresql/i
163 if Redmine::Database.postgresql_unaccent?
164 "#{column} ILIKE #{value}"
164 "unaccent(#{column}) ILIKE unaccent(#{value})"
165 else
166 "#{column} ILIKE #{value}"
167 end
165 else
168 else
166 "#{column} LIKE #{value}"
169 "#{column} LIKE #{value}"
167 end
170 end
168 end
171 end
169 private :search_token_match_statement
172 private :search_token_match_statement
170
173
171 def fetch_ranks_and_ids(scope, limit)
174 def fetch_ranks_and_ids(scope, limit)
172 scope.
175 scope.
173 reorder(searchable_options[:date_column] => :desc, :id => :desc).
176 reorder(searchable_options[:date_column] => :desc, :id => :desc).
174 limit(limit).
177 limit(limit).
175 uniq.
178 uniq.
176 pluck(searchable_options[:date_column], :id).
179 pluck(searchable_options[:date_column], :id).
177 # converts timestamps to integers for faster sort
180 # converts timestamps to integers for faster sort
178 map {|timestamp, id| [timestamp.to_i, id]}
181 map {|timestamp, id| [timestamp.to_i, id]}
179 end
182 end
180 private :fetch_ranks_and_ids
183 private :fetch_ranks_and_ids
181
184
182 # Returns the search scope for user and projects
185 # Returns the search scope for user and projects
183 def search_scope(user, projects, options={})
186 def search_scope(user, projects, options={})
184 if projects.is_a?(Array) && projects.empty?
187 if projects.is_a?(Array) && projects.empty?
185 # no results
188 # no results
186 return none
189 return none
187 end
190 end
188
191
189 scope = (searchable_options[:scope] || self)
192 scope = (searchable_options[:scope] || self)
190 if scope.is_a? Proc
193 if scope.is_a? Proc
191 scope = scope.call(options)
194 scope = scope.call(options)
192 end
195 end
193
196
194 if respond_to?(:visible) && !searchable_options.has_key?(:permission)
197 if respond_to?(:visible) && !searchable_options.has_key?(:permission)
195 scope = scope.visible(user)
198 scope = scope.visible(user)
196 else
199 else
197 permission = searchable_options[:permission] || :view_project
200 permission = searchable_options[:permission] || :view_project
198 scope = scope.where(Project.allowed_to_condition(user, permission))
201 scope = scope.where(Project.allowed_to_condition(user, permission))
199 end
202 end
200
203
201 if projects
204 if projects
202 scope = scope.where("#{searchable_options[:project_key]} IN (?)", projects.map(&:id))
205 scope = scope.where("#{searchable_options[:project_key]} IN (?)", projects.map(&:id))
203 end
206 end
204 scope
207 scope
205 end
208 end
206 private :search_scope
209 private :search_scope
207
210
208 # Returns search results of given ids
211 # Returns search results of given ids
209 def search_results_from_ids(ids)
212 def search_results_from_ids(ids)
210 where(:id => ids).preload(searchable_options[:preload]).to_a
213 where(:id => ids).preload(searchable_options[:preload]).to_a
211 end
214 end
212
215
213 # Returns search results with same arguments as search_result_ranks_and_ids
216 # Returns search results with same arguments as search_result_ranks_and_ids
214 def search_results(*args)
217 def search_results(*args)
215 ranks_and_ids = search_result_ranks_and_ids(*args)
218 ranks_and_ids = search_result_ranks_and_ids(*args)
216 search_results_from_ids(ranks_and_ids.map(&:last))
219 search_results_from_ids(ranks_and_ids.map(&:last))
217 end
220 end
218 end
221 end
219 end
222 end
220 end
223 end
221 end
224 end
222 end
225 end
@@ -1,335 +1,339
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 if ENV["COVERAGE"]
18 if ENV["COVERAGE"]
19 require 'simplecov'
19 require 'simplecov'
20 require File.expand_path(File.dirname(__FILE__) + "/coverage/html_formatter")
20 require File.expand_path(File.dirname(__FILE__) + "/coverage/html_formatter")
21 SimpleCov.formatter = Redmine::Coverage::HtmlFormatter
21 SimpleCov.formatter = Redmine::Coverage::HtmlFormatter
22 SimpleCov.start 'rails'
22 SimpleCov.start 'rails'
23 end
23 end
24
24
25 ENV["RAILS_ENV"] = "test"
25 ENV["RAILS_ENV"] = "test"
26 require File.expand_path(File.dirname(__FILE__) + "/../config/environment")
26 require File.expand_path(File.dirname(__FILE__) + "/../config/environment")
27 require 'rails/test_help'
27 require 'rails/test_help'
28 require Rails.root.join('test', 'mocks', 'open_id_authentication_mock.rb').to_s
28 require Rails.root.join('test', 'mocks', 'open_id_authentication_mock.rb').to_s
29
29
30 require File.expand_path(File.dirname(__FILE__) + '/object_helpers')
30 require File.expand_path(File.dirname(__FILE__) + '/object_helpers')
31 include ObjectHelpers
31 include ObjectHelpers
32
32
33 require 'net/ldap'
33 require 'net/ldap'
34 require 'mocha/setup'
34 require 'mocha/setup'
35
35
36 class ActionView::TestCase
36 class ActionView::TestCase
37 helper :application
37 helper :application
38 include ApplicationHelper
38 include ApplicationHelper
39 end
39 end
40
40
41 class ActiveSupport::TestCase
41 class ActiveSupport::TestCase
42 include ActionDispatch::TestProcess
42 include ActionDispatch::TestProcess
43
43
44 self.use_transactional_fixtures = true
44 self.use_transactional_fixtures = true
45 self.use_instantiated_fixtures = false
45 self.use_instantiated_fixtures = false
46
46
47 def uploaded_test_file(name, mime)
47 def uploaded_test_file(name, mime)
48 fixture_file_upload("files/#{name}", mime, true)
48 fixture_file_upload("files/#{name}", mime, true)
49 end
49 end
50
50
51 # Mock out a file
51 # Mock out a file
52 def self.mock_file
52 def self.mock_file
53 file = 'a_file.png'
53 file = 'a_file.png'
54 file.stubs(:size).returns(32)
54 file.stubs(:size).returns(32)
55 file.stubs(:original_filename).returns('a_file.png')
55 file.stubs(:original_filename).returns('a_file.png')
56 file.stubs(:content_type).returns('image/png')
56 file.stubs(:content_type).returns('image/png')
57 file.stubs(:read).returns(false)
57 file.stubs(:read).returns(false)
58 file
58 file
59 end
59 end
60
60
61 def mock_file
61 def mock_file
62 self.class.mock_file
62 self.class.mock_file
63 end
63 end
64
64
65 def mock_file_with_options(options={})
65 def mock_file_with_options(options={})
66 file = ''
66 file = ''
67 file.stubs(:size).returns(32)
67 file.stubs(:size).returns(32)
68 original_filename = options[:original_filename] || nil
68 original_filename = options[:original_filename] || nil
69 file.stubs(:original_filename).returns(original_filename)
69 file.stubs(:original_filename).returns(original_filename)
70 content_type = options[:content_type] || nil
70 content_type = options[:content_type] || nil
71 file.stubs(:content_type).returns(content_type)
71 file.stubs(:content_type).returns(content_type)
72 file.stubs(:read).returns(false)
72 file.stubs(:read).returns(false)
73 file
73 file
74 end
74 end
75
75
76 # Use a temporary directory for attachment related tests
76 # Use a temporary directory for attachment related tests
77 def set_tmp_attachments_directory
77 def set_tmp_attachments_directory
78 Dir.mkdir "#{Rails.root}/tmp/test" unless File.directory?("#{Rails.root}/tmp/test")
78 Dir.mkdir "#{Rails.root}/tmp/test" unless File.directory?("#{Rails.root}/tmp/test")
79 unless File.directory?("#{Rails.root}/tmp/test/attachments")
79 unless File.directory?("#{Rails.root}/tmp/test/attachments")
80 Dir.mkdir "#{Rails.root}/tmp/test/attachments"
80 Dir.mkdir "#{Rails.root}/tmp/test/attachments"
81 end
81 end
82 Attachment.storage_path = "#{Rails.root}/tmp/test/attachments"
82 Attachment.storage_path = "#{Rails.root}/tmp/test/attachments"
83 end
83 end
84
84
85 def set_fixtures_attachments_directory
85 def set_fixtures_attachments_directory
86 Attachment.storage_path = "#{Rails.root}/test/fixtures/files"
86 Attachment.storage_path = "#{Rails.root}/test/fixtures/files"
87 end
87 end
88
88
89 def with_settings(options, &block)
89 def with_settings(options, &block)
90 saved_settings = options.keys.inject({}) do |h, k|
90 saved_settings = options.keys.inject({}) do |h, k|
91 h[k] = case Setting[k]
91 h[k] = case Setting[k]
92 when Symbol, false, true, nil
92 when Symbol, false, true, nil
93 Setting[k]
93 Setting[k]
94 else
94 else
95 Setting[k].dup
95 Setting[k].dup
96 end
96 end
97 h
97 h
98 end
98 end
99 options.each {|k, v| Setting[k] = v}
99 options.each {|k, v| Setting[k] = v}
100 yield
100 yield
101 ensure
101 ensure
102 saved_settings.each {|k, v| Setting[k] = v} if saved_settings
102 saved_settings.each {|k, v| Setting[k] = v} if saved_settings
103 end
103 end
104
104
105 # Yields the block with user as the current user
105 # Yields the block with user as the current user
106 def with_current_user(user, &block)
106 def with_current_user(user, &block)
107 saved_user = User.current
107 saved_user = User.current
108 User.current = user
108 User.current = user
109 yield
109 yield
110 ensure
110 ensure
111 User.current = saved_user
111 User.current = saved_user
112 end
112 end
113
113
114 def with_locale(locale, &block)
114 def with_locale(locale, &block)
115 saved_localed = ::I18n.locale
115 saved_localed = ::I18n.locale
116 ::I18n.locale = locale
116 ::I18n.locale = locale
117 yield
117 yield
118 ensure
118 ensure
119 ::I18n.locale = saved_localed
119 ::I18n.locale = saved_localed
120 end
120 end
121
121
122 def self.ldap_configured?
122 def self.ldap_configured?
123 @test_ldap = Net::LDAP.new(:host => '127.0.0.1', :port => 389)
123 @test_ldap = Net::LDAP.new(:host => '127.0.0.1', :port => 389)
124 return @test_ldap.bind
124 return @test_ldap.bind
125 rescue Exception => e
125 rescue Exception => e
126 # LDAP is not listening
126 # LDAP is not listening
127 return nil
127 return nil
128 end
128 end
129
129
130 def self.convert_installed?
130 def self.convert_installed?
131 Redmine::Thumbnail.convert_available?
131 Redmine::Thumbnail.convert_available?
132 end
132 end
133
133
134 def convert_installed?
134 def convert_installed?
135 self.class.convert_installed?
135 self.class.convert_installed?
136 end
136 end
137
137
138 # Returns the path to the test +vendor+ repository
138 # Returns the path to the test +vendor+ repository
139 def self.repository_path(vendor)
139 def self.repository_path(vendor)
140 path = Rails.root.join("tmp/test/#{vendor.downcase}_repository").to_s
140 path = Rails.root.join("tmp/test/#{vendor.downcase}_repository").to_s
141 # Unlike ruby, JRuby returns Rails.root with backslashes under Windows
141 # Unlike ruby, JRuby returns Rails.root with backslashes under Windows
142 path.tr("\\", "/")
142 path.tr("\\", "/")
143 end
143 end
144
144
145 # Returns the url of the subversion test repository
145 # Returns the url of the subversion test repository
146 def self.subversion_repository_url
146 def self.subversion_repository_url
147 path = repository_path('subversion')
147 path = repository_path('subversion')
148 path = '/' + path unless path.starts_with?('/')
148 path = '/' + path unless path.starts_with?('/')
149 "file://#{path}"
149 "file://#{path}"
150 end
150 end
151
151
152 # Returns true if the +vendor+ test repository is configured
152 # Returns true if the +vendor+ test repository is configured
153 def self.repository_configured?(vendor)
153 def self.repository_configured?(vendor)
154 File.directory?(repository_path(vendor))
154 File.directory?(repository_path(vendor))
155 end
155 end
156
156
157 def repository_path_hash(arr)
157 def repository_path_hash(arr)
158 hs = {}
158 hs = {}
159 hs[:path] = arr.join("/")
159 hs[:path] = arr.join("/")
160 hs[:param] = arr.join("/")
160 hs[:param] = arr.join("/")
161 hs
161 hs
162 end
162 end
163
163
164 def sqlite?
164 def sqlite?
165 ActiveRecord::Base.connection.adapter_name =~ /sqlite/i
165 ActiveRecord::Base.connection.adapter_name =~ /sqlite/i
166 end
166 end
167
167
168 def mysql?
168 def mysql?
169 ActiveRecord::Base.connection.adapter_name =~ /mysql/i
169 ActiveRecord::Base.connection.adapter_name =~ /mysql/i
170 end
170 end
171
171
172 def postgresql?
173 ActiveRecord::Base.connection.adapter_name =~ /postgresql/i
174 end
175
172 def assert_save(object)
176 def assert_save(object)
173 saved = object.save
177 saved = object.save
174 message = "#{object.class} could not be saved"
178 message = "#{object.class} could not be saved"
175 errors = object.errors.full_messages.map {|m| "- #{m}"}
179 errors = object.errors.full_messages.map {|m| "- #{m}"}
176 message << ":\n#{errors.join("\n")}" if errors.any?
180 message << ":\n#{errors.join("\n")}" if errors.any?
177 assert_equal true, saved, message
181 assert_equal true, saved, message
178 end
182 end
179
183
180 def assert_select_error(arg)
184 def assert_select_error(arg)
181 assert_select '#errorExplanation', :text => arg
185 assert_select '#errorExplanation', :text => arg
182 end
186 end
183
187
184 def assert_include(expected, s, message=nil)
188 def assert_include(expected, s, message=nil)
185 assert s.include?(expected), (message || "\"#{expected}\" not found in \"#{s}\"")
189 assert s.include?(expected), (message || "\"#{expected}\" not found in \"#{s}\"")
186 end
190 end
187
191
188 def assert_not_include(expected, s, message=nil)
192 def assert_not_include(expected, s, message=nil)
189 assert !s.include?(expected), (message || "\"#{expected}\" found in \"#{s}\"")
193 assert !s.include?(expected), (message || "\"#{expected}\" found in \"#{s}\"")
190 end
194 end
191
195
192 def assert_select_in(text, *args, &block)
196 def assert_select_in(text, *args, &block)
193 d = Nokogiri::HTML(CGI::unescapeHTML(String.new(text))).root
197 d = Nokogiri::HTML(CGI::unescapeHTML(String.new(text))).root
194 assert_select(d, *args, &block)
198 assert_select(d, *args, &block)
195 end
199 end
196
200
197 def assert_select_email(*args, &block)
201 def assert_select_email(*args, &block)
198 email = ActionMailer::Base.deliveries.last
202 email = ActionMailer::Base.deliveries.last
199 assert_not_nil email
203 assert_not_nil email
200 html_body = email.parts.detect {|part| part.content_type.include?('text/html')}.try(&:body)
204 html_body = email.parts.detect {|part| part.content_type.include?('text/html')}.try(&:body)
201 assert_not_nil html_body
205 assert_not_nil html_body
202 assert_select_in html_body.encoded, *args, &block
206 assert_select_in html_body.encoded, *args, &block
203 end
207 end
204
208
205 def assert_mail_body_match(expected, mail, message=nil)
209 def assert_mail_body_match(expected, mail, message=nil)
206 if expected.is_a?(String)
210 if expected.is_a?(String)
207 assert_include expected, mail_body(mail), message
211 assert_include expected, mail_body(mail), message
208 else
212 else
209 assert_match expected, mail_body(mail), message
213 assert_match expected, mail_body(mail), message
210 end
214 end
211 end
215 end
212
216
213 def assert_mail_body_no_match(expected, mail, message=nil)
217 def assert_mail_body_no_match(expected, mail, message=nil)
214 if expected.is_a?(String)
218 if expected.is_a?(String)
215 assert_not_include expected, mail_body(mail), message
219 assert_not_include expected, mail_body(mail), message
216 else
220 else
217 assert_no_match expected, mail_body(mail), message
221 assert_no_match expected, mail_body(mail), message
218 end
222 end
219 end
223 end
220
224
221 def mail_body(mail)
225 def mail_body(mail)
222 mail.parts.first.body.encoded
226 mail.parts.first.body.encoded
223 end
227 end
224
228
225 # Returns the lft value for a new root issue
229 # Returns the lft value for a new root issue
226 def new_issue_lft
230 def new_issue_lft
227 1
231 1
228 end
232 end
229 end
233 end
230
234
231 module Redmine
235 module Redmine
232 class RoutingTest < ActionDispatch::IntegrationTest
236 class RoutingTest < ActionDispatch::IntegrationTest
233 def should_route(arg)
237 def should_route(arg)
234 arg = arg.dup
238 arg = arg.dup
235 request = arg.keys.detect {|key| key.is_a?(String)}
239 request = arg.keys.detect {|key| key.is_a?(String)}
236 raise ArgumentError unless request
240 raise ArgumentError unless request
237 options = arg.slice!(request)
241 options = arg.slice!(request)
238
242
239 raise ArgumentError unless request =~ /\A(GET|POST|PUT|PATCH|DELETE)\s+(.+)\z/
243 raise ArgumentError unless request =~ /\A(GET|POST|PUT|PATCH|DELETE)\s+(.+)\z/
240 method, path = $1.downcase.to_sym, $2
244 method, path = $1.downcase.to_sym, $2
241
245
242 raise ArgumentError unless arg.values.first =~ /\A(.+)#(.+)\z/
246 raise ArgumentError unless arg.values.first =~ /\A(.+)#(.+)\z/
243 controller, action = $1, $2
247 controller, action = $1, $2
244
248
245 assert_routing(
249 assert_routing(
246 {:method => method, :path => path},
250 {:method => method, :path => path},
247 options.merge(:controller => controller, :action => action)
251 options.merge(:controller => controller, :action => action)
248 )
252 )
249 end
253 end
250 end
254 end
251
255
252 class IntegrationTest < ActionDispatch::IntegrationTest
256 class IntegrationTest < ActionDispatch::IntegrationTest
253 def log_user(login, password)
257 def log_user(login, password)
254 User.anonymous
258 User.anonymous
255 get "/login"
259 get "/login"
256 assert_equal nil, session[:user_id]
260 assert_equal nil, session[:user_id]
257 assert_response :success
261 assert_response :success
258 assert_template "account/login"
262 assert_template "account/login"
259 post "/login", :username => login, :password => password
263 post "/login", :username => login, :password => password
260 assert_equal login, User.find(session[:user_id]).login
264 assert_equal login, User.find(session[:user_id]).login
261 end
265 end
262
266
263 def credentials(user, password=nil)
267 def credentials(user, password=nil)
264 {'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials(user, password || user)}
268 {'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials(user, password || user)}
265 end
269 end
266 end
270 end
267
271
268 module ApiTest
272 module ApiTest
269 API_FORMATS = %w(json xml).freeze
273 API_FORMATS = %w(json xml).freeze
270
274
271 # Base class for API tests
275 # Base class for API tests
272 class Base < Redmine::IntegrationTest
276 class Base < Redmine::IntegrationTest
273 def setup
277 def setup
274 Setting.rest_api_enabled = '1'
278 Setting.rest_api_enabled = '1'
275 end
279 end
276
280
277 def teardown
281 def teardown
278 Setting.rest_api_enabled = '0'
282 Setting.rest_api_enabled = '0'
279 end
283 end
280
284
281 # Uploads content using the XML API and returns the attachment token
285 # Uploads content using the XML API and returns the attachment token
282 def xml_upload(content, credentials)
286 def xml_upload(content, credentials)
283 upload('xml', content, credentials)
287 upload('xml', content, credentials)
284 end
288 end
285
289
286 # Uploads content using the JSON API and returns the attachment token
290 # Uploads content using the JSON API and returns the attachment token
287 def json_upload(content, credentials)
291 def json_upload(content, credentials)
288 upload('json', content, credentials)
292 upload('json', content, credentials)
289 end
293 end
290
294
291 def upload(format, content, credentials)
295 def upload(format, content, credentials)
292 set_tmp_attachments_directory
296 set_tmp_attachments_directory
293 assert_difference 'Attachment.count' do
297 assert_difference 'Attachment.count' do
294 post "/uploads.#{format}", content, {"CONTENT_TYPE" => 'application/octet-stream'}.merge(credentials)
298 post "/uploads.#{format}", content, {"CONTENT_TYPE" => 'application/octet-stream'}.merge(credentials)
295 assert_response :created
299 assert_response :created
296 end
300 end
297 data = response_data
301 data = response_data
298 assert_kind_of Hash, data['upload']
302 assert_kind_of Hash, data['upload']
299 token = data['upload']['token']
303 token = data['upload']['token']
300 assert_not_nil token
304 assert_not_nil token
301 token
305 token
302 end
306 end
303
307
304 # Parses the response body based on its content type
308 # Parses the response body based on its content type
305 def response_data
309 def response_data
306 unless response.content_type.to_s =~ /^application\/(.+)/
310 unless response.content_type.to_s =~ /^application\/(.+)/
307 raise "Unexpected response type: #{response.content_type}"
311 raise "Unexpected response type: #{response.content_type}"
308 end
312 end
309 format = $1
313 format = $1
310 case format
314 case format
311 when 'xml'
315 when 'xml'
312 Hash.from_xml(response.body)
316 Hash.from_xml(response.body)
313 when 'json'
317 when 'json'
314 ActiveSupport::JSON.decode(response.body)
318 ActiveSupport::JSON.decode(response.body)
315 else
319 else
316 raise "Unknown response format: #{format}"
320 raise "Unknown response format: #{format}"
317 end
321 end
318 end
322 end
319 end
323 end
320
324
321 class Routing < Redmine::RoutingTest
325 class Routing < Redmine::RoutingTest
322 def should_route(arg)
326 def should_route(arg)
323 arg = arg.dup
327 arg = arg.dup
324 request = arg.keys.detect {|key| key.is_a?(String)}
328 request = arg.keys.detect {|key| key.is_a?(String)}
325 raise ArgumentError unless request
329 raise ArgumentError unless request
326 options = arg.slice!(request)
330 options = arg.slice!(request)
327
331
328 API_FORMATS.each do |format|
332 API_FORMATS.each do |format|
329 format_request = request.sub /$/, ".#{format}"
333 format_request = request.sub /$/, ".#{format}"
330 super options.merge(format_request => arg[request], :format => format)
334 super options.merge(format_request => arg[request], :format => format)
331 end
335 end
332 end
336 end
333 end
337 end
334 end
338 end
335 end
339 end
@@ -1,180 +1,199
1 # encoding: utf-8
1 # encoding: utf-8
2 #
2 #
3 # Redmine - project management software
3 # Redmine - project management software
4 # Copyright (C) 2006-2015 Jean-Philippe Lang
4 # Copyright (C) 2006-2015 Jean-Philippe Lang
5 #
5 #
6 # This program is free software; you can redistribute it and/or
6 # This program is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU General Public License
7 # modify it under the terms of the GNU General Public License
8 # as published by the Free Software Foundation; either version 2
8 # as published by the Free Software Foundation; either version 2
9 # of the License, or (at your option) any later version.
9 # of the License, or (at your option) any later version.
10 #
10 #
11 # This program is distributed in the hope that it will be useful,
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
14 # GNU General Public License for more details.
15 #
15 #
16 # You should have received a copy of the GNU General Public License
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19
19
20 require File.expand_path('../../test_helper', __FILE__)
20 require File.expand_path('../../test_helper', __FILE__)
21
21
22 class SearchTest < ActiveSupport::TestCase
22 class SearchTest < ActiveSupport::TestCase
23 fixtures :users,
23 fixtures :users,
24 :members,
24 :members,
25 :member_roles,
25 :member_roles,
26 :projects,
26 :projects,
27 :projects_trackers,
27 :projects_trackers,
28 :roles,
28 :roles,
29 :enabled_modules,
29 :enabled_modules,
30 :issues,
30 :issues,
31 :trackers,
31 :trackers,
32 :issue_statuses,
32 :issue_statuses,
33 :enumerations,
33 :enumerations,
34 :journals,
34 :journals,
35 :journal_details,
35 :journal_details,
36 :repositories,
36 :repositories,
37 :changesets
37 :changesets
38
38
39 def setup
39 def setup
40 @project = Project.find(1)
40 @project = Project.find(1)
41 @issue_keyword = '%unable to print recipes%'
41 @issue_keyword = '%unable to print recipes%'
42 @issue = Issue.find(1)
42 @issue = Issue.find(1)
43 @changeset_keyword = '%very first commit%'
43 @changeset_keyword = '%very first commit%'
44 @changeset = Changeset.find(100)
44 @changeset = Changeset.find(100)
45 end
45 end
46
46
47 def test_search_by_anonymous
47 def test_search_by_anonymous
48 User.current = nil
48 User.current = nil
49
49
50 r = Issue.search_results(@issue_keyword)
50 r = Issue.search_results(@issue_keyword)
51 assert r.include?(@issue)
51 assert r.include?(@issue)
52 r = Changeset.search_results(@changeset_keyword)
52 r = Changeset.search_results(@changeset_keyword)
53 assert r.include?(@changeset)
53 assert r.include?(@changeset)
54
54
55 # Removes the :view_changesets permission from Anonymous role
55 # Removes the :view_changesets permission from Anonymous role
56 remove_permission Role.anonymous, :view_changesets
56 remove_permission Role.anonymous, :view_changesets
57 User.current = nil
57 User.current = nil
58
58
59 r = Issue.search_results(@issue_keyword)
59 r = Issue.search_results(@issue_keyword)
60 assert r.include?(@issue)
60 assert r.include?(@issue)
61 r = Changeset.search_results(@changeset_keyword)
61 r = Changeset.search_results(@changeset_keyword)
62 assert !r.include?(@changeset)
62 assert !r.include?(@changeset)
63
63
64 # Make the project private
64 # Make the project private
65 @project.update_attribute :is_public, false
65 @project.update_attribute :is_public, false
66 r = Issue.search_results(@issue_keyword)
66 r = Issue.search_results(@issue_keyword)
67 assert !r.include?(@issue)
67 assert !r.include?(@issue)
68 r = Changeset.search_results(@changeset_keyword)
68 r = Changeset.search_results(@changeset_keyword)
69 assert !r.include?(@changeset)
69 assert !r.include?(@changeset)
70 end
70 end
71
71
72 def test_search_by_user
72 def test_search_by_user
73 User.current = User.find_by_login('rhill')
73 User.current = User.find_by_login('rhill')
74 assert User.current.memberships.empty?
74 assert User.current.memberships.empty?
75
75
76 r = Issue.search_results(@issue_keyword)
76 r = Issue.search_results(@issue_keyword)
77 assert r.include?(@issue)
77 assert r.include?(@issue)
78 r = Changeset.search_results(@changeset_keyword)
78 r = Changeset.search_results(@changeset_keyword)
79 assert r.include?(@changeset)
79 assert r.include?(@changeset)
80
80
81 # Removes the :view_changesets permission from Non member role
81 # Removes the :view_changesets permission from Non member role
82 remove_permission Role.non_member, :view_changesets
82 remove_permission Role.non_member, :view_changesets
83 User.current = User.find_by_login('rhill')
83 User.current = User.find_by_login('rhill')
84
84
85 r = Issue.search_results(@issue_keyword)
85 r = Issue.search_results(@issue_keyword)
86 assert r.include?(@issue)
86 assert r.include?(@issue)
87 r = Changeset.search_results(@changeset_keyword)
87 r = Changeset.search_results(@changeset_keyword)
88 assert !r.include?(@changeset)
88 assert !r.include?(@changeset)
89
89
90 # Make the project private
90 # Make the project private
91 @project.update_attribute :is_public, false
91 @project.update_attribute :is_public, false
92 r = Issue.search_results(@issue_keyword)
92 r = Issue.search_results(@issue_keyword)
93 assert !r.include?(@issue)
93 assert !r.include?(@issue)
94 r = Changeset.search_results(@changeset_keyword)
94 r = Changeset.search_results(@changeset_keyword)
95 assert !r.include?(@changeset)
95 assert !r.include?(@changeset)
96 end
96 end
97
97
98 def test_search_by_allowed_member
98 def test_search_by_allowed_member
99 User.current = User.find_by_login('jsmith')
99 User.current = User.find_by_login('jsmith')
100 assert User.current.projects.include?(@project)
100 assert User.current.projects.include?(@project)
101
101
102 r = Issue.search_results(@issue_keyword)
102 r = Issue.search_results(@issue_keyword)
103 assert r.include?(@issue)
103 assert r.include?(@issue)
104 r = Changeset.search_results(@changeset_keyword)
104 r = Changeset.search_results(@changeset_keyword)
105 assert r.include?(@changeset)
105 assert r.include?(@changeset)
106
106
107 # Make the project private
107 # Make the project private
108 @project.update_attribute :is_public, false
108 @project.update_attribute :is_public, false
109 r = Issue.search_results(@issue_keyword)
109 r = Issue.search_results(@issue_keyword)
110 assert r.include?(@issue)
110 assert r.include?(@issue)
111 r = Changeset.search_results(@changeset_keyword)
111 r = Changeset.search_results(@changeset_keyword)
112 assert r.include?(@changeset)
112 assert r.include?(@changeset)
113 end
113 end
114
114
115 def test_search_by_unallowed_member
115 def test_search_by_unallowed_member
116 # Removes the :view_changesets permission from user's and non member role
116 # Removes the :view_changesets permission from user's and non member role
117 remove_permission Role.find(1), :view_changesets
117 remove_permission Role.find(1), :view_changesets
118 remove_permission Role.non_member, :view_changesets
118 remove_permission Role.non_member, :view_changesets
119
119
120 User.current = User.find_by_login('jsmith')
120 User.current = User.find_by_login('jsmith')
121 assert User.current.projects.include?(@project)
121 assert User.current.projects.include?(@project)
122
122
123 r = Issue.search_results(@issue_keyword)
123 r = Issue.search_results(@issue_keyword)
124 assert r.include?(@issue)
124 assert r.include?(@issue)
125 r = Changeset.search_results(@changeset_keyword)
125 r = Changeset.search_results(@changeset_keyword)
126 assert !r.include?(@changeset)
126 assert !r.include?(@changeset)
127
127
128 # Make the project private
128 # Make the project private
129 @project.update_attribute :is_public, false
129 @project.update_attribute :is_public, false
130 r = Issue.search_results(@issue_keyword)
130 r = Issue.search_results(@issue_keyword)
131 assert r.include?(@issue)
131 assert r.include?(@issue)
132 r = Changeset.search_results(@changeset_keyword)
132 r = Changeset.search_results(@changeset_keyword)
133 assert !r.include?(@changeset)
133 assert !r.include?(@changeset)
134 end
134 end
135
135
136 def test_search_issue_with_multiple_hits_in_journals
136 def test_search_issue_with_multiple_hits_in_journals
137 issue = Issue.find(1)
137 issue = Issue.find(1)
138 assert_equal 2, issue.journals.where("notes LIKE '%notes%'").count
138 assert_equal 2, issue.journals.where("notes LIKE '%notes%'").count
139
139
140 r = Issue.search_results('%notes%')
140 r = Issue.search_results('%notes%')
141 assert_equal 1, r.size
141 assert_equal 1, r.size
142 assert_equal issue, r.first
142 assert_equal issue, r.first
143 end
143 end
144
144
145 def test_search_should_be_case_insensitive
145 def test_search_should_be_case_insensitive
146 issue = Issue.generate!(:subject => "AzerTY")
146 issue = Issue.generate!(:subject => "AzerTY")
147
147
148 r = Issue.search_results('AZERty')
148 r = Issue.search_results('AZERty')
149 assert_include issue, r
149 assert_include issue, r
150 end
150 end
151
151
152 def test_search_should_be_case_insensitive_with_accented_characters
152 def test_search_should_be_case_insensitive_with_accented_characters
153 unless sqlite?
153 unless sqlite?
154 issue1 = Issue.generate!(:subject => "Special chars: ÖÖ")
154 issue1 = Issue.generate!(:subject => "Special chars: ÖÖ")
155 issue2 = Issue.generate!(:subject => "Special chars: Öö")
155 issue2 = Issue.generate!(:subject => "Special chars: Öö")
156
156
157 r = Issue.search_results('ÖÖ')
157 r = Issue.search_results('ÖÖ')
158 assert_include issue1, r
158 assert_include issue1, r
159 assert_include issue2, r
159 assert_include issue2, r
160 end
160 end
161 end
161 end
162
162
163 def test_search_should_be_case_and_accent_insensitive_with_mysql
163 def test_search_should_be_case_and_accent_insensitive_with_mysql
164 if mysql?
164 if mysql?
165 issue1 = Issue.generate!(:subject => "OO")
165 issue1 = Issue.generate!(:subject => "OO")
166 issue2 = Issue.generate!(:subject => "oo")
166 issue2 = Issue.generate!(:subject => "oo")
167
167
168 r = Issue.search_results('ÖÖ')
168 r = Issue.search_results('ÖÖ')
169 assert_include issue1, r
169 assert_include issue1, r
170 assert_include issue2, r
170 assert_include issue2, r
171 end
171 end
172 end
172 end
173
173
174 def test_search_should_be_case_and_accent_insensitive_with_postgresql_and_noaccent_extension
175 if postgresql?
176 skip unless Redmine::Database.postgresql_version >= 90000
177 # Extension will be rollbacked with the test transaction
178 ActiveRecord::Base.connection.execute("CREATE EXTENSION IF NOT EXISTS unaccent")
179 Redmine::Database.reset
180 assert Redmine::Database.postgresql_unaccent?
181
182 issue1 = Issue.generate!(:subject => "OO")
183 issue2 = Issue.generate!(:subject => "oo")
184
185 r = Issue.search_results('ÖÖ')
186 assert_include issue1, r
187 assert_include issue2, r
188 end
189 ensure
190 Redmine::Database.reset
191 end
192
174 private
193 private
175
194
176 def remove_permission(role, permission)
195 def remove_permission(role, permission)
177 role.permissions = role.permissions - [ permission ]
196 role.permissions = role.permissions - [ permission ]
178 role.save
197 role.save
179 end
198 end
180 end
199 end
General Comments 0
You need to be logged in to leave comments. Login now