##// END OF EJS Templates
Merged r11509 and r11512 from trunk (#13309)....
Jean-Philippe Lang -
r11284:5bb2f5e211b7
parent child
Show More
@@ -1,1426 +1,1426
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2013 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Issue < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 include Redmine::Utils::DateCalculation
21 21
22 22 belongs_to :project
23 23 belongs_to :tracker
24 24 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
25 25 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
26 26 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
27 27 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
28 28 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
29 29 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
30 30
31 31 has_many :journals, :as => :journalized, :dependent => :destroy
32 32 has_many :visible_journals,
33 33 :class_name => 'Journal',
34 34 :as => :journalized,
35 35 :conditions => Proc.new {
36 36 ["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false]
37 37 },
38 38 :readonly => true
39 39
40 40 has_many :time_entries, :dependent => :delete_all
41 41 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
42 42
43 43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
44 44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
45 45
46 46 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
47 47 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
48 48 acts_as_customizable
49 49 acts_as_watchable
50 50 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
51 51 :include => [:project, :visible_journals],
52 52 # sort by id so that limited eager loading doesn't break with postgresql
53 53 :order_column => "#{table_name}.id"
54 54 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
55 55 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
56 56 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
57 57
58 58 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
59 59 :author_key => :author_id
60 60
61 61 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
62 62
63 63 attr_reader :current_journal
64 64 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
65 65
66 66 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
67 67
68 68 validates_length_of :subject, :maximum => 255
69 69 validates_inclusion_of :done_ratio, :in => 0..100
70 70 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
71 71 validates :start_date, :date => true
72 72 validates :due_date, :date => true
73 73 validate :validate_issue, :validate_required_fields
74 74
75 75 scope :visible, lambda {|*args|
76 76 includes(:project).where(Issue.visible_condition(args.shift || User.current, *args))
77 77 }
78 78
79 79 scope :open, lambda {|*args|
80 80 is_closed = args.size > 0 ? !args.first : false
81 81 includes(:status).where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
82 82 }
83 83
84 84 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
85 85 scope :on_active_project, lambda {
86 86 includes(:status, :project, :tracker).where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
87 87 }
88 88 scope :fixed_version, lambda {|versions|
89 89 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
90 90 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
91 91 }
92 92
93 93 before_create :default_assign
94 94 before_save :close_duplicates, :update_done_ratio_from_issue_status, :force_updated_on_change, :update_closed_on
95 95 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
96 96 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
97 97 # Should be after_create but would be called before previous after_save callbacks
98 98 after_save :after_create_from_copy
99 99 after_destroy :update_parent_attributes
100 100
101 101 # Returns a SQL conditions string used to find all issues visible by the specified user
102 102 def self.visible_condition(user, options={})
103 103 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
104 104 if user.logged?
105 105 case role.issues_visibility
106 106 when 'all'
107 107 nil
108 108 when 'default'
109 109 user_ids = [user.id] + user.groups.map(&:id)
110 110 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
111 111 when 'own'
112 112 user_ids = [user.id] + user.groups.map(&:id)
113 113 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
114 114 else
115 115 '1=0'
116 116 end
117 117 else
118 118 "(#{table_name}.is_private = #{connection.quoted_false})"
119 119 end
120 120 end
121 121 end
122 122
123 123 # Returns true if usr or current user is allowed to view the issue
124 124 def visible?(usr=nil)
125 125 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
126 126 if user.logged?
127 127 case role.issues_visibility
128 128 when 'all'
129 129 true
130 130 when 'default'
131 131 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
132 132 when 'own'
133 133 self.author == user || user.is_or_belongs_to?(assigned_to)
134 134 else
135 135 false
136 136 end
137 137 else
138 138 !self.is_private?
139 139 end
140 140 end
141 141 end
142 142
143 143 # Returns true if user or current user is allowed to edit or add a note to the issue
144 144 def editable?(user=User.current)
145 145 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
146 146 end
147 147
148 148 def initialize(attributes=nil, *args)
149 149 super
150 150 if new_record?
151 151 # set default values for new records only
152 152 self.status ||= IssueStatus.default
153 153 self.priority ||= IssuePriority.default
154 154 self.watcher_user_ids = []
155 155 end
156 156 end
157 157
158 158 def create_or_update
159 159 super
160 160 ensure
161 161 @status_was = nil
162 162 end
163 163 private :create_or_update
164 164
165 165 # AR#Persistence#destroy would raise and RecordNotFound exception
166 166 # if the issue was already deleted or updated (non matching lock_version).
167 167 # This is a problem when bulk deleting issues or deleting a project
168 168 # (because an issue may already be deleted if its parent was deleted
169 169 # first).
170 170 # The issue is reloaded by the nested_set before being deleted so
171 171 # the lock_version condition should not be an issue but we handle it.
172 172 def destroy
173 173 super
174 174 rescue ActiveRecord::RecordNotFound
175 175 # Stale or already deleted
176 176 begin
177 177 reload
178 178 rescue ActiveRecord::RecordNotFound
179 179 # The issue was actually already deleted
180 180 @destroyed = true
181 181 return freeze
182 182 end
183 183 # The issue was stale, retry to destroy
184 184 super
185 185 end
186 186
187 187 def reload(*args)
188 188 @workflow_rule_by_attribute = nil
189 189 @assignable_versions = nil
190 190 @relations = nil
191 191 super
192 192 end
193 193
194 194 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
195 195 def available_custom_fields
196 196 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
197 197 end
198 198
199 199 # Copies attributes from another issue, arg can be an id or an Issue
200 200 def copy_from(arg, options={})
201 201 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
202 202 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
203 203 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
204 204 self.status = issue.status
205 205 self.author = User.current
206 206 unless options[:attachments] == false
207 207 self.attachments = issue.attachments.map do |attachement|
208 208 attachement.copy(:container => self)
209 209 end
210 210 end
211 211 @copied_from = issue
212 212 @copy_options = options
213 213 self
214 214 end
215 215
216 216 # Returns an unsaved copy of the issue
217 217 def copy(attributes=nil, copy_options={})
218 218 copy = self.class.new.copy_from(self, copy_options)
219 219 copy.attributes = attributes if attributes
220 220 copy
221 221 end
222 222
223 223 # Returns true if the issue is a copy
224 224 def copy?
225 225 @copied_from.present?
226 226 end
227 227
228 228 # Moves/copies an issue to a new project and tracker
229 229 # Returns the moved/copied issue on success, false on failure
230 230 def move_to_project(new_project, new_tracker=nil, options={})
231 231 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
232 232
233 233 if options[:copy]
234 234 issue = self.copy
235 235 else
236 236 issue = self
237 237 end
238 238
239 239 issue.init_journal(User.current, options[:notes])
240 240
241 241 # Preserve previous behaviour
242 242 # #move_to_project doesn't change tracker automatically
243 243 issue.send :project=, new_project, true
244 244 if new_tracker
245 245 issue.tracker = new_tracker
246 246 end
247 247 # Allow bulk setting of attributes on the issue
248 248 if options[:attributes]
249 249 issue.attributes = options[:attributes]
250 250 end
251 251
252 252 issue.save ? issue : false
253 253 end
254 254
255 255 def status_id=(sid)
256 256 self.status = nil
257 257 result = write_attribute(:status_id, sid)
258 258 @workflow_rule_by_attribute = nil
259 259 result
260 260 end
261 261
262 262 def priority_id=(pid)
263 263 self.priority = nil
264 264 write_attribute(:priority_id, pid)
265 265 end
266 266
267 267 def category_id=(cid)
268 268 self.category = nil
269 269 write_attribute(:category_id, cid)
270 270 end
271 271
272 272 def fixed_version_id=(vid)
273 273 self.fixed_version = nil
274 274 write_attribute(:fixed_version_id, vid)
275 275 end
276 276
277 277 def tracker_id=(tid)
278 278 self.tracker = nil
279 279 result = write_attribute(:tracker_id, tid)
280 280 @custom_field_values = nil
281 281 @workflow_rule_by_attribute = nil
282 282 result
283 283 end
284 284
285 285 def project_id=(project_id)
286 286 if project_id.to_s != self.project_id.to_s
287 287 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
288 288 end
289 289 end
290 290
291 291 def project=(project, keep_tracker=false)
292 292 project_was = self.project
293 293 write_attribute(:project_id, project ? project.id : nil)
294 294 association_instance_set('project', project)
295 295 if project_was && project && project_was != project
296 296 @assignable_versions = nil
297 297
298 298 unless keep_tracker || project.trackers.include?(tracker)
299 299 self.tracker = project.trackers.first
300 300 end
301 301 # Reassign to the category with same name if any
302 302 if category
303 303 self.category = project.issue_categories.find_by_name(category.name)
304 304 end
305 305 # Keep the fixed_version if it's still valid in the new_project
306 306 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
307 307 self.fixed_version = nil
308 308 end
309 309 # Clear the parent task if it's no longer valid
310 310 unless valid_parent_project?
311 311 self.parent_issue_id = nil
312 312 end
313 313 @custom_field_values = nil
314 314 end
315 315 end
316 316
317 317 def description=(arg)
318 318 if arg.is_a?(String)
319 319 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
320 320 end
321 321 write_attribute(:description, arg)
322 322 end
323 323
324 324 # Overrides assign_attributes so that project and tracker get assigned first
325 325 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
326 326 return if new_attributes.nil?
327 327 attrs = new_attributes.dup
328 328 attrs.stringify_keys!
329 329
330 330 %w(project project_id tracker tracker_id).each do |attr|
331 331 if attrs.has_key?(attr)
332 332 send "#{attr}=", attrs.delete(attr)
333 333 end
334 334 end
335 335 send :assign_attributes_without_project_and_tracker_first, attrs, *args
336 336 end
337 337 # Do not redefine alias chain on reload (see #4838)
338 338 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
339 339
340 340 def estimated_hours=(h)
341 341 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
342 342 end
343 343
344 344 safe_attributes 'project_id',
345 345 :if => lambda {|issue, user|
346 346 if issue.new_record?
347 347 issue.copy?
348 348 elsif user.allowed_to?(:move_issues, issue.project)
349 349 projects = Issue.allowed_target_projects_on_move(user)
350 350 projects.include?(issue.project) && projects.size > 1
351 351 end
352 352 }
353 353
354 354 safe_attributes 'tracker_id',
355 355 'status_id',
356 356 'category_id',
357 357 'assigned_to_id',
358 358 'priority_id',
359 359 'fixed_version_id',
360 360 'subject',
361 361 'description',
362 362 'start_date',
363 363 'due_date',
364 364 'done_ratio',
365 365 'estimated_hours',
366 366 'custom_field_values',
367 367 'custom_fields',
368 368 'lock_version',
369 369 'notes',
370 370 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
371 371
372 372 safe_attributes 'status_id',
373 373 'assigned_to_id',
374 374 'fixed_version_id',
375 375 'done_ratio',
376 376 'lock_version',
377 377 'notes',
378 378 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
379 379
380 380 safe_attributes 'notes',
381 381 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
382 382
383 383 safe_attributes 'private_notes',
384 384 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
385 385
386 386 safe_attributes 'watcher_user_ids',
387 387 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
388 388
389 389 safe_attributes 'is_private',
390 390 :if => lambda {|issue, user|
391 391 user.allowed_to?(:set_issues_private, issue.project) ||
392 392 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
393 393 }
394 394
395 395 safe_attributes 'parent_issue_id',
396 396 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
397 397 user.allowed_to?(:manage_subtasks, issue.project)}
398 398
399 399 def safe_attribute_names(user=nil)
400 400 names = super
401 401 names -= disabled_core_fields
402 402 names -= read_only_attribute_names(user)
403 403 names
404 404 end
405 405
406 406 # Safely sets attributes
407 407 # Should be called from controllers instead of #attributes=
408 408 # attr_accessible is too rough because we still want things like
409 409 # Issue.new(:project => foo) to work
410 410 def safe_attributes=(attrs, user=User.current)
411 411 return unless attrs.is_a?(Hash)
412 412
413 413 attrs = attrs.dup
414 414
415 415 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
416 416 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
417 417 if allowed_target_projects(user).collect(&:id).include?(p.to_i)
418 418 self.project_id = p
419 419 end
420 420 end
421 421
422 422 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
423 423 self.tracker_id = t
424 424 end
425 425
426 426 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
427 427 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
428 428 self.status_id = s
429 429 end
430 430 end
431 431
432 432 attrs = delete_unsafe_attributes(attrs, user)
433 433 return if attrs.empty?
434 434
435 435 unless leaf?
436 436 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
437 437 end
438 438
439 439 if attrs['parent_issue_id'].present?
440 440 s = attrs['parent_issue_id'].to_s
441 441 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
442 442 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
443 443 end
444 444 end
445 445
446 446 if attrs['custom_field_values'].present?
447 447 attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| read_only_attribute_names(user).include? k.to_s}
448 448 end
449 449
450 450 if attrs['custom_fields'].present?
451 451 attrs['custom_fields'] = attrs['custom_fields'].reject {|c| read_only_attribute_names(user).include? c['id'].to_s}
452 452 end
453 453
454 454 # mass-assignment security bypass
455 455 assign_attributes attrs, :without_protection => true
456 456 end
457 457
458 458 def disabled_core_fields
459 459 tracker ? tracker.disabled_core_fields : []
460 460 end
461 461
462 462 # Returns the custom_field_values that can be edited by the given user
463 463 def editable_custom_field_values(user=nil)
464 464 custom_field_values.reject do |value|
465 465 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
466 466 end
467 467 end
468 468
469 469 # Returns the names of attributes that are read-only for user or the current user
470 470 # For users with multiple roles, the read-only fields are the intersection of
471 471 # read-only fields of each role
472 472 # The result is an array of strings where sustom fields are represented with their ids
473 473 #
474 474 # Examples:
475 475 # issue.read_only_attribute_names # => ['due_date', '2']
476 476 # issue.read_only_attribute_names(user) # => []
477 477 def read_only_attribute_names(user=nil)
478 478 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
479 479 end
480 480
481 481 # Returns the names of required attributes for user or the current user
482 482 # For users with multiple roles, the required fields are the intersection of
483 483 # required fields of each role
484 484 # The result is an array of strings where sustom fields are represented with their ids
485 485 #
486 486 # Examples:
487 487 # issue.required_attribute_names # => ['due_date', '2']
488 488 # issue.required_attribute_names(user) # => []
489 489 def required_attribute_names(user=nil)
490 490 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
491 491 end
492 492
493 493 # Returns true if the attribute is required for user
494 494 def required_attribute?(name, user=nil)
495 495 required_attribute_names(user).include?(name.to_s)
496 496 end
497 497
498 498 # Returns a hash of the workflow rule by attribute for the given user
499 499 #
500 500 # Examples:
501 501 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
502 502 def workflow_rule_by_attribute(user=nil)
503 503 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
504 504
505 505 user_real = user || User.current
506 506 roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
507 507 return {} if roles.empty?
508 508
509 509 result = {}
510 510 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).all
511 511 if workflow_permissions.any?
512 512 workflow_rules = workflow_permissions.inject({}) do |h, wp|
513 513 h[wp.field_name] ||= []
514 514 h[wp.field_name] << wp.rule
515 515 h
516 516 end
517 517 workflow_rules.each do |attr, rules|
518 518 next if rules.size < roles.size
519 519 uniq_rules = rules.uniq
520 520 if uniq_rules.size == 1
521 521 result[attr] = uniq_rules.first
522 522 else
523 523 result[attr] = 'required'
524 524 end
525 525 end
526 526 end
527 527 @workflow_rule_by_attribute = result if user.nil?
528 528 result
529 529 end
530 530 private :workflow_rule_by_attribute
531 531
532 532 def done_ratio
533 533 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
534 534 status.default_done_ratio
535 535 else
536 536 read_attribute(:done_ratio)
537 537 end
538 538 end
539 539
540 540 def self.use_status_for_done_ratio?
541 541 Setting.issue_done_ratio == 'issue_status'
542 542 end
543 543
544 544 def self.use_field_for_done_ratio?
545 545 Setting.issue_done_ratio == 'issue_field'
546 546 end
547 547
548 548 def validate_issue
549 549 if due_date && start_date && due_date < start_date
550 550 errors.add :due_date, :greater_than_start_date
551 551 end
552 552
553 553 if start_date && soonest_start && start_date < soonest_start
554 554 errors.add :start_date, :invalid
555 555 end
556 556
557 557 if fixed_version
558 558 if !assignable_versions.include?(fixed_version)
559 559 errors.add :fixed_version_id, :inclusion
560 560 elsif reopened? && fixed_version.closed?
561 561 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
562 562 end
563 563 end
564 564
565 565 # Checks that the issue can not be added/moved to a disabled tracker
566 566 if project && (tracker_id_changed? || project_id_changed?)
567 567 unless project.trackers.include?(tracker)
568 568 errors.add :tracker_id, :inclusion
569 569 end
570 570 end
571 571
572 572 # Checks parent issue assignment
573 573 if @invalid_parent_issue_id.present?
574 574 errors.add :parent_issue_id, :invalid
575 575 elsif @parent_issue
576 576 if !valid_parent_project?(@parent_issue)
577 577 errors.add :parent_issue_id, :invalid
578 578 elsif !new_record?
579 579 # moving an existing issue
580 580 if @parent_issue.root_id != root_id
581 581 # we can always move to another tree
582 582 elsif move_possible?(@parent_issue)
583 583 # move accepted inside tree
584 584 else
585 585 errors.add :parent_issue_id, :invalid
586 586 end
587 587 end
588 588 end
589 589 end
590 590
591 591 # Validates the issue against additional workflow requirements
592 592 def validate_required_fields
593 593 user = new_record? ? author : current_journal.try(:user)
594 594
595 595 required_attribute_names(user).each do |attribute|
596 596 if attribute =~ /^\d+$/
597 597 attribute = attribute.to_i
598 598 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
599 599 if v && v.value.blank?
600 600 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
601 601 end
602 602 else
603 603 if respond_to?(attribute) && send(attribute).blank?
604 604 errors.add attribute, :blank
605 605 end
606 606 end
607 607 end
608 608 end
609 609
610 610 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
611 611 # even if the user turns off the setting later
612 612 def update_done_ratio_from_issue_status
613 613 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
614 614 self.done_ratio = status.default_done_ratio
615 615 end
616 616 end
617 617
618 618 def init_journal(user, notes = "")
619 619 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
620 620 if new_record?
621 621 @current_journal.notify = false
622 622 else
623 623 @attributes_before_change = attributes.dup
624 624 @custom_values_before_change = {}
625 625 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
626 626 end
627 627 @current_journal
628 628 end
629 629
630 630 # Returns the id of the last journal or nil
631 631 def last_journal_id
632 632 if new_record?
633 633 nil
634 634 else
635 635 journals.maximum(:id)
636 636 end
637 637 end
638 638
639 639 # Returns a scope for journals that have an id greater than journal_id
640 640 def journals_after(journal_id)
641 641 scope = journals.reorder("#{Journal.table_name}.id ASC")
642 642 if journal_id.present?
643 643 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
644 644 end
645 645 scope
646 646 end
647 647
648 648 # Returns the initial status of the issue
649 649 # Returns nil for a new issue
650 650 def status_was
651 651 if status_id_was && status_id_was.to_i > 0
652 652 @status_was ||= IssueStatus.find_by_id(status_id_was)
653 653 end
654 654 end
655 655
656 656 # Return true if the issue is closed, otherwise false
657 657 def closed?
658 658 self.status.is_closed?
659 659 end
660 660
661 661 # Return true if the issue is being reopened
662 662 def reopened?
663 663 if !new_record? && status_id_changed?
664 664 status_was = IssueStatus.find_by_id(status_id_was)
665 665 status_new = IssueStatus.find_by_id(status_id)
666 666 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
667 667 return true
668 668 end
669 669 end
670 670 false
671 671 end
672 672
673 673 # Return true if the issue is being closed
674 674 def closing?
675 675 if !new_record? && status_id_changed?
676 676 if status_was && status && !status_was.is_closed? && status.is_closed?
677 677 return true
678 678 end
679 679 end
680 680 false
681 681 end
682 682
683 683 # Returns true if the issue is overdue
684 684 def overdue?
685 685 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
686 686 end
687 687
688 688 # Is the amount of work done less than it should for the due date
689 689 def behind_schedule?
690 690 return false if start_date.nil? || due_date.nil?
691 691 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
692 692 return done_date <= Date.today
693 693 end
694 694
695 695 # Does this issue have children?
696 696 def children?
697 697 !leaf?
698 698 end
699 699
700 700 # Users the issue can be assigned to
701 701 def assignable_users
702 702 users = project.assignable_users
703 703 users << author if author
704 704 users << assigned_to if assigned_to
705 705 users.uniq.sort
706 706 end
707 707
708 708 # Versions that the issue can be assigned to
709 709 def assignable_versions
710 710 return @assignable_versions if @assignable_versions
711 711
712 712 versions = project.shared_versions.open.all
713 713 if fixed_version
714 714 if fixed_version_id_changed?
715 715 # nothing to do
716 716 elsif project_id_changed?
717 717 if project.shared_versions.include?(fixed_version)
718 718 versions << fixed_version
719 719 end
720 720 else
721 721 versions << fixed_version
722 722 end
723 723 end
724 724 @assignable_versions = versions.uniq.sort
725 725 end
726 726
727 727 # Returns true if this issue is blocked by another issue that is still open
728 728 def blocked?
729 729 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
730 730 end
731 731
732 732 # Returns an array of statuses that user is able to apply
733 733 def new_statuses_allowed_to(user=User.current, include_default=false)
734 734 if new_record? && @copied_from
735 735 [IssueStatus.default, @copied_from.status].compact.uniq.sort
736 736 else
737 737 initial_status = nil
738 738 if new_record?
739 739 initial_status = IssueStatus.default
740 740 elsif status_id_was
741 741 initial_status = IssueStatus.find_by_id(status_id_was)
742 742 end
743 743 initial_status ||= status
744 744
745 745 statuses = initial_status.find_new_statuses_allowed_to(
746 746 user.admin ? Role.all : user.roles_for_project(project),
747 747 tracker,
748 748 author == user,
749 749 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
750 750 )
751 751 statuses << initial_status unless statuses.empty?
752 752 statuses << IssueStatus.default if include_default
753 753 statuses = statuses.compact.uniq.sort
754 754 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
755 755 end
756 756 end
757 757
758 758 def assigned_to_was
759 759 if assigned_to_id_changed? && assigned_to_id_was.present?
760 760 @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
761 761 end
762 762 end
763 763
764 764 # Returns the users that should be notified
765 765 def notified_users
766 766 notified = []
767 767 # Author and assignee are always notified unless they have been
768 768 # locked or don't want to be notified
769 769 notified << author if author
770 770 if assigned_to
771 771 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
772 772 end
773 773 if assigned_to_was
774 774 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
775 775 end
776 776 notified = notified.select {|u| u.active? && u.notify_about?(self)}
777 777
778 778 notified += project.notified_users
779 779 notified.uniq!
780 780 # Remove users that can not view the issue
781 781 notified.reject! {|user| !visible?(user)}
782 782 notified
783 783 end
784 784
785 785 # Returns the email addresses that should be notified
786 786 def recipients
787 787 notified_users.collect(&:mail)
788 788 end
789 789
790 790 # Returns the number of hours spent on this issue
791 791 def spent_hours
792 792 @spent_hours ||= time_entries.sum(:hours) || 0
793 793 end
794 794
795 795 # Returns the total number of hours spent on this issue and its descendants
796 796 #
797 797 # Example:
798 798 # spent_hours => 0.0
799 799 # spent_hours => 50.2
800 800 def total_spent_hours
801 801 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
802 802 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
803 803 end
804 804
805 805 def relations
806 806 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
807 807 end
808 808
809 809 # Preloads relations for a collection of issues
810 810 def self.load_relations(issues)
811 811 if issues.any?
812 812 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
813 813 issues.each do |issue|
814 814 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
815 815 end
816 816 end
817 817 end
818 818
819 819 # Preloads visible spent time for a collection of issues
820 820 def self.load_visible_spent_hours(issues, user=User.current)
821 821 if issues.any?
822 822 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
823 823 issues.each do |issue|
824 824 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
825 825 end
826 826 end
827 827 end
828 828
829 829 # Preloads visible relations for a collection of issues
830 830 def self.load_visible_relations(issues, user=User.current)
831 831 if issues.any?
832 832 issue_ids = issues.map(&:id)
833 833 # Relations with issue_from in given issues and visible issue_to
834 834 relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all
835 835 # Relations with issue_to in given issues and visible issue_from
836 836 relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all
837 837
838 838 issues.each do |issue|
839 839 relations =
840 840 relations_from.select {|relation| relation.issue_from_id == issue.id} +
841 841 relations_to.select {|relation| relation.issue_to_id == issue.id}
842 842
843 843 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
844 844 end
845 845 end
846 846 end
847 847
848 848 # Finds an issue relation given its id.
849 849 def find_relation(relation_id)
850 850 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
851 851 end
852 852
853 853 def all_dependent_issues(except=[])
854 854 except << self
855 855 dependencies = []
856 856 relations_from.each do |relation|
857 857 if relation.issue_to && !except.include?(relation.issue_to)
858 858 dependencies << relation.issue_to
859 859 dependencies += relation.issue_to.all_dependent_issues(except)
860 860 end
861 861 end
862 862 dependencies
863 863 end
864 864
865 865 # Returns an array of issues that duplicate this one
866 866 def duplicates
867 867 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
868 868 end
869 869
870 870 # Returns the due date or the target due date if any
871 871 # Used on gantt chart
872 872 def due_before
873 873 due_date || (fixed_version ? fixed_version.effective_date : nil)
874 874 end
875 875
876 876 # Returns the time scheduled for this issue.
877 877 #
878 878 # Example:
879 879 # Start Date: 2/26/09, End Date: 3/04/09
880 880 # duration => 6
881 881 def duration
882 882 (start_date && due_date) ? due_date - start_date : 0
883 883 end
884 884
885 885 # Returns the duration in working days
886 886 def working_duration
887 887 (start_date && due_date) ? working_days(start_date, due_date) : 0
888 888 end
889 889
890 890 def soonest_start(reload=false)
891 891 @soonest_start = nil if reload
892 892 @soonest_start ||= (
893 893 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
894 894 ancestors.collect(&:soonest_start)
895 895 ).compact.max
896 896 end
897 897
898 898 # Sets start_date on the given date or the next working day
899 899 # and changes due_date to keep the same working duration.
900 900 def reschedule_on(date)
901 901 wd = working_duration
902 902 date = next_working_date(date)
903 903 self.start_date = date
904 904 self.due_date = add_working_days(date, wd)
905 905 end
906 906
907 907 # Reschedules the issue on the given date or the next working day and saves the record.
908 908 # If the issue is a parent task, this is done by rescheduling its subtasks.
909 909 def reschedule_on!(date)
910 910 return if date.nil?
911 911 if leaf?
912 912 if start_date.nil? || start_date != date
913 913 if start_date && start_date > date
914 914 # Issue can not be moved earlier than its soonest start date
915 915 date = [soonest_start(true), date].compact.max
916 916 end
917 917 reschedule_on(date)
918 918 begin
919 919 save
920 920 rescue ActiveRecord::StaleObjectError
921 921 reload
922 922 reschedule_on(date)
923 923 save
924 924 end
925 925 end
926 926 else
927 927 leaves.each do |leaf|
928 928 if leaf.start_date
929 929 # Only move subtask if it starts at the same date as the parent
930 930 # or if it starts before the given date
931 931 if start_date == leaf.start_date || date > leaf.start_date
932 932 leaf.reschedule_on!(date)
933 933 end
934 934 else
935 935 leaf.reschedule_on!(date)
936 936 end
937 937 end
938 938 end
939 939 end
940 940
941 941 def <=>(issue)
942 942 if issue.nil?
943 943 -1
944 944 elsif root_id != issue.root_id
945 945 (root_id || 0) <=> (issue.root_id || 0)
946 946 else
947 947 (lft || 0) <=> (issue.lft || 0)
948 948 end
949 949 end
950 950
951 951 def to_s
952 952 "#{tracker} ##{id}: #{subject}"
953 953 end
954 954
955 955 # Returns a string of css classes that apply to the issue
956 956 def css_classes
957 s = "issue status-#{status_id} #{priority.try(:css_classes)}"
957 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
958 958 s << ' closed' if closed?
959 959 s << ' overdue' if overdue?
960 960 s << ' child' if child?
961 961 s << ' parent' unless leaf?
962 962 s << ' private' if is_private?
963 963 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
964 964 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
965 965 s
966 966 end
967 967
968 968 # Saves an issue and a time_entry from the parameters
969 969 def save_issue_with_child_records(params, existing_time_entry=nil)
970 970 Issue.transaction do
971 971 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
972 972 @time_entry = existing_time_entry || TimeEntry.new
973 973 @time_entry.project = project
974 974 @time_entry.issue = self
975 975 @time_entry.user = User.current
976 976 @time_entry.spent_on = User.current.today
977 977 @time_entry.attributes = params[:time_entry]
978 978 self.time_entries << @time_entry
979 979 end
980 980
981 981 # TODO: Rename hook
982 982 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
983 983 if save
984 984 # TODO: Rename hook
985 985 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
986 986 else
987 987 raise ActiveRecord::Rollback
988 988 end
989 989 end
990 990 end
991 991
992 992 # Unassigns issues from +version+ if it's no longer shared with issue's project
993 993 def self.update_versions_from_sharing_change(version)
994 994 # Update issues assigned to the version
995 995 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
996 996 end
997 997
998 998 # Unassigns issues from versions that are no longer shared
999 999 # after +project+ was moved
1000 1000 def self.update_versions_from_hierarchy_change(project)
1001 1001 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1002 1002 # Update issues of the moved projects and issues assigned to a version of a moved project
1003 1003 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
1004 1004 end
1005 1005
1006 1006 def parent_issue_id=(arg)
1007 1007 s = arg.to_s.strip.presence
1008 1008 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1009 1009 @parent_issue.id
1010 1010 else
1011 1011 @parent_issue = nil
1012 1012 @invalid_parent_issue_id = arg
1013 1013 end
1014 1014 end
1015 1015
1016 1016 def parent_issue_id
1017 1017 if @invalid_parent_issue_id
1018 1018 @invalid_parent_issue_id
1019 1019 elsif instance_variable_defined? :@parent_issue
1020 1020 @parent_issue.nil? ? nil : @parent_issue.id
1021 1021 else
1022 1022 parent_id
1023 1023 end
1024 1024 end
1025 1025
1026 1026 # Returns true if issue's project is a valid
1027 1027 # parent issue project
1028 1028 def valid_parent_project?(issue=parent)
1029 1029 return true if issue.nil? || issue.project_id == project_id
1030 1030
1031 1031 case Setting.cross_project_subtasks
1032 1032 when 'system'
1033 1033 true
1034 1034 when 'tree'
1035 1035 issue.project.root == project.root
1036 1036 when 'hierarchy'
1037 1037 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1038 1038 when 'descendants'
1039 1039 issue.project.is_or_is_ancestor_of?(project)
1040 1040 else
1041 1041 false
1042 1042 end
1043 1043 end
1044 1044
1045 1045 # Extracted from the ReportsController.
1046 1046 def self.by_tracker(project)
1047 1047 count_and_group_by(:project => project,
1048 1048 :field => 'tracker_id',
1049 1049 :joins => Tracker.table_name)
1050 1050 end
1051 1051
1052 1052 def self.by_version(project)
1053 1053 count_and_group_by(:project => project,
1054 1054 :field => 'fixed_version_id',
1055 1055 :joins => Version.table_name)
1056 1056 end
1057 1057
1058 1058 def self.by_priority(project)
1059 1059 count_and_group_by(:project => project,
1060 1060 :field => 'priority_id',
1061 1061 :joins => IssuePriority.table_name)
1062 1062 end
1063 1063
1064 1064 def self.by_category(project)
1065 1065 count_and_group_by(:project => project,
1066 1066 :field => 'category_id',
1067 1067 :joins => IssueCategory.table_name)
1068 1068 end
1069 1069
1070 1070 def self.by_assigned_to(project)
1071 1071 count_and_group_by(:project => project,
1072 1072 :field => 'assigned_to_id',
1073 1073 :joins => User.table_name)
1074 1074 end
1075 1075
1076 1076 def self.by_author(project)
1077 1077 count_and_group_by(:project => project,
1078 1078 :field => 'author_id',
1079 1079 :joins => User.table_name)
1080 1080 end
1081 1081
1082 1082 def self.by_subproject(project)
1083 1083 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1084 1084 s.is_closed as closed,
1085 1085 #{Issue.table_name}.project_id as project_id,
1086 1086 count(#{Issue.table_name}.id) as total
1087 1087 from
1088 1088 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
1089 1089 where
1090 1090 #{Issue.table_name}.status_id=s.id
1091 1091 and #{Issue.table_name}.project_id = #{Project.table_name}.id
1092 1092 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
1093 1093 and #{Issue.table_name}.project_id <> #{project.id}
1094 1094 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
1095 1095 end
1096 1096 # End ReportsController extraction
1097 1097
1098 1098 # Returns an array of projects that user can assign the issue to
1099 1099 def allowed_target_projects(user=User.current)
1100 1100 if new_record?
1101 1101 Project.all(:conditions => Project.allowed_to_condition(user, :add_issues))
1102 1102 else
1103 1103 self.class.allowed_target_projects_on_move(user)
1104 1104 end
1105 1105 end
1106 1106
1107 1107 # Returns an array of projects that user can move issues to
1108 1108 def self.allowed_target_projects_on_move(user=User.current)
1109 1109 Project.all(:conditions => Project.allowed_to_condition(user, :move_issues))
1110 1110 end
1111 1111
1112 1112 private
1113 1113
1114 1114 def after_project_change
1115 1115 # Update project_id on related time entries
1116 1116 TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id})
1117 1117
1118 1118 # Delete issue relations
1119 1119 unless Setting.cross_project_issue_relations?
1120 1120 relations_from.clear
1121 1121 relations_to.clear
1122 1122 end
1123 1123
1124 1124 # Move subtasks that were in the same project
1125 1125 children.each do |child|
1126 1126 next unless child.project_id == project_id_was
1127 1127 # Change project and keep project
1128 1128 child.send :project=, project, true
1129 1129 unless child.save
1130 1130 raise ActiveRecord::Rollback
1131 1131 end
1132 1132 end
1133 1133 end
1134 1134
1135 1135 # Callback for after the creation of an issue by copy
1136 1136 # * adds a "copied to" relation with the copied issue
1137 1137 # * copies subtasks from the copied issue
1138 1138 def after_create_from_copy
1139 1139 return unless copy? && !@after_create_from_copy_handled
1140 1140
1141 1141 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1142 1142 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1143 1143 unless relation.save
1144 1144 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1145 1145 end
1146 1146 end
1147 1147
1148 1148 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1149 1149 @copied_from.children.each do |child|
1150 1150 unless child.visible?
1151 1151 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1152 1152 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1153 1153 next
1154 1154 end
1155 1155 copy = Issue.new.copy_from(child, @copy_options)
1156 1156 copy.author = author
1157 1157 copy.project = project
1158 1158 copy.parent_issue_id = id
1159 1159 # Children subtasks are copied recursively
1160 1160 unless copy.save
1161 1161 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1162 1162 end
1163 1163 end
1164 1164 end
1165 1165 @after_create_from_copy_handled = true
1166 1166 end
1167 1167
1168 1168 def update_nested_set_attributes
1169 1169 if root_id.nil?
1170 1170 # issue was just created
1171 1171 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1172 1172 set_default_left_and_right
1173 1173 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
1174 1174 if @parent_issue
1175 1175 move_to_child_of(@parent_issue)
1176 1176 end
1177 1177 reload
1178 1178 elsif parent_issue_id != parent_id
1179 1179 former_parent_id = parent_id
1180 1180 # moving an existing issue
1181 1181 if @parent_issue && @parent_issue.root_id == root_id
1182 1182 # inside the same tree
1183 1183 move_to_child_of(@parent_issue)
1184 1184 else
1185 1185 # to another tree
1186 1186 unless root?
1187 1187 move_to_right_of(root)
1188 1188 reload
1189 1189 end
1190 1190 old_root_id = root_id
1191 1191 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
1192 1192 target_maxright = nested_set_scope.maximum(right_column_name) || 0
1193 1193 offset = target_maxright + 1 - lft
1194 1194 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
1195 1195 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
1196 1196 self[left_column_name] = lft + offset
1197 1197 self[right_column_name] = rgt + offset
1198 1198 if @parent_issue
1199 1199 move_to_child_of(@parent_issue)
1200 1200 end
1201 1201 end
1202 1202 reload
1203 1203 # delete invalid relations of all descendants
1204 1204 self_and_descendants.each do |issue|
1205 1205 issue.relations.each do |relation|
1206 1206 relation.destroy unless relation.valid?
1207 1207 end
1208 1208 end
1209 1209 # update former parent
1210 1210 recalculate_attributes_for(former_parent_id) if former_parent_id
1211 1211 end
1212 1212 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1213 1213 end
1214 1214
1215 1215 def update_parent_attributes
1216 1216 recalculate_attributes_for(parent_id) if parent_id
1217 1217 end
1218 1218
1219 1219 def recalculate_attributes_for(issue_id)
1220 1220 if issue_id && p = Issue.find_by_id(issue_id)
1221 1221 # priority = highest priority of children
1222 1222 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority)
1223 1223 p.priority = IssuePriority.find_by_position(priority_position)
1224 1224 end
1225 1225
1226 1226 # start/due dates = lowest/highest dates of children
1227 1227 p.start_date = p.children.minimum(:start_date)
1228 1228 p.due_date = p.children.maximum(:due_date)
1229 1229 if p.start_date && p.due_date && p.due_date < p.start_date
1230 1230 p.start_date, p.due_date = p.due_date, p.start_date
1231 1231 end
1232 1232
1233 1233 # done ratio = weighted average ratio of leaves
1234 1234 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1235 1235 leaves_count = p.leaves.count
1236 1236 if leaves_count > 0
1237 1237 average = p.leaves.average(:estimated_hours).to_f
1238 1238 if average == 0
1239 1239 average = 1
1240 1240 end
1241 1241 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :joins => :status).to_f
1242 1242 progress = done / (average * leaves_count)
1243 1243 p.done_ratio = progress.round
1244 1244 end
1245 1245 end
1246 1246
1247 1247 # estimate = sum of leaves estimates
1248 1248 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1249 1249 p.estimated_hours = nil if p.estimated_hours == 0.0
1250 1250
1251 1251 # ancestors will be recursively updated
1252 1252 p.save(:validate => false)
1253 1253 end
1254 1254 end
1255 1255
1256 1256 # Update issues so their versions are not pointing to a
1257 1257 # fixed_version that is not shared with the issue's project
1258 1258 def self.update_versions(conditions=nil)
1259 1259 # Only need to update issues with a fixed_version from
1260 1260 # a different project and that is not systemwide shared
1261 1261 Issue.scoped(:conditions => conditions).all(
1262 1262 :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1263 1263 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1264 1264 " AND #{Version.table_name}.sharing <> 'system'",
1265 1265 :include => [:project, :fixed_version]
1266 1266 ).each do |issue|
1267 1267 next if issue.project.nil? || issue.fixed_version.nil?
1268 1268 unless issue.project.shared_versions.include?(issue.fixed_version)
1269 1269 issue.init_journal(User.current)
1270 1270 issue.fixed_version = nil
1271 1271 issue.save
1272 1272 end
1273 1273 end
1274 1274 end
1275 1275
1276 1276 # Callback on file attachment
1277 1277 def attachment_added(obj)
1278 1278 if @current_journal && !obj.new_record?
1279 1279 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
1280 1280 end
1281 1281 end
1282 1282
1283 1283 # Callback on attachment deletion
1284 1284 def attachment_removed(obj)
1285 1285 if @current_journal && !obj.new_record?
1286 1286 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
1287 1287 @current_journal.save
1288 1288 end
1289 1289 end
1290 1290
1291 1291 # Default assignment based on category
1292 1292 def default_assign
1293 1293 if assigned_to.nil? && category && category.assigned_to
1294 1294 self.assigned_to = category.assigned_to
1295 1295 end
1296 1296 end
1297 1297
1298 1298 # Updates start/due dates of following issues
1299 1299 def reschedule_following_issues
1300 1300 if start_date_changed? || due_date_changed?
1301 1301 relations_from.each do |relation|
1302 1302 relation.set_issue_to_dates
1303 1303 end
1304 1304 end
1305 1305 end
1306 1306
1307 1307 # Closes duplicates if the issue is being closed
1308 1308 def close_duplicates
1309 1309 if closing?
1310 1310 duplicates.each do |duplicate|
1311 1311 # Reload is need in case the duplicate was updated by a previous duplicate
1312 1312 duplicate.reload
1313 1313 # Don't re-close it if it's already closed
1314 1314 next if duplicate.closed?
1315 1315 # Same user and notes
1316 1316 if @current_journal
1317 1317 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1318 1318 end
1319 1319 duplicate.update_attribute :status, self.status
1320 1320 end
1321 1321 end
1322 1322 end
1323 1323
1324 1324 # Make sure updated_on is updated when adding a note and set updated_on now
1325 1325 # so we can set closed_on with the same value on closing
1326 1326 def force_updated_on_change
1327 1327 if @current_journal || changed?
1328 1328 self.updated_on = current_time_from_proper_timezone
1329 1329 if new_record?
1330 1330 self.created_on = updated_on
1331 1331 end
1332 1332 end
1333 1333 end
1334 1334
1335 1335 # Callback for setting closed_on when the issue is closed.
1336 1336 # The closed_on attribute stores the time of the last closing
1337 1337 # and is preserved when the issue is reopened.
1338 1338 def update_closed_on
1339 1339 if closing? || (new_record? && closed?)
1340 1340 self.closed_on = updated_on
1341 1341 end
1342 1342 end
1343 1343
1344 1344 # Saves the changes in a Journal
1345 1345 # Called after_save
1346 1346 def create_journal
1347 1347 if @current_journal
1348 1348 # attributes changes
1349 1349 if @attributes_before_change
1350 1350 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)).each {|c|
1351 1351 before = @attributes_before_change[c]
1352 1352 after = send(c)
1353 1353 next if before == after || (before.blank? && after.blank?)
1354 1354 @current_journal.details << JournalDetail.new(:property => 'attr',
1355 1355 :prop_key => c,
1356 1356 :old_value => before,
1357 1357 :value => after)
1358 1358 }
1359 1359 end
1360 1360 if @custom_values_before_change
1361 1361 # custom fields changes
1362 1362 custom_field_values.each {|c|
1363 1363 before = @custom_values_before_change[c.custom_field_id]
1364 1364 after = c.value
1365 1365 next if before == after || (before.blank? && after.blank?)
1366 1366
1367 1367 if before.is_a?(Array) || after.is_a?(Array)
1368 1368 before = [before] unless before.is_a?(Array)
1369 1369 after = [after] unless after.is_a?(Array)
1370 1370
1371 1371 # values removed
1372 1372 (before - after).reject(&:blank?).each do |value|
1373 1373 @current_journal.details << JournalDetail.new(:property => 'cf',
1374 1374 :prop_key => c.custom_field_id,
1375 1375 :old_value => value,
1376 1376 :value => nil)
1377 1377 end
1378 1378 # values added
1379 1379 (after - before).reject(&:blank?).each do |value|
1380 1380 @current_journal.details << JournalDetail.new(:property => 'cf',
1381 1381 :prop_key => c.custom_field_id,
1382 1382 :old_value => nil,
1383 1383 :value => value)
1384 1384 end
1385 1385 else
1386 1386 @current_journal.details << JournalDetail.new(:property => 'cf',
1387 1387 :prop_key => c.custom_field_id,
1388 1388 :old_value => before,
1389 1389 :value => after)
1390 1390 end
1391 1391 }
1392 1392 end
1393 1393 @current_journal.save
1394 1394 # reset current journal
1395 1395 init_journal @current_journal.user, @current_journal.notes
1396 1396 end
1397 1397 end
1398 1398
1399 1399 # Query generator for selecting groups of issue counts for a project
1400 1400 # based on specific criteria
1401 1401 #
1402 1402 # Options
1403 1403 # * project - Project to search in.
1404 1404 # * field - String. Issue field to key off of in the grouping.
1405 1405 # * joins - String. The table name to join against.
1406 1406 def self.count_and_group_by(options)
1407 1407 project = options.delete(:project)
1408 1408 select_field = options.delete(:field)
1409 1409 joins = options.delete(:joins)
1410 1410
1411 1411 where = "#{Issue.table_name}.#{select_field}=j.id"
1412 1412
1413 1413 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1414 1414 s.is_closed as closed,
1415 1415 j.id as #{select_field},
1416 1416 count(#{Issue.table_name}.id) as total
1417 1417 from
1418 1418 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1419 1419 where
1420 1420 #{Issue.table_name}.status_id=s.id
1421 1421 and #{where}
1422 1422 and #{Issue.table_name}.project_id=#{Project.table_name}.id
1423 1423 and #{visible_condition(User.current, :project => project)}
1424 1424 group by s.id, s.is_closed, j.id")
1425 1425 end
1426 1426 end
@@ -1,1201 +1,1201
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2013 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require File.expand_path('../../../test_helper', __FILE__)
21 21
22 22 class ApplicationHelperTest < ActionView::TestCase
23 23 include ERB::Util
24 24 include Rails.application.routes.url_helpers
25 25
26 26 fixtures :projects, :roles, :enabled_modules, :users,
27 27 :repositories, :changesets,
28 28 :trackers, :issue_statuses, :issues, :versions, :documents,
29 29 :wikis, :wiki_pages, :wiki_contents,
30 30 :boards, :messages, :news,
31 31 :attachments, :enumerations
32 32
33 33 def setup
34 34 super
35 35 set_tmp_attachments_directory
36 36 end
37 37
38 38 context "#link_to_if_authorized" do
39 39 context "authorized user" do
40 40 should "be tested"
41 41 end
42 42
43 43 context "unauthorized user" do
44 44 should "be tested"
45 45 end
46 46
47 47 should "allow using the :controller and :action for the target link" do
48 48 User.current = User.find_by_login('admin')
49 49
50 50 @project = Issue.first.project # Used by helper
51 51 response = link_to_if_authorized("By controller/action",
52 52 {:controller => 'issues', :action => 'edit', :id => Issue.first.id})
53 53 assert_match /href/, response
54 54 end
55 55
56 56 end
57 57
58 58 def test_auto_links
59 59 to_test = {
60 60 'http://foo.bar' => '<a class="external" href="http://foo.bar">http://foo.bar</a>',
61 61 'http://foo.bar/~user' => '<a class="external" href="http://foo.bar/~user">http://foo.bar/~user</a>',
62 62 'http://foo.bar.' => '<a class="external" href="http://foo.bar">http://foo.bar</a>.',
63 63 'https://foo.bar.' => '<a class="external" href="https://foo.bar">https://foo.bar</a>.',
64 64 'This is a link: http://foo.bar.' => 'This is a link: <a class="external" href="http://foo.bar">http://foo.bar</a>.',
65 65 'A link (eg. http://foo.bar).' => 'A link (eg. <a class="external" href="http://foo.bar">http://foo.bar</a>).',
66 66 'http://foo.bar/foo.bar#foo.bar.' => '<a class="external" href="http://foo.bar/foo.bar#foo.bar">http://foo.bar/foo.bar#foo.bar</a>.',
67 67 'http://www.foo.bar/Test_(foobar)' => '<a class="external" href="http://www.foo.bar/Test_(foobar)">http://www.foo.bar/Test_(foobar)</a>',
68 68 '(see inline link : http://www.foo.bar/Test_(foobar))' => '(see inline link : <a class="external" href="http://www.foo.bar/Test_(foobar)">http://www.foo.bar/Test_(foobar)</a>)',
69 69 '(see inline link : http://www.foo.bar/Test)' => '(see inline link : <a class="external" href="http://www.foo.bar/Test">http://www.foo.bar/Test</a>)',
70 70 '(see inline link : http://www.foo.bar/Test).' => '(see inline link : <a class="external" href="http://www.foo.bar/Test">http://www.foo.bar/Test</a>).',
71 71 '(see "inline link":http://www.foo.bar/Test_(foobar))' => '(see <a href="http://www.foo.bar/Test_(foobar)" class="external">inline link</a>)',
72 72 '(see "inline link":http://www.foo.bar/Test)' => '(see <a href="http://www.foo.bar/Test" class="external">inline link</a>)',
73 73 '(see "inline link":http://www.foo.bar/Test).' => '(see <a href="http://www.foo.bar/Test" class="external">inline link</a>).',
74 74 'www.foo.bar' => '<a class="external" href="http://www.foo.bar">www.foo.bar</a>',
75 75 'http://foo.bar/page?p=1&t=z&s=' => '<a class="external" href="http://foo.bar/page?p=1&#38;t=z&#38;s=">http://foo.bar/page?p=1&#38;t=z&#38;s=</a>',
76 76 'http://foo.bar/page#125' => '<a class="external" href="http://foo.bar/page#125">http://foo.bar/page#125</a>',
77 77 'http://foo@www.bar.com' => '<a class="external" href="http://foo@www.bar.com">http://foo@www.bar.com</a>',
78 78 'http://foo:bar@www.bar.com' => '<a class="external" href="http://foo:bar@www.bar.com">http://foo:bar@www.bar.com</a>',
79 79 'ftp://foo.bar' => '<a class="external" href="ftp://foo.bar">ftp://foo.bar</a>',
80 80 'ftps://foo.bar' => '<a class="external" href="ftps://foo.bar">ftps://foo.bar</a>',
81 81 'sftp://foo.bar' => '<a class="external" href="sftp://foo.bar">sftp://foo.bar</a>',
82 82 # two exclamation marks
83 83 'http://example.net/path!602815048C7B5C20!302.html' => '<a class="external" href="http://example.net/path!602815048C7B5C20!302.html">http://example.net/path!602815048C7B5C20!302.html</a>',
84 84 # escaping
85 85 'http://foo"bar' => '<a class="external" href="http://foo&quot;bar">http://foo&quot;bar</a>',
86 86 # wrap in angle brackets
87 87 '<http://foo.bar>' => '&lt;<a class="external" href="http://foo.bar">http://foo.bar</a>&gt;'
88 88 }
89 89 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
90 90 end
91 91
92 92 if 'ruby'.respond_to?(:encoding)
93 93 def test_auto_links_with_non_ascii_characters
94 94 to_test = {
95 95 'http://foo.bar/тСст' => '<a class="external" href="http://foo.bar/тСст">http://foo.bar/тСст</a>'
96 96 }
97 97 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
98 98 end
99 99 else
100 100 puts 'Skipping test_auto_links_with_non_ascii_characters, unsupported ruby version'
101 101 end
102 102
103 103 def test_auto_mailto
104 104 assert_equal '<p><a class="email" href="mailto:test@foo.bar">test@foo.bar</a></p>',
105 105 textilizable('test@foo.bar')
106 106 end
107 107
108 108 def test_inline_images
109 109 to_test = {
110 110 '!http://foo.bar/image.jpg!' => '<img src="http://foo.bar/image.jpg" alt="" />',
111 111 'floating !>http://foo.bar/image.jpg!' => 'floating <div style="float:right"><img src="http://foo.bar/image.jpg" alt="" /></div>',
112 112 'with class !(some-class)http://foo.bar/image.jpg!' => 'with class <img src="http://foo.bar/image.jpg" class="some-class" alt="" />',
113 113 'with style !{width:100px;height:100px}http://foo.bar/image.jpg!' => 'with style <img src="http://foo.bar/image.jpg" style="width:100px;height:100px;" alt="" />',
114 114 'with title !http://foo.bar/image.jpg(This is a title)!' => 'with title <img src="http://foo.bar/image.jpg" title="This is a title" alt="This is a title" />',
115 115 'with title !http://foo.bar/image.jpg(This is a double-quoted "title")!' => 'with title <img src="http://foo.bar/image.jpg" title="This is a double-quoted &quot;title&quot;" alt="This is a double-quoted &quot;title&quot;" />',
116 116 }
117 117 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
118 118 end
119 119
120 120 def test_inline_images_inside_tags
121 121 raw = <<-RAW
122 122 h1. !foo.png! Heading
123 123
124 124 Centered image:
125 125
126 126 p=. !bar.gif!
127 127 RAW
128 128
129 129 assert textilizable(raw).include?('<img src="foo.png" alt="" />')
130 130 assert textilizable(raw).include?('<img src="bar.gif" alt="" />')
131 131 end
132 132
133 133 def test_attached_images
134 134 to_test = {
135 135 'Inline image: !logo.gif!' => 'Inline image: <img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" />',
136 136 'Inline image: !logo.GIF!' => 'Inline image: <img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" />',
137 137 'No match: !ogo.gif!' => 'No match: <img src="ogo.gif" alt="" />',
138 138 'No match: !ogo.GIF!' => 'No match: <img src="ogo.GIF" alt="" />',
139 139 # link image
140 140 '!logo.gif!:http://foo.bar/' => '<a href="http://foo.bar/"><img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" /></a>',
141 141 }
142 142 attachments = Attachment.all
143 143 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :attachments => attachments) }
144 144 end
145 145
146 146 def test_attached_images_filename_extension
147 147 set_tmp_attachments_directory
148 148 a1 = Attachment.new(
149 149 :container => Issue.find(1),
150 150 :file => mock_file_with_options({:original_filename => "testtest.JPG"}),
151 151 :author => User.find(1))
152 152 assert a1.save
153 153 assert_equal "testtest.JPG", a1.filename
154 154 assert_equal "image/jpeg", a1.content_type
155 155 assert a1.image?
156 156
157 157 a2 = Attachment.new(
158 158 :container => Issue.find(1),
159 159 :file => mock_file_with_options({:original_filename => "testtest.jpeg"}),
160 160 :author => User.find(1))
161 161 assert a2.save
162 162 assert_equal "testtest.jpeg", a2.filename
163 163 assert_equal "image/jpeg", a2.content_type
164 164 assert a2.image?
165 165
166 166 a3 = Attachment.new(
167 167 :container => Issue.find(1),
168 168 :file => mock_file_with_options({:original_filename => "testtest.JPE"}),
169 169 :author => User.find(1))
170 170 assert a3.save
171 171 assert_equal "testtest.JPE", a3.filename
172 172 assert_equal "image/jpeg", a3.content_type
173 173 assert a3.image?
174 174
175 175 a4 = Attachment.new(
176 176 :container => Issue.find(1),
177 177 :file => mock_file_with_options({:original_filename => "Testtest.BMP"}),
178 178 :author => User.find(1))
179 179 assert a4.save
180 180 assert_equal "Testtest.BMP", a4.filename
181 181 assert_equal "image/x-ms-bmp", a4.content_type
182 182 assert a4.image?
183 183
184 184 to_test = {
185 185 'Inline image: !testtest.jpg!' =>
186 186 'Inline image: <img src="/attachments/download/' + a1.id.to_s + '/testtest.JPG" alt="" />',
187 187 'Inline image: !testtest.jpeg!' =>
188 188 'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testtest.jpeg" alt="" />',
189 189 'Inline image: !testtest.jpe!' =>
190 190 'Inline image: <img src="/attachments/download/' + a3.id.to_s + '/testtest.JPE" alt="" />',
191 191 'Inline image: !testtest.bmp!' =>
192 192 'Inline image: <img src="/attachments/download/' + a4.id.to_s + '/Testtest.BMP" alt="" />',
193 193 }
194 194
195 195 attachments = [a1, a2, a3, a4]
196 196 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :attachments => attachments) }
197 197 end
198 198
199 199 def test_attached_images_should_read_later
200 200 set_fixtures_attachments_directory
201 201 a1 = Attachment.find(16)
202 202 assert_equal "testfile.png", a1.filename
203 203 assert a1.readable?
204 204 assert (! a1.visible?(User.anonymous))
205 205 assert a1.visible?(User.find(2))
206 206 a2 = Attachment.find(17)
207 207 assert_equal "testfile.PNG", a2.filename
208 208 assert a2.readable?
209 209 assert (! a2.visible?(User.anonymous))
210 210 assert a2.visible?(User.find(2))
211 211 assert a1.created_on < a2.created_on
212 212
213 213 to_test = {
214 214 'Inline image: !testfile.png!' =>
215 215 'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testfile.PNG" alt="" />',
216 216 'Inline image: !Testfile.PNG!' =>
217 217 'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testfile.PNG" alt="" />',
218 218 }
219 219 attachments = [a1, a2]
220 220 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :attachments => attachments) }
221 221 set_tmp_attachments_directory
222 222 end
223 223
224 224 def test_textile_external_links
225 225 to_test = {
226 226 'This is a "link":http://foo.bar' => 'This is a <a href="http://foo.bar" class="external">link</a>',
227 227 'This is an intern "link":/foo/bar' => 'This is an intern <a href="/foo/bar">link</a>',
228 228 '"link (Link title)":http://foo.bar' => '<a href="http://foo.bar" title="Link title" class="external">link</a>',
229 229 '"link (Link title with "double-quotes")":http://foo.bar' => '<a href="http://foo.bar" title="Link title with &quot;double-quotes&quot;" class="external">link</a>',
230 230 "This is not a \"Link\":\n\nAnother paragraph" => "This is not a \"Link\":</p>\n\n\n\t<p>Another paragraph",
231 231 # no multiline link text
232 232 "This is a double quote \"on the first line\nand another on a second line\":test" => "This is a double quote \"on the first line<br />and another on a second line\":test",
233 233 # mailto link
234 234 "\"system administrator\":mailto:sysadmin@example.com?subject=redmine%20permissions" => "<a href=\"mailto:sysadmin@example.com?subject=redmine%20permissions\">system administrator</a>",
235 235 # two exclamation marks
236 236 '"a link":http://example.net/path!602815048C7B5C20!302.html' => '<a href="http://example.net/path!602815048C7B5C20!302.html" class="external">a link</a>',
237 237 # escaping
238 238 '"test":http://foo"bar' => '<a href="http://foo&quot;bar" class="external">test</a>',
239 239 }
240 240 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
241 241 end
242 242
243 243 if 'ruby'.respond_to?(:encoding)
244 244 def test_textile_external_links_with_non_ascii_characters
245 245 to_test = {
246 246 'This is a "link":http://foo.bar/тСст' => 'This is a <a href="http://foo.bar/тСст" class="external">link</a>'
247 247 }
248 248 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
249 249 end
250 250 else
251 251 puts 'Skipping test_textile_external_links_with_non_ascii_characters, unsupported ruby version'
252 252 end
253 253
254 254 def test_redmine_links
255 255 issue_link = link_to('#3', {:controller => 'issues', :action => 'show', :id => 3},
256 :class => 'issue status-1 priority-4 priority-lowest overdue', :title => 'Error 281 when updating a recipe (New)')
256 :class => Issue.find(3).css_classes, :title => 'Error 281 when updating a recipe (New)')
257 257 note_link = link_to('#3', {:controller => 'issues', :action => 'show', :id => 3, :anchor => 'note-14'},
258 :class => 'issue status-1 priority-4 priority-lowest overdue', :title => 'Error 281 when updating a recipe (New)')
258 :class => Issue.find(3).css_classes, :title => 'Error 281 when updating a recipe (New)')
259 259
260 260 changeset_link = link_to('r1', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 1},
261 261 :class => 'changeset', :title => 'My very first commit')
262 262 changeset_link2 = link_to('r2', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 2},
263 263 :class => 'changeset', :title => 'This commit fixes #1, #2 and references #1 & #3')
264 264
265 265 document_link = link_to('Test document', {:controller => 'documents', :action => 'show', :id => 1},
266 266 :class => 'document')
267 267
268 268 version_link = link_to('1.0', {:controller => 'versions', :action => 'show', :id => 2},
269 269 :class => 'version')
270 270
271 271 board_url = {:controller => 'boards', :action => 'show', :id => 2, :project_id => 'ecookbook'}
272 272
273 273 message_url = {:controller => 'messages', :action => 'show', :board_id => 1, :id => 4}
274 274
275 275 news_url = {:controller => 'news', :action => 'show', :id => 1}
276 276
277 277 project_url = {:controller => 'projects', :action => 'show', :id => 'subproject1'}
278 278
279 279 source_url = '/projects/ecookbook/repository/entry/some/file'
280 280 source_url_with_rev = '/projects/ecookbook/repository/revisions/52/entry/some/file'
281 281 source_url_with_ext = '/projects/ecookbook/repository/entry/some/file.ext'
282 282 source_url_with_rev_and_ext = '/projects/ecookbook/repository/revisions/52/entry/some/file.ext'
283 283 source_url_with_branch = '/projects/ecookbook/repository/revisions/branch/entry/some/file'
284 284
285 285 export_url = '/projects/ecookbook/repository/raw/some/file'
286 286 export_url_with_rev = '/projects/ecookbook/repository/revisions/52/raw/some/file'
287 287 export_url_with_ext = '/projects/ecookbook/repository/raw/some/file.ext'
288 288 export_url_with_rev_and_ext = '/projects/ecookbook/repository/revisions/52/raw/some/file.ext'
289 289 export_url_with_branch = '/projects/ecookbook/repository/revisions/branch/raw/some/file'
290 290
291 291 to_test = {
292 292 # tickets
293 293 '#3, [#3], (#3) and #3.' => "#{issue_link}, [#{issue_link}], (#{issue_link}) and #{issue_link}.",
294 294 # ticket notes
295 295 '#3-14' => note_link,
296 296 '#3#note-14' => note_link,
297 297 # should not ignore leading zero
298 298 '#03' => '#03',
299 299 # changesets
300 300 'r1' => changeset_link,
301 301 'r1.' => "#{changeset_link}.",
302 302 'r1, r2' => "#{changeset_link}, #{changeset_link2}",
303 303 'r1,r2' => "#{changeset_link},#{changeset_link2}",
304 304 # documents
305 305 'document#1' => document_link,
306 306 'document:"Test document"' => document_link,
307 307 # versions
308 308 'version#2' => version_link,
309 309 'version:1.0' => version_link,
310 310 'version:"1.0"' => version_link,
311 311 # source
312 312 'source:some/file' => link_to('source:some/file', source_url, :class => 'source'),
313 313 'source:/some/file' => link_to('source:/some/file', source_url, :class => 'source'),
314 314 'source:/some/file.' => link_to('source:/some/file', source_url, :class => 'source') + ".",
315 315 'source:/some/file.ext.' => link_to('source:/some/file.ext', source_url_with_ext, :class => 'source') + ".",
316 316 'source:/some/file. ' => link_to('source:/some/file', source_url, :class => 'source') + ".",
317 317 'source:/some/file.ext. ' => link_to('source:/some/file.ext', source_url_with_ext, :class => 'source') + ".",
318 318 'source:/some/file, ' => link_to('source:/some/file', source_url, :class => 'source') + ",",
319 319 'source:/some/file@52' => link_to('source:/some/file@52', source_url_with_rev, :class => 'source'),
320 320 'source:/some/file@branch' => link_to('source:/some/file@branch', source_url_with_branch, :class => 'source'),
321 321 'source:/some/file.ext@52' => link_to('source:/some/file.ext@52', source_url_with_rev_and_ext, :class => 'source'),
322 322 'source:/some/file#L110' => link_to('source:/some/file#L110', source_url + "#L110", :class => 'source'),
323 323 'source:/some/file.ext#L110' => link_to('source:/some/file.ext#L110', source_url_with_ext + "#L110", :class => 'source'),
324 324 'source:/some/file@52#L110' => link_to('source:/some/file@52#L110', source_url_with_rev + "#L110", :class => 'source'),
325 325 # export
326 326 'export:/some/file' => link_to('export:/some/file', export_url, :class => 'source download'),
327 327 'export:/some/file.ext' => link_to('export:/some/file.ext', export_url_with_ext, :class => 'source download'),
328 328 'export:/some/file@52' => link_to('export:/some/file@52', export_url_with_rev, :class => 'source download'),
329 329 'export:/some/file.ext@52' => link_to('export:/some/file.ext@52', export_url_with_rev_and_ext, :class => 'source download'),
330 330 'export:/some/file@branch' => link_to('export:/some/file@branch', export_url_with_branch, :class => 'source download'),
331 331 # forum
332 332 'forum#2' => link_to('Discussion', board_url, :class => 'board'),
333 333 'forum:Discussion' => link_to('Discussion', board_url, :class => 'board'),
334 334 # message
335 335 'message#4' => link_to('Post 2', message_url, :class => 'message'),
336 336 'message#5' => link_to('RE: post 2', message_url.merge(:anchor => 'message-5', :r => 5), :class => 'message'),
337 337 # news
338 338 'news#1' => link_to('eCookbook first release !', news_url, :class => 'news'),
339 339 'news:"eCookbook first release !"' => link_to('eCookbook first release !', news_url, :class => 'news'),
340 340 # project
341 341 'project#3' => link_to('eCookbook Subproject 1', project_url, :class => 'project'),
342 342 'project:subproject1' => link_to('eCookbook Subproject 1', project_url, :class => 'project'),
343 343 'project:"eCookbook subProject 1"' => link_to('eCookbook Subproject 1', project_url, :class => 'project'),
344 344 # not found
345 345 '#0123456789' => '#0123456789',
346 346 # invalid expressions
347 347 'source:' => 'source:',
348 348 # url hash
349 349 "http://foo.bar/FAQ#3" => '<a class="external" href="http://foo.bar/FAQ#3">http://foo.bar/FAQ#3</a>',
350 350 }
351 351 @project = Project.find(1)
352 352 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text), "#{text} failed" }
353 353 end
354 354
355 355 def test_redmine_links_with_a_different_project_before_current_project
356 356 vp1 = Version.generate!(:project_id => 1, :name => '1.4.4')
357 357 vp3 = Version.generate!(:project_id => 3, :name => '1.4.4')
358 358
359 359 @project = Project.find(3)
360 360 assert_equal %(<p><a href="/versions/#{vp1.id}" class="version">1.4.4</a> <a href="/versions/#{vp3.id}" class="version">1.4.4</a></p>),
361 361 textilizable("ecookbook:version:1.4.4 version:1.4.4")
362 362 end
363 363
364 364 def test_escaped_redmine_links_should_not_be_parsed
365 365 to_test = [
366 366 '#3.',
367 367 '#3-14.',
368 368 '#3#-note14.',
369 369 'r1',
370 370 'document#1',
371 371 'document:"Test document"',
372 372 'version#2',
373 373 'version:1.0',
374 374 'version:"1.0"',
375 375 'source:/some/file'
376 376 ]
377 377 @project = Project.find(1)
378 378 to_test.each { |text| assert_equal "<p>#{text}</p>", textilizable("!" + text), "#{text} failed" }
379 379 end
380 380
381 381 def test_cross_project_redmine_links
382 382 source_link = link_to('ecookbook:source:/some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :path => ['some', 'file']},
383 383 :class => 'source')
384 384
385 385 changeset_link = link_to('ecookbook:r2', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 2},
386 386 :class => 'changeset', :title => 'This commit fixes #1, #2 and references #1 & #3')
387 387
388 388 to_test = {
389 389 # documents
390 390 'document:"Test document"' => 'document:"Test document"',
391 391 'ecookbook:document:"Test document"' => '<a href="/documents/1" class="document">Test document</a>',
392 392 'invalid:document:"Test document"' => 'invalid:document:"Test document"',
393 393 # versions
394 394 'version:"1.0"' => 'version:"1.0"',
395 395 'ecookbook:version:"1.0"' => '<a href="/versions/2" class="version">1.0</a>',
396 396 'invalid:version:"1.0"' => 'invalid:version:"1.0"',
397 397 # changeset
398 398 'r2' => 'r2',
399 399 'ecookbook:r2' => changeset_link,
400 400 'invalid:r2' => 'invalid:r2',
401 401 # source
402 402 'source:/some/file' => 'source:/some/file',
403 403 'ecookbook:source:/some/file' => source_link,
404 404 'invalid:source:/some/file' => 'invalid:source:/some/file',
405 405 }
406 406 @project = Project.find(3)
407 407 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text), "#{text} failed" }
408 408 end
409 409
410 410 def test_multiple_repositories_redmine_links
411 411 svn = Repository::Subversion.create!(:project_id => 1, :identifier => 'svn_repo-1', :url => 'file:///foo/hg')
412 412 Changeset.create!(:repository => svn, :committed_on => Time.now, :revision => '123')
413 413 hg = Repository::Mercurial.create!(:project_id => 1, :identifier => 'hg1', :url => '/foo/hg')
414 414 Changeset.create!(:repository => hg, :committed_on => Time.now, :revision => '123', :scmid => 'abcd')
415 415
416 416 changeset_link = link_to('r2', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 2},
417 417 :class => 'changeset', :title => 'This commit fixes #1, #2 and references #1 & #3')
418 418 svn_changeset_link = link_to('svn_repo-1|r123', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :repository_id => 'svn_repo-1', :rev => 123},
419 419 :class => 'changeset', :title => '')
420 420 hg_changeset_link = link_to('hg1|abcd', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :repository_id => 'hg1', :rev => 'abcd'},
421 421 :class => 'changeset', :title => '')
422 422
423 423 source_link = link_to('source:some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :path => ['some', 'file']}, :class => 'source')
424 424 hg_source_link = link_to('source:hg1|some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :repository_id => 'hg1', :path => ['some', 'file']}, :class => 'source')
425 425
426 426 to_test = {
427 427 'r2' => changeset_link,
428 428 'svn_repo-1|r123' => svn_changeset_link,
429 429 'invalid|r123' => 'invalid|r123',
430 430 'commit:hg1|abcd' => hg_changeset_link,
431 431 'commit:invalid|abcd' => 'commit:invalid|abcd',
432 432 # source
433 433 'source:some/file' => source_link,
434 434 'source:hg1|some/file' => hg_source_link,
435 435 'source:invalid|some/file' => 'source:invalid|some/file',
436 436 }
437 437
438 438 @project = Project.find(1)
439 439 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text), "#{text} failed" }
440 440 end
441 441
442 442 def test_cross_project_multiple_repositories_redmine_links
443 443 svn = Repository::Subversion.create!(:project_id => 1, :identifier => 'svn1', :url => 'file:///foo/hg')
444 444 Changeset.create!(:repository => svn, :committed_on => Time.now, :revision => '123')
445 445 hg = Repository::Mercurial.create!(:project_id => 1, :identifier => 'hg1', :url => '/foo/hg')
446 446 Changeset.create!(:repository => hg, :committed_on => Time.now, :revision => '123', :scmid => 'abcd')
447 447
448 448 changeset_link = link_to('ecookbook:r2', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 2},
449 449 :class => 'changeset', :title => 'This commit fixes #1, #2 and references #1 & #3')
450 450 svn_changeset_link = link_to('ecookbook:svn1|r123', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :repository_id => 'svn1', :rev => 123},
451 451 :class => 'changeset', :title => '')
452 452 hg_changeset_link = link_to('ecookbook:hg1|abcd', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :repository_id => 'hg1', :rev => 'abcd'},
453 453 :class => 'changeset', :title => '')
454 454
455 455 source_link = link_to('ecookbook:source:some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :path => ['some', 'file']}, :class => 'source')
456 456 hg_source_link = link_to('ecookbook:source:hg1|some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :repository_id => 'hg1', :path => ['some', 'file']}, :class => 'source')
457 457
458 458 to_test = {
459 459 'ecookbook:r2' => changeset_link,
460 460 'ecookbook:svn1|r123' => svn_changeset_link,
461 461 'ecookbook:invalid|r123' => 'ecookbook:invalid|r123',
462 462 'ecookbook:commit:hg1|abcd' => hg_changeset_link,
463 463 'ecookbook:commit:invalid|abcd' => 'ecookbook:commit:invalid|abcd',
464 464 'invalid:commit:invalid|abcd' => 'invalid:commit:invalid|abcd',
465 465 # source
466 466 'ecookbook:source:some/file' => source_link,
467 467 'ecookbook:source:hg1|some/file' => hg_source_link,
468 468 'ecookbook:source:invalid|some/file' => 'ecookbook:source:invalid|some/file',
469 469 'invalid:source:invalid|some/file' => 'invalid:source:invalid|some/file',
470 470 }
471 471
472 472 @project = Project.find(3)
473 473 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text), "#{text} failed" }
474 474 end
475 475
476 476 def test_redmine_links_git_commit
477 477 changeset_link = link_to('abcd',
478 478 {
479 479 :controller => 'repositories',
480 480 :action => 'revision',
481 481 :id => 'subproject1',
482 482 :rev => 'abcd',
483 483 },
484 484 :class => 'changeset', :title => 'test commit')
485 485 to_test = {
486 486 'commit:abcd' => changeset_link,
487 487 }
488 488 @project = Project.find(3)
489 489 r = Repository::Git.create!(:project => @project, :url => '/tmp/test/git')
490 490 assert r
491 491 c = Changeset.new(:repository => r,
492 492 :committed_on => Time.now,
493 493 :revision => 'abcd',
494 494 :scmid => 'abcd',
495 495 :comments => 'test commit')
496 496 assert( c.save )
497 497 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
498 498 end
499 499
500 500 # TODO: Bazaar commit id contains mail address, so it contains '@' and '_'.
501 501 def test_redmine_links_darcs_commit
502 502 changeset_link = link_to('20080308225258-98289-abcd456efg.gz',
503 503 {
504 504 :controller => 'repositories',
505 505 :action => 'revision',
506 506 :id => 'subproject1',
507 507 :rev => '123',
508 508 },
509 509 :class => 'changeset', :title => 'test commit')
510 510 to_test = {
511 511 'commit:20080308225258-98289-abcd456efg.gz' => changeset_link,
512 512 }
513 513 @project = Project.find(3)
514 514 r = Repository::Darcs.create!(
515 515 :project => @project, :url => '/tmp/test/darcs',
516 516 :log_encoding => 'UTF-8')
517 517 assert r
518 518 c = Changeset.new(:repository => r,
519 519 :committed_on => Time.now,
520 520 :revision => '123',
521 521 :scmid => '20080308225258-98289-abcd456efg.gz',
522 522 :comments => 'test commit')
523 523 assert( c.save )
524 524 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
525 525 end
526 526
527 527 def test_redmine_links_mercurial_commit
528 528 changeset_link_rev = link_to('r123',
529 529 {
530 530 :controller => 'repositories',
531 531 :action => 'revision',
532 532 :id => 'subproject1',
533 533 :rev => '123' ,
534 534 },
535 535 :class => 'changeset', :title => 'test commit')
536 536 changeset_link_commit = link_to('abcd',
537 537 {
538 538 :controller => 'repositories',
539 539 :action => 'revision',
540 540 :id => 'subproject1',
541 541 :rev => 'abcd' ,
542 542 },
543 543 :class => 'changeset', :title => 'test commit')
544 544 to_test = {
545 545 'r123' => changeset_link_rev,
546 546 'commit:abcd' => changeset_link_commit,
547 547 }
548 548 @project = Project.find(3)
549 549 r = Repository::Mercurial.create!(:project => @project, :url => '/tmp/test')
550 550 assert r
551 551 c = Changeset.new(:repository => r,
552 552 :committed_on => Time.now,
553 553 :revision => '123',
554 554 :scmid => 'abcd',
555 555 :comments => 'test commit')
556 556 assert( c.save )
557 557 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
558 558 end
559 559
560 560 def test_attachment_links
561 561 to_test = {
562 562 'attachment:error281.txt' => '<a href="/attachments/download/1/error281.txt" class="attachment">error281.txt</a>'
563 563 }
564 564 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :attachments => Issue.find(3).attachments), "#{text} failed" }
565 565 end
566 566
567 567 def test_attachment_link_should_link_to_latest_attachment
568 568 set_tmp_attachments_directory
569 569 a1 = Attachment.generate!(:filename => "test.txt", :created_on => 1.hour.ago)
570 570 a2 = Attachment.generate!(:filename => "test.txt")
571 571
572 572 assert_equal %(<p><a href="/attachments/download/#{a2.id}/test.txt" class="attachment">test.txt</a></p>),
573 573 textilizable('attachment:test.txt', :attachments => [a1, a2])
574 574 end
575 575
576 576 def test_wiki_links
577 577 to_test = {
578 578 '[[CookBook documentation]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation" class="wiki-page">CookBook documentation</a>',
579 579 '[[Another page|Page]]' => '<a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">Page</a>',
580 580 # title content should be formatted
581 581 '[[Another page|With _styled_ *title*]]' => '<a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">With <em>styled</em> <strong>title</strong></a>',
582 582 '[[Another page|With title containing <strong>HTML entities &amp; markups</strong>]]' => '<a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">With title containing &lt;strong&gt;HTML entities &amp; markups&lt;/strong&gt;</a>',
583 583 # link with anchor
584 584 '[[CookBook documentation#One-section]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation#One-section" class="wiki-page">CookBook documentation</a>',
585 585 '[[Another page#anchor|Page]]' => '<a href="/projects/ecookbook/wiki/Another_page#anchor" class="wiki-page">Page</a>',
586 586 # UTF8 anchor
587 587 '[[Another_page#ВСст|ВСст]]' => %|<a href="/projects/ecookbook/wiki/Another_page##{CGI.escape 'ВСст'}" class="wiki-page">ВСст</a>|,
588 588 # page that doesn't exist
589 589 '[[Unknown page]]' => '<a href="/projects/ecookbook/wiki/Unknown_page" class="wiki-page new">Unknown page</a>',
590 590 '[[Unknown page|404]]' => '<a href="/projects/ecookbook/wiki/Unknown_page" class="wiki-page new">404</a>',
591 591 # link to another project wiki
592 592 '[[onlinestore:]]' => '<a href="/projects/onlinestore/wiki" class="wiki-page">onlinestore</a>',
593 593 '[[onlinestore:|Wiki]]' => '<a href="/projects/onlinestore/wiki" class="wiki-page">Wiki</a>',
594 594 '[[onlinestore:Start page]]' => '<a href="/projects/onlinestore/wiki/Start_page" class="wiki-page">Start page</a>',
595 595 '[[onlinestore:Start page|Text]]' => '<a href="/projects/onlinestore/wiki/Start_page" class="wiki-page">Text</a>',
596 596 '[[onlinestore:Unknown page]]' => '<a href="/projects/onlinestore/wiki/Unknown_page" class="wiki-page new">Unknown page</a>',
597 597 # striked through link
598 598 '-[[Another page|Page]]-' => '<del><a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">Page</a></del>',
599 599 '-[[Another page|Page]] link-' => '<del><a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">Page</a> link</del>',
600 600 # escaping
601 601 '![[Another page|Page]]' => '[[Another page|Page]]',
602 602 # project does not exist
603 603 '[[unknowproject:Start]]' => '[[unknowproject:Start]]',
604 604 '[[unknowproject:Start|Page title]]' => '[[unknowproject:Start|Page title]]',
605 605 }
606 606
607 607 @project = Project.find(1)
608 608 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
609 609 end
610 610
611 611 def test_wiki_links_within_local_file_generation_context
612 612
613 613 to_test = {
614 614 # link to a page
615 615 '[[CookBook documentation]]' => '<a href="CookBook_documentation.html" class="wiki-page">CookBook documentation</a>',
616 616 '[[CookBook documentation|documentation]]' => '<a href="CookBook_documentation.html" class="wiki-page">documentation</a>',
617 617 '[[CookBook documentation#One-section]]' => '<a href="CookBook_documentation.html#One-section" class="wiki-page">CookBook documentation</a>',
618 618 '[[CookBook documentation#One-section|documentation]]' => '<a href="CookBook_documentation.html#One-section" class="wiki-page">documentation</a>',
619 619 # page that doesn't exist
620 620 '[[Unknown page]]' => '<a href="Unknown_page.html" class="wiki-page new">Unknown page</a>',
621 621 '[[Unknown page|404]]' => '<a href="Unknown_page.html" class="wiki-page new">404</a>',
622 622 '[[Unknown page#anchor]]' => '<a href="Unknown_page.html#anchor" class="wiki-page new">Unknown page</a>',
623 623 '[[Unknown page#anchor|404]]' => '<a href="Unknown_page.html#anchor" class="wiki-page new">404</a>',
624 624 }
625 625
626 626 @project = Project.find(1)
627 627
628 628 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :wiki_links => :local) }
629 629 end
630 630
631 631 def test_wiki_links_within_wiki_page_context
632 632
633 633 page = WikiPage.find_by_title('Another_page' )
634 634
635 635 to_test = {
636 636 # link to another page
637 637 '[[CookBook documentation]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation" class="wiki-page">CookBook documentation</a>',
638 638 '[[CookBook documentation|documentation]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation" class="wiki-page">documentation</a>',
639 639 '[[CookBook documentation#One-section]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation#One-section" class="wiki-page">CookBook documentation</a>',
640 640 '[[CookBook documentation#One-section|documentation]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation#One-section" class="wiki-page">documentation</a>',
641 641 # link to the current page
642 642 '[[Another page]]' => '<a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">Another page</a>',
643 643 '[[Another page|Page]]' => '<a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">Page</a>',
644 644 '[[Another page#anchor]]' => '<a href="#anchor" class="wiki-page">Another page</a>',
645 645 '[[Another page#anchor|Page]]' => '<a href="#anchor" class="wiki-page">Page</a>',
646 646 # page that doesn't exist
647 647 '[[Unknown page]]' => '<a href="/projects/ecookbook/wiki/Unknown_page?parent=Another_page" class="wiki-page new">Unknown page</a>',
648 648 '[[Unknown page|404]]' => '<a href="/projects/ecookbook/wiki/Unknown_page?parent=Another_page" class="wiki-page new">404</a>',
649 649 '[[Unknown page#anchor]]' => '<a href="/projects/ecookbook/wiki/Unknown_page?parent=Another_page#anchor" class="wiki-page new">Unknown page</a>',
650 650 '[[Unknown page#anchor|404]]' => '<a href="/projects/ecookbook/wiki/Unknown_page?parent=Another_page#anchor" class="wiki-page new">404</a>',
651 651 }
652 652
653 653 @project = Project.find(1)
654 654
655 655 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(WikiContent.new( :text => text, :page => page ), :text) }
656 656 end
657 657
658 658 def test_wiki_links_anchor_option_should_prepend_page_title_to_href
659 659
660 660 to_test = {
661 661 # link to a page
662 662 '[[CookBook documentation]]' => '<a href="#CookBook_documentation" class="wiki-page">CookBook documentation</a>',
663 663 '[[CookBook documentation|documentation]]' => '<a href="#CookBook_documentation" class="wiki-page">documentation</a>',
664 664 '[[CookBook documentation#One-section]]' => '<a href="#CookBook_documentation_One-section" class="wiki-page">CookBook documentation</a>',
665 665 '[[CookBook documentation#One-section|documentation]]' => '<a href="#CookBook_documentation_One-section" class="wiki-page">documentation</a>',
666 666 # page that doesn't exist
667 667 '[[Unknown page]]' => '<a href="#Unknown_page" class="wiki-page new">Unknown page</a>',
668 668 '[[Unknown page|404]]' => '<a href="#Unknown_page" class="wiki-page new">404</a>',
669 669 '[[Unknown page#anchor]]' => '<a href="#Unknown_page_anchor" class="wiki-page new">Unknown page</a>',
670 670 '[[Unknown page#anchor|404]]' => '<a href="#Unknown_page_anchor" class="wiki-page new">404</a>',
671 671 }
672 672
673 673 @project = Project.find(1)
674 674
675 675 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :wiki_links => :anchor) }
676 676 end
677 677
678 678 def test_html_tags
679 679 to_test = {
680 680 "<div>content</div>" => "<p>&lt;div&gt;content&lt;/div&gt;</p>",
681 681 "<div class=\"bold\">content</div>" => "<p>&lt;div class=\"bold\"&gt;content&lt;/div&gt;</p>",
682 682 "<script>some script;</script>" => "<p>&lt;script&gt;some script;&lt;/script&gt;</p>",
683 683 # do not escape pre/code tags
684 684 "<pre>\nline 1\nline2</pre>" => "<pre>\nline 1\nline2</pre>",
685 685 "<pre><code>\nline 1\nline2</code></pre>" => "<pre><code>\nline 1\nline2</code></pre>",
686 686 "<pre><div>content</div></pre>" => "<pre>&lt;div&gt;content&lt;/div&gt;</pre>",
687 687 "HTML comment: <!-- no comments -->" => "<p>HTML comment: &lt;!-- no comments --&gt;</p>",
688 688 "<!-- opening comment" => "<p>&lt;!-- opening comment</p>",
689 689 # remove attributes except class
690 690 "<pre class='foo'>some text</pre>" => "<pre class='foo'>some text</pre>",
691 691 '<pre class="foo">some text</pre>' => '<pre class="foo">some text</pre>',
692 692 "<pre class='foo bar'>some text</pre>" => "<pre class='foo bar'>some text</pre>",
693 693 '<pre class="foo bar">some text</pre>' => '<pre class="foo bar">some text</pre>',
694 694 "<pre onmouseover='alert(1)'>some text</pre>" => "<pre>some text</pre>",
695 695 # xss
696 696 '<pre><code class=""onmouseover="alert(1)">text</code></pre>' => '<pre><code>text</code></pre>',
697 697 '<pre class=""onmouseover="alert(1)">text</pre>' => '<pre>text</pre>',
698 698 }
699 699 to_test.each { |text, result| assert_equal result, textilizable(text) }
700 700 end
701 701
702 702 def test_allowed_html_tags
703 703 to_test = {
704 704 "<pre>preformatted text</pre>" => "<pre>preformatted text</pre>",
705 705 "<notextile>no *textile* formatting</notextile>" => "no *textile* formatting",
706 706 "<notextile>this is <tag>a tag</tag></notextile>" => "this is &lt;tag&gt;a tag&lt;/tag&gt;"
707 707 }
708 708 to_test.each { |text, result| assert_equal result, textilizable(text) }
709 709 end
710 710
711 711 def test_pre_tags
712 712 raw = <<-RAW
713 713 Before
714 714
715 715 <pre>
716 716 <prepared-statement-cache-size>32</prepared-statement-cache-size>
717 717 </pre>
718 718
719 719 After
720 720 RAW
721 721
722 722 expected = <<-EXPECTED
723 723 <p>Before</p>
724 724 <pre>
725 725 &lt;prepared-statement-cache-size&gt;32&lt;/prepared-statement-cache-size&gt;
726 726 </pre>
727 727 <p>After</p>
728 728 EXPECTED
729 729
730 730 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
731 731 end
732 732
733 733 def test_pre_content_should_not_parse_wiki_and_redmine_links
734 734 raw = <<-RAW
735 735 [[CookBook documentation]]
736 736
737 737 #1
738 738
739 739 <pre>
740 740 [[CookBook documentation]]
741 741
742 742 #1
743 743 </pre>
744 744 RAW
745 745
746 746 expected = <<-EXPECTED
747 747 <p><a href="/projects/ecookbook/wiki/CookBook_documentation" class="wiki-page">CookBook documentation</a></p>
748 <p><a href="/issues/1" class="issue status-1 priority-4 priority-lowest" title="Can&#x27;t print recipes (New)">#1</a></p>
748 <p><a href="/issues/1" class="#{Issue.find(1).css_classes}" title="Can&#x27;t print recipes (New)">#1</a></p>
749 749 <pre>
750 750 [[CookBook documentation]]
751 751
752 752 #1
753 753 </pre>
754 754 EXPECTED
755 755
756 756 @project = Project.find(1)
757 757 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
758 758 end
759 759
760 760 def test_non_closing_pre_blocks_should_be_closed
761 761 raw = <<-RAW
762 762 <pre><code>
763 763 RAW
764 764
765 765 expected = <<-EXPECTED
766 766 <pre><code>
767 767 </code></pre>
768 768 EXPECTED
769 769
770 770 @project = Project.find(1)
771 771 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
772 772 end
773 773
774 774 def test_syntax_highlight
775 775 raw = <<-RAW
776 776 <pre><code class="ruby">
777 777 # Some ruby code here
778 778 </code></pre>
779 779 RAW
780 780
781 781 expected = <<-EXPECTED
782 782 <pre><code class="ruby syntaxhl"><span class=\"CodeRay\"><span class="comment"># Some ruby code here</span></span>
783 783 </code></pre>
784 784 EXPECTED
785 785
786 786 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
787 787 end
788 788
789 789 def test_to_path_param
790 790 assert_equal 'test1/test2', to_path_param('test1/test2')
791 791 assert_equal 'test1/test2', to_path_param('/test1/test2/')
792 792 assert_equal 'test1/test2', to_path_param('//test1/test2/')
793 793 assert_equal nil, to_path_param('/')
794 794 end
795 795
796 796 def test_wiki_links_in_tables
797 797 to_test = {"|[[Page|Link title]]|[[Other Page|Other title]]|\n|Cell 21|[[Last page]]|" =>
798 798 '<tr><td><a href="/projects/ecookbook/wiki/Page" class="wiki-page new">Link title</a></td>' +
799 799 '<td><a href="/projects/ecookbook/wiki/Other_Page" class="wiki-page new">Other title</a></td>' +
800 800 '</tr><tr><td>Cell 21</td><td><a href="/projects/ecookbook/wiki/Last_page" class="wiki-page new">Last page</a></td></tr>'
801 801 }
802 802 @project = Project.find(1)
803 803 to_test.each { |text, result| assert_equal "<table>#{result}</table>", textilizable(text).gsub(/[\t\n]/, '') }
804 804 end
805 805
806 806 def test_text_formatting
807 807 to_test = {'*_+bold, italic and underline+_*' => '<strong><em><ins>bold, italic and underline</ins></em></strong>',
808 808 '(_text within parentheses_)' => '(<em>text within parentheses</em>)',
809 809 'a *Humane Web* Text Generator' => 'a <strong>Humane Web</strong> Text Generator',
810 810 'a H *umane* W *eb* T *ext* G *enerator*' => 'a H <strong>umane</strong> W <strong>eb</strong> T <strong>ext</strong> G <strong>enerator</strong>',
811 811 'a *H* umane *W* eb *T* ext *G* enerator' => 'a <strong>H</strong> umane <strong>W</strong> eb <strong>T</strong> ext <strong>G</strong> enerator',
812 812 }
813 813 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
814 814 end
815 815
816 816 def test_wiki_horizontal_rule
817 817 assert_equal '<hr />', textilizable('---')
818 818 assert_equal '<p>Dashes: ---</p>', textilizable('Dashes: ---')
819 819 end
820 820
821 821 def test_footnotes
822 822 raw = <<-RAW
823 823 This is some text[1].
824 824
825 825 fn1. This is the foot note
826 826 RAW
827 827
828 828 expected = <<-EXPECTED
829 829 <p>This is some text<sup><a href=\"#fn1\">1</a></sup>.</p>
830 830 <p id="fn1" class="footnote"><sup>1</sup> This is the foot note</p>
831 831 EXPECTED
832 832
833 833 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
834 834 end
835 835
836 836 def test_headings
837 837 raw = 'h1. Some heading'
838 838 expected = %|<a name="Some-heading"></a>\n<h1 >Some heading<a href="#Some-heading" class="wiki-anchor">&para;</a></h1>|
839 839
840 840 assert_equal expected, textilizable(raw)
841 841 end
842 842
843 843 def test_headings_with_special_chars
844 844 # This test makes sure that the generated anchor names match the expected
845 845 # ones even if the heading text contains unconventional characters
846 846 raw = 'h1. Some heading related to version 0.5'
847 847 anchor = sanitize_anchor_name("Some-heading-related-to-version-0.5")
848 848 expected = %|<a name="#{anchor}"></a>\n<h1 >Some heading related to version 0.5<a href="##{anchor}" class="wiki-anchor">&para;</a></h1>|
849 849
850 850 assert_equal expected, textilizable(raw)
851 851 end
852 852
853 853 def test_headings_in_wiki_single_page_export_should_be_prepended_with_page_title
854 854 page = WikiPage.new( :title => 'Page Title', :wiki_id => 1 )
855 855 content = WikiContent.new( :text => 'h1. Some heading', :page => page )
856 856
857 857 expected = %|<a name="Page_Title_Some-heading"></a>\n<h1 >Some heading<a href="#Page_Title_Some-heading" class="wiki-anchor">&para;</a></h1>|
858 858
859 859 assert_equal expected, textilizable(content, :text, :wiki_links => :anchor )
860 860 end
861 861
862 862 def test_table_of_content
863 863 raw = <<-RAW
864 864 {{toc}}
865 865
866 866 h1. Title
867 867
868 868 Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.
869 869
870 870 h2. Subtitle with a [[Wiki]] link
871 871
872 872 Nullam commodo metus accumsan nulla. Curabitur lobortis dui id dolor.
873 873
874 874 h2. Subtitle with [[Wiki|another Wiki]] link
875 875
876 876 h2. Subtitle with %{color:red}red text%
877 877
878 878 <pre>
879 879 some code
880 880 </pre>
881 881
882 882 h3. Subtitle with *some* _modifiers_
883 883
884 884 h3. Subtitle with @inline code@
885 885
886 886 h1. Another title
887 887
888 888 h3. An "Internet link":http://www.redmine.org/ inside subtitle
889 889
890 890 h2. "Project Name !/attachments/1234/logo_small.gif! !/attachments/5678/logo_2.png!":/projects/projectname/issues
891 891
892 892 RAW
893 893
894 894 expected = '<ul class="toc">' +
895 895 '<li><a href="#Title">Title</a>' +
896 896 '<ul>' +
897 897 '<li><a href="#Subtitle-with-a-Wiki-link">Subtitle with a Wiki link</a></li>' +
898 898 '<li><a href="#Subtitle-with-another-Wiki-link">Subtitle with another Wiki link</a></li>' +
899 899 '<li><a href="#Subtitle-with-red-text">Subtitle with red text</a>' +
900 900 '<ul>' +
901 901 '<li><a href="#Subtitle-with-some-modifiers">Subtitle with some modifiers</a></li>' +
902 902 '<li><a href="#Subtitle-with-inline-code">Subtitle with inline code</a></li>' +
903 903 '</ul>' +
904 904 '</li>' +
905 905 '</ul>' +
906 906 '</li>' +
907 907 '<li><a href="#Another-title">Another title</a>' +
908 908 '<ul>' +
909 909 '<li>' +
910 910 '<ul>' +
911 911 '<li><a href="#An-Internet-link-inside-subtitle">An Internet link inside subtitle</a></li>' +
912 912 '</ul>' +
913 913 '</li>' +
914 914 '<li><a href="#Project-Name">Project Name</a></li>' +
915 915 '</ul>' +
916 916 '</li>' +
917 917 '</ul>'
918 918
919 919 @project = Project.find(1)
920 920 assert textilizable(raw).gsub("\n", "").include?(expected)
921 921 end
922 922
923 923 def test_table_of_content_should_generate_unique_anchors
924 924 raw = <<-RAW
925 925 {{toc}}
926 926
927 927 h1. Title
928 928
929 929 h2. Subtitle
930 930
931 931 h2. Subtitle
932 932 RAW
933 933
934 934 expected = '<ul class="toc">' +
935 935 '<li><a href="#Title">Title</a>' +
936 936 '<ul>' +
937 937 '<li><a href="#Subtitle">Subtitle</a></li>' +
938 938 '<li><a href="#Subtitle-2">Subtitle</a></li>'
939 939 '</ul>'
940 940 '</li>' +
941 941 '</ul>'
942 942
943 943 @project = Project.find(1)
944 944 result = textilizable(raw).gsub("\n", "")
945 945 assert_include expected, result
946 946 assert_include '<a name="Subtitle">', result
947 947 assert_include '<a name="Subtitle-2">', result
948 948 end
949 949
950 950 def test_table_of_content_should_contain_included_page_headings
951 951 raw = <<-RAW
952 952 {{toc}}
953 953
954 954 h1. Included
955 955
956 956 {{include(Child_1)}}
957 957 RAW
958 958
959 959 expected = '<ul class="toc">' +
960 960 '<li><a href="#Included">Included</a></li>' +
961 961 '<li><a href="#Child-page-1">Child page 1</a></li>' +
962 962 '</ul>'
963 963
964 964 @project = Project.find(1)
965 965 assert textilizable(raw).gsub("\n", "").include?(expected)
966 966 end
967 967
968 968 def test_section_edit_links
969 969 raw = <<-RAW
970 970 h1. Title
971 971
972 972 Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.
973 973
974 974 h2. Subtitle with a [[Wiki]] link
975 975
976 976 h2. Subtitle with *some* _modifiers_
977 977
978 978 h2. Subtitle with @inline code@
979 979
980 980 <pre>
981 981 some code
982 982
983 983 h2. heading inside pre
984 984
985 985 <h2>html heading inside pre</h2>
986 986 </pre>
987 987
988 988 h2. Subtitle after pre tag
989 989 RAW
990 990
991 991 @project = Project.find(1)
992 992 set_language_if_valid 'en'
993 993 result = textilizable(raw, :edit_section_links => {:controller => 'wiki', :action => 'edit', :project_id => '1', :id => 'Test'}).gsub("\n", "")
994 994
995 995 # heading that contains inline code
996 996 assert_match Regexp.new('<div class="contextual" title="Edit this section">' +
997 997 '<a href="/projects/1/wiki/Test/edit\?section=4"><img alt="Edit" src="/images/edit.png(\?\d+)?" /></a></div>' +
998 998 '<a name="Subtitle-with-inline-code"></a>' +
999 999 '<h2 >Subtitle with <code>inline code</code><a href="#Subtitle-with-inline-code" class="wiki-anchor">&para;</a></h2>'),
1000 1000 result
1001 1001
1002 1002 # last heading
1003 1003 assert_match Regexp.new('<div class="contextual" title="Edit this section">' +
1004 1004 '<a href="/projects/1/wiki/Test/edit\?section=5"><img alt="Edit" src="/images/edit.png(\?\d+)?" /></a></div>' +
1005 1005 '<a name="Subtitle-after-pre-tag"></a>' +
1006 1006 '<h2 >Subtitle after pre tag<a href="#Subtitle-after-pre-tag" class="wiki-anchor">&para;</a></h2>'),
1007 1007 result
1008 1008 end
1009 1009
1010 1010 def test_default_formatter
1011 1011 with_settings :text_formatting => 'unknown' do
1012 1012 text = 'a *link*: http://www.example.net/'
1013 1013 assert_equal '<p>a *link*: <a class="external" href="http://www.example.net/">http://www.example.net/</a></p>', textilizable(text)
1014 1014 end
1015 1015 end
1016 1016
1017 1017 def test_due_date_distance_in_words
1018 1018 to_test = { Date.today => 'Due in 0 days',
1019 1019 Date.today + 1 => 'Due in 1 day',
1020 1020 Date.today + 100 => 'Due in about 3 months',
1021 1021 Date.today + 20000 => 'Due in over 54 years',
1022 1022 Date.today - 1 => '1 day late',
1023 1023 Date.today - 100 => 'about 3 months late',
1024 1024 Date.today - 20000 => 'over 54 years late',
1025 1025 }
1026 1026 ::I18n.locale = :en
1027 1027 to_test.each do |date, expected|
1028 1028 assert_equal expected, due_date_distance_in_words(date)
1029 1029 end
1030 1030 end
1031 1031
1032 1032 def test_avatar_enabled
1033 1033 with_settings :gravatar_enabled => '1' do
1034 1034 assert avatar(User.find_by_mail('jsmith@somenet.foo')).include?(Digest::MD5.hexdigest('jsmith@somenet.foo'))
1035 1035 assert avatar('jsmith <jsmith@somenet.foo>').include?(Digest::MD5.hexdigest('jsmith@somenet.foo'))
1036 1036 # Default size is 50
1037 1037 assert avatar('jsmith <jsmith@somenet.foo>').include?('size=50')
1038 1038 assert avatar('jsmith <jsmith@somenet.foo>', :size => 24).include?('size=24')
1039 1039 # Non-avatar options should be considered html options
1040 1040 assert avatar('jsmith <jsmith@somenet.foo>', :title => 'John Smith').include?('title="John Smith"')
1041 1041 # The default class of the img tag should be gravatar
1042 1042 assert avatar('jsmith <jsmith@somenet.foo>').include?('class="gravatar"')
1043 1043 assert !avatar('jsmith <jsmith@somenet.foo>', :class => 'picture').include?('class="gravatar"')
1044 1044 assert_nil avatar('jsmith')
1045 1045 assert_nil avatar(nil)
1046 1046 end
1047 1047 end
1048 1048
1049 1049 def test_avatar_disabled
1050 1050 with_settings :gravatar_enabled => '0' do
1051 1051 assert_equal '', avatar(User.find_by_mail('jsmith@somenet.foo'))
1052 1052 end
1053 1053 end
1054 1054
1055 1055 def test_link_to_user
1056 1056 user = User.find(2)
1057 1057 assert_equal '<a href="/users/2" class="user active">John Smith</a>', link_to_user(user)
1058 1058 end
1059 1059
1060 1060 def test_link_to_user_should_not_link_to_locked_user
1061 1061 with_current_user nil do
1062 1062 user = User.find(5)
1063 1063 assert user.locked?
1064 1064 assert_equal 'Dave2 Lopper2', link_to_user(user)
1065 1065 end
1066 1066 end
1067 1067
1068 1068 def test_link_to_user_should_link_to_locked_user_if_current_user_is_admin
1069 1069 with_current_user User.find(1) do
1070 1070 user = User.find(5)
1071 1071 assert user.locked?
1072 1072 assert_equal '<a href="/users/5" class="user locked">Dave2 Lopper2</a>', link_to_user(user)
1073 1073 end
1074 1074 end
1075 1075
1076 1076 def test_link_to_user_should_not_link_to_anonymous
1077 1077 user = User.anonymous
1078 1078 assert user.anonymous?
1079 1079 t = link_to_user(user)
1080 1080 assert_equal ::I18n.t(:label_user_anonymous), t
1081 1081 end
1082 1082
1083 1083 def test_link_to_attachment
1084 1084 a = Attachment.find(3)
1085 1085 assert_equal '<a href="/attachments/3/logo.gif">logo.gif</a>',
1086 1086 link_to_attachment(a)
1087 1087 assert_equal '<a href="/attachments/3/logo.gif">Text</a>',
1088 1088 link_to_attachment(a, :text => 'Text')
1089 1089 assert_equal '<a href="/attachments/3/logo.gif" class="foo">logo.gif</a>',
1090 1090 link_to_attachment(a, :class => 'foo')
1091 1091 assert_equal '<a href="/attachments/download/3/logo.gif">logo.gif</a>',
1092 1092 link_to_attachment(a, :download => true)
1093 1093 assert_equal '<a href="http://test.host/attachments/3/logo.gif">logo.gif</a>',
1094 1094 link_to_attachment(a, :only_path => false)
1095 1095 end
1096 1096
1097 1097 def test_thumbnail_tag
1098 1098 a = Attachment.find(3)
1099 1099 assert_equal '<a href="/attachments/3/logo.gif" title="logo.gif"><img alt="3" src="/attachments/thumbnail/3" /></a>',
1100 1100 thumbnail_tag(a)
1101 1101 end
1102 1102
1103 1103 def test_link_to_project
1104 1104 project = Project.find(1)
1105 1105 assert_equal %(<a href="/projects/ecookbook">eCookbook</a>),
1106 1106 link_to_project(project)
1107 1107 assert_equal %(<a href="/projects/ecookbook/settings">eCookbook</a>),
1108 1108 link_to_project(project, :action => 'settings')
1109 1109 assert_equal %(<a href="http://test.host/projects/ecookbook?jump=blah">eCookbook</a>),
1110 1110 link_to_project(project, {:only_path => false, :jump => 'blah'})
1111 1111 assert_equal %(<a href="/projects/ecookbook/settings" class="project">eCookbook</a>),
1112 1112 link_to_project(project, {:action => 'settings'}, :class => "project")
1113 1113 end
1114 1114
1115 1115 def test_link_to_project_settings
1116 1116 project = Project.find(1)
1117 1117 assert_equal '<a href="/projects/ecookbook/settings">eCookbook</a>', link_to_project_settings(project)
1118 1118
1119 1119 project.status = Project::STATUS_CLOSED
1120 1120 assert_equal '<a href="/projects/ecookbook">eCookbook</a>', link_to_project_settings(project)
1121 1121
1122 1122 project.status = Project::STATUS_ARCHIVED
1123 1123 assert_equal 'eCookbook', link_to_project_settings(project)
1124 1124 end
1125 1125
1126 1126 def test_link_to_legacy_project_with_numerical_identifier_should_use_id
1127 1127 # numeric identifier are no longer allowed
1128 1128 Project.update_all "identifier=25", "id=1"
1129 1129
1130 1130 assert_equal '<a href="/projects/1">eCookbook</a>',
1131 1131 link_to_project(Project.find(1))
1132 1132 end
1133 1133
1134 1134 def test_principals_options_for_select_with_users
1135 1135 User.current = nil
1136 1136 users = [User.find(2), User.find(4)]
1137 1137 assert_equal %(<option value="2">John Smith</option><option value="4">Robert Hill</option>),
1138 1138 principals_options_for_select(users)
1139 1139 end
1140 1140
1141 1141 def test_principals_options_for_select_with_selected
1142 1142 User.current = nil
1143 1143 users = [User.find(2), User.find(4)]
1144 1144 assert_equal %(<option value="2">John Smith</option><option value="4" selected="selected">Robert Hill</option>),
1145 1145 principals_options_for_select(users, User.find(4))
1146 1146 end
1147 1147
1148 1148 def test_principals_options_for_select_with_users_and_groups
1149 1149 User.current = nil
1150 1150 users = [User.find(2), Group.find(11), User.find(4), Group.find(10)]
1151 1151 assert_equal %(<option value="2">John Smith</option><option value="4">Robert Hill</option>) +
1152 1152 %(<optgroup label="Groups"><option value="10">A Team</option><option value="11">B Team</option></optgroup>),
1153 1153 principals_options_for_select(users)
1154 1154 end
1155 1155
1156 1156 def test_principals_options_for_select_with_empty_collection
1157 1157 assert_equal '', principals_options_for_select([])
1158 1158 end
1159 1159
1160 1160 def test_principals_options_for_select_should_include_me_option_when_current_user_is_in_collection
1161 1161 users = [User.find(2), User.find(4)]
1162 1162 User.current = User.find(4)
1163 1163 assert_include '<option value="4">&lt;&lt; me &gt;&gt;</option>', principals_options_for_select(users)
1164 1164 end
1165 1165
1166 1166 def test_stylesheet_link_tag_should_pick_the_default_stylesheet
1167 1167 assert_match 'href="/stylesheets/styles.css"', stylesheet_link_tag("styles")
1168 1168 end
1169 1169
1170 1170 def test_stylesheet_link_tag_for_plugin_should_pick_the_plugin_stylesheet
1171 1171 assert_match 'href="/plugin_assets/foo/stylesheets/styles.css"', stylesheet_link_tag("styles", :plugin => :foo)
1172 1172 end
1173 1173
1174 1174 def test_image_tag_should_pick_the_default_image
1175 1175 assert_match 'src="/images/image.png"', image_tag("image.png")
1176 1176 end
1177 1177
1178 1178 def test_image_tag_should_pick_the_theme_image_if_it_exists
1179 1179 theme = Redmine::Themes.themes.last
1180 1180 theme.images << 'image.png'
1181 1181
1182 1182 with_settings :ui_theme => theme.id do
1183 1183 assert_match %|src="/themes/#{theme.dir}/images/image.png"|, image_tag("image.png")
1184 1184 assert_match %|src="/images/other.png"|, image_tag("other.png")
1185 1185 end
1186 1186 ensure
1187 1187 theme.images.delete 'image.png'
1188 1188 end
1189 1189
1190 1190 def test_image_tag_sfor_plugin_should_pick_the_plugin_image
1191 1191 assert_match 'src="/plugin_assets/foo/images/image.png"', image_tag("image.png", :plugin => :foo)
1192 1192 end
1193 1193
1194 1194 def test_javascript_include_tag_should_pick_the_default_javascript
1195 1195 assert_match 'src="/javascripts/scripts.js"', javascript_include_tag("scripts")
1196 1196 end
1197 1197
1198 1198 def test_javascript_include_tag_for_plugin_should_pick_the_plugin_javascript
1199 1199 assert_match 'src="/plugin_assets/foo/javascripts/scripts.js"', javascript_include_tag("scripts", :plugin => :foo)
1200 1200 end
1201 1201 end
@@ -1,2006 +1,2012
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2013 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../test_helper', __FILE__)
19 19
20 20 class IssueTest < ActiveSupport::TestCase
21 21 fixtures :projects, :users, :members, :member_roles, :roles,
22 22 :groups_users,
23 23 :trackers, :projects_trackers,
24 24 :enabled_modules,
25 25 :versions,
26 26 :issue_statuses, :issue_categories, :issue_relations, :workflows,
27 27 :enumerations,
28 28 :issues, :journals, :journal_details,
29 29 :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values,
30 30 :time_entries
31 31
32 32 include Redmine::I18n
33 33
34 34 def teardown
35 35 User.current = nil
36 36 end
37 37
38 38 def test_initialize
39 39 issue = Issue.new
40 40
41 41 assert_nil issue.project_id
42 42 assert_nil issue.tracker_id
43 43 assert_nil issue.author_id
44 44 assert_nil issue.assigned_to_id
45 45 assert_nil issue.category_id
46 46
47 47 assert_equal IssueStatus.default, issue.status
48 48 assert_equal IssuePriority.default, issue.priority
49 49 end
50 50
51 51 def test_create
52 52 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3,
53 53 :status_id => 1, :priority => IssuePriority.all.first,
54 54 :subject => 'test_create',
55 55 :description => 'IssueTest#test_create', :estimated_hours => '1:30')
56 56 assert issue.save
57 57 issue.reload
58 58 assert_equal 1.5, issue.estimated_hours
59 59 end
60 60
61 61 def test_create_minimal
62 62 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3,
63 63 :status_id => 1, :priority => IssuePriority.all.first,
64 64 :subject => 'test_create')
65 65 assert issue.save
66 66 assert issue.description.nil?
67 67 assert_nil issue.estimated_hours
68 68 end
69 69
70 70 def test_start_date_format_should_be_validated
71 71 set_language_if_valid 'en'
72 72 ['2012', 'ABC', '2012-15-20'].each do |invalid_date|
73 73 issue = Issue.new(:start_date => invalid_date)
74 74 assert !issue.valid?
75 75 assert_include 'Start date is not a valid date', issue.errors.full_messages, "No error found for invalid date #{invalid_date}"
76 76 end
77 77 end
78 78
79 79 def test_due_date_format_should_be_validated
80 80 set_language_if_valid 'en'
81 81 ['2012', 'ABC', '2012-15-20'].each do |invalid_date|
82 82 issue = Issue.new(:due_date => invalid_date)
83 83 assert !issue.valid?
84 84 assert_include 'Due date is not a valid date', issue.errors.full_messages, "No error found for invalid date #{invalid_date}"
85 85 end
86 86 end
87 87
88 88 def test_due_date_lesser_than_start_date_should_not_validate
89 89 set_language_if_valid 'en'
90 90 issue = Issue.new(:start_date => '2012-10-06', :due_date => '2012-10-02')
91 91 assert !issue.valid?
92 92 assert_include 'Due date must be greater than start date', issue.errors.full_messages
93 93 end
94 94
95 95 def test_estimated_hours_should_be_validated
96 96 set_language_if_valid 'en'
97 97 ['-2'].each do |invalid|
98 98 issue = Issue.new(:estimated_hours => invalid)
99 99 assert !issue.valid?
100 100 assert_include 'Estimated time is invalid', issue.errors.full_messages
101 101 end
102 102 end
103 103
104 104 def test_create_with_required_custom_field
105 105 set_language_if_valid 'en'
106 106 field = IssueCustomField.find_by_name('Database')
107 107 field.update_attribute(:is_required, true)
108 108
109 109 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
110 110 :status_id => 1, :subject => 'test_create',
111 111 :description => 'IssueTest#test_create_with_required_custom_field')
112 112 assert issue.available_custom_fields.include?(field)
113 113 # No value for the custom field
114 114 assert !issue.save
115 115 assert_equal ["Database can't be blank"], issue.errors.full_messages
116 116 # Blank value
117 117 issue.custom_field_values = { field.id => '' }
118 118 assert !issue.save
119 119 assert_equal ["Database can't be blank"], issue.errors.full_messages
120 120 # Invalid value
121 121 issue.custom_field_values = { field.id => 'SQLServer' }
122 122 assert !issue.save
123 123 assert_equal ["Database is not included in the list"], issue.errors.full_messages
124 124 # Valid value
125 125 issue.custom_field_values = { field.id => 'PostgreSQL' }
126 126 assert issue.save
127 127 issue.reload
128 128 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
129 129 end
130 130
131 131 def test_create_with_group_assignment
132 132 with_settings :issue_group_assignment => '1' do
133 133 assert Issue.new(:project_id => 2, :tracker_id => 1, :author_id => 1,
134 134 :subject => 'Group assignment',
135 135 :assigned_to_id => 11).save
136 136 issue = Issue.first(:order => 'id DESC')
137 137 assert_kind_of Group, issue.assigned_to
138 138 assert_equal Group.find(11), issue.assigned_to
139 139 end
140 140 end
141 141
142 142 def test_create_with_parent_issue_id
143 143 issue = Issue.new(:project_id => 1, :tracker_id => 1,
144 144 :author_id => 1, :subject => 'Group assignment',
145 145 :parent_issue_id => 1)
146 146 assert_save issue
147 147 assert_equal 1, issue.parent_issue_id
148 148 assert_equal Issue.find(1), issue.parent
149 149 end
150 150
151 151 def test_create_with_sharp_parent_issue_id
152 152 issue = Issue.new(:project_id => 1, :tracker_id => 1,
153 153 :author_id => 1, :subject => 'Group assignment',
154 154 :parent_issue_id => "#1")
155 155 assert_save issue
156 156 assert_equal 1, issue.parent_issue_id
157 157 assert_equal Issue.find(1), issue.parent
158 158 end
159 159
160 160 def test_create_with_invalid_parent_issue_id
161 161 set_language_if_valid 'en'
162 162 issue = Issue.new(:project_id => 1, :tracker_id => 1,
163 163 :author_id => 1, :subject => 'Group assignment',
164 164 :parent_issue_id => '01ABC')
165 165 assert !issue.save
166 166 assert_equal '01ABC', issue.parent_issue_id
167 167 assert_include 'Parent task is invalid', issue.errors.full_messages
168 168 end
169 169
170 170 def test_create_with_invalid_sharp_parent_issue_id
171 171 set_language_if_valid 'en'
172 172 issue = Issue.new(:project_id => 1, :tracker_id => 1,
173 173 :author_id => 1, :subject => 'Group assignment',
174 174 :parent_issue_id => '#01ABC')
175 175 assert !issue.save
176 176 assert_equal '#01ABC', issue.parent_issue_id
177 177 assert_include 'Parent task is invalid', issue.errors.full_messages
178 178 end
179 179
180 180 def assert_visibility_match(user, issues)
181 181 assert_equal issues.collect(&:id).sort, Issue.all.select {|issue| issue.visible?(user)}.collect(&:id).sort
182 182 end
183 183
184 184 def test_visible_scope_for_anonymous
185 185 # Anonymous user should see issues of public projects only
186 186 issues = Issue.visible(User.anonymous).all
187 187 assert issues.any?
188 188 assert_nil issues.detect {|issue| !issue.project.is_public?}
189 189 assert_nil issues.detect {|issue| issue.is_private?}
190 190 assert_visibility_match User.anonymous, issues
191 191 end
192 192
193 193 def test_visible_scope_for_anonymous_without_view_issues_permissions
194 194 # Anonymous user should not see issues without permission
195 195 Role.anonymous.remove_permission!(:view_issues)
196 196 issues = Issue.visible(User.anonymous).all
197 197 assert issues.empty?
198 198 assert_visibility_match User.anonymous, issues
199 199 end
200 200
201 201 def test_anonymous_should_not_see_private_issues_with_issues_visibility_set_to_default
202 202 assert Role.anonymous.update_attribute(:issues_visibility, 'default')
203 203 issue = Issue.generate!(:author => User.anonymous, :assigned_to => User.anonymous, :is_private => true)
204 204 assert_nil Issue.where(:id => issue.id).visible(User.anonymous).first
205 205 assert !issue.visible?(User.anonymous)
206 206 end
207 207
208 208 def test_anonymous_should_not_see_private_issues_with_issues_visibility_set_to_own
209 209 assert Role.anonymous.update_attribute(:issues_visibility, 'own')
210 210 issue = Issue.generate!(:author => User.anonymous, :assigned_to => User.anonymous, :is_private => true)
211 211 assert_nil Issue.where(:id => issue.id).visible(User.anonymous).first
212 212 assert !issue.visible?(User.anonymous)
213 213 end
214 214
215 215 def test_visible_scope_for_non_member
216 216 user = User.find(9)
217 217 assert user.projects.empty?
218 218 # Non member user should see issues of public projects only
219 219 issues = Issue.visible(user).all
220 220 assert issues.any?
221 221 assert_nil issues.detect {|issue| !issue.project.is_public?}
222 222 assert_nil issues.detect {|issue| issue.is_private?}
223 223 assert_visibility_match user, issues
224 224 end
225 225
226 226 def test_visible_scope_for_non_member_with_own_issues_visibility
227 227 Role.non_member.update_attribute :issues_visibility, 'own'
228 228 Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 9, :subject => 'Issue by non member')
229 229 user = User.find(9)
230 230
231 231 issues = Issue.visible(user).all
232 232 assert issues.any?
233 233 assert_nil issues.detect {|issue| issue.author != user}
234 234 assert_visibility_match user, issues
235 235 end
236 236
237 237 def test_visible_scope_for_non_member_without_view_issues_permissions
238 238 # Non member user should not see issues without permission
239 239 Role.non_member.remove_permission!(:view_issues)
240 240 user = User.find(9)
241 241 assert user.projects.empty?
242 242 issues = Issue.visible(user).all
243 243 assert issues.empty?
244 244 assert_visibility_match user, issues
245 245 end
246 246
247 247 def test_visible_scope_for_member
248 248 user = User.find(9)
249 249 # User should see issues of projects for which he has view_issues permissions only
250 250 Role.non_member.remove_permission!(:view_issues)
251 251 Member.create!(:principal => user, :project_id => 3, :role_ids => [2])
252 252 issues = Issue.visible(user).all
253 253 assert issues.any?
254 254 assert_nil issues.detect {|issue| issue.project_id != 3}
255 255 assert_nil issues.detect {|issue| issue.is_private?}
256 256 assert_visibility_match user, issues
257 257 end
258 258
259 259 def test_visible_scope_for_member_with_groups_should_return_assigned_issues
260 260 user = User.find(8)
261 261 assert user.groups.any?
262 262 Member.create!(:principal => user.groups.first, :project_id => 1, :role_ids => [2])
263 263 Role.non_member.remove_permission!(:view_issues)
264 264
265 265 issue = Issue.create(:project_id => 1, :tracker_id => 1, :author_id => 3,
266 266 :status_id => 1, :priority => IssuePriority.all.first,
267 267 :subject => 'Assignment test',
268 268 :assigned_to => user.groups.first,
269 269 :is_private => true)
270 270
271 271 Role.find(2).update_attribute :issues_visibility, 'default'
272 272 issues = Issue.visible(User.find(8)).all
273 273 assert issues.any?
274 274 assert issues.include?(issue)
275 275
276 276 Role.find(2).update_attribute :issues_visibility, 'own'
277 277 issues = Issue.visible(User.find(8)).all
278 278 assert issues.any?
279 279 assert issues.include?(issue)
280 280 end
281 281
282 282 def test_visible_scope_for_admin
283 283 user = User.find(1)
284 284 user.members.each(&:destroy)
285 285 assert user.projects.empty?
286 286 issues = Issue.visible(user).all
287 287 assert issues.any?
288 288 # Admin should see issues on private projects that he does not belong to
289 289 assert issues.detect {|issue| !issue.project.is_public?}
290 290 # Admin should see private issues of other users
291 291 assert issues.detect {|issue| issue.is_private? && issue.author != user}
292 292 assert_visibility_match user, issues
293 293 end
294 294
295 295 def test_visible_scope_with_project
296 296 project = Project.find(1)
297 297 issues = Issue.visible(User.find(2), :project => project).all
298 298 projects = issues.collect(&:project).uniq
299 299 assert_equal 1, projects.size
300 300 assert_equal project, projects.first
301 301 end
302 302
303 303 def test_visible_scope_with_project_and_subprojects
304 304 project = Project.find(1)
305 305 issues = Issue.visible(User.find(2), :project => project, :with_subprojects => true).all
306 306 projects = issues.collect(&:project).uniq
307 307 assert projects.size > 1
308 308 assert_equal [], projects.select {|p| !p.is_or_is_descendant_of?(project)}
309 309 end
310 310
311 311 def test_visible_and_nested_set_scopes
312 312 assert_equal 0, Issue.find(1).descendants.visible.all.size
313 313 end
314 314
315 315 def test_open_scope
316 316 issues = Issue.open.all
317 317 assert_nil issues.detect(&:closed?)
318 318 end
319 319
320 320 def test_open_scope_with_arg
321 321 issues = Issue.open(false).all
322 322 assert_equal issues, issues.select(&:closed?)
323 323 end
324 324
325 325 def test_fixed_version_scope_with_a_version_should_return_its_fixed_issues
326 326 version = Version.find(2)
327 327 assert version.fixed_issues.any?
328 328 assert_equal version.fixed_issues.to_a.sort, Issue.fixed_version(version).to_a.sort
329 329 end
330 330
331 331 def test_fixed_version_scope_with_empty_array_should_return_no_result
332 332 assert_equal 0, Issue.fixed_version([]).count
333 333 end
334 334
335 335 def test_errors_full_messages_should_include_custom_fields_errors
336 336 field = IssueCustomField.find_by_name('Database')
337 337
338 338 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
339 339 :status_id => 1, :subject => 'test_create',
340 340 :description => 'IssueTest#test_create_with_required_custom_field')
341 341 assert issue.available_custom_fields.include?(field)
342 342 # Invalid value
343 343 issue.custom_field_values = { field.id => 'SQLServer' }
344 344
345 345 assert !issue.valid?
346 346 assert_equal 1, issue.errors.full_messages.size
347 347 assert_equal "Database #{I18n.translate('activerecord.errors.messages.inclusion')}",
348 348 issue.errors.full_messages.first
349 349 end
350 350
351 351 def test_update_issue_with_required_custom_field
352 352 field = IssueCustomField.find_by_name('Database')
353 353 field.update_attribute(:is_required, true)
354 354
355 355 issue = Issue.find(1)
356 356 assert_nil issue.custom_value_for(field)
357 357 assert issue.available_custom_fields.include?(field)
358 358 # No change to custom values, issue can be saved
359 359 assert issue.save
360 360 # Blank value
361 361 issue.custom_field_values = { field.id => '' }
362 362 assert !issue.save
363 363 # Valid value
364 364 issue.custom_field_values = { field.id => 'PostgreSQL' }
365 365 assert issue.save
366 366 issue.reload
367 367 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
368 368 end
369 369
370 370 def test_should_not_update_attributes_if_custom_fields_validation_fails
371 371 issue = Issue.find(1)
372 372 field = IssueCustomField.find_by_name('Database')
373 373 assert issue.available_custom_fields.include?(field)
374 374
375 375 issue.custom_field_values = { field.id => 'Invalid' }
376 376 issue.subject = 'Should be not be saved'
377 377 assert !issue.save
378 378
379 379 issue.reload
380 380 assert_equal "Can't print recipes", issue.subject
381 381 end
382 382
383 383 def test_should_not_recreate_custom_values_objects_on_update
384 384 field = IssueCustomField.find_by_name('Database')
385 385
386 386 issue = Issue.find(1)
387 387 issue.custom_field_values = { field.id => 'PostgreSQL' }
388 388 assert issue.save
389 389 custom_value = issue.custom_value_for(field)
390 390 issue.reload
391 391 issue.custom_field_values = { field.id => 'MySQL' }
392 392 assert issue.save
393 393 issue.reload
394 394 assert_equal custom_value.id, issue.custom_value_for(field).id
395 395 end
396 396
397 397 def test_should_not_update_custom_fields_on_changing_tracker_with_different_custom_fields
398 398 issue = Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1,
399 399 :status_id => 1, :subject => 'Test',
400 400 :custom_field_values => {'2' => 'Test'})
401 401 assert !Tracker.find(2).custom_field_ids.include?(2)
402 402
403 403 issue = Issue.find(issue.id)
404 404 issue.attributes = {:tracker_id => 2, :custom_field_values => {'1' => ''}}
405 405
406 406 issue = Issue.find(issue.id)
407 407 custom_value = issue.custom_value_for(2)
408 408 assert_not_nil custom_value
409 409 assert_equal 'Test', custom_value.value
410 410 end
411 411
412 412 def test_assigning_tracker_id_should_reload_custom_fields_values
413 413 issue = Issue.new(:project => Project.find(1))
414 414 assert issue.custom_field_values.empty?
415 415 issue.tracker_id = 1
416 416 assert issue.custom_field_values.any?
417 417 end
418 418
419 419 def test_assigning_attributes_should_assign_project_and_tracker_first
420 420 seq = sequence('seq')
421 421 issue = Issue.new
422 422 issue.expects(:project_id=).in_sequence(seq)
423 423 issue.expects(:tracker_id=).in_sequence(seq)
424 424 issue.expects(:subject=).in_sequence(seq)
425 425 issue.attributes = {:tracker_id => 2, :project_id => 1, :subject => 'Test'}
426 426 end
427 427
428 428 def test_assigning_tracker_and_custom_fields_should_assign_custom_fields
429 429 attributes = ActiveSupport::OrderedHash.new
430 430 attributes['custom_field_values'] = { '1' => 'MySQL' }
431 431 attributes['tracker_id'] = '1'
432 432 issue = Issue.new(:project => Project.find(1))
433 433 issue.attributes = attributes
434 434 assert_equal 'MySQL', issue.custom_field_value(1)
435 435 end
436 436
437 437 def test_reload_should_reload_custom_field_values
438 438 issue = Issue.generate!
439 439 issue.custom_field_values = {'2' => 'Foo'}
440 440 issue.save!
441 441
442 442 issue = Issue.order('id desc').first
443 443 assert_equal 'Foo', issue.custom_field_value(2)
444 444
445 445 issue.custom_field_values = {'2' => 'Bar'}
446 446 assert_equal 'Bar', issue.custom_field_value(2)
447 447
448 448 issue.reload
449 449 assert_equal 'Foo', issue.custom_field_value(2)
450 450 end
451 451
452 452 def test_should_update_issue_with_disabled_tracker
453 453 p = Project.find(1)
454 454 issue = Issue.find(1)
455 455
456 456 p.trackers.delete(issue.tracker)
457 457 assert !p.trackers.include?(issue.tracker)
458 458
459 459 issue.reload
460 460 issue.subject = 'New subject'
461 461 assert issue.save
462 462 end
463 463
464 464 def test_should_not_set_a_disabled_tracker
465 465 p = Project.find(1)
466 466 p.trackers.delete(Tracker.find(2))
467 467
468 468 issue = Issue.find(1)
469 469 issue.tracker_id = 2
470 470 issue.subject = 'New subject'
471 471 assert !issue.save
472 472 assert_not_nil issue.errors[:tracker_id]
473 473 end
474 474
475 475 def test_category_based_assignment
476 476 issue = Issue.create(:project_id => 1, :tracker_id => 1, :author_id => 3,
477 477 :status_id => 1, :priority => IssuePriority.all.first,
478 478 :subject => 'Assignment test',
479 479 :description => 'Assignment test', :category_id => 1)
480 480 assert_equal IssueCategory.find(1).assigned_to, issue.assigned_to
481 481 end
482 482
483 483 def test_new_statuses_allowed_to
484 484 WorkflowTransition.delete_all
485 485 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1,
486 486 :old_status_id => 1, :new_status_id => 2,
487 487 :author => false, :assignee => false)
488 488 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1,
489 489 :old_status_id => 1, :new_status_id => 3,
490 490 :author => true, :assignee => false)
491 491 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1,
492 492 :new_status_id => 4, :author => false,
493 493 :assignee => true)
494 494 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1,
495 495 :old_status_id => 1, :new_status_id => 5,
496 496 :author => true, :assignee => true)
497 497 status = IssueStatus.find(1)
498 498 role = Role.find(1)
499 499 tracker = Tracker.find(1)
500 500 user = User.find(2)
501 501
502 502 issue = Issue.generate!(:tracker => tracker, :status => status,
503 503 :project_id => 1, :author_id => 1)
504 504 assert_equal [1, 2], issue.new_statuses_allowed_to(user).map(&:id)
505 505
506 506 issue = Issue.generate!(:tracker => tracker, :status => status,
507 507 :project_id => 1, :author => user)
508 508 assert_equal [1, 2, 3, 5], issue.new_statuses_allowed_to(user).map(&:id)
509 509
510 510 issue = Issue.generate!(:tracker => tracker, :status => status,
511 511 :project_id => 1, :author_id => 1,
512 512 :assigned_to => user)
513 513 assert_equal [1, 2, 4, 5], issue.new_statuses_allowed_to(user).map(&:id)
514 514
515 515 issue = Issue.generate!(:tracker => tracker, :status => status,
516 516 :project_id => 1, :author => user,
517 517 :assigned_to => user)
518 518 assert_equal [1, 2, 3, 4, 5], issue.new_statuses_allowed_to(user).map(&:id)
519 519 end
520 520
521 521 def test_new_statuses_allowed_to_should_return_all_transitions_for_admin
522 522 admin = User.find(1)
523 523 issue = Issue.find(1)
524 524 assert !admin.member_of?(issue.project)
525 525 expected_statuses = [issue.status] +
526 526 WorkflowTransition.find_all_by_old_status_id(
527 527 issue.status_id).map(&:new_status).uniq.sort
528 528 assert_equal expected_statuses, issue.new_statuses_allowed_to(admin)
529 529 end
530 530
531 531 def test_new_statuses_allowed_to_should_return_default_and_current_status_when_copying
532 532 issue = Issue.find(1).copy
533 533 assert_equal [1], issue.new_statuses_allowed_to(User.find(2)).map(&:id)
534 534
535 535 issue = Issue.find(2).copy
536 536 assert_equal [1, 2], issue.new_statuses_allowed_to(User.find(2)).map(&:id)
537 537 end
538 538
539 539 def test_safe_attributes_names_should_not_include_disabled_field
540 540 tracker = Tracker.new(:core_fields => %w(assigned_to_id fixed_version_id))
541 541
542 542 issue = Issue.new(:tracker => tracker)
543 543 assert_include 'tracker_id', issue.safe_attribute_names
544 544 assert_include 'status_id', issue.safe_attribute_names
545 545 assert_include 'subject', issue.safe_attribute_names
546 546 assert_include 'description', issue.safe_attribute_names
547 547 assert_include 'custom_field_values', issue.safe_attribute_names
548 548 assert_include 'custom_fields', issue.safe_attribute_names
549 549 assert_include 'lock_version', issue.safe_attribute_names
550 550
551 551 tracker.core_fields.each do |field|
552 552 assert_include field, issue.safe_attribute_names
553 553 end
554 554
555 555 tracker.disabled_core_fields.each do |field|
556 556 assert_not_include field, issue.safe_attribute_names
557 557 end
558 558 end
559 559
560 560 def test_safe_attributes_should_ignore_disabled_fields
561 561 tracker = Tracker.find(1)
562 562 tracker.core_fields = %w(assigned_to_id due_date)
563 563 tracker.save!
564 564
565 565 issue = Issue.new(:tracker => tracker)
566 566 issue.safe_attributes = {'start_date' => '2012-07-14', 'due_date' => '2012-07-14'}
567 567 assert_nil issue.start_date
568 568 assert_equal Date.parse('2012-07-14'), issue.due_date
569 569 end
570 570
571 571 def test_safe_attributes_should_accept_target_tracker_enabled_fields
572 572 source = Tracker.find(1)
573 573 source.core_fields = []
574 574 source.save!
575 575 target = Tracker.find(2)
576 576 target.core_fields = %w(assigned_to_id due_date)
577 577 target.save!
578 578
579 579 issue = Issue.new(:tracker => source)
580 580 issue.safe_attributes = {'tracker_id' => 2, 'due_date' => '2012-07-14'}
581 581 assert_equal target, issue.tracker
582 582 assert_equal Date.parse('2012-07-14'), issue.due_date
583 583 end
584 584
585 585 def test_safe_attributes_should_not_include_readonly_fields
586 586 WorkflowPermission.delete_all
587 587 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
588 588 :role_id => 1, :field_name => 'due_date',
589 589 :rule => 'readonly')
590 590 user = User.find(2)
591 591
592 592 issue = Issue.new(:project_id => 1, :tracker_id => 1)
593 593 assert_equal %w(due_date), issue.read_only_attribute_names(user)
594 594 assert_not_include 'due_date', issue.safe_attribute_names(user)
595 595
596 596 issue.send :safe_attributes=, {'start_date' => '2012-07-14', 'due_date' => '2012-07-14'}, user
597 597 assert_equal Date.parse('2012-07-14'), issue.start_date
598 598 assert_nil issue.due_date
599 599 end
600 600
601 601 def test_safe_attributes_should_not_include_readonly_custom_fields
602 602 cf1 = IssueCustomField.create!(:name => 'Writable field',
603 603 :field_format => 'string',
604 604 :is_for_all => true, :tracker_ids => [1])
605 605 cf2 = IssueCustomField.create!(:name => 'Readonly field',
606 606 :field_format => 'string',
607 607 :is_for_all => true, :tracker_ids => [1])
608 608 WorkflowPermission.delete_all
609 609 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
610 610 :role_id => 1, :field_name => cf2.id.to_s,
611 611 :rule => 'readonly')
612 612 user = User.find(2)
613 613 issue = Issue.new(:project_id => 1, :tracker_id => 1)
614 614 assert_equal [cf2.id.to_s], issue.read_only_attribute_names(user)
615 615 assert_not_include cf2.id.to_s, issue.safe_attribute_names(user)
616 616
617 617 issue.send :safe_attributes=, {'custom_field_values' => {
618 618 cf1.id.to_s => 'value1', cf2.id.to_s => 'value2'
619 619 }}, user
620 620 assert_equal 'value1', issue.custom_field_value(cf1)
621 621 assert_nil issue.custom_field_value(cf2)
622 622
623 623 issue.send :safe_attributes=, {'custom_fields' => [
624 624 {'id' => cf1.id.to_s, 'value' => 'valuea'},
625 625 {'id' => cf2.id.to_s, 'value' => 'valueb'}
626 626 ]}, user
627 627 assert_equal 'valuea', issue.custom_field_value(cf1)
628 628 assert_nil issue.custom_field_value(cf2)
629 629 end
630 630
631 631 def test_editable_custom_field_values_should_return_non_readonly_custom_values
632 632 cf1 = IssueCustomField.create!(:name => 'Writable field', :field_format => 'string',
633 633 :is_for_all => true, :tracker_ids => [1, 2])
634 634 cf2 = IssueCustomField.create!(:name => 'Readonly field', :field_format => 'string',
635 635 :is_for_all => true, :tracker_ids => [1, 2])
636 636 WorkflowPermission.delete_all
637 637 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1, :role_id => 1,
638 638 :field_name => cf2.id.to_s, :rule => 'readonly')
639 639 user = User.find(2)
640 640
641 641 issue = Issue.new(:project_id => 1, :tracker_id => 1)
642 642 values = issue.editable_custom_field_values(user)
643 643 assert values.detect {|value| value.custom_field == cf1}
644 644 assert_nil values.detect {|value| value.custom_field == cf2}
645 645
646 646 issue.tracker_id = 2
647 647 values = issue.editable_custom_field_values(user)
648 648 assert values.detect {|value| value.custom_field == cf1}
649 649 assert values.detect {|value| value.custom_field == cf2}
650 650 end
651 651
652 652 def test_safe_attributes_should_accept_target_tracker_writable_fields
653 653 WorkflowPermission.delete_all
654 654 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
655 655 :role_id => 1, :field_name => 'due_date',
656 656 :rule => 'readonly')
657 657 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 2,
658 658 :role_id => 1, :field_name => 'start_date',
659 659 :rule => 'readonly')
660 660 user = User.find(2)
661 661
662 662 issue = Issue.new(:project_id => 1, :tracker_id => 1, :status_id => 1)
663 663
664 664 issue.send :safe_attributes=, {'start_date' => '2012-07-12',
665 665 'due_date' => '2012-07-14'}, user
666 666 assert_equal Date.parse('2012-07-12'), issue.start_date
667 667 assert_nil issue.due_date
668 668
669 669 issue.send :safe_attributes=, {'start_date' => '2012-07-15',
670 670 'due_date' => '2012-07-16',
671 671 'tracker_id' => 2}, user
672 672 assert_equal Date.parse('2012-07-12'), issue.start_date
673 673 assert_equal Date.parse('2012-07-16'), issue.due_date
674 674 end
675 675
676 676 def test_safe_attributes_should_accept_target_status_writable_fields
677 677 WorkflowPermission.delete_all
678 678 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
679 679 :role_id => 1, :field_name => 'due_date',
680 680 :rule => 'readonly')
681 681 WorkflowPermission.create!(:old_status_id => 2, :tracker_id => 1,
682 682 :role_id => 1, :field_name => 'start_date',
683 683 :rule => 'readonly')
684 684 user = User.find(2)
685 685
686 686 issue = Issue.new(:project_id => 1, :tracker_id => 1, :status_id => 1)
687 687
688 688 issue.send :safe_attributes=, {'start_date' => '2012-07-12',
689 689 'due_date' => '2012-07-14'},
690 690 user
691 691 assert_equal Date.parse('2012-07-12'), issue.start_date
692 692 assert_nil issue.due_date
693 693
694 694 issue.send :safe_attributes=, {'start_date' => '2012-07-15',
695 695 'due_date' => '2012-07-16',
696 696 'status_id' => 2},
697 697 user
698 698 assert_equal Date.parse('2012-07-12'), issue.start_date
699 699 assert_equal Date.parse('2012-07-16'), issue.due_date
700 700 end
701 701
702 702 def test_required_attributes_should_be_validated
703 703 cf = IssueCustomField.create!(:name => 'Foo', :field_format => 'string',
704 704 :is_for_all => true, :tracker_ids => [1, 2])
705 705
706 706 WorkflowPermission.delete_all
707 707 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
708 708 :role_id => 1, :field_name => 'due_date',
709 709 :rule => 'required')
710 710 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
711 711 :role_id => 1, :field_name => 'category_id',
712 712 :rule => 'required')
713 713 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
714 714 :role_id => 1, :field_name => cf.id.to_s,
715 715 :rule => 'required')
716 716
717 717 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 2,
718 718 :role_id => 1, :field_name => 'start_date',
719 719 :rule => 'required')
720 720 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 2,
721 721 :role_id => 1, :field_name => cf.id.to_s,
722 722 :rule => 'required')
723 723 user = User.find(2)
724 724
725 725 issue = Issue.new(:project_id => 1, :tracker_id => 1,
726 726 :status_id => 1, :subject => 'Required fields',
727 727 :author => user)
728 728 assert_equal [cf.id.to_s, "category_id", "due_date"],
729 729 issue.required_attribute_names(user).sort
730 730 assert !issue.save, "Issue was saved"
731 731 assert_equal ["Category can't be blank", "Due date can't be blank", "Foo can't be blank"],
732 732 issue.errors.full_messages.sort
733 733
734 734 issue.tracker_id = 2
735 735 assert_equal [cf.id.to_s, "start_date"], issue.required_attribute_names(user).sort
736 736 assert !issue.save, "Issue was saved"
737 737 assert_equal ["Foo can't be blank", "Start date can't be blank"],
738 738 issue.errors.full_messages.sort
739 739
740 740 issue.start_date = Date.today
741 741 issue.custom_field_values = {cf.id.to_s => 'bar'}
742 742 assert issue.save
743 743 end
744 744
745 745 def test_required_attribute_names_for_multiple_roles_should_intersect_rules
746 746 WorkflowPermission.delete_all
747 747 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
748 748 :role_id => 1, :field_name => 'due_date',
749 749 :rule => 'required')
750 750 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
751 751 :role_id => 1, :field_name => 'start_date',
752 752 :rule => 'required')
753 753 user = User.find(2)
754 754 member = Member.find(1)
755 755 issue = Issue.new(:project_id => 1, :tracker_id => 1, :status_id => 1)
756 756
757 757 assert_equal %w(due_date start_date), issue.required_attribute_names(user).sort
758 758
759 759 member.role_ids = [1, 2]
760 760 member.save!
761 761 assert_equal [], issue.required_attribute_names(user.reload)
762 762
763 763 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
764 764 :role_id => 2, :field_name => 'due_date',
765 765 :rule => 'required')
766 766 assert_equal %w(due_date), issue.required_attribute_names(user)
767 767
768 768 member.role_ids = [1, 2, 3]
769 769 member.save!
770 770 assert_equal [], issue.required_attribute_names(user.reload)
771 771
772 772 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
773 773 :role_id => 2, :field_name => 'due_date',
774 774 :rule => 'readonly')
775 775 # required + readonly => required
776 776 assert_equal %w(due_date), issue.required_attribute_names(user)
777 777 end
778 778
779 779 def test_read_only_attribute_names_for_multiple_roles_should_intersect_rules
780 780 WorkflowPermission.delete_all
781 781 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
782 782 :role_id => 1, :field_name => 'due_date',
783 783 :rule => 'readonly')
784 784 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
785 785 :role_id => 1, :field_name => 'start_date',
786 786 :rule => 'readonly')
787 787 user = User.find(2)
788 788 member = Member.find(1)
789 789 issue = Issue.new(:project_id => 1, :tracker_id => 1, :status_id => 1)
790 790
791 791 assert_equal %w(due_date start_date), issue.read_only_attribute_names(user).sort
792 792
793 793 member.role_ids = [1, 2]
794 794 member.save!
795 795 assert_equal [], issue.read_only_attribute_names(user.reload)
796 796
797 797 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
798 798 :role_id => 2, :field_name => 'due_date',
799 799 :rule => 'readonly')
800 800 assert_equal %w(due_date), issue.read_only_attribute_names(user)
801 801 end
802 802
803 803 def test_copy
804 804 issue = Issue.new.copy_from(1)
805 805 assert issue.copy?
806 806 assert issue.save
807 807 issue.reload
808 808 orig = Issue.find(1)
809 809 assert_equal orig.subject, issue.subject
810 810 assert_equal orig.tracker, issue.tracker
811 811 assert_equal "125", issue.custom_value_for(2).value
812 812 end
813 813
814 814 def test_copy_should_copy_status
815 815 orig = Issue.find(8)
816 816 assert orig.status != IssueStatus.default
817 817
818 818 issue = Issue.new.copy_from(orig)
819 819 assert issue.save
820 820 issue.reload
821 821 assert_equal orig.status, issue.status
822 822 end
823 823
824 824 def test_copy_should_add_relation_with_copied_issue
825 825 copied = Issue.find(1)
826 826 issue = Issue.new.copy_from(copied)
827 827 assert issue.save
828 828 issue.reload
829 829
830 830 assert_equal 1, issue.relations.size
831 831 relation = issue.relations.first
832 832 assert_equal 'copied_to', relation.relation_type
833 833 assert_equal copied, relation.issue_from
834 834 assert_equal issue, relation.issue_to
835 835 end
836 836
837 837 def test_copy_should_copy_subtasks
838 838 issue = Issue.generate_with_descendants!
839 839
840 840 copy = issue.reload.copy
841 841 copy.author = User.find(7)
842 842 assert_difference 'Issue.count', 1+issue.descendants.count do
843 843 assert copy.save
844 844 end
845 845 copy.reload
846 846 assert_equal %w(Child1 Child2), copy.children.map(&:subject).sort
847 847 child_copy = copy.children.detect {|c| c.subject == 'Child1'}
848 848 assert_equal %w(Child11), child_copy.children.map(&:subject).sort
849 849 assert_equal copy.author, child_copy.author
850 850 end
851 851
852 852 def test_copy_should_copy_subtasks_to_target_project
853 853 issue = Issue.generate_with_descendants!
854 854
855 855 copy = issue.copy(:project_id => 3)
856 856 assert_difference 'Issue.count', 1+issue.descendants.count do
857 857 assert copy.save
858 858 end
859 859 assert_equal [3], copy.reload.descendants.map(&:project_id).uniq
860 860 end
861 861
862 862 def test_copy_should_not_copy_subtasks_twice_when_saving_twice
863 863 issue = Issue.generate_with_descendants!
864 864
865 865 copy = issue.reload.copy
866 866 assert_difference 'Issue.count', 1+issue.descendants.count do
867 867 assert copy.save
868 868 assert copy.save
869 869 end
870 870 end
871 871
872 872 def test_should_not_call_after_project_change_on_creation
873 873 issue = Issue.new(:project_id => 1, :tracker_id => 1, :status_id => 1,
874 874 :subject => 'Test', :author_id => 1)
875 875 issue.expects(:after_project_change).never
876 876 issue.save!
877 877 end
878 878
879 879 def test_should_not_call_after_project_change_on_update
880 880 issue = Issue.find(1)
881 881 issue.project = Project.find(1)
882 882 issue.subject = 'No project change'
883 883 issue.expects(:after_project_change).never
884 884 issue.save!
885 885 end
886 886
887 887 def test_should_call_after_project_change_on_project_change
888 888 issue = Issue.find(1)
889 889 issue.project = Project.find(2)
890 890 issue.expects(:after_project_change).once
891 891 issue.save!
892 892 end
893 893
894 894 def test_adding_journal_should_update_timestamp
895 895 issue = Issue.find(1)
896 896 updated_on_was = issue.updated_on
897 897
898 898 issue.init_journal(User.first, "Adding notes")
899 899 assert_difference 'Journal.count' do
900 900 assert issue.save
901 901 end
902 902 issue.reload
903 903
904 904 assert_not_equal updated_on_was, issue.updated_on
905 905 end
906 906
907 907 def test_should_close_duplicates
908 908 # Create 3 issues
909 909 issue1 = Issue.generate!
910 910 issue2 = Issue.generate!
911 911 issue3 = Issue.generate!
912 912
913 913 # 2 is a dupe of 1
914 914 IssueRelation.create!(:issue_from => issue2, :issue_to => issue1,
915 915 :relation_type => IssueRelation::TYPE_DUPLICATES)
916 916 # And 3 is a dupe of 2
917 917 IssueRelation.create!(:issue_from => issue3, :issue_to => issue2,
918 918 :relation_type => IssueRelation::TYPE_DUPLICATES)
919 919 # And 3 is a dupe of 1 (circular duplicates)
920 920 IssueRelation.create!(:issue_from => issue3, :issue_to => issue1,
921 921 :relation_type => IssueRelation::TYPE_DUPLICATES)
922 922
923 923 assert issue1.reload.duplicates.include?(issue2)
924 924
925 925 # Closing issue 1
926 926 issue1.init_journal(User.first, "Closing issue1")
927 927 issue1.status = IssueStatus.where(:is_closed => true).first
928 928 assert issue1.save
929 929 # 2 and 3 should be also closed
930 930 assert issue2.reload.closed?
931 931 assert issue3.reload.closed?
932 932 end
933 933
934 934 def test_should_not_close_duplicated_issue
935 935 issue1 = Issue.generate!
936 936 issue2 = Issue.generate!
937 937
938 938 # 2 is a dupe of 1
939 939 IssueRelation.create(:issue_from => issue2, :issue_to => issue1,
940 940 :relation_type => IssueRelation::TYPE_DUPLICATES)
941 941 # 2 is a dup of 1 but 1 is not a duplicate of 2
942 942 assert !issue2.reload.duplicates.include?(issue1)
943 943
944 944 # Closing issue 2
945 945 issue2.init_journal(User.first, "Closing issue2")
946 946 issue2.status = IssueStatus.where(:is_closed => true).first
947 947 assert issue2.save
948 948 # 1 should not be also closed
949 949 assert !issue1.reload.closed?
950 950 end
951 951
952 952 def test_assignable_versions
953 953 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
954 954 :status_id => 1, :fixed_version_id => 1,
955 955 :subject => 'New issue')
956 956 assert_equal ['open'], issue.assignable_versions.collect(&:status).uniq
957 957 end
958 958
959 959 def test_should_not_be_able_to_assign_a_new_issue_to_a_closed_version
960 960 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
961 961 :status_id => 1, :fixed_version_id => 1,
962 962 :subject => 'New issue')
963 963 assert !issue.save
964 964 assert_not_nil issue.errors[:fixed_version_id]
965 965 end
966 966
967 967 def test_should_not_be_able_to_assign_a_new_issue_to_a_locked_version
968 968 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
969 969 :status_id => 1, :fixed_version_id => 2,
970 970 :subject => 'New issue')
971 971 assert !issue.save
972 972 assert_not_nil issue.errors[:fixed_version_id]
973 973 end
974 974
975 975 def test_should_be_able_to_assign_a_new_issue_to_an_open_version
976 976 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
977 977 :status_id => 1, :fixed_version_id => 3,
978 978 :subject => 'New issue')
979 979 assert issue.save
980 980 end
981 981
982 982 def test_should_be_able_to_update_an_issue_assigned_to_a_closed_version
983 983 issue = Issue.find(11)
984 984 assert_equal 'closed', issue.fixed_version.status
985 985 issue.subject = 'Subject changed'
986 986 assert issue.save
987 987 end
988 988
989 989 def test_should_not_be_able_to_reopen_an_issue_assigned_to_a_closed_version
990 990 issue = Issue.find(11)
991 991 issue.status_id = 1
992 992 assert !issue.save
993 993 assert_not_nil issue.errors[:base]
994 994 end
995 995
996 996 def test_should_be_able_to_reopen_and_reassign_an_issue_assigned_to_a_closed_version
997 997 issue = Issue.find(11)
998 998 issue.status_id = 1
999 999 issue.fixed_version_id = 3
1000 1000 assert issue.save
1001 1001 end
1002 1002
1003 1003 def test_should_be_able_to_reopen_an_issue_assigned_to_a_locked_version
1004 1004 issue = Issue.find(12)
1005 1005 assert_equal 'locked', issue.fixed_version.status
1006 1006 issue.status_id = 1
1007 1007 assert issue.save
1008 1008 end
1009 1009
1010 1010 def test_should_not_be_able_to_keep_unshared_version_when_changing_project
1011 1011 issue = Issue.find(2)
1012 1012 assert_equal 2, issue.fixed_version_id
1013 1013 issue.project_id = 3
1014 1014 assert_nil issue.fixed_version_id
1015 1015 issue.fixed_version_id = 2
1016 1016 assert !issue.save
1017 1017 assert_include 'Target version is not included in the list', issue.errors.full_messages
1018 1018 end
1019 1019
1020 1020 def test_should_keep_shared_version_when_changing_project
1021 1021 Version.find(2).update_attribute :sharing, 'tree'
1022 1022
1023 1023 issue = Issue.find(2)
1024 1024 assert_equal 2, issue.fixed_version_id
1025 1025 issue.project_id = 3
1026 1026 assert_equal 2, issue.fixed_version_id
1027 1027 assert issue.save
1028 1028 end
1029 1029
1030 1030 def test_allowed_target_projects_on_move_should_include_projects_with_issue_tracking_enabled
1031 1031 assert_include Project.find(2), Issue.allowed_target_projects_on_move(User.find(2))
1032 1032 end
1033 1033
1034 1034 def test_allowed_target_projects_on_move_should_not_include_projects_with_issue_tracking_disabled
1035 1035 Project.find(2).disable_module! :issue_tracking
1036 1036 assert_not_include Project.find(2), Issue.allowed_target_projects_on_move(User.find(2))
1037 1037 end
1038 1038
1039 1039 def test_move_to_another_project_with_same_category
1040 1040 issue = Issue.find(1)
1041 1041 issue.project = Project.find(2)
1042 1042 assert issue.save
1043 1043 issue.reload
1044 1044 assert_equal 2, issue.project_id
1045 1045 # Category changes
1046 1046 assert_equal 4, issue.category_id
1047 1047 # Make sure time entries were move to the target project
1048 1048 assert_equal 2, issue.time_entries.first.project_id
1049 1049 end
1050 1050
1051 1051 def test_move_to_another_project_without_same_category
1052 1052 issue = Issue.find(2)
1053 1053 issue.project = Project.find(2)
1054 1054 assert issue.save
1055 1055 issue.reload
1056 1056 assert_equal 2, issue.project_id
1057 1057 # Category cleared
1058 1058 assert_nil issue.category_id
1059 1059 end
1060 1060
1061 1061 def test_move_to_another_project_should_clear_fixed_version_when_not_shared
1062 1062 issue = Issue.find(1)
1063 1063 issue.update_attribute(:fixed_version_id, 1)
1064 1064 issue.project = Project.find(2)
1065 1065 assert issue.save
1066 1066 issue.reload
1067 1067 assert_equal 2, issue.project_id
1068 1068 # Cleared fixed_version
1069 1069 assert_equal nil, issue.fixed_version
1070 1070 end
1071 1071
1072 1072 def test_move_to_another_project_should_keep_fixed_version_when_shared_with_the_target_project
1073 1073 issue = Issue.find(1)
1074 1074 issue.update_attribute(:fixed_version_id, 4)
1075 1075 issue.project = Project.find(5)
1076 1076 assert issue.save
1077 1077 issue.reload
1078 1078 assert_equal 5, issue.project_id
1079 1079 # Keep fixed_version
1080 1080 assert_equal 4, issue.fixed_version_id
1081 1081 end
1082 1082
1083 1083 def test_move_to_another_project_should_clear_fixed_version_when_not_shared_with_the_target_project
1084 1084 issue = Issue.find(1)
1085 1085 issue.update_attribute(:fixed_version_id, 1)
1086 1086 issue.project = Project.find(5)
1087 1087 assert issue.save
1088 1088 issue.reload
1089 1089 assert_equal 5, issue.project_id
1090 1090 # Cleared fixed_version
1091 1091 assert_equal nil, issue.fixed_version
1092 1092 end
1093 1093
1094 1094 def test_move_to_another_project_should_keep_fixed_version_when_shared_systemwide
1095 1095 issue = Issue.find(1)
1096 1096 issue.update_attribute(:fixed_version_id, 7)
1097 1097 issue.project = Project.find(2)
1098 1098 assert issue.save
1099 1099 issue.reload
1100 1100 assert_equal 2, issue.project_id
1101 1101 # Keep fixed_version
1102 1102 assert_equal 7, issue.fixed_version_id
1103 1103 end
1104 1104
1105 1105 def test_move_to_another_project_should_keep_parent_if_valid
1106 1106 issue = Issue.find(1)
1107 1107 issue.update_attribute(:parent_issue_id, 2)
1108 1108 issue.project = Project.find(3)
1109 1109 assert issue.save
1110 1110 issue.reload
1111 1111 assert_equal 2, issue.parent_id
1112 1112 end
1113 1113
1114 1114 def test_move_to_another_project_should_clear_parent_if_not_valid
1115 1115 issue = Issue.find(1)
1116 1116 issue.update_attribute(:parent_issue_id, 2)
1117 1117 issue.project = Project.find(2)
1118 1118 assert issue.save
1119 1119 issue.reload
1120 1120 assert_nil issue.parent_id
1121 1121 end
1122 1122
1123 1123 def test_move_to_another_project_with_disabled_tracker
1124 1124 issue = Issue.find(1)
1125 1125 target = Project.find(2)
1126 1126 target.tracker_ids = [3]
1127 1127 target.save
1128 1128 issue.project = target
1129 1129 assert issue.save
1130 1130 issue.reload
1131 1131 assert_equal 2, issue.project_id
1132 1132 assert_equal 3, issue.tracker_id
1133 1133 end
1134 1134
1135 1135 def test_copy_to_the_same_project
1136 1136 issue = Issue.find(1)
1137 1137 copy = issue.copy
1138 1138 assert_difference 'Issue.count' do
1139 1139 copy.save!
1140 1140 end
1141 1141 assert_kind_of Issue, copy
1142 1142 assert_equal issue.project, copy.project
1143 1143 assert_equal "125", copy.custom_value_for(2).value
1144 1144 end
1145 1145
1146 1146 def test_copy_to_another_project_and_tracker
1147 1147 issue = Issue.find(1)
1148 1148 copy = issue.copy(:project_id => 3, :tracker_id => 2)
1149 1149 assert_difference 'Issue.count' do
1150 1150 copy.save!
1151 1151 end
1152 1152 copy.reload
1153 1153 assert_kind_of Issue, copy
1154 1154 assert_equal Project.find(3), copy.project
1155 1155 assert_equal Tracker.find(2), copy.tracker
1156 1156 # Custom field #2 is not associated with target tracker
1157 1157 assert_nil copy.custom_value_for(2)
1158 1158 end
1159 1159
1160 1160 test "#copy should not create a journal" do
1161 1161 copy = Issue.find(1).copy(:project_id => 3, :tracker_id => 2, :assigned_to_id => 3)
1162 1162 copy.save!
1163 1163 assert_equal 0, copy.reload.journals.size
1164 1164 end
1165 1165
1166 1166 test "#copy should allow assigned_to changes" do
1167 1167 copy = Issue.find(1).copy(:project_id => 3, :tracker_id => 2, :assigned_to_id => 3)
1168 1168 assert_equal 3, copy.assigned_to_id
1169 1169 end
1170 1170
1171 1171 test "#copy should allow status changes" do
1172 1172 copy = Issue.find(1).copy(:project_id => 3, :tracker_id => 2, :status_id => 2)
1173 1173 assert_equal 2, copy.status_id
1174 1174 end
1175 1175
1176 1176 test "#copy should allow start date changes" do
1177 1177 date = Date.today
1178 1178 copy = Issue.find(1).copy(:project_id => 3, :tracker_id => 2, :start_date => date)
1179 1179 assert_equal date, copy.start_date
1180 1180 end
1181 1181
1182 1182 test "#copy should allow due date changes" do
1183 1183 date = Date.today
1184 1184 copy = Issue.find(1).copy(:project_id => 3, :tracker_id => 2, :due_date => date)
1185 1185 assert_equal date, copy.due_date
1186 1186 end
1187 1187
1188 1188 test "#copy should set current user as author" do
1189 1189 User.current = User.find(9)
1190 1190 copy = Issue.find(1).copy(:project_id => 3, :tracker_id => 2)
1191 1191 assert_equal User.current, copy.author
1192 1192 end
1193 1193
1194 1194 test "#copy should create a journal with notes" do
1195 1195 date = Date.today
1196 1196 notes = "Notes added when copying"
1197 1197 copy = Issue.find(1).copy(:project_id => 3, :tracker_id => 2, :start_date => date)
1198 1198 copy.init_journal(User.current, notes)
1199 1199 copy.save!
1200 1200
1201 1201 assert_equal 1, copy.journals.size
1202 1202 journal = copy.journals.first
1203 1203 assert_equal 0, journal.details.size
1204 1204 assert_equal notes, journal.notes
1205 1205 end
1206 1206
1207 1207 def test_valid_parent_project
1208 1208 issue = Issue.find(1)
1209 1209 issue_in_same_project = Issue.find(2)
1210 1210 issue_in_child_project = Issue.find(5)
1211 1211 issue_in_grandchild_project = Issue.generate!(:project_id => 6, :tracker_id => 1)
1212 1212 issue_in_other_child_project = Issue.find(6)
1213 1213 issue_in_different_tree = Issue.find(4)
1214 1214
1215 1215 with_settings :cross_project_subtasks => '' do
1216 1216 assert_equal true, issue.valid_parent_project?(issue_in_same_project)
1217 1217 assert_equal false, issue.valid_parent_project?(issue_in_child_project)
1218 1218 assert_equal false, issue.valid_parent_project?(issue_in_grandchild_project)
1219 1219 assert_equal false, issue.valid_parent_project?(issue_in_different_tree)
1220 1220 end
1221 1221
1222 1222 with_settings :cross_project_subtasks => 'system' do
1223 1223 assert_equal true, issue.valid_parent_project?(issue_in_same_project)
1224 1224 assert_equal true, issue.valid_parent_project?(issue_in_child_project)
1225 1225 assert_equal true, issue.valid_parent_project?(issue_in_different_tree)
1226 1226 end
1227 1227
1228 1228 with_settings :cross_project_subtasks => 'tree' do
1229 1229 assert_equal true, issue.valid_parent_project?(issue_in_same_project)
1230 1230 assert_equal true, issue.valid_parent_project?(issue_in_child_project)
1231 1231 assert_equal true, issue.valid_parent_project?(issue_in_grandchild_project)
1232 1232 assert_equal false, issue.valid_parent_project?(issue_in_different_tree)
1233 1233
1234 1234 assert_equal true, issue_in_child_project.valid_parent_project?(issue_in_same_project)
1235 1235 assert_equal true, issue_in_child_project.valid_parent_project?(issue_in_other_child_project)
1236 1236 end
1237 1237
1238 1238 with_settings :cross_project_subtasks => 'descendants' do
1239 1239 assert_equal true, issue.valid_parent_project?(issue_in_same_project)
1240 1240 assert_equal false, issue.valid_parent_project?(issue_in_child_project)
1241 1241 assert_equal false, issue.valid_parent_project?(issue_in_grandchild_project)
1242 1242 assert_equal false, issue.valid_parent_project?(issue_in_different_tree)
1243 1243
1244 1244 assert_equal true, issue_in_child_project.valid_parent_project?(issue)
1245 1245 assert_equal false, issue_in_child_project.valid_parent_project?(issue_in_other_child_project)
1246 1246 end
1247 1247 end
1248 1248
1249 1249 def test_recipients_should_include_previous_assignee
1250 1250 user = User.find(3)
1251 1251 user.members.update_all ["mail_notification = ?", false]
1252 1252 user.update_attribute :mail_notification, 'only_assigned'
1253 1253
1254 1254 issue = Issue.find(2)
1255 1255 issue.assigned_to = nil
1256 1256 assert_include user.mail, issue.recipients
1257 1257 issue.save!
1258 1258 assert !issue.recipients.include?(user.mail)
1259 1259 end
1260 1260
1261 1261 def test_recipients_should_not_include_users_that_cannot_view_the_issue
1262 1262 issue = Issue.find(12)
1263 1263 assert issue.recipients.include?(issue.author.mail)
1264 1264 # copy the issue to a private project
1265 1265 copy = issue.copy(:project_id => 5, :tracker_id => 2)
1266 1266 # author is not a member of project anymore
1267 1267 assert !copy.recipients.include?(copy.author.mail)
1268 1268 end
1269 1269
1270 1270 def test_recipients_should_include_the_assigned_group_members
1271 1271 group_member = User.generate!
1272 1272 group = Group.generate!
1273 1273 group.users << group_member
1274 1274
1275 1275 issue = Issue.find(12)
1276 1276 issue.assigned_to = group
1277 1277 assert issue.recipients.include?(group_member.mail)
1278 1278 end
1279 1279
1280 1280 def test_watcher_recipients_should_not_include_users_that_cannot_view_the_issue
1281 1281 user = User.find(3)
1282 1282 issue = Issue.find(9)
1283 1283 Watcher.create!(:user => user, :watchable => issue)
1284 1284 assert issue.watched_by?(user)
1285 1285 assert !issue.watcher_recipients.include?(user.mail)
1286 1286 end
1287 1287
1288 1288 def test_issue_destroy
1289 1289 Issue.find(1).destroy
1290 1290 assert_nil Issue.find_by_id(1)
1291 1291 assert_nil TimeEntry.find_by_issue_id(1)
1292 1292 end
1293 1293
1294 1294 def test_destroying_a_deleted_issue_should_not_raise_an_error
1295 1295 issue = Issue.find(1)
1296 1296 Issue.find(1).destroy
1297 1297
1298 1298 assert_nothing_raised do
1299 1299 assert_no_difference 'Issue.count' do
1300 1300 issue.destroy
1301 1301 end
1302 1302 assert issue.destroyed?
1303 1303 end
1304 1304 end
1305 1305
1306 1306 def test_destroying_a_stale_issue_should_not_raise_an_error
1307 1307 issue = Issue.find(1)
1308 1308 Issue.find(1).update_attribute :subject, "Updated"
1309 1309
1310 1310 assert_nothing_raised do
1311 1311 assert_difference 'Issue.count', -1 do
1312 1312 issue.destroy
1313 1313 end
1314 1314 assert issue.destroyed?
1315 1315 end
1316 1316 end
1317 1317
1318 1318 def test_blocked
1319 1319 blocked_issue = Issue.find(9)
1320 1320 blocking_issue = Issue.find(10)
1321 1321
1322 1322 assert blocked_issue.blocked?
1323 1323 assert !blocking_issue.blocked?
1324 1324 end
1325 1325
1326 1326 def test_blocked_issues_dont_allow_closed_statuses
1327 1327 blocked_issue = Issue.find(9)
1328 1328
1329 1329 allowed_statuses = blocked_issue.new_statuses_allowed_to(users(:users_002))
1330 1330 assert !allowed_statuses.empty?
1331 1331 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
1332 1332 assert closed_statuses.empty?
1333 1333 end
1334 1334
1335 1335 def test_unblocked_issues_allow_closed_statuses
1336 1336 blocking_issue = Issue.find(10)
1337 1337
1338 1338 allowed_statuses = blocking_issue.new_statuses_allowed_to(users(:users_002))
1339 1339 assert !allowed_statuses.empty?
1340 1340 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
1341 1341 assert !closed_statuses.empty?
1342 1342 end
1343 1343
1344 1344 def test_reschedule_an_issue_without_dates
1345 1345 with_settings :non_working_week_days => [] do
1346 1346 issue = Issue.new(:start_date => nil, :due_date => nil)
1347 1347 issue.reschedule_on '2012-10-09'.to_date
1348 1348 assert_equal '2012-10-09'.to_date, issue.start_date
1349 1349 assert_equal '2012-10-09'.to_date, issue.due_date
1350 1350 end
1351 1351
1352 1352 with_settings :non_working_week_days => %w(6 7) do
1353 1353 issue = Issue.new(:start_date => nil, :due_date => nil)
1354 1354 issue.reschedule_on '2012-10-09'.to_date
1355 1355 assert_equal '2012-10-09'.to_date, issue.start_date
1356 1356 assert_equal '2012-10-09'.to_date, issue.due_date
1357 1357
1358 1358 issue = Issue.new(:start_date => nil, :due_date => nil)
1359 1359 issue.reschedule_on '2012-10-13'.to_date
1360 1360 assert_equal '2012-10-15'.to_date, issue.start_date
1361 1361 assert_equal '2012-10-15'.to_date, issue.due_date
1362 1362 end
1363 1363 end
1364 1364
1365 1365 def test_reschedule_an_issue_with_start_date
1366 1366 with_settings :non_working_week_days => [] do
1367 1367 issue = Issue.new(:start_date => '2012-10-09', :due_date => nil)
1368 1368 issue.reschedule_on '2012-10-13'.to_date
1369 1369 assert_equal '2012-10-13'.to_date, issue.start_date
1370 1370 assert_equal '2012-10-13'.to_date, issue.due_date
1371 1371 end
1372 1372
1373 1373 with_settings :non_working_week_days => %w(6 7) do
1374 1374 issue = Issue.new(:start_date => '2012-10-09', :due_date => nil)
1375 1375 issue.reschedule_on '2012-10-11'.to_date
1376 1376 assert_equal '2012-10-11'.to_date, issue.start_date
1377 1377 assert_equal '2012-10-11'.to_date, issue.due_date
1378 1378
1379 1379 issue = Issue.new(:start_date => '2012-10-09', :due_date => nil)
1380 1380 issue.reschedule_on '2012-10-13'.to_date
1381 1381 assert_equal '2012-10-15'.to_date, issue.start_date
1382 1382 assert_equal '2012-10-15'.to_date, issue.due_date
1383 1383 end
1384 1384 end
1385 1385
1386 1386 def test_reschedule_an_issue_with_start_and_due_dates
1387 1387 with_settings :non_working_week_days => [] do
1388 1388 issue = Issue.new(:start_date => '2012-10-09', :due_date => '2012-10-15')
1389 1389 issue.reschedule_on '2012-10-13'.to_date
1390 1390 assert_equal '2012-10-13'.to_date, issue.start_date
1391 1391 assert_equal '2012-10-19'.to_date, issue.due_date
1392 1392 end
1393 1393
1394 1394 with_settings :non_working_week_days => %w(6 7) do
1395 1395 issue = Issue.new(:start_date => '2012-10-09', :due_date => '2012-10-19') # 8 working days
1396 1396 issue.reschedule_on '2012-10-11'.to_date
1397 1397 assert_equal '2012-10-11'.to_date, issue.start_date
1398 1398 assert_equal '2012-10-23'.to_date, issue.due_date
1399 1399
1400 1400 issue = Issue.new(:start_date => '2012-10-09', :due_date => '2012-10-19')
1401 1401 issue.reschedule_on '2012-10-13'.to_date
1402 1402 assert_equal '2012-10-15'.to_date, issue.start_date
1403 1403 assert_equal '2012-10-25'.to_date, issue.due_date
1404 1404 end
1405 1405 end
1406 1406
1407 1407 def test_rescheduling_an_issue_to_a_later_due_date_should_reschedule_following_issue
1408 1408 issue1 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1409 1409 issue2 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1410 1410 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2,
1411 1411 :relation_type => IssueRelation::TYPE_PRECEDES)
1412 1412 assert_equal Date.parse('2012-10-18'), issue2.reload.start_date
1413 1413
1414 1414 issue1.due_date = '2012-10-23'
1415 1415 issue1.save!
1416 1416 issue2.reload
1417 1417 assert_equal Date.parse('2012-10-24'), issue2.start_date
1418 1418 assert_equal Date.parse('2012-10-26'), issue2.due_date
1419 1419 end
1420 1420
1421 1421 def test_rescheduling_an_issue_to_an_earlier_due_date_should_reschedule_following_issue
1422 1422 issue1 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1423 1423 issue2 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1424 1424 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2,
1425 1425 :relation_type => IssueRelation::TYPE_PRECEDES)
1426 1426 assert_equal Date.parse('2012-10-18'), issue2.reload.start_date
1427 1427
1428 1428 issue1.start_date = '2012-09-17'
1429 1429 issue1.due_date = '2012-09-18'
1430 1430 issue1.save!
1431 1431 issue2.reload
1432 1432 assert_equal Date.parse('2012-09-19'), issue2.start_date
1433 1433 assert_equal Date.parse('2012-09-21'), issue2.due_date
1434 1434 end
1435 1435
1436 1436 def test_rescheduling_reschedule_following_issue_earlier_should_consider_other_preceding_issues
1437 1437 issue1 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1438 1438 issue2 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1439 1439 issue3 = Issue.generate!(:start_date => '2012-10-01', :due_date => '2012-10-02')
1440 1440 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2,
1441 1441 :relation_type => IssueRelation::TYPE_PRECEDES)
1442 1442 IssueRelation.create!(:issue_from => issue3, :issue_to => issue2,
1443 1443 :relation_type => IssueRelation::TYPE_PRECEDES)
1444 1444 assert_equal Date.parse('2012-10-18'), issue2.reload.start_date
1445 1445
1446 1446 issue1.start_date = '2012-09-17'
1447 1447 issue1.due_date = '2012-09-18'
1448 1448 issue1.save!
1449 1449 issue2.reload
1450 1450 # Issue 2 must start after Issue 3
1451 1451 assert_equal Date.parse('2012-10-03'), issue2.start_date
1452 1452 assert_equal Date.parse('2012-10-05'), issue2.due_date
1453 1453 end
1454 1454
1455 1455 def test_rescheduling_a_stale_issue_should_not_raise_an_error
1456 1456 with_settings :non_working_week_days => [] do
1457 1457 stale = Issue.find(1)
1458 1458 issue = Issue.find(1)
1459 1459 issue.subject = "Updated"
1460 1460 issue.save!
1461 1461 date = 10.days.from_now.to_date
1462 1462 assert_nothing_raised do
1463 1463 stale.reschedule_on!(date)
1464 1464 end
1465 1465 assert_equal date, stale.reload.start_date
1466 1466 end
1467 1467 end
1468 1468
1469 1469 def test_overdue
1470 1470 assert Issue.new(:due_date => 1.day.ago.to_date).overdue?
1471 1471 assert !Issue.new(:due_date => Date.today).overdue?
1472 1472 assert !Issue.new(:due_date => 1.day.from_now.to_date).overdue?
1473 1473 assert !Issue.new(:due_date => nil).overdue?
1474 1474 assert !Issue.new(:due_date => 1.day.ago.to_date,
1475 1475 :status => IssueStatus.where(:is_closed => true).first
1476 1476 ).overdue?
1477 1477 end
1478 1478
1479 1479 test "#behind_schedule? should be false if the issue has no start_date" do
1480 1480 assert !Issue.new(:start_date => nil,
1481 1481 :due_date => 1.day.from_now.to_date,
1482 1482 :done_ratio => 0).behind_schedule?
1483 1483 end
1484 1484
1485 1485 test "#behind_schedule? should be false if the issue has no end_date" do
1486 1486 assert !Issue.new(:start_date => 1.day.from_now.to_date,
1487 1487 :due_date => nil,
1488 1488 :done_ratio => 0).behind_schedule?
1489 1489 end
1490 1490
1491 1491 test "#behind_schedule? should be false if the issue has more done than it's calendar time" do
1492 1492 assert !Issue.new(:start_date => 50.days.ago.to_date,
1493 1493 :due_date => 50.days.from_now.to_date,
1494 1494 :done_ratio => 90).behind_schedule?
1495 1495 end
1496 1496
1497 1497 test "#behind_schedule? should be true if the issue hasn't been started at all" do
1498 1498 assert Issue.new(:start_date => 1.day.ago.to_date,
1499 1499 :due_date => 1.day.from_now.to_date,
1500 1500 :done_ratio => 0).behind_schedule?
1501 1501 end
1502 1502
1503 1503 test "#behind_schedule? should be true if the issue has used more calendar time than it's done ratio" do
1504 1504 assert Issue.new(:start_date => 100.days.ago.to_date,
1505 1505 :due_date => Date.today,
1506 1506 :done_ratio => 90).behind_schedule?
1507 1507 end
1508 1508
1509 1509 test "#assignable_users should be Users" do
1510 1510 assert_kind_of User, Issue.find(1).assignable_users.first
1511 1511 end
1512 1512
1513 1513 test "#assignable_users should include the issue author" do
1514 1514 non_project_member = User.generate!
1515 1515 issue = Issue.generate!(:author => non_project_member)
1516 1516
1517 1517 assert issue.assignable_users.include?(non_project_member)
1518 1518 end
1519 1519
1520 1520 test "#assignable_users should include the current assignee" do
1521 1521 user = User.generate!
1522 1522 issue = Issue.generate!(:assigned_to => user)
1523 1523 user.lock!
1524 1524
1525 1525 assert Issue.find(issue.id).assignable_users.include?(user)
1526 1526 end
1527 1527
1528 1528 test "#assignable_users should not show the issue author twice" do
1529 1529 assignable_user_ids = Issue.find(1).assignable_users.collect(&:id)
1530 1530 assert_equal 2, assignable_user_ids.length
1531 1531
1532 1532 assignable_user_ids.each do |user_id|
1533 1533 assert_equal 1, assignable_user_ids.select {|i| i == user_id}.length,
1534 1534 "User #{user_id} appears more or less than once"
1535 1535 end
1536 1536 end
1537 1537
1538 1538 test "#assignable_users with issue_group_assignment should include groups" do
1539 1539 issue = Issue.new(:project => Project.find(2))
1540 1540
1541 1541 with_settings :issue_group_assignment => '1' do
1542 1542 assert_equal %w(Group User), issue.assignable_users.map {|a| a.class.name}.uniq.sort
1543 1543 assert issue.assignable_users.include?(Group.find(11))
1544 1544 end
1545 1545 end
1546 1546
1547 1547 test "#assignable_users without issue_group_assignment should not include groups" do
1548 1548 issue = Issue.new(:project => Project.find(2))
1549 1549
1550 1550 with_settings :issue_group_assignment => '0' do
1551 1551 assert_equal %w(User), issue.assignable_users.map {|a| a.class.name}.uniq.sort
1552 1552 assert !issue.assignable_users.include?(Group.find(11))
1553 1553 end
1554 1554 end
1555 1555
1556 1556 def test_create_should_send_email_notification
1557 1557 ActionMailer::Base.deliveries.clear
1558 1558 issue = Issue.new(:project_id => 1, :tracker_id => 1,
1559 1559 :author_id => 3, :status_id => 1,
1560 1560 :priority => IssuePriority.all.first,
1561 1561 :subject => 'test_create', :estimated_hours => '1:30')
1562 1562
1563 1563 assert issue.save
1564 1564 assert_equal 1, ActionMailer::Base.deliveries.size
1565 1565 end
1566 1566
1567 1567 def test_stale_issue_should_not_send_email_notification
1568 1568 ActionMailer::Base.deliveries.clear
1569 1569 issue = Issue.find(1)
1570 1570 stale = Issue.find(1)
1571 1571
1572 1572 issue.init_journal(User.find(1))
1573 1573 issue.subject = 'Subjet update'
1574 1574 assert issue.save
1575 1575 assert_equal 1, ActionMailer::Base.deliveries.size
1576 1576 ActionMailer::Base.deliveries.clear
1577 1577
1578 1578 stale.init_journal(User.find(1))
1579 1579 stale.subject = 'Another subjet update'
1580 1580 assert_raise ActiveRecord::StaleObjectError do
1581 1581 stale.save
1582 1582 end
1583 1583 assert ActionMailer::Base.deliveries.empty?
1584 1584 end
1585 1585
1586 1586 def test_journalized_description
1587 1587 IssueCustomField.delete_all
1588 1588
1589 1589 i = Issue.first
1590 1590 old_description = i.description
1591 1591 new_description = "This is the new description"
1592 1592
1593 1593 i.init_journal(User.find(2))
1594 1594 i.description = new_description
1595 1595 assert_difference 'Journal.count', 1 do
1596 1596 assert_difference 'JournalDetail.count', 1 do
1597 1597 i.save!
1598 1598 end
1599 1599 end
1600 1600
1601 1601 detail = JournalDetail.first(:order => 'id DESC')
1602 1602 assert_equal i, detail.journal.journalized
1603 1603 assert_equal 'attr', detail.property
1604 1604 assert_equal 'description', detail.prop_key
1605 1605 assert_equal old_description, detail.old_value
1606 1606 assert_equal new_description, detail.value
1607 1607 end
1608 1608
1609 1609 def test_blank_descriptions_should_not_be_journalized
1610 1610 IssueCustomField.delete_all
1611 1611 Issue.update_all("description = NULL", "id=1")
1612 1612
1613 1613 i = Issue.find(1)
1614 1614 i.init_journal(User.find(2))
1615 1615 i.subject = "blank description"
1616 1616 i.description = "\r\n"
1617 1617
1618 1618 assert_difference 'Journal.count', 1 do
1619 1619 assert_difference 'JournalDetail.count', 1 do
1620 1620 i.save!
1621 1621 end
1622 1622 end
1623 1623 end
1624 1624
1625 1625 def test_journalized_multi_custom_field
1626 1626 field = IssueCustomField.create!(:name => 'filter', :field_format => 'list',
1627 1627 :is_filter => true, :is_for_all => true,
1628 1628 :tracker_ids => [1],
1629 1629 :possible_values => ['value1', 'value2', 'value3'],
1630 1630 :multiple => true)
1631 1631
1632 1632 issue = Issue.create!(:project_id => 1, :tracker_id => 1,
1633 1633 :subject => 'Test', :author_id => 1)
1634 1634
1635 1635 assert_difference 'Journal.count' do
1636 1636 assert_difference 'JournalDetail.count' do
1637 1637 issue.init_journal(User.first)
1638 1638 issue.custom_field_values = {field.id => ['value1']}
1639 1639 issue.save!
1640 1640 end
1641 1641 assert_difference 'JournalDetail.count' do
1642 1642 issue.init_journal(User.first)
1643 1643 issue.custom_field_values = {field.id => ['value1', 'value2']}
1644 1644 issue.save!
1645 1645 end
1646 1646 assert_difference 'JournalDetail.count', 2 do
1647 1647 issue.init_journal(User.first)
1648 1648 issue.custom_field_values = {field.id => ['value3', 'value2']}
1649 1649 issue.save!
1650 1650 end
1651 1651 assert_difference 'JournalDetail.count', 2 do
1652 1652 issue.init_journal(User.first)
1653 1653 issue.custom_field_values = {field.id => nil}
1654 1654 issue.save!
1655 1655 end
1656 1656 end
1657 1657 end
1658 1658
1659 1659 def test_description_eol_should_be_normalized
1660 1660 i = Issue.new(:description => "CR \r LF \n CRLF \r\n")
1661 1661 assert_equal "CR \r\n LF \r\n CRLF \r\n", i.description
1662 1662 end
1663 1663
1664 1664 def test_saving_twice_should_not_duplicate_journal_details
1665 1665 i = Issue.first
1666 1666 i.init_journal(User.find(2), 'Some notes')
1667 1667 # initial changes
1668 1668 i.subject = 'New subject'
1669 1669 i.done_ratio = i.done_ratio + 10
1670 1670 assert_difference 'Journal.count' do
1671 1671 assert i.save
1672 1672 end
1673 1673 # 1 more change
1674 1674 i.priority = IssuePriority.where("id <> ?", i.priority_id).first
1675 1675 assert_no_difference 'Journal.count' do
1676 1676 assert_difference 'JournalDetail.count', 1 do
1677 1677 i.save
1678 1678 end
1679 1679 end
1680 1680 # no more change
1681 1681 assert_no_difference 'Journal.count' do
1682 1682 assert_no_difference 'JournalDetail.count' do
1683 1683 i.save
1684 1684 end
1685 1685 end
1686 1686 end
1687 1687
1688 1688 def test_all_dependent_issues
1689 1689 IssueRelation.delete_all
1690 1690 assert IssueRelation.create!(:issue_from => Issue.find(1),
1691 1691 :issue_to => Issue.find(2),
1692 1692 :relation_type => IssueRelation::TYPE_PRECEDES)
1693 1693 assert IssueRelation.create!(:issue_from => Issue.find(2),
1694 1694 :issue_to => Issue.find(3),
1695 1695 :relation_type => IssueRelation::TYPE_PRECEDES)
1696 1696 assert IssueRelation.create!(:issue_from => Issue.find(3),
1697 1697 :issue_to => Issue.find(8),
1698 1698 :relation_type => IssueRelation::TYPE_PRECEDES)
1699 1699
1700 1700 assert_equal [2, 3, 8], Issue.find(1).all_dependent_issues.collect(&:id).sort
1701 1701 end
1702 1702
1703 1703 def test_all_dependent_issues_with_persistent_circular_dependency
1704 1704 IssueRelation.delete_all
1705 1705 assert IssueRelation.create!(:issue_from => Issue.find(1),
1706 1706 :issue_to => Issue.find(2),
1707 1707 :relation_type => IssueRelation::TYPE_PRECEDES)
1708 1708 assert IssueRelation.create!(:issue_from => Issue.find(2),
1709 1709 :issue_to => Issue.find(3),
1710 1710 :relation_type => IssueRelation::TYPE_PRECEDES)
1711 1711
1712 1712 r = IssueRelation.create!(:issue_from => Issue.find(3),
1713 1713 :issue_to => Issue.find(7),
1714 1714 :relation_type => IssueRelation::TYPE_PRECEDES)
1715 1715 IssueRelation.update_all("issue_to_id = 1", ["id = ?", r.id])
1716 1716
1717 1717 assert_equal [2, 3], Issue.find(1).all_dependent_issues.collect(&:id).sort
1718 1718 end
1719 1719
1720 1720 def test_all_dependent_issues_with_persistent_multiple_circular_dependencies
1721 1721 IssueRelation.delete_all
1722 1722 assert IssueRelation.create!(:issue_from => Issue.find(1),
1723 1723 :issue_to => Issue.find(2),
1724 1724 :relation_type => IssueRelation::TYPE_RELATES)
1725 1725 assert IssueRelation.create!(:issue_from => Issue.find(2),
1726 1726 :issue_to => Issue.find(3),
1727 1727 :relation_type => IssueRelation::TYPE_RELATES)
1728 1728 assert IssueRelation.create!(:issue_from => Issue.find(3),
1729 1729 :issue_to => Issue.find(8),
1730 1730 :relation_type => IssueRelation::TYPE_RELATES)
1731 1731
1732 1732 r = IssueRelation.create!(:issue_from => Issue.find(8),
1733 1733 :issue_to => Issue.find(7),
1734 1734 :relation_type => IssueRelation::TYPE_RELATES)
1735 1735 IssueRelation.update_all("issue_to_id = 2", ["id = ?", r.id])
1736 1736
1737 1737 r = IssueRelation.create!(:issue_from => Issue.find(3),
1738 1738 :issue_to => Issue.find(7),
1739 1739 :relation_type => IssueRelation::TYPE_RELATES)
1740 1740 IssueRelation.update_all("issue_to_id = 1", ["id = ?", r.id])
1741 1741
1742 1742 assert_equal [2, 3, 8], Issue.find(1).all_dependent_issues.collect(&:id).sort
1743 1743 end
1744 1744
1745 1745 test "#done_ratio should use the issue_status according to Setting.issue_done_ratio" do
1746 1746 @issue = Issue.find(1)
1747 1747 @issue_status = IssueStatus.find(1)
1748 1748 @issue_status.update_attribute(:default_done_ratio, 50)
1749 1749 @issue2 = Issue.find(2)
1750 1750 @issue_status2 = IssueStatus.find(2)
1751 1751 @issue_status2.update_attribute(:default_done_ratio, 0)
1752 1752
1753 1753 with_settings :issue_done_ratio => 'issue_field' do
1754 1754 assert_equal 0, @issue.done_ratio
1755 1755 assert_equal 30, @issue2.done_ratio
1756 1756 end
1757 1757
1758 1758 with_settings :issue_done_ratio => 'issue_status' do
1759 1759 assert_equal 50, @issue.done_ratio
1760 1760 assert_equal 0, @issue2.done_ratio
1761 1761 end
1762 1762 end
1763 1763
1764 1764 test "#update_done_ratio_from_issue_status should update done_ratio according to Setting.issue_done_ratio" do
1765 1765 @issue = Issue.find(1)
1766 1766 @issue_status = IssueStatus.find(1)
1767 1767 @issue_status.update_attribute(:default_done_ratio, 50)
1768 1768 @issue2 = Issue.find(2)
1769 1769 @issue_status2 = IssueStatus.find(2)
1770 1770 @issue_status2.update_attribute(:default_done_ratio, 0)
1771 1771
1772 1772 with_settings :issue_done_ratio => 'issue_field' do
1773 1773 @issue.update_done_ratio_from_issue_status
1774 1774 @issue2.update_done_ratio_from_issue_status
1775 1775
1776 1776 assert_equal 0, @issue.read_attribute(:done_ratio)
1777 1777 assert_equal 30, @issue2.read_attribute(:done_ratio)
1778 1778 end
1779 1779
1780 1780 with_settings :issue_done_ratio => 'issue_status' do
1781 1781 @issue.update_done_ratio_from_issue_status
1782 1782 @issue2.update_done_ratio_from_issue_status
1783 1783
1784 1784 assert_equal 50, @issue.read_attribute(:done_ratio)
1785 1785 assert_equal 0, @issue2.read_attribute(:done_ratio)
1786 1786 end
1787 1787 end
1788 1788
1789 1789 test "#by_tracker" do
1790 1790 User.current = User.anonymous
1791 1791 groups = Issue.by_tracker(Project.find(1))
1792 1792 assert_equal 3, groups.size
1793 1793 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1794 1794 end
1795 1795
1796 1796 test "#by_version" do
1797 1797 User.current = User.anonymous
1798 1798 groups = Issue.by_version(Project.find(1))
1799 1799 assert_equal 3, groups.size
1800 1800 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1801 1801 end
1802 1802
1803 1803 test "#by_priority" do
1804 1804 User.current = User.anonymous
1805 1805 groups = Issue.by_priority(Project.find(1))
1806 1806 assert_equal 4, groups.size
1807 1807 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1808 1808 end
1809 1809
1810 1810 test "#by_category" do
1811 1811 User.current = User.anonymous
1812 1812 groups = Issue.by_category(Project.find(1))
1813 1813 assert_equal 2, groups.size
1814 1814 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1815 1815 end
1816 1816
1817 1817 test "#by_assigned_to" do
1818 1818 User.current = User.anonymous
1819 1819 groups = Issue.by_assigned_to(Project.find(1))
1820 1820 assert_equal 2, groups.size
1821 1821 assert_equal 2, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1822 1822 end
1823 1823
1824 1824 test "#by_author" do
1825 1825 User.current = User.anonymous
1826 1826 groups = Issue.by_author(Project.find(1))
1827 1827 assert_equal 4, groups.size
1828 1828 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1829 1829 end
1830 1830
1831 1831 test "#by_subproject" do
1832 1832 User.current = User.anonymous
1833 1833 groups = Issue.by_subproject(Project.find(1))
1834 1834 # Private descendant not visible
1835 1835 assert_equal 1, groups.size
1836 1836 assert_equal 2, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1837 1837 end
1838 1838
1839 1839 def test_recently_updated_scope
1840 1840 #should return the last updated issue
1841 1841 assert_equal Issue.reorder("updated_on DESC").first, Issue.recently_updated.limit(1).first
1842 1842 end
1843 1843
1844 1844 def test_on_active_projects_scope
1845 1845 assert Project.find(2).archive
1846 1846
1847 1847 before = Issue.on_active_project.length
1848 1848 # test inclusion to results
1849 1849 issue = Issue.generate!(:tracker => Project.find(2).trackers.first)
1850 1850 assert_equal before + 1, Issue.on_active_project.length
1851 1851
1852 1852 # Move to an archived project
1853 1853 issue.project = Project.find(2)
1854 1854 assert issue.save
1855 1855 assert_equal before, Issue.on_active_project.length
1856 1856 end
1857 1857
1858 1858 test "Issue#recipients should include project recipients" do
1859 1859 issue = Issue.generate!
1860 1860 assert issue.project.recipients.present?
1861 1861 issue.project.recipients.each do |project_recipient|
1862 1862 assert issue.recipients.include?(project_recipient)
1863 1863 end
1864 1864 end
1865 1865
1866 1866 test "Issue#recipients should include the author if the author is active" do
1867 1867 issue = Issue.generate!(:author => User.generate!)
1868 1868 assert issue.author, "No author set for Issue"
1869 1869 assert issue.recipients.include?(issue.author.mail)
1870 1870 end
1871 1871
1872 1872 test "Issue#recipients should include the assigned to user if the assigned to user is active" do
1873 1873 issue = Issue.generate!(:assigned_to => User.generate!)
1874 1874 assert issue.assigned_to, "No assigned_to set for Issue"
1875 1875 assert issue.recipients.include?(issue.assigned_to.mail)
1876 1876 end
1877 1877
1878 1878 test "Issue#recipients should not include users who opt out of all email" do
1879 1879 issue = Issue.generate!(:author => User.generate!)
1880 1880 issue.author.update_attribute(:mail_notification, :none)
1881 1881 assert !issue.recipients.include?(issue.author.mail)
1882 1882 end
1883 1883
1884 1884 test "Issue#recipients should not include the issue author if they are only notified of assigned issues" do
1885 1885 issue = Issue.generate!(:author => User.generate!)
1886 1886 issue.author.update_attribute(:mail_notification, :only_assigned)
1887 1887 assert !issue.recipients.include?(issue.author.mail)
1888 1888 end
1889 1889
1890 1890 test "Issue#recipients should not include the assigned user if they are only notified of owned issues" do
1891 1891 issue = Issue.generate!(:assigned_to => User.generate!)
1892 1892 issue.assigned_to.update_attribute(:mail_notification, :only_owner)
1893 1893 assert !issue.recipients.include?(issue.assigned_to.mail)
1894 1894 end
1895 1895
1896 1896 def test_last_journal_id_with_journals_should_return_the_journal_id
1897 1897 assert_equal 2, Issue.find(1).last_journal_id
1898 1898 end
1899 1899
1900 1900 def test_last_journal_id_without_journals_should_return_nil
1901 1901 assert_nil Issue.find(3).last_journal_id
1902 1902 end
1903 1903
1904 1904 def test_journals_after_should_return_journals_with_greater_id
1905 1905 assert_equal [Journal.find(2)], Issue.find(1).journals_after('1')
1906 1906 assert_equal [], Issue.find(1).journals_after('2')
1907 1907 end
1908 1908
1909 1909 def test_journals_after_with_blank_arg_should_return_all_journals
1910 1910 assert_equal [Journal.find(1), Journal.find(2)], Issue.find(1).journals_after('')
1911 1911 end
1912 1912
1913 def test_css_classes_should_include_tracker
1914 issue = Issue.new(:tracker => Tracker.find(2))
1915 classes = issue.css_classes.split(' ')
1916 assert_include 'tracker-2', classes
1917 end
1918
1913 1919 def test_css_classes_should_include_priority
1914 1920 issue = Issue.new(:priority => IssuePriority.find(8))
1915 1921 classes = issue.css_classes.split(' ')
1916 1922 assert_include 'priority-8', classes
1917 1923 assert_include 'priority-highest', classes
1918 1924 end
1919 1925
1920 1926 def test_save_attachments_with_hash_should_save_attachments_in_keys_order
1921 1927 set_tmp_attachments_directory
1922 1928 issue = Issue.generate!
1923 1929 issue.save_attachments({
1924 1930 'p0' => {'file' => mock_file_with_options(:original_filename => 'upload')},
1925 1931 '3' => {'file' => mock_file_with_options(:original_filename => 'bar')},
1926 1932 '1' => {'file' => mock_file_with_options(:original_filename => 'foo')}
1927 1933 })
1928 1934 issue.attach_saved_attachments
1929 1935
1930 1936 assert_equal 3, issue.reload.attachments.count
1931 1937 assert_equal %w(upload foo bar), issue.attachments.map(&:filename)
1932 1938 end
1933 1939
1934 1940 def test_closed_on_should_be_nil_when_creating_an_open_issue
1935 1941 issue = Issue.generate!(:status_id => 1).reload
1936 1942 assert !issue.closed?
1937 1943 assert_nil issue.closed_on
1938 1944 end
1939 1945
1940 1946 def test_closed_on_should_be_set_when_creating_a_closed_issue
1941 1947 issue = Issue.generate!(:status_id => 5).reload
1942 1948 assert issue.closed?
1943 1949 assert_not_nil issue.closed_on
1944 1950 assert_equal issue.updated_on, issue.closed_on
1945 1951 assert_equal issue.created_on, issue.closed_on
1946 1952 end
1947 1953
1948 1954 def test_closed_on_should_be_nil_when_updating_an_open_issue
1949 1955 issue = Issue.find(1)
1950 1956 issue.subject = 'Not closed yet'
1951 1957 issue.save!
1952 1958 issue.reload
1953 1959 assert_nil issue.closed_on
1954 1960 end
1955 1961
1956 1962 def test_closed_on_should_be_set_when_closing_an_open_issue
1957 1963 issue = Issue.find(1)
1958 1964 issue.subject = 'Now closed'
1959 1965 issue.status_id = 5
1960 1966 issue.save!
1961 1967 issue.reload
1962 1968 assert_not_nil issue.closed_on
1963 1969 assert_equal issue.updated_on, issue.closed_on
1964 1970 end
1965 1971
1966 1972 def test_closed_on_should_not_be_updated_when_updating_a_closed_issue
1967 1973 issue = Issue.open(false).first
1968 1974 was_closed_on = issue.closed_on
1969 1975 assert_not_nil was_closed_on
1970 1976 issue.subject = 'Updating a closed issue'
1971 1977 issue.save!
1972 1978 issue.reload
1973 1979 assert_equal was_closed_on, issue.closed_on
1974 1980 end
1975 1981
1976 1982 def test_closed_on_should_be_preserved_when_reopening_a_closed_issue
1977 1983 issue = Issue.open(false).first
1978 1984 was_closed_on = issue.closed_on
1979 1985 assert_not_nil was_closed_on
1980 1986 issue.subject = 'Reopening a closed issue'
1981 1987 issue.status_id = 1
1982 1988 issue.save!
1983 1989 issue.reload
1984 1990 assert !issue.closed?
1985 1991 assert_equal was_closed_on, issue.closed_on
1986 1992 end
1987 1993
1988 1994 def test_status_was_should_return_nil_for_new_issue
1989 1995 issue = Issue.new
1990 1996 assert_nil issue.status_was
1991 1997 end
1992 1998
1993 1999 def test_status_was_should_return_status_before_change
1994 2000 issue = Issue.find(1)
1995 2001 issue.status = IssueStatus.find(2)
1996 2002 assert_equal IssueStatus.find(1), issue.status_was
1997 2003 end
1998 2004
1999 2005 def test_status_was_should_be_reset_on_save
2000 2006 issue = Issue.find(1)
2001 2007 issue.status = IssueStatus.find(2)
2002 2008 assert_equal IssueStatus.find(1), issue.status_was
2003 2009 assert issue.save!
2004 2010 assert_equal IssueStatus.find(2), issue.status_was
2005 2011 end
2006 2012 end
General Comments 0
You need to be logged in to leave comments. Login now